mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge branch 'ux/contacts-files'
This commit is contained in:
@@ -9,6 +9,10 @@ vi.mock('@/lib/keyboard-shortcuts/calendar', () => ({
|
||||
CALENDAR_SECTIONS: [{ id: 'views', title: 'calendar.views', entries: [{ key: 'd', description: 'calendar.day_view' }] }],
|
||||
}));
|
||||
|
||||
// Apps without keys get no tab; Contacts and Files are left empty here for that.
|
||||
vi.mock('@/lib/keyboard-shortcuts/contacts', () => ({ CONTACTS_SECTIONS: [] }));
|
||||
vi.mock('@/lib/keyboard-shortcuts/files', () => ({ FILES_SECTIONS: [] }));
|
||||
|
||||
const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
|
||||
|
||||
describe('keyboard shortcuts help per app', () => {
|
||||
|
||||
@@ -124,3 +124,12 @@ describe('vCard export of contact photos', () => {
|
||||
expect(await exported!.text()).toContain('FN:Contact 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('vCard file names', () => {
|
||||
it('keep letters of any script instead of turning them into underscores', async () => {
|
||||
const { sanitizeFilename } = await import('../contact-export');
|
||||
expect(sanitizeFilename('Jürgen Müller')).toBe('Jürgen_Müller');
|
||||
expect(sanitizeFilename('李 雷')).toBe('李_雷');
|
||||
expect(sanitizeFilename('a/b:c')).toBe('a_b_c');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('ContactForm', () => {
|
||||
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('name_required');
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -460,7 +460,7 @@ describe('ContactForm', () => {
|
||||
fireEvent.submit(screen.getByText('save').closest('form')!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('name_required')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('name_required');
|
||||
});
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -513,4 +513,15 @@ describe('ContactForm', () => {
|
||||
});
|
||||
expect(onSave.mock.calls[0][0].kind).toBe('individual');
|
||||
});
|
||||
|
||||
it("names the account of an address book once, not twice", () => {
|
||||
const books = [
|
||||
{ id: 'b1', name: 'Stalwart Address Book (usera@example.org)', accountName: 'usera@example.org', isDefault: true },
|
||||
{ id: 'b2', name: 'Team', accountName: 'userb@example.org', isShared: true },
|
||||
] as never;
|
||||
render(<ContactForm addressBooks={books} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
const options = screen.getAllByRole('option').map((o) => o.textContent);
|
||||
expect(options).toContain('Stalwart Address Book (usera@example.org)');
|
||||
expect(options).toContain('Team (userb@example.org)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,4 +183,75 @@ describe('ContactList', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("writes to a contact from the right-click menu in the app's composer, not through mailto:", () => {
|
||||
const onComposeContact = vi.fn();
|
||||
const href = window.location.href;
|
||||
render(<ContactList {...defaultProps} onComposeContact={onComposeContact} />);
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.send_email'));
|
||||
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']);
|
||||
});
|
||||
|
||||
it("moves through the list with the arrow keys and opens the contact, like Mail and Files", () => {
|
||||
const onSelectContact = vi.fn();
|
||||
render(<ContactList {...defaultProps} onSelectContact={onSelectContact} />);
|
||||
const rows = screen.getAllByRole('option');
|
||||
expect(rows[0]).toHaveAttribute('tabindex', '0');
|
||||
expect(rows[1]).toHaveAttribute('tabindex', '-1');
|
||||
rows[0].focus();
|
||||
fireEvent.keyDown(rows[0], { key: 'ArrowDown' });
|
||||
expect(onSelectContact).toHaveBeenLastCalledWith('2');
|
||||
expect(document.activeElement).toBe(screen.getAllByRole('option')[1]);
|
||||
});
|
||||
|
||||
it("selects all with Ctrl+A, deletes with Delete, clears with Escape, and searches with /", () => {
|
||||
const onSelectAll = vi.fn();
|
||||
const onBulkDelete = vi.fn();
|
||||
const onClearSelection = vi.fn();
|
||||
const { rerender } = render(<ContactList {...defaultProps} onSelectAll={onSelectAll} onBulkDelete={onBulkDelete} onClearSelection={onClearSelection} />);
|
||||
const row = screen.getAllByRole('option')[0];
|
||||
fireEvent.keyDown(row, { key: 'a', code: 'KeyA', ctrlKey: true });
|
||||
expect(onSelectAll).toHaveBeenCalledWith(['1', '2']);
|
||||
|
||||
rerender(<ContactList {...defaultProps} selectedContactIds={new Set(['1'])} onSelectAll={onSelectAll} onBulkDelete={onBulkDelete} onClearSelection={onClearSelection} />);
|
||||
fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Delete' });
|
||||
expect(onBulkDelete).toHaveBeenCalled();
|
||||
fireEvent.keyDown(screen.getAllByRole('option')[0], { key: 'Escape' });
|
||||
expect(onClearSelection).toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(document.body, { key: '/' });
|
||||
expect(document.activeElement).toBe(screen.getByPlaceholderText('search_placeholder'));
|
||||
});
|
||||
|
||||
it("opens a contact's menu on long-press, with Select to start selecting", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onToggleSelection = vi.fn();
|
||||
render(<ContactList {...defaultProps} onToggleSelection={onToggleSelection} />);
|
||||
const row = screen.getAllByRole('option')[0];
|
||||
const touchStart = new Event('touchstart', { bubbles: true });
|
||||
Object.defineProperty(touchStart, 'touches', { value: [{ clientX: 5, clientY: 5 }] });
|
||||
act(() => { row.dispatchEvent(touchStart); });
|
||||
act(() => { vi.advanceTimersByTime(600); });
|
||||
fireEvent.click(screen.getByText('filters.select'));
|
||||
expect(onToggleSelection).toHaveBeenCalledWith('1');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { printContact } from '../contact-print';
|
||||
import { printContact, printLabels } from '../contact-print';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
const LABELS = printLabels((key) => key, 'en');
|
||||
|
||||
const base: ContactCard = {
|
||||
id: 'c1',
|
||||
addressBookIds: {},
|
||||
@@ -36,7 +38,7 @@ describe('printContact', () => {
|
||||
it('opens the window right away and prints a Stalwart 1.0 photo inline as a data: URI', async () => {
|
||||
fetchBlob.mockResolvedValue(new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }));
|
||||
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } });
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }, LABELS);
|
||||
// Before anything is awaited, while the click still allows a popup.
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
await done;
|
||||
@@ -47,7 +49,7 @@ describe('printContact', () => {
|
||||
});
|
||||
|
||||
it('prints a Stalwart 0.16 photo as it is', async () => {
|
||||
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } });
|
||||
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }, LABELS);
|
||||
|
||||
expect(fetchBlob).not.toHaveBeenCalled();
|
||||
expect(written()).toContain('src="data:image/jpeg;base64,/9j/"');
|
||||
@@ -57,7 +59,7 @@ describe('printContact', () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404'));
|
||||
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } });
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }, LABELS);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await done;
|
||||
|
||||
@@ -68,9 +70,21 @@ describe('printContact', () => {
|
||||
|
||||
it('does not write into a window the user already closed', async () => {
|
||||
fetchBlob.mockResolvedValue(new Blob(['x'], { type: 'image/jpeg' }));
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } });
|
||||
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }, LABELS);
|
||||
printWindow.closed = true;
|
||||
await done;
|
||||
expect(printWindow.document.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints the headings and dates in the language of the app', async () => {
|
||||
const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record<string, string>)[key] ?? key, 'de');
|
||||
await printContact({
|
||||
...base,
|
||||
emails: { e: { address: 'ann@example.com' } },
|
||||
anniversaries: { b: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } },
|
||||
} as ContactCard, de);
|
||||
expect(written()).toContain('<h2>E-Mail-Adressen</h2>');
|
||||
expect(written()).toContain('<dt>Geburtstag</dt><dd>15. März 1988</dd>');
|
||||
expect(written()).not.toContain('>Email<');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// The whole Contacts app in local mode (no JMAP client): the stores hold the
|
||||
// contacts, and the shell around it is stubbed out.
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
usePathname: () => '/en/contacts',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
|
||||
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
|
||||
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
|
||||
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
|
||||
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
|
||||
vi.mock('@/hooks/use-sidebar-apps', () => ({
|
||||
useSidebarApps: () => ({
|
||||
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
|
||||
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ContactsApp } from '../contacts-app';
|
||||
import { useRegisteredCommandGroups, type CommandGroup } from '@/lib/command-registry';
|
||||
|
||||
/** The palette's command groups as the palette would read them now. */
|
||||
function getRegisteredGroups(): CommandGroup[] {
|
||||
let groups: CommandGroup[] = [];
|
||||
function Probe() {
|
||||
groups = useRegisteredCommandGroups();
|
||||
return null;
|
||||
}
|
||||
const { unmount } = render(<Probe />);
|
||||
unmount();
|
||||
return groups;
|
||||
}
|
||||
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',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
export const bob: ContactCard = {
|
||||
id: 'c-bob',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
|
||||
emails: { e0: { address: 'bob@example.com' } },
|
||||
};
|
||||
|
||||
// Only asked whether the server has a directory; supportsSync stays false.
|
||||
const FAKE_CLIENT = {
|
||||
supportsPrincipals: () => false,
|
||||
} as unknown as IJMAPClient;
|
||||
|
||||
export function renderContacts(contacts: ContactCard[] = [alice, bob]) {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: true, client: FAKE_CLIENT, isLoading: false });
|
||||
useContactStore.setState({
|
||||
contacts,
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
selectedContactIds: new Set(),
|
||||
searchQuery: '',
|
||||
supportsSync: false,
|
||||
contactsLoaded: true,
|
||||
});
|
||||
});
|
||||
return render(<ContactsApp />);
|
||||
}
|
||||
|
||||
/** The categories drawer of a narrow window (jsdom matches no media query). */
|
||||
function drawer() {
|
||||
return screen.getByTestId('contacts-sidebar-drawer');
|
||||
}
|
||||
|
||||
describe('ContactsApp', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
useContactStore.setState({ contacts: [], selectedContactId: null, selectedContactIds: new Set() });
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the closed drawer out of reach, so it can't cover the nav rail or catch Tab", () => {
|
||||
renderContacts();
|
||||
expect(drawer()).toHaveAttribute('inert');
|
||||
expect(drawer().className).toContain('invisible');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
expect(drawer()).not.toHaveAttribute('inert');
|
||||
expect(within(drawer()).getByText('tabs.all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the drawer when something in it is picked, so it doesn't stay over the form", () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
expect(drawer()).not.toHaveAttribute('inert');
|
||||
|
||||
// The header's "+" menu, then "New contact".
|
||||
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
|
||||
fireEvent.click(screen.getByText('create_new'));
|
||||
expect(drawer()).toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('asks before another contact replaces a contact form with changes, and keeps them on "Keep editing"', async () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
|
||||
|
||||
fireEvent.click(screen.getByText('Bob Jones'));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('form.discard_title')).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'form.keep_editing' }));
|
||||
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
|
||||
expect(screen.getByPlaceholderText('middle_name')).toHaveValue('Marie');
|
||||
|
||||
fireEvent.click(screen.getByText('Bob Jones'));
|
||||
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'form.discard' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('middle_name')).toBeNull());
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
|
||||
});
|
||||
|
||||
it('leaves an untouched contact form without asking', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
|
||||
fireEvent.click(screen.getByText('Bob Jones'));
|
||||
expect(screen.queryByRole('alertdialog')).toBeNull();
|
||||
expect(useContactStore.getState().selectedContactId).toBe('c-bob');
|
||||
});
|
||||
|
||||
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
|
||||
renderContacts();
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.add_to_group'));
|
||||
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a new group, with the selection in it, next to the existing groups', () => {
|
||||
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
|
||||
renderContacts([alice, bob, team]);
|
||||
fireEvent.contextMenu(screen.getByText('Bob Jones'));
|
||||
fireEvent.click(screen.getByText('context_menu.add_to_group'));
|
||||
fireEvent.click(screen.getByText('bulk.new_group'));
|
||||
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('imports in a real dialog that Escape closes', () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
|
||||
fireEvent.click(screen.getByText('import.title'));
|
||||
const dialog = screen.getByRole('dialog', { name: 'import.title' });
|
||||
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));
|
||||
});
|
||||
|
||||
it('has a create button on the phone, as Mail and Files do', () => {
|
||||
const matchMedia = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia;
|
||||
try {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'create_new' }));
|
||||
expect(screen.getByPlaceholderText('middle_name')).toBeInTheDocument();
|
||||
} finally {
|
||||
window.matchMedia = matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
it('shows a new contact once it is saved, instead of an empty pane', async () => {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByRole('button', { name: 'new' }));
|
||||
fireEvent.click(screen.getByText('create_new'));
|
||||
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Zed' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'save' }));
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Zed' })).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('adds New contact, Import and New group to the command palette while open', () => {
|
||||
renderContacts();
|
||||
const groups = getRegisteredGroups();
|
||||
const contacts = groups.find((g) => g.label === 'contacts');
|
||||
expect(contacts?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['create_new', 'import.title', 'groups.create']));
|
||||
act(() => { contacts!.commands.find((c) => c.id === 'new-contact')!.run(); });
|
||||
expect(screen.getByPlaceholderText('middle_name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts New group and Import in the phone header menu', () => {
|
||||
const matchMedia = window.matchMedia;
|
||||
window.matchMedia = ((query: string) => ({ ...matchMedia(query), matches: query.includes('max-width') })) as typeof window.matchMedia;
|
||||
try {
|
||||
renderContacts();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more' }));
|
||||
fireEvent.click(screen.getByText('import.title'));
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
} finally {
|
||||
window.matchMedia = matchMedia;
|
||||
}
|
||||
});
|
||||
|
||||
it('says an empty address book is empty, not that the account has no contacts, and offers Import', () => {
|
||||
renderContacts([alice]);
|
||||
act(() => { useContactStore.setState({ addressBooks: [{ id: 'b2', name: 'Work', isDefault: false } as never] }); });
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
|
||||
fireEvent.click(within(drawer()).getByText('Work'));
|
||||
expect(screen.getByText('empty_view_title')).toBeInTheDocument();
|
||||
expect(screen.queryByText('empty_state_title')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: /import.title/ }));
|
||||
expect(screen.getByRole('dialog', { name: 'import.title' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a message started from Contacts as the app's own, so Mail does not ask for an account", async () => {
|
||||
const { consumePendingMailto } = await import('@/lib/protocol-handlers/session');
|
||||
renderContacts();
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.send_email'));
|
||||
expect(consumePendingMailto()).toMatchObject({ to: [expect.stringContaining('alice@example.com')], inApp: true });
|
||||
});
|
||||
|
||||
it('says so when a duplicate fails', async () => {
|
||||
renderContacts();
|
||||
const addLocalContact = vi.fn(async () => { throw new Error('quota'); });
|
||||
act(() => { useContactStore.setState({ addLocalContact }); });
|
||||
fireEvent.contextMenu(screen.getByText('Alice Smith'));
|
||||
fireEvent.click(screen.getByText('context_menu.duplicate'));
|
||||
await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ContactForm } from '../contact-form';
|
||||
import { ContactGroupDetail } from '../contact-group-detail';
|
||||
import { ContactList } from '../contact-list';
|
||||
import { FilePreviewModal } from '@/components/files/file-preview-modal';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
// Every button says what it does to a screen reader (R1): aria-label, or text.
|
||||
function unnamedButtons(): HTMLElement[] {
|
||||
return screen.getAllByRole('button').filter((b) => !(b.getAttribute('aria-label') || b.textContent?.trim()));
|
||||
}
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' }, e1: { address: 'a@example.org' } },
|
||||
phones: { p0: { number: '+1 555' } },
|
||||
addresses: { a0: { street: 'Main St' } },
|
||||
keywords: { Friends: true },
|
||||
};
|
||||
const bob: ContactCard = { id: '2', addressBookIds: {}, name: { full: 'Bob' } };
|
||||
|
||||
describe('icon buttons in Contacts and Files have names', () => {
|
||||
it('in the contact form', () => {
|
||||
render(<ContactForm contact={alice} onSave={vi.fn()} onCancel={vi.fn()} />);
|
||||
expect(unnamedButtons()).toEqual([]);
|
||||
});
|
||||
|
||||
it('on a group page', () => {
|
||||
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
|
||||
render(<ContactGroupDetail group={group} members={[alice, bob]} onEdit={vi.fn()} onDelete={vi.fn()} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
|
||||
expect(unnamedButtons()).toEqual([]);
|
||||
});
|
||||
|
||||
it('in the contact list while selecting', () => {
|
||||
render(
|
||||
<ContactList
|
||||
contacts={[alice, bob]} selectedContactId={null} searchQuery="" onSearchChange={vi.fn()} onSelectContact={vi.fn()}
|
||||
onCreateNew={vi.fn()} selectedContactIds={new Set(['1'])} onToggleSelection={vi.fn()} onSelectRangeContacts={vi.fn()}
|
||||
onSelectAll={vi.fn()} onClearSelection={vi.fn()} onBulkDelete={vi.fn()} onBulkAddToGroup={vi.fn()} onBulkExport={vi.fn()}
|
||||
onEditContact={vi.fn()} onDeleteContact={vi.fn()} onAddContactToGroup={vi.fn()} onMenuClick={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(unnamedButtons()).toEqual([]);
|
||||
expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('in the file preview', () => {
|
||||
render(<FilePreviewModal name="a.txt" onClose={vi.fn()} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} />);
|
||||
expect(unnamedButtons()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const nav = vi.hoisted(() => ({ pathname: '/' }));
|
||||
vi.mock('@/i18n/navigation', () => ({ usePathname: () => nav.pathname }));
|
||||
vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => false }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
|
||||
|
||||
const { KeyboardShortcutsModal } = await import('@/components/keyboard-shortcuts-modal');
|
||||
|
||||
describe('Contacts and Files keys in the shortcuts help', () => {
|
||||
it('lists the Contacts keys on the Contacts tab', () => {
|
||||
nav.pathname = '/contacts';
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByRole('tab', { name: 'sidebar.contacts' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('contacts.shortcuts.move')).toBeInTheDocument();
|
||||
expect(screen.getByText('contacts.shortcuts.search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the Files keys on the Files tab, with Backspace deleting and Alt+Up going up', () => {
|
||||
nav.pathname = '/files';
|
||||
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByRole('tab', { name: 'sidebar.files' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('files.shortcuts.up')).toBeInTheDocument();
|
||||
expect(screen.getByText('files.shortcuts.delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import { ContactGroupDetail } from '../contact-group-detail';
|
||||
import { ContactList } from '../contact-list';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
|
||||
|
||||
describe('one action list per contact (R6)', () => {
|
||||
it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
|
||||
const noop = vi.fn();
|
||||
render(
|
||||
<ContactList
|
||||
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
|
||||
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
|
||||
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
|
||||
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('Alice'));
|
||||
const rightClick = menuLabels();
|
||||
expect(rightClick).toContain('context_menu.duplicate');
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
|
||||
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
|
||||
const detail = menuLabels();
|
||||
// The detail's ⋮ is the same list, without what the page has as buttons.
|
||||
expect(detail).toEqual(rightClick.filter((l) => !['context_menu.open', 'context_menu.send_email', 'context_menu.edit'].includes(l ?? '')));
|
||||
});
|
||||
|
||||
it('says "Edit group" on a group page and keeps Delete in a ⋮ like a contact page', () => {
|
||||
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactGroupDetail group={group} members={[]} onEdit={vi.fn()} onDelete={onDelete} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /groups.edit/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText('form.edit_title')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
|
||||
fireEvent.click(screen.getByText('groups.delete'));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuItem,
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
Trash2,
|
||||
Copy,
|
||||
Printer,
|
||||
FolderInput,
|
||||
CheckSquare,
|
||||
} from "@/components/icons";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { exportContact, useContactExportBusy } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { printContact, printLabels } from "./contact-print";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
|
||||
function getContactPrimaryPhone(contact: ContactCard): string {
|
||||
@@ -43,11 +45,22 @@ interface ContactContextMenuProps {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
/** Left out where the contact is already open (the detail's ⋮). */
|
||||
onOpen?: () => void;
|
||||
/** Touch: start selecting with this contact (there is no Ctrl+click). */
|
||||
onSelect?: () => void;
|
||||
/** Left out where Edit is a button next to the menu. */
|
||||
onEdit?: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
/** Leave out what the detail shows as buttons of its own. */
|
||||
inDetail?: boolean;
|
||||
/** 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;
|
||||
@@ -62,15 +75,21 @@ export function ContactContextMenu({
|
||||
isMultiSelect = false,
|
||||
selectedCount = 1,
|
||||
onOpen,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddToGroup,
|
||||
onCompose,
|
||||
onDuplicate,
|
||||
onMoveTo,
|
||||
onBatchMoveTo,
|
||||
inDetail = false,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
}: ContactContextMenuProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const phone = getContactPrimaryPhone(contact);
|
||||
const showBatchActions = isMultiSelect && selectedCount > 1;
|
||||
@@ -81,11 +100,6 @@ export function ContactContextMenu({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSendEmail = () => {
|
||||
if (!email) return;
|
||||
window.location.href = `mailto:${email}`;
|
||||
};
|
||||
|
||||
const handleCall = () => {
|
||||
if (!phone) return;
|
||||
window.location.href = `tel:${phone}`;
|
||||
@@ -108,7 +122,7 @@ export function ContactContextMenu({
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
void printContact(contact);
|
||||
void printContact(contact, printLabels(t, locale));
|
||||
};
|
||||
|
||||
if (showBatchActions) {
|
||||
@@ -123,6 +137,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")}
|
||||
@@ -143,23 +160,43 @@ export function ContactContextMenu({
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{(email || phone) && <ContextMenuSeparator />}
|
||||
{email && (
|
||||
{/* R6 order: Open, write, Edit, then the rest; the detail's ⋮ leaves
|
||||
out what it has as buttons (Edit, Send email, Call). */}
|
||||
{onOpen && <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />}
|
||||
{onSelect && <ContextMenuItem icon={CheckSquare} label={t("filters.select")} onClick={handle(onSelect)} />}
|
||||
{email && onCompose && !inDetail && (
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(handleSendEmail)}
|
||||
onClick={handle(onCompose)}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
{phone && !inDetail && (
|
||||
<ContextMenuItem
|
||||
icon={Phone}
|
||||
label={t("context_menu.call")}
|
||||
onClick={handle(handleCall)}
|
||||
/>
|
||||
)}
|
||||
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
|
||||
{!inDetail && <ContextMenuSeparator />}
|
||||
{onAddToGroup && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
{onMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
|
||||
)}
|
||||
{email && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
@@ -174,19 +211,6 @@ export function ContactContextMenu({
|
||||
onClick={handle(() => handleCopy(phone))}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
"use client";
|
||||
|
||||
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 { useEffect, useRef, useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { contactDateParts, formatContactDate } from "@/lib/contact-dates";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, MoreVertical } from "@/components/icons";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact, useContactExportBusy } from "./contact-export";
|
||||
import { printContact } from "./contact-print";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { contactHooks } from "@/lib/plugin-hooks";
|
||||
import { contactToView } from "@/lib/plugin-projection";
|
||||
|
||||
type MoreItem =
|
||||
| {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: false;
|
||||
}
|
||||
| { separator: true };
|
||||
|
||||
interface ContactDetailProps {
|
||||
contact: ContactCard | null;
|
||||
onEdit: () => void;
|
||||
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;
|
||||
@@ -64,32 +56,8 @@ function formatPhoneFeatures(features: Record<string, boolean> | undefined, labe
|
||||
return Object.keys(features).filter(k => features[k]).map(label).join(", ");
|
||||
}
|
||||
|
||||
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === "object" && dateInput !== null) {
|
||||
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
|
||||
const d = new Date(dateInput.utc);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith("--")) {
|
||||
const parts = s.substring(2).split("-");
|
||||
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
|
||||
}
|
||||
const d = new Date(s);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
const { year, month, day } = getDateParts(dateInput);
|
||||
const { year, month, day } = contactDateParts(dateInput);
|
||||
if (!year) return null;
|
||||
const now = new Date();
|
||||
let years = now.getFullYear() - year;
|
||||
@@ -102,50 +70,17 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
return years;
|
||||
}
|
||||
|
||||
function formatDate(dateInput: AnniversaryDate): string {
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||
try {
|
||||
const d = new Date(dateInput.utc as string);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
return String(dateInput.utc);
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const parts: string[] = [];
|
||||
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
|
||||
if (pd.day) parts.push(String(pd.day));
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
return parts.join(' ') || String(dateInput);
|
||||
}
|
||||
const dateStr = String(dateInput);
|
||||
if (dateStr.startsWith("--")) {
|
||||
const parts = dateStr.substring(2).split("-");
|
||||
const month = parseInt(parts[0], 10);
|
||||
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
|
||||
}
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
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 locale = useLocale();
|
||||
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
|
||||
const moreMenu = useContextMenu<ContactCard>();
|
||||
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);
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
|
||||
const exporting = useContactExportBusy();
|
||||
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
|
||||
|
||||
// Tell plugins once per contact shown here, not on every edit of that card.
|
||||
@@ -172,25 +107,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
const photo = getContactPhotoRef(contact) ?? null;
|
||||
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (await exportContact(contact)) toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
void printContact(contact, name);
|
||||
};
|
||||
|
||||
const moreItems: MoreItem[] = [];
|
||||
if (onAddToGroup) {
|
||||
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||
}
|
||||
if (onDuplicate) {
|
||||
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||
}
|
||||
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 });
|
||||
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
@@ -221,7 +137,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
|
||||
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
|
||||
<div className={cn("flex gap-4", isMobile ? "flex-col" : "flex-wrap items-start justify-between")}>
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1 basis-60">
|
||||
<div className={cn("flex items-center gap-4 min-w-0 flex-1", !isMobile && "basis-60")}>
|
||||
<Avatar name={name} email={email} contactPhoto={photo} size={isMobile ? "md" : "lg"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "-"}</h2>
|
||||
@@ -258,7 +174,32 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
label={t("detail.more_actions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreMenu.contextMenu.isOpen}
|
||||
onClick={(e) => moreMenu.openContextMenuFrom(e.currentTarget, contact, "end")}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
{moreMenu.contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={moreMenu.contextMenu.data}
|
||||
position={moreMenu.contextMenu.position}
|
||||
isOpen={moreMenu.contextMenu.isOpen}
|
||||
onClose={moreMenu.closeContextMenu}
|
||||
menuRef={moreMenu.menuRef}
|
||||
inDetail
|
||||
onCompose={onCompose}
|
||||
onDelete={onDelete}
|
||||
onAddToGroup={onAddToGroup}
|
||||
onDuplicate={onDuplicate}
|
||||
onMoveTo={onMoveTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +332,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
return (
|
||||
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
|
||||
<div className="text-sm">
|
||||
{formatDate(ann.date)}
|
||||
{formatContactDate(ann.date, locale)}
|
||||
{years !== null && (
|
||||
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
|
||||
)}
|
||||
@@ -538,8 +479,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
|
||||
{(contact.created || contact.updated) && (
|
||||
<div className="py-4 text-xs text-muted-foreground space-y-1">
|
||||
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
|
||||
{contact.created && <div>{t("detail.created")}: {formatContactDate(contact.created, locale)}</div>}
|
||||
{contact.updated && <div>{t("detail.updated")}: {formatContactDate(contact.updated, locale)}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -581,71 +522,6 @@ function RowActions({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] bg-background rounded-md shadow-lg border border-border py-1"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.separator) {
|
||||
return <ContextMenuSeparator key={i} />;
|
||||
}
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={i}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
destructive={item.destructive}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -67,6 +67,12 @@ function downloadVcf(content: string, filename: string) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, "_").substring(0, 50);
|
||||
// Letters of any script stay ("Jürgen_Müller.vcf"); only what file systems
|
||||
// refuse or treat specially becomes "_".
|
||||
export function sanitizeFilename(name: string): string {
|
||||
const safe = Array.from(name.normalize("NFC"), (ch) => (ch.charCodeAt(0) < 32 || '\\/:*?"<>|.'.includes(ch) || /\s/.test(ch) ? "_" : ch))
|
||||
.join("")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "");
|
||||
return safe.substring(0, 50) || "contact";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
|
||||
@@ -64,6 +65,8 @@ interface ContactFormProps {
|
||||
* may be older.
|
||||
*/
|
||||
loadCurrentMedia?: () => Promise<Pick<ContactCard, "media"> | null>;
|
||||
/** Reports whether the form holds changes not yet saved. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
/** What the user did to the photo: nothing, picked a new one (a data: URI), or removed it. */
|
||||
@@ -209,7 +212,7 @@ function Select({ value, onChange, children, className }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia }: ContactFormProps) {
|
||||
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const isEditing = !!contact;
|
||||
|
||||
@@ -447,6 +450,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [emailErrors, setEmailErrors] = useState<Record<number, string>>({});
|
||||
|
||||
// Everything the user can edit, to tell whether anything changed since the
|
||||
// form opened (R5: leaving asks first).
|
||||
const fields = JSON.stringify([
|
||||
prefix, givenName, additionalName, surname, suffix, nickname, emails, phones, organization,
|
||||
department, isOrg, jobTitle, role, addresses, onlineServices, anniversaries, personalInfoEntries,
|
||||
keywordsStr, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
|
||||
]);
|
||||
const [initialFields] = useState(fields);
|
||||
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
|
||||
const handlePhotoSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
@@ -676,15 +690,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditing ? t("edit_title") : t("create_title")}
|
||||
</h2>
|
||||
<button type="button" onClick={onCancel} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<IconButton label={t("close")} onClick={onCancel}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 max-w-3xl">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 mb-4">
|
||||
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -702,6 +716,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
name={previewName || undefined}
|
||||
email={previewEmail || undefined}
|
||||
contactPhoto={previewPhoto}
|
||||
// Nothing typed yet: a neutral circle, not a coloured "?".
|
||||
fallbackColor={previewName || previewEmail ? undefined : "var(--color-muted-foreground)"}
|
||||
size="lg"
|
||||
className="!w-20 !h-20 !text-xl"
|
||||
/>
|
||||
@@ -723,7 +739,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<div className="flex flex-col gap-1 min-w-0 flex-1">
|
||||
<p className="text-xs text-muted-foreground">{t("photo_hint")}</p>
|
||||
{photoError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{photoError}</p>
|
||||
<p className="text-xs text-destructive">{photoError}</p>
|
||||
)}
|
||||
{previewPhoto && (
|
||||
<button
|
||||
@@ -741,16 +757,17 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<div className="divide-y divide-border/60">
|
||||
|
||||
{addressBooks && addressBooks.length > 1 && (
|
||||
<FormSection icon={Book} title={t("section_address_book") || "Directory"}>
|
||||
<FormSection icon={Book} title={t("section_address_book")}>
|
||||
<select
|
||||
value={selectedBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="">{t("select_address_book") || "Select a directory..."}</option>
|
||||
<option value="">{t("select_address_book")}</option>
|
||||
{addressBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.accountName ? `${book.name} (${book.accountName})` : book.name}
|
||||
{/* A shared book says whose it is, unless its name already does. */}
|
||||
{book.isShared && book.accountName && !book.name.includes(book.accountName) ? `${book.name} (${book.accountName})` : book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -788,7 +805,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("organization")} <span className="text-red-500">*</span>
|
||||
{t("organization")} <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
|
||||
</div>
|
||||
@@ -805,15 +822,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<Input value={prefix} onChange={(e) => setPrefix(e.target.value)} placeholder={t("prefix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("given_name")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("given_name")}</label>
|
||||
<Input value={givenName} onChange={(e) => setGivenName(e.target.value)} placeholder={t("given_name")} autoFocus />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t("surname")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("surname")}</label>
|
||||
<Input value={surname} onChange={(e) => setSurname(e.target.value)} placeholder={t("surname")} />
|
||||
</div>
|
||||
<div>
|
||||
@@ -821,6 +834,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<Input value={suffix} onChange={(e) => setSuffix(e.target.value)} placeholder={t("suffix_placeholder")} className="w-20" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Either name is enough (or an organization), so neither is starred. */}
|
||||
<p className="text-xs text-muted-foreground -mt-1">{t("name_required")}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("middle_name")}</label>
|
||||
@@ -855,7 +870,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
}}
|
||||
onBlur={() => handleEmailBlur(i, entry.address)}
|
||||
placeholder={t("email_placeholder")}
|
||||
className={cn("flex-1", emailErrors[i] && "border-red-500 focus:ring-red-500")}
|
||||
className={cn("flex-1", emailErrors[i] && "border-destructive focus-visible:border-destructive")}
|
||||
/>
|
||||
<Select
|
||||
value={entry.context}
|
||||
@@ -870,13 +885,13 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
{emails.length > 1 && (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setEmails(emails.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
{emailErrors[i] && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
|
||||
<p className="text-xs text-destructive mt-1 ms-1">{emailErrors[i]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -933,9 +948,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<option value="work">{t("context_work")}</option>
|
||||
<option value="private">{t("context_private")}</option>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setPhones(phones.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
|
||||
@@ -975,9 +990,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<div className="space-y-3">
|
||||
{addresses.map((addr, i) => (
|
||||
<div key={i} className="rounded-md border border-border/60 bg-muted/20 p-3 space-y-2 relative">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="h-6 w-6 absolute top-2 right-2">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
|
||||
<X />
|
||||
</IconButton>
|
||||
<Input value={addr.street} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input value={addr.locality} onChange={(e) => { const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} />
|
||||
@@ -1021,9 +1036,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
placeholder={t("service_placeholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setOnlineServices(onlineServices.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
|
||||
@@ -1053,9 +1068,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<option value="death">{t("anniversary_death")}</option>
|
||||
<option value="other">{t("anniversary_other")}</option>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setAnniversaries(anniversaries.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
|
||||
@@ -1094,9 +1109,9 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
|
||||
<option value="medium">{t("level_medium")}</option>
|
||||
<option value="low">{t("level_low")}</option>
|
||||
</Select>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="h-8 w-8 shrink-0">
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
<IconButton label={t("remove_entry")} onClick={() => setPersonalInfoEntries(personalInfoEntries.filter((_, j) => j !== i))} className="shrink-0">
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
|
||||
@@ -1198,6 +1213,7 @@ function CategoryComboBox({
|
||||
hint: string;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const t = useTranslations("contacts.form");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -1260,6 +1276,8 @@ function CategoryComboBox({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(kw)}
|
||||
aria-label={t("remove_category", { name: kw })}
|
||||
title={t("remove_category", { name: kw })}
|
||||
className="hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail } from "@/components/icons";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail, MoreVertical } from "@/components/icons";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
@@ -34,6 +37,8 @@ export function ContactGroupDetail({
|
||||
}: ContactGroupDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const groupName = getContactDisplayName(group);
|
||||
// Delete sits in a ⋮, as on a contact's page (R6).
|
||||
const menu = useContextMenu<null>();
|
||||
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||
|
||||
return (
|
||||
@@ -54,16 +59,24 @@ export function ContactGroupDetail({
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
{t("groups.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"
|
||||
label={t("detail.more_actions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.contextMenu.isOpen}
|
||||
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, null, "end")}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
|
||||
<ContextMenuItem icon={Pencil} label={t("groups.edit")} onClick={() => { menu.closeContextMenu(); onEdit(); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("groups.delete")} destructive onClick={() => { menu.closeContextMenu(); onDelete(); }} />
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
{onComposeGroup && hasEmailMembers && (
|
||||
@@ -117,17 +130,16 @@ export function ContactGroupDetail({
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<IconButton
|
||||
label={t("groups.remove_member", { name: mName })}
|
||||
className={cn(
|
||||
"h-8 w-8 transition-opacity",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
"transition-opacity",
|
||||
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
|
||||
)}
|
||||
onClick={() => onRemoveMember(member.id)}
|
||||
>
|
||||
<UserMinus className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
<UserMinus className="text-muted-foreground" />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Search, Check, X } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -16,6 +16,8 @@ interface ContactGroupFormProps {
|
||||
currentMemberIds?: string[];
|
||||
onSave: (name: string, memberIds: string[]) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
/** Reports whether the form holds changes not yet saved. */
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export function ContactGroupForm({
|
||||
@@ -24,6 +26,7 @@ export function ContactGroupForm({
|
||||
currentMemberIds = [],
|
||||
onSave,
|
||||
onCancel,
|
||||
onDirtyChange,
|
||||
}: ContactGroupFormProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const isEditing = !!group;
|
||||
@@ -38,6 +41,10 @@ export function ContactGroupForm({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [initial] = useState(() => ({ name, members: [...selectedIds].sort().join(",") }));
|
||||
const dirty = name !== initial.name || [...selectedIds].sort().join(",") !== initial.members;
|
||||
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
|
||||
|
||||
const filteredIndividuals = useMemo(() => {
|
||||
if (!memberSearch) return individuals;
|
||||
const lower = memberSearch.toLowerCase();
|
||||
@@ -87,7 +94,7 @@ export function ContactGroupForm({
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded">
|
||||
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -109,7 +116,7 @@ export function ContactGroupForm({
|
||||
{t("groups.members_label")} ({selectedIds.size})
|
||||
</label>
|
||||
<div className="relative mb-2">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("groups.search_members")}
|
||||
value={memberSearch}
|
||||
@@ -175,7 +182,9 @@ export function ContactGroupForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMember(id)}
|
||||
className="hover:text-red-600 dark:hover:text-red-400"
|
||||
aria-label={t("groups.remove_member", { name: getContactDisplayName(contact) })}
|
||||
title={t("groups.remove_member", { name: getContactDisplayName(contact) })}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "@/components/icons";
|
||||
import { Upload, FileText, AlertTriangle, Check } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
@@ -99,16 +100,36 @@ export function ContactImportDialog({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{t("import.title")}</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
const errorBanner = error && (
|
||||
<div role="alert" className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t("import.title")}
|
||||
size="lg"
|
||||
// Picked cards are not thrown away by a stray click outside.
|
||||
dirty={parsed.length > 0 && result === null}
|
||||
footer={parsed.length > 0 && result === null ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground me-auto self-center">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="space-y-4 pt-4">
|
||||
{result !== null ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mb-4">
|
||||
@@ -144,21 +165,11 @@ export function ContactImportDialog({
|
||||
<p className="text-xs">{t("import.file_types")}</p>
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{errorBanner}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950 px-3 py-2 rounded flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{errorBanner}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -216,22 +227,6 @@ export function ContactImportDialog({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parsed.length > 0 && result === null && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("import.selected", { count: selected.size })}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isImporting}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
|
||||
{isImporting ? t("import.importing") : t("import.import_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, type DragEvent } from "react";
|
||||
import { useCallback, useRef, type DragEvent } from "react";
|
||||
import { useLongPress } from "@/hooks/use-long-press";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
@@ -18,9 +20,19 @@ interface ContactListItemProps {
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onCheckboxClick: (e: React.MouseEvent) => void;
|
||||
onContextMenu?: (e: React.MouseEvent, contact: ContactCard) => void;
|
||||
/** The keyboard cursor: the row that takes Tab and shows a focus ring. */
|
||||
isFocused?: boolean;
|
||||
onFocus?: () => void;
|
||||
/** Long-press on touch: the row's menu, as right-click does (R7). */
|
||||
onLongPress?: (row: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu }: ContactListItemProps) {
|
||||
export function ContactListItem({ contact, isSelected, isChecked, hasSelection, density, selectedContactIds, onClick, onCheckboxClick, onContextMenu, isFocused = false, onFocus, onLongPress }: ContactListItemProps) {
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const longPress = useLongPress(() => {
|
||||
if (rowRef.current) onLongPress?.(rowRef.current);
|
||||
}, !!onLongPress);
|
||||
const t = useTranslations("contacts");
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const org = contact.organizations
|
||||
@@ -46,20 +58,27 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500;
|
||||
z-index: 9999; white-space: nowrap; pointer-events: none;
|
||||
`;
|
||||
preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`;
|
||||
preview.textContent = ids.length === 1 && name ? name : t("drag_count", { count: ids.length });
|
||||
document.body.appendChild(preview);
|
||||
e.dataTransfer.setDragImage(preview, 0, 0);
|
||||
requestAnimationFrame(() => preview.remove());
|
||||
}, [contact.id, name, email, selectedContactIds]);
|
||||
}, [contact.id, name, email, selectedContactIds, t]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
{...(onLongPress ? longPress : {})}
|
||||
role="option"
|
||||
aria-selected={isSelected || isChecked}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
data-contact-id={contact.id}
|
||||
onFocus={onFocus}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ? (e) => onContextMenu(e, contact) : undefined}
|
||||
className={cn(
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border",
|
||||
"w-full flex items-center cursor-pointer select-none transition-all duration-200 border-b border-border outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
isSelected
|
||||
? "bg-selection shadow-sm"
|
||||
: "bg-background hover:bg-muted hover:shadow-sm",
|
||||
@@ -69,6 +88,10 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
|
||||
>
|
||||
{hasSelection && (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={isChecked}
|
||||
aria-label={t("select_contact", { name: name || email || "-" })}
|
||||
onClick={onCheckboxClick}
|
||||
className={cn(
|
||||
"p-1 rounded flex-shrink-0 transition-all duration-200",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, 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, Upload } from "@/components/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContactListItem } from "./contact-list-item";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContactExportBusy } from "./contact-export";
|
||||
import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { isContextMenuKey, useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
|
||||
@@ -115,8 +117,18 @@ interface ContactListProps {
|
||||
onEditContact: (id: string) => void;
|
||||
onDeleteContact: (contact: ContactCard) => void;
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Opens the app's composer to the contact. */
|
||||
onComposeContact?: (contact: ContactCard) => void;
|
||||
onDuplicateContact?: (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 list shows one address book, category or group, not the whole account. */
|
||||
emptyView?: boolean;
|
||||
onImport?: () => void;
|
||||
/** Contacts' own entries in the phone header's ⋮. */
|
||||
phoneMenuItems?: (close: () => void) => React.ReactNode;
|
||||
/** The first fetch is still running: show placeholders, not "No contacts yet". */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
@@ -140,7 +152,13 @@ export function ContactList({
|
||||
onEditContact,
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onComposeContact,
|
||||
onDuplicateContact,
|
||||
onMoveContacts,
|
||||
onMenuClick,
|
||||
phoneMenuItems,
|
||||
emptyView = false,
|
||||
onImport,
|
||||
isLoading = false,
|
||||
}: ContactListProps) {
|
||||
const t = useTranslations("contacts");
|
||||
@@ -149,7 +167,9 @@ export function ContactList({
|
||||
const groupByLetter = useSettingsStore((state) => state.groupContactsByLetter);
|
||||
const sortByLastName = useSettingsStore((state) => state.sortContactsByLastName);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<ContactCard>();
|
||||
// Opened by a long-press: the menu also offers Select, as touch has no Ctrl+click.
|
||||
const [menuByTouch, setMenuByTouch] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<ListFilters>(EMPTY_FILTERS);
|
||||
const activeFilters = countActiveFilters(filters);
|
||||
@@ -275,6 +295,87 @@ export function ContactList({
|
||||
const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
|
||||
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
|
||||
|
||||
// ---- Keyboard (R8): the list keys Mail and Files share --------------------
|
||||
// One row takes Tab (roving focus): the open contact, else the first.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const cursorId = focusedId && sortedIds.includes(focusedId)
|
||||
? focusedId
|
||||
: selectedContactId && sortedIds.includes(selectedContactId) ? selectedContactId : sortedIds[0] ?? null;
|
||||
const focusRow = useCallback((id: string) => {
|
||||
setFocusedId(id);
|
||||
const row = listRef.current?.querySelector<HTMLElement>(`[data-contact-id="${CSS.escape(id)}"]`);
|
||||
row?.focus();
|
||||
row?.scrollIntoView?.({ block: "nearest" });
|
||||
}, []);
|
||||
|
||||
const handleListKeyDown = (e: React.KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("input, textarea, select, [contenteditable=true]")) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const index = cursorId ? sortedIds.indexOf(cursorId) : -1;
|
||||
const contact = cursorId ? sorted.find((c) => c.id === cursorId) : undefined;
|
||||
const moveTo = (next: number, extend: boolean) => {
|
||||
if (sortedIds.length === 0) return;
|
||||
const id = sortedIds[Math.max(0, Math.min(sortedIds.length - 1, next))];
|
||||
focusRow(id);
|
||||
if (extend) onSelectRangeContacts(id, sortedIds);
|
||||
else if (!hasSelection) onSelectContact(id);
|
||||
};
|
||||
if (isContextMenuKey(e) && contact) {
|
||||
e.preventDefault();
|
||||
const row = listRef.current?.querySelector<HTMLElement>(`[data-contact-id="${CSS.escape(contact.id)}"]`);
|
||||
if (row) openContextMenuFrom(row, contact);
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
moveTo(index + (e.key === "ArrowDown" ? 1 : -1), e.shiftKey);
|
||||
} else if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
moveTo(e.key === "Home" ? 0 : sortedIds.length - 1, e.shiftKey);
|
||||
} else if (e.key === "Enter" && contact) {
|
||||
e.preventDefault();
|
||||
onSelectContact(contact.id);
|
||||
} else if (e.key === " " && contact) {
|
||||
e.preventDefault();
|
||||
onToggleSelection(contact.id);
|
||||
} else if (mod && e.code === "KeyA") {
|
||||
e.preventDefault();
|
||||
onSelectAll(sortedIds);
|
||||
} else if ((e.key === "Delete" || e.key === "Backspace") && !e.altKey) {
|
||||
// Backspace deletes too (⌘⌫ on a Mac), as in Mail and Files.
|
||||
if (hasSelection) {
|
||||
e.preventDefault();
|
||||
onBulkDelete();
|
||||
} else if (contact) {
|
||||
e.preventDefault();
|
||||
onDeleteContact(contact);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
if (hasSelection) {
|
||||
e.preventDefault();
|
||||
onClearSelection();
|
||||
} else if (searchQuery) {
|
||||
e.preventDefault();
|
||||
onSearchChange("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// "/" goes to the search from anywhere in Contacts, as in Mail.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest("input, textarea, select, [contenteditable=true]")) return;
|
||||
if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
{/* Toolbar: select / search / filter */}
|
||||
@@ -282,17 +383,12 @@ export function ContactList({
|
||||
<div className="px-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-1.5 w-full">
|
||||
{onMenuClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMenuClick}
|
||||
className="flex-shrink-0 p-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
aria-label={t("open_categories")}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton label={t("open_categories")} onClick={onMenuClick} className="flex-shrink-0 text-muted-foreground hover:text-foreground">
|
||||
<Menu />
|
||||
</IconButton>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
label={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||
onClick={() => {
|
||||
if (hasSelection) {
|
||||
if (allSelected) onClearSelection();
|
||||
@@ -305,18 +401,18 @@ export function ContactList({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
"flex-shrink-0",
|
||||
hasSelection
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
? "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={hasSelection ? (allSelected ? t("bulk.clear") : t("bulk.select_all")) : t("filters.select")}
|
||||
>
|
||||
{hasSelection ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
{hasSelection ? <CheckSquare /> : <Square />}
|
||||
</IconButton>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
placeholder={t("search_placeholder")}
|
||||
value={searchQuery}
|
||||
@@ -327,33 +423,32 @@ export function ContactList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
label={t("filters.toggle")}
|
||||
onClick={() => setFiltersOpen((v) => !v)}
|
||||
aria-expanded={filtersOpen}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
"flex-shrink-0",
|
||||
filtersOpen || activeFilters > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
? "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
title={t("filters.toggle")}
|
||||
aria-label={t("filters.toggle")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<Filter />
|
||||
{!filtersOpen && activeFilters > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
<span className="absolute -top-1 -end-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
|
||||
{activeFilters}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<PhoneHeaderActions below="md" />
|
||||
</IconButton>
|
||||
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -367,22 +462,15 @@ export function ContactList({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={() => setFilters(EMPTY_FILTERS)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 me-1" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFiltersOpen(false)}
|
||||
className="h-7 w-7"
|
||||
aria-label={t("filters.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<IconButton label={t("filters.close")} onClick={() => setFiltersOpen(false)}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -489,8 +577,14 @@ export function ContactList({
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{hasSelection && (
|
||||
<div className="px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-2 flex-wrap">
|
||||
// One line at any width: below 28rem the buttons show only their icons.
|
||||
<div className="@container px-3 py-1.5 border-b border-border bg-accent/30 flex items-center gap-1 flex-nowrap min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={allSelected ? true : "mixed"}
|
||||
aria-label={allSelected ? t("bulk.clear") : t("bulk.select_all")}
|
||||
title={allSelected ? t("bulk.clear") : t("bulk.select_all")}
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
onClearSelection();
|
||||
@@ -506,38 +600,53 @@ export function ContactList({
|
||||
<Square className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
<span className="text-xs font-medium text-foreground whitespace-nowrap">
|
||||
{t("bulk.selected", { count: selectedContactIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
|
||||
<Users className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.add_to_group")}
|
||||
<Button variant="ghost" size="xs" onClick={onBulkAddToGroup} aria-label={t("bulk.add_to_group")} title={t("bulk.add_to_group")} className="shrink-0">
|
||||
<Users className="w-3.5 h-3.5 @md:me-1" />
|
||||
<span className="hidden @md:inline">{t("bulk.add_to_group")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} className="h-7 text-xs">
|
||||
{onMoveContacts && (
|
||||
<Button variant="ghost" size="xs" onClick={() => onMoveContacts([...selectedContactIds])} aria-label={t("address_books.move_to")} title={t("address_books.move_to")} className="shrink-0">
|
||||
<FolderInput className="w-3.5 h-3.5 @md:me-1" />
|
||||
<span className="hidden @md:inline">{t("address_books.move_to")}</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} aria-label={t("bulk.export")} title={t("bulk.export")} className="shrink-0">
|
||||
{exporting
|
||||
? <Loader2 className="w-3.5 h-3.5 me-1 animate-spin" />
|
||||
: <Download className="w-3.5 h-3.5 me-1" />}
|
||||
{exporting ? t("export.exporting") : t("bulk.export")}
|
||||
? <Loader2 className="w-3.5 h-3.5 @md:me-1 animate-spin" />
|
||||
: <Download className="w-3.5 h-3.5 @md:me-1" />}
|
||||
<span className="hidden @md:inline">{exporting ? t("export.exporting") : t("bulk.export")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
size="xs"
|
||||
onClick={onBulkDelete}
|
||||
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
aria-label={t("bulk.delete")}
|
||||
title={t("bulk.delete")}
|
||||
className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 me-1" />
|
||||
{t("bulk.delete")}
|
||||
<Trash2 className="w-3.5 h-3.5 @md:me-1" />
|
||||
<span className="hidden @md:inline">{t("bulk.delete")}</span>
|
||||
</Button>
|
||||
<PluginActions surface="contact-list-actions" context={pluginContext} />
|
||||
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<IconButton label={t("bulk.clear")} onClick={onClearSelection}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
ref={listRef}
|
||||
role="listbox"
|
||||
aria-label={t("title")}
|
||||
aria-multiselectable
|
||||
onKeyDown={handleListKeyDown}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
{sorted.length === 0 && isLoading && !searchQuery && activeFilters === 0 ? (
|
||||
<div aria-hidden className="py-1" data-testid="contact-list-skeleton">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
@@ -551,21 +660,14 @@ export function ContactList({
|
||||
))}
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 text-center">
|
||||
{searchQuery || activeFilters > 0 ? (
|
||||
<>
|
||||
{activeFilters > 0 && !searchQuery ? (
|
||||
<Filter className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
) : (
|
||||
<Search className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
)}
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
searchQuery || activeFilters > 0 ? (
|
||||
<EmptyState
|
||||
compact
|
||||
icon={activeFilters > 0 && !searchQuery ? Filter : Search}
|
||||
title={searchQuery ? t("empty_search") : t("empty_filtered")}
|
||||
description={searchQuery ? t("empty_search_hint") : t("empty_filtered_hint")}
|
||||
actions={
|
||||
<>
|
||||
{searchQuery && (
|
||||
<Button variant="outline" size="sm" onClick={() => onSearchChange("")}>
|
||||
{t("clear_search")}
|
||||
@@ -577,20 +679,33 @@ export function ContactList({
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BookUser className="w-10 h-10 mb-3 text-muted-foreground/30" />
|
||||
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
|
||||
<Button size="sm" className="mt-3" onClick={onCreateNew}>
|
||||
<UserPlus className="w-4 h-4 me-1.5" />
|
||||
{t("create_new")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
// The whole account is empty, or only the address book, category
|
||||
// or group on screen: each says so, and both offer New and Import.
|
||||
<EmptyState
|
||||
compact
|
||||
icon={BookUser}
|
||||
title={emptyView ? t("empty_view_title") : t("empty_state_title")}
|
||||
description={emptyView ? t("empty_view_hint") : t("empty_state_subtitle")}
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" onClick={onCreateNew}>
|
||||
<UserPlus className="w-4 h-4 me-1.5" />
|
||||
{t("create_new")}
|
||||
</Button>
|
||||
{onImport && (
|
||||
<Button variant="outline" size="sm" onClick={onImport}>
|
||||
<Upload className="w-4 h-4 me-1.5" />
|
||||
{t("import.title")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
(() => {
|
||||
const renderItem = (contact: ContactCard) => (
|
||||
@@ -600,6 +715,8 @@ export function ContactList({
|
||||
isSelected={contact.id === selectedContactId}
|
||||
isChecked={selectedContactIds.has(contact.id)}
|
||||
hasSelection={hasSelection}
|
||||
isFocused={contact.id === cursorId}
|
||||
onFocus={() => setFocusedId(contact.id)}
|
||||
density={density}
|
||||
selectedContactIds={selectedContactIds}
|
||||
onClick={(e) => {
|
||||
@@ -618,14 +735,15 @@ export function ContactList({
|
||||
e.stopPropagation();
|
||||
onToggleSelection(contact.id);
|
||||
}}
|
||||
onContextMenu={(e, c) => openContextMenu(e, c)}
|
||||
onContextMenu={(e, c) => { setMenuByTouch(false); openContextMenu(e, c); }}
|
||||
onLongPress={(row) => { setMenuByTouch(true); openContextMenuFrom(row, contact); }}
|
||||
/>
|
||||
);
|
||||
return groupByLetter ? (
|
||||
<div>
|
||||
{groupedSections.map(({ letter, items }) => (
|
||||
<section key={letter}>
|
||||
<div className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
<section key={letter} role="group" aria-label={letter}>
|
||||
<div aria-hidden className="sticky top-0 z-10 px-4 py-0.5 bg-background/90 backdrop-blur-sm text-[11px] font-medium text-muted-foreground/70 uppercase tracking-wide">
|
||||
{letter}
|
||||
</div>
|
||||
{items.map(renderItem)}
|
||||
@@ -649,9 +767,14 @@ export function ContactList({
|
||||
isMultiSelect={selectedContactIds.has(contextMenu.data.id)}
|
||||
selectedCount={selectedContactIds.size}
|
||||
onOpen={() => onSelectContact(contextMenu.data!.id)}
|
||||
onSelect={menuByTouch ? () => onToggleSelection(contextMenu.data!.id) : undefined}
|
||||
onEdit={() => onEditContact(contextMenu.data!.id)}
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
|
||||
onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined}
|
||||
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
|
||||
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
|
||||
onBatchExport={onBulkExport}
|
||||
onBatchAddToGroup={onBulkAddToGroup}
|
||||
onBatchDelete={onBulkDelete}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { formatContactDate } from "@/lib/contact-dates";
|
||||
import { getContactDisplayName, getContactPhotoRef } from "@/stores/contact-store";
|
||||
import { loadContactMediaBlob } from "@/lib/contact-media";
|
||||
import { blobToDataUri } from "@/lib/data-uri";
|
||||
@@ -37,7 +38,53 @@ async function printablePhotoUri(contact: ContactCard): Promise<string | undefin
|
||||
return blob ? blobToDataUri(blob, photo.mediaType).catch(() => undefined) : undefined;
|
||||
}
|
||||
|
||||
export async function printContact(contact: ContactCard, displayName?: string): Promise<void> {
|
||||
/** The print sheet's words, in the UI language. */
|
||||
export interface PrintLabels {
|
||||
locale: string;
|
||||
contact: string;
|
||||
emails: string;
|
||||
email: string;
|
||||
phones: string;
|
||||
phone: string;
|
||||
organizations: string;
|
||||
organization: string;
|
||||
titles: string;
|
||||
title: string;
|
||||
role: string;
|
||||
addresses: string;
|
||||
address: string;
|
||||
online: string;
|
||||
onlineService: string;
|
||||
anniversaries: string;
|
||||
anniversary: (kind: string) => string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
/** The print labels from the contacts translations (`t` of the "contacts" namespace). */
|
||||
export function printLabels(t: (key: string) => string, locale: string): PrintLabels {
|
||||
return {
|
||||
locale,
|
||||
contact: t("detail.section_contact"),
|
||||
emails: t("detail.emails"),
|
||||
email: t("detail.email_default_label"),
|
||||
phones: t("detail.phones"),
|
||||
phone: t("detail.phone_default_label"),
|
||||
organizations: t("detail.organizations"),
|
||||
organization: t("detail.organization_label"),
|
||||
titles: t("detail.titles"),
|
||||
title: t("detail.title_label"),
|
||||
role: t("detail.role_label"),
|
||||
addresses: t("detail.addresses"),
|
||||
address: t("detail.address_default_label"),
|
||||
online: t("detail.online_services"),
|
||||
onlineService: t("detail.online_service_default_label"),
|
||||
anniversaries: t("detail.anniversaries"),
|
||||
anniversary: (kind) => t(`detail.anniversary_${["birth", "death", "wedding"].includes(kind) ? kind : "other"}`),
|
||||
notes: t("detail.notes"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise<void> {
|
||||
// Open the window before anything is awaited: browsers allow the popup only
|
||||
// while the click that asked for it is being handled.
|
||||
const printWindow = window.open("", "_blank");
|
||||
@@ -63,62 +110,45 @@ export async function printContact(contact: ContactCard, displayName?: string):
|
||||
const row = (label: string, value: string) =>
|
||||
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
|
||||
|
||||
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
|
||||
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
|
||||
section(labels.emails, emails.map(e => row(e.label || labels.email, e.address || "")));
|
||||
section(labels.phones, phones.map(p => row(p.label || labels.phone, p.number || "")));
|
||||
section(
|
||||
"Organization",
|
||||
labels.organizations,
|
||||
orgs.map(o => {
|
||||
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
|
||||
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
|
||||
return row("Organization", value);
|
||||
return row(labels.organization, value);
|
||||
}),
|
||||
);
|
||||
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
|
||||
section(labels.titles, titles.map(t => row(t.kind === "role" ? labels.role : labels.title, t.name || "")));
|
||||
section(
|
||||
"Address",
|
||||
labels.addresses,
|
||||
addresses.map(a => {
|
||||
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
|
||||
.map(p => (typeof p === "string" ? p : ""))
|
||||
.filter(Boolean);
|
||||
return row(a.label || "Address", parts.join(", "));
|
||||
return row(a.label || labels.address, parts.join(", "));
|
||||
}),
|
||||
);
|
||||
section(
|
||||
"Online",
|
||||
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
|
||||
labels.online,
|
||||
onlineServices.map(s => row(s.label || s.service || labels.onlineService, s.uri || s.user || "")),
|
||||
);
|
||||
section(
|
||||
"Anniversary",
|
||||
anniversaries.map(a => {
|
||||
const date = a.date;
|
||||
let formatted = "";
|
||||
if (typeof date === "object" && date !== null) {
|
||||
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
|
||||
else {
|
||||
const parts: string[] = [];
|
||||
const pd = date as PartialDate;
|
||||
if (pd.year) parts.push(String(pd.year));
|
||||
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
|
||||
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
|
||||
formatted = parts.join("-");
|
||||
}
|
||||
} else if (date) {
|
||||
formatted = String(date);
|
||||
}
|
||||
return row(a.kind || "Date", formatted);
|
||||
}),
|
||||
labels.anniversaries,
|
||||
anniversaries.map(a => row(labels.anniversary(a.kind || "other"), a.date ? formatContactDate(a.date, labels.locale) : "")),
|
||||
);
|
||||
section("Notes", notes.map(n => row("", n.note || "")));
|
||||
section(labels.notes, notes.map(n => row("", n.note || "")));
|
||||
|
||||
const photoTag = photoUri
|
||||
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
|
||||
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<html lang="${escapeHtml(labels.locale)}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(name || "Contact")}</title>
|
||||
<title>${escapeHtml(name || labels.contact)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
|
||||
@@ -137,7 +167,7 @@ export async function printContact(contact: ContactCard, displayName?: string):
|
||||
<body>
|
||||
<header>
|
||||
${photoTag}
|
||||
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
|
||||
<div><h1>${escapeHtml(name || labels.contact)}</h1></div>
|
||||
</header>
|
||||
${rows.join("")}
|
||||
</body>
|
||||
|
||||
@@ -4,8 +4,9 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "rea
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { ArrowLeft, Users, AlertTriangle } from "@/components/icons";
|
||||
import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { ContactList } from "@/components/contacts/contact-list";
|
||||
@@ -15,9 +16,10 @@ 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 { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
|
||||
@@ -48,6 +50,8 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
|
||||
|
||||
@@ -115,6 +119,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
shareAddressBook,
|
||||
setDefaultAddressBook,
|
||||
renameKeyword,
|
||||
removeKeyword,
|
||||
addKeyword,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
@@ -128,7 +134,11 @@ 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[]>([]);
|
||||
const [directorySelection, setDirectorySelection] = useState<Set<string>>(() => new Set());
|
||||
const [openDirectoryKey, setOpenDirectoryKey] = useState<string | null>(null);
|
||||
const directoryPrincipals = useContactStore((s) => s.directoryPrincipals);
|
||||
@@ -151,6 +161,68 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const isNarrow = !isDesktop;
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
// A new view (a form, a group, a contact) replaces whatever the drawer was for.
|
||||
useEffect(() => { setNarrowSidebarOpen(false); }, [view]);
|
||||
// R5: an editor with unsaved changes asks before anything replaces it.
|
||||
const [editorDirty, setEditorDirtyState] = useState(false);
|
||||
const editorDirtyRef = useRef(false);
|
||||
const setEditorDirty = useCallback((dirty: boolean) => {
|
||||
editorDirtyRef.current = dirty;
|
||||
setEditorDirtyState(dirty);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (view !== "create" && view !== "edit" && view !== "group-create" && view !== "group-edit") setEditorDirty(false);
|
||||
}, [view, setEditorDirty]);
|
||||
const confirmDiscard = useCallback(async () => {
|
||||
if (!editorDirtyRef.current) return true;
|
||||
const discard = await confirmDialog({
|
||||
title: t("form.discard_title"),
|
||||
message: t("form.discard_message"),
|
||||
confirmText: t("form.discard"),
|
||||
cancelText: t("form.keep_editing"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (discard) setEditorDirty(false);
|
||||
return discard;
|
||||
}, [confirmDialog, t, setEditorDirty]);
|
||||
const guard = <A extends unknown[]>(action: (...args: A) => unknown) => (...args: A): void => {
|
||||
if (!editorDirtyRef.current) {
|
||||
action(...args);
|
||||
return;
|
||||
}
|
||||
void confirmDiscard().then((discard) => { if (discard) action(...args); });
|
||||
};
|
||||
// Leaving the page or going Back asks too.
|
||||
useEffect(() => {
|
||||
if (!editorDirty) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}, [editorDirty]);
|
||||
const handleCancelRef = useRef<() => void>(() => {});
|
||||
// Set when the app itself goes Back (to the email it came from): the
|
||||
// guard's history entry is in the way then, and is stepped over.
|
||||
const leavingRef = useRef(false);
|
||||
const backGuard = useCloseOnBack(() => {
|
||||
if (leavingRef.current) {
|
||||
leavingRef.current = false;
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
void confirmDiscard().then((discard) => {
|
||||
if (discard) handleCancelRef.current();
|
||||
else backGuard.rearm();
|
||||
});
|
||||
}, editorDirty);
|
||||
const routerBack = useCallback(() => {
|
||||
if (backGuard.armed()) leavingRef.current = true;
|
||||
router.back();
|
||||
}, [backGuard, router]);
|
||||
// Anything picked in the drawer closes it (R7).
|
||||
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
|
||||
setNarrowSidebarOpen(false);
|
||||
return action(...args);
|
||||
};
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
@@ -350,6 +422,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 {
|
||||
@@ -398,7 +508,6 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
await createAddressBook(client, name);
|
||||
await refreshAddressBooks();
|
||||
toast.success(t("address_books.created"));
|
||||
setCreatingAddressBook(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create address book:', error);
|
||||
toast.error(t("address_books.create_failed"));
|
||||
@@ -475,12 +584,25 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
clearSelection();
|
||||
toggleContactSelection(id);
|
||||
if (groups.length === 0) {
|
||||
// No group to add to yet: a new one, with the contact already in it.
|
||||
setNewGroupMemberIds([id]);
|
||||
setView("group-create");
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
}, [clearSelection, toggleContactSelection, groups.length]);
|
||||
|
||||
// After a save or a duplicate the new card is on screen, not "Select a contact".
|
||||
const showNewestContact = useCallback(() => {
|
||||
const newest = useContactStore.getState().contacts.at(-1);
|
||||
if (!newest) {
|
||||
setView("list");
|
||||
return;
|
||||
}
|
||||
setSelectedContact(newest.id);
|
||||
setView("detail");
|
||||
}, [setSelectedContact]);
|
||||
|
||||
const handleDuplicateContact = useCallback(async (source: ContactCard) => {
|
||||
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
|
||||
void _id; void _uid; void _created; void _updated;
|
||||
@@ -494,15 +616,24 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
localAccountId: source.localAccountId,
|
||||
}, "keep");
|
||||
}
|
||||
const created = supportsSync && client
|
||||
? await createContact(client, data)
|
||||
: await addLocalContact({
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
});
|
||||
if (created) toast.success(t("toast.created"));
|
||||
}, [supportsSync, client, createContact, addLocalContact, t]);
|
||||
let created: boolean;
|
||||
try {
|
||||
created = supportsSync && client
|
||||
? await createContact(client, data)
|
||||
: await addLocalContact({
|
||||
id: `local-${generateUUID()}`,
|
||||
addressBookIds: data.addressBookIds || {},
|
||||
...data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate contact:", error);
|
||||
toast.error(t("toast.error_create"));
|
||||
return;
|
||||
}
|
||||
if (!created) return;
|
||||
toast.success(t("toast.created"));
|
||||
showNewestContact();
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, showNewestContact]);
|
||||
|
||||
const handleSaveNew = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
|
||||
const created = supportsSync && client
|
||||
@@ -515,15 +646,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// A plugin stopped it: keep the form open with what was typed.
|
||||
if (!created) return;
|
||||
toast.success(t("toast.created"));
|
||||
setEditorDirty(false);
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
setCreatePrefill(undefined);
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
routerBack();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, router]);
|
||||
showNewestContact();
|
||||
}, [supportsSync, client, createContact, addLocalContact, t, returnToEmail, routerBack, setEditorDirty, showNewestContact]);
|
||||
|
||||
const handleSaveEdit = useCallback(async (data: Partial<ContactCard>, options?: ContactSaveOptions) => {
|
||||
if (!selectedContact) return;
|
||||
@@ -537,12 +669,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditorDirty(false);
|
||||
setDefaultBookIdForCreate(undefined);
|
||||
// Came from email → cancel returns to the email instead of the contact list.
|
||||
if (returnToEmail && view === "create") {
|
||||
setCreatePrefill(undefined);
|
||||
setReturnToEmail(false);
|
||||
router.back();
|
||||
routerBack();
|
||||
return;
|
||||
}
|
||||
if (view === "create") setCreatePrefill(undefined);
|
||||
@@ -555,6 +688,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
};
|
||||
|
||||
handleCancelRef.current = handleCancel;
|
||||
|
||||
const _handleSelectGroup = (id: string) => {
|
||||
setSelectedGroupId(id);
|
||||
setActiveCategory({ groupId: id });
|
||||
@@ -563,6 +698,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
setSelectedGroupId(null);
|
||||
setNewGroupMemberIds([]);
|
||||
setView("group-create");
|
||||
};
|
||||
|
||||
// "Add to group": a new group starting with the selected contacts.
|
||||
const handleCreateGroupFromSelection = () => {
|
||||
setSelectedGroupId(null);
|
||||
setNewGroupMemberIds(Array.from(selectedContactIds));
|
||||
setView("group-create");
|
||||
};
|
||||
|
||||
@@ -591,6 +734,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
bcc: field === "bcc" ? recipients : [],
|
||||
subject: "",
|
||||
body: "",
|
||||
inApp: true,
|
||||
});
|
||||
router.push("/");
|
||||
}, [router]);
|
||||
@@ -702,9 +846,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
} else {
|
||||
await createGroup(jmapClient, name, memberIds);
|
||||
toast.success(t("toast.created"));
|
||||
clearSelection();
|
||||
setView("list");
|
||||
}
|
||||
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, t]);
|
||||
}, [view, selectedGroup, selectedGroupMembers, supportsSync, client, createGroup, updateGroup, addMembersToGroup, removeMembersFromGroup, clearSelection, t]);
|
||||
|
||||
const handleRemoveGroupMember = async (memberId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
@@ -748,7 +893,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const handleBulkAddToGroup = () => {
|
||||
if (selectedContactIds.size === 0) return;
|
||||
if (groups.length === 0) {
|
||||
setView("group-create");
|
||||
handleCreateGroupFromSelection();
|
||||
return;
|
||||
}
|
||||
setView("bulk-add-to-group");
|
||||
@@ -762,6 +907,14 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
};
|
||||
|
||||
// The command palette's Contacts group (Ctrl/⌘+K).
|
||||
useRegisterCommands(tSidebar("contacts"), [
|
||||
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
|
||||
{ id: "import", label: t("import.title"), icon: Upload, run: () => setShowImportDialog(true) },
|
||||
{ id: "new-group", label: t("groups.create"), icon: UsersRound, run: () => guard(handleCreateGroup)() },
|
||||
{ id: "new-address-book", label: t("address_books.create"), icon: BookPlus, run: () => setCreatingAddressBook(true), when: () => !!client },
|
||||
], contactsEnabled);
|
||||
|
||||
const handleBulkAddToGroupConfirm = async (groupId: string) => {
|
||||
try {
|
||||
await bulkAddToGroup(
|
||||
@@ -786,13 +939,13 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<DirectoryEntryDetail
|
||||
entry={directoryPrincipals.find((e) => directoryKey(e) === openDirectoryKey) ?? null}
|
||||
onCompose={(entry) => handleComposeDirectory([entry])}
|
||||
onAddToContacts={handleAddDirectoryEntryToContacts}
|
||||
onAddToContacts={guard(handleAddDirectoryEntryToContacts)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
switch (view) {
|
||||
case "create":
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={handleCancel} />;
|
||||
return <ContactForm addressBooks={addressBooks} allKeywords={allKeywords} defaultAddressBookId={defaultBookIdForCreate} prefill={createPrefill} onSave={handleSaveNew} onCancel={guard(handleCancel)} onDirtyChange={setEditorDirty} />;
|
||||
|
||||
case "edit":
|
||||
if (!selectedContact) return null;
|
||||
@@ -802,7 +955,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
addressBooks={addressBooks}
|
||||
allKeywords={allKeywords}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancel}
|
||||
onCancel={guard(handleCancel)}
|
||||
onDirtyChange={setEditorDirty}
|
||||
loadCurrentMedia={supportsSync && client ? () => fetchContactFromServer(client, selectedContact.id) : undefined}
|
||||
/>
|
||||
);
|
||||
@@ -818,10 +972,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onRemoveMember={handleRemoveGroupMember}
|
||||
onComposeGroup={(field) => handleComposeGroupFromSidebar(selectedGroup.id, field)}
|
||||
isMobile={isMobile}
|
||||
onSelectMember={(id) => {
|
||||
onSelectMember={guard((id: string) => {
|
||||
setSelectedContact(id);
|
||||
setView("detail");
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -829,8 +983,10 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
return (
|
||||
<ContactGroupForm
|
||||
individuals={individuals}
|
||||
currentMemberIds={newGroupMemberIds}
|
||||
onSave={handleSaveGroup}
|
||||
onCancel={handleCancel}
|
||||
onCancel={guard(handleCancel)}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -842,7 +998,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
individuals={individuals}
|
||||
currentMemberIds={selectedGroupMembers.map(m => m.id)}
|
||||
onSave={handleSaveGroup}
|
||||
onCancel={handleCancel}
|
||||
onCancel={guard(handleCancel)}
|
||||
onDirtyChange={setEditorDirty}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -856,6 +1013,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateGroupFromSelection}
|
||||
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<UsersRound className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-sm font-medium">{t("bulk.new_group")}</div>
|
||||
</button>
|
||||
{groups.map((group) => {
|
||||
const gName = getContactDisplayName(group);
|
||||
const memberCount = group.members
|
||||
@@ -909,6 +1076,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
? () => void handleDuplicateContact(selectedContact)
|
||||
: undefined
|
||||
}
|
||||
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
@@ -919,9 +1087,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center bg-background p-6">
|
||||
<div className="max-w-lg text-center space-y-3">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
|
||||
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
|
||||
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
|
||||
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -934,7 +1102,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
if (returnToEmail) {
|
||||
setReturnToEmail(false);
|
||||
setCreatePrefill(undefined);
|
||||
router.back();
|
||||
routerBack();
|
||||
return;
|
||||
}
|
||||
setView("list");
|
||||
@@ -975,7 +1143,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
{inlineApp && (
|
||||
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} />
|
||||
)}
|
||||
<div className={cn("relative flex flex-1 min-h-0", inlineApp && "hidden")}>
|
||||
{/* Clips the closed drawer, so it never sits over the nav rail. */}
|
||||
<div className={cn("relative flex flex-1 min-h-0 overflow-hidden", inlineApp && "hidden")}>
|
||||
{/* Narrow-pane backdrop for the overlay categories sidebar */}
|
||||
{isNarrow && narrowSidebarOpen && (
|
||||
<div
|
||||
@@ -992,15 +1161,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
{(!isMobile || isNarrow) && (
|
||||
<>
|
||||
<div
|
||||
data-testid={isNarrow ? "contacts-sidebar-drawer" : undefined}
|
||||
className={cn(
|
||||
"border-e border-border flex flex-col flex-shrink-0 bg-background",
|
||||
!isSidebarResizing && "transition-[width] duration-300",
|
||||
isNarrow && cn(
|
||||
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full"
|
||||
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
|
||||
"transform transition-[transform,visibility] duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full"
|
||||
)
|
||||
)}
|
||||
// Closed, the drawer is out of reach: no clicks, no Tab stops.
|
||||
inert={isNarrow && !narrowSidebarOpen}
|
||||
style={isNarrow ? undefined : { width: `${sidebarWidth}px` }}
|
||||
>
|
||||
<ContactsSidebar
|
||||
@@ -1008,32 +1180,32 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
individuals={individuals}
|
||||
addressBooks={addressBooks}
|
||||
activeCategory={activeCategory}
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onCreateAddressBook={client ? () => setCreatingAddressBook(true) : undefined}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onComposeGroup={handleComposeGroupFromSidebar}
|
||||
onSelectCategory={guard(handleSelectCategory)}
|
||||
onCreateGroup={fromDrawer(guard(handleCreateGroup))}
|
||||
onCreateContact={fromDrawer(guard(handleCreateNew))}
|
||||
onCreateAddressBook={client ? fromDrawer(() => setCreatingAddressBook(true)) : undefined}
|
||||
onImport={fromDrawer(() => setShowImportDialog(true))}
|
||||
onEditGroup={fromDrawer(guard(handleEditGroupFromSidebar))}
|
||||
onDeleteGroup={fromDrawer(handleDeleteGroupFromSidebar)}
|
||||
onComposeGroup={fromDrawer(handleComposeGroupFromSidebar)}
|
||||
onDropContacts={handleDropContacts}
|
||||
onDropContactsToCategory={handleDropContactsToCategory}
|
||||
onRenameAddressBook={client ? (book) => setRenamingAddressBook(book) : undefined}
|
||||
onShareAddressBook={client ? (book) => setSharingAddressBookId(book.id) : undefined}
|
||||
onSetDefaultAddressBook={client ? async (book) => {
|
||||
onRenameAddressBook={client ? fromDrawer((book: AddressBook) => setRenamingAddressBook(book)) : undefined}
|
||||
onShareAddressBook={client ? fromDrawer((book: AddressBook) => setSharingAddressBookId(book.id)) : undefined}
|
||||
onSetDefaultAddressBook={client ? fromDrawer(async (book: AddressBook) => {
|
||||
try {
|
||||
await setDefaultAddressBook(client, book);
|
||||
toast.success(t("address_books.default_updated"));
|
||||
} catch {
|
||||
toast.error(t("address_books.set_default_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onCreateContactInBook={(book) => {
|
||||
}) : undefined}
|
||||
onCreateContactInBook={fromDrawer(guard((book: AddressBook) => {
|
||||
setDefaultBookIdForCreate(book.id);
|
||||
setSelectedContact(null);
|
||||
setView("create");
|
||||
}}
|
||||
onDeleteAddressBook={client ? async (book) => {
|
||||
}))}
|
||||
onDeleteAddressBook={client ? fromDrawer(async (book: AddressBook) => {
|
||||
const ok = await confirmDialog({
|
||||
title: t("address_books.delete"),
|
||||
message: t("address_books.confirm_delete", { name: book.name }),
|
||||
@@ -1047,8 +1219,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
} catch {
|
||||
toast.error(t("address_books.delete_failed"));
|
||||
}
|
||||
} : undefined}
|
||||
onRenameKeyword={(kw) => setRenamingKeyword(kw)}
|
||||
}) : undefined}
|
||||
onRenameKeyword={fromDrawer((kw: string) => setRenamingKeyword(kw))}
|
||||
onDeleteKeyword={fromDrawer((kw: string) => void handleDeleteKeyword(kw))}
|
||||
directoryCount={showDirectory ? directoryPrincipals.length : undefined}
|
||||
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
|
||||
/>
|
||||
@@ -1071,7 +1244,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<div
|
||||
data-tour="contacts-list"
|
||||
className={cn(
|
||||
"border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||
"relative border-e border-border bg-background flex flex-col flex-shrink-0",
|
||||
isMobile ? "w-full" : "",
|
||||
!isListResizing && !isMobile && "transition-[width] duration-300"
|
||||
)}
|
||||
@@ -1111,8 +1284,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSelectContact={handleSelectContact}
|
||||
onCreateNew={handleCreateNew}
|
||||
onSelectContact={guard(handleSelectContact)}
|
||||
onCreateNew={guard(handleCreateNew)}
|
||||
className="flex-1"
|
||||
selectedContactIds={selectedContactIds}
|
||||
onToggleSelection={toggleContactSelection}
|
||||
@@ -1120,15 +1293,39 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onSelectAll={selectAllContacts}
|
||||
onClearSelection={clearSelection}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkAddToGroup={handleBulkAddToGroup}
|
||||
onBulkAddToGroup={guard(handleBulkAddToGroup)}
|
||||
onBulkExport={handleBulkExport}
|
||||
onEditContact={handleEditContact}
|
||||
onEditContact={guard(handleEditContact)}
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={handleAddContactToGroup}
|
||||
onAddContactToGroup={guard(handleAddContactToGroup)}
|
||||
onComposeContact={handleComposeContact}
|
||||
onDuplicateContact={(contact) => void handleDuplicateContact(contact)}
|
||||
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
emptyView={activeCategory !== "all"}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
phoneMenuItems={(close) => (
|
||||
// On the phone these are otherwise only in the drawer's "+".
|
||||
<>
|
||||
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
|
||||
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { close(); setShowImportDialog(true); }} />
|
||||
</>
|
||||
)}
|
||||
isLoading={supportsSync && !contactsLoaded}
|
||||
/>
|
||||
)}
|
||||
{/* The phone's create button, as in Mail and Files (R7). */}
|
||||
{isMobile && activeCategory !== "directory" && selectedContactIds.size === 0 && (
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
|
||||
aria-label={t("create_new")}
|
||||
title={t("create_new")}
|
||||
onClick={guard(handleCreateNew)}
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
@@ -1153,7 +1350,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={mobileBackToList}
|
||||
onClick={guard(mobileBackToList)}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 me-2" />
|
||||
@@ -1181,65 +1378,78 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
|
||||
<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) => {
|
||||
<PromptDialog
|
||||
isOpen={renamingKeyword !== null}
|
||||
onClose={() => setRenamingKeyword(null)}
|
||||
title={t("rename_category")}
|
||||
placeholder={t("category_name_label")}
|
||||
defaultValue={renamingKeyword ?? ""}
|
||||
confirmText={t("form.save")}
|
||||
cancelText={t("form.cancel")}
|
||||
onSubmit={(newName) => {
|
||||
const keyword = renamingKeyword;
|
||||
if (keyword === null || newName === keyword) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await renameKeyword(supportsSync && client ? client : null, renamingKeyword, newName);
|
||||
await renameKeyword(supportsSync && client ? client : null, keyword, newName);
|
||||
toast.success(t("category_renamed"));
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === renamingKeyword) {
|
||||
setActiveCategory({ keyword: newName.trim() });
|
||||
if (typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword) {
|
||||
setActiveCategory({ keyword: newName });
|
||||
}
|
||||
setRenamingKeyword(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename category:", err);
|
||||
toast.error(t("category_rename_failed"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{creatingAddressBook && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("address_books.create")}
|
||||
label={t("address_books.name_label")}
|
||||
onCancel={() => setCreatingAddressBook(false)}
|
||||
onConfirm={handleCreateAddressBook}
|
||||
/>
|
||||
)}
|
||||
{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;
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<PromptDialog
|
||||
isOpen={creatingAddressBook}
|
||||
onClose={() => setCreatingAddressBook(false)}
|
||||
title={t("address_books.create")}
|
||||
placeholder={t("address_books.name_label")}
|
||||
confirmText={t("address_books.create_confirm")}
|
||||
cancelText={t("form.cancel")}
|
||||
onSubmit={(name) => void handleCreateAddressBook(name)}
|
||||
/>
|
||||
<PromptDialog
|
||||
isOpen={renamingAddressBook !== null}
|
||||
onClose={() => setRenamingAddressBook(null)}
|
||||
title={t("address_books.rename")}
|
||||
placeholder={t("address_books.name_label")}
|
||||
defaultValue={renamingAddressBook?.name ?? ""}
|
||||
confirmText={t("form.save")}
|
||||
cancelText={t("form.cancel")}
|
||||
onSubmit={(newName) => {
|
||||
const book = renamingAddressBook;
|
||||
if (!client || !book || newName === book.name) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await renameAddressBook(client, renamingAddressBook, newName);
|
||||
await renameAddressBook(client, book, 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"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<AddressBookPickerDialog
|
||||
isOpen={movingContactIds !== null}
|
||||
onClose={() => setMovingContactIds(null)}
|
||||
addressBooks={writableBooks}
|
||||
currentId={movingFromBookId}
|
||||
count={movingContactIds?.length ?? 0}
|
||||
onPick={(book) => {
|
||||
if (movingContactIds) void handleDropContacts(movingContactIds, book);
|
||||
}}
|
||||
/>
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingAddressBookId);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useMemo, useState, useCallback, 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 +36,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,21 +111,27 @@ 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);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const newMenu = useContextMenu<null>();
|
||||
|
||||
const toggleSection = useCallback((key: string) => {
|
||||
setCollapsed(prev => {
|
||||
@@ -132,21 +141,6 @@ export function ContactsSidebar({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showMenu]);
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return [...groups].sort((a, b) =>
|
||||
getContactDisplayName(a).localeCompare(getContactDisplayName(b))
|
||||
@@ -279,54 +273,31 @@ export function ContactsSidebar({
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Button
|
||||
ref={menuBtnRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setShowMenu(v => !v)}
|
||||
className="h-7 w-7"
|
||||
<IconButton
|
||||
label={t("new")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={newMenu.contextMenu.isOpen}
|
||||
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (newMenu.contextMenu.isOpen) newMenu.closeContextMenu();
|
||||
else newMenu.openContextMenuFrom(e.currentTarget, null, "end");
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute end-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("create_new")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||
>
|
||||
<UsersRound className="w-4 h-4" />
|
||||
{t("groups.create")}
|
||||
</button>
|
||||
{onCreateAddressBook && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
|
||||
>
|
||||
<BookPlus className="w-4 h-4" />
|
||||
{t("address_books.create")}
|
||||
</button>
|
||||
)}
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
|
||||
onClick={() => { setShowMenu(false); onImport(); }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("import.title")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Plus />
|
||||
</IconButton>
|
||||
<ContextMenu ref={newMenu.menuRef} isOpen={newMenu.contextMenu.isOpen} position={newMenu.contextMenu.position} onClose={newMenu.closeContextMenu}>
|
||||
<ContextMenuItem icon={UserPlus} label={t("create_new")} onClick={() => { newMenu.closeContextMenu(); onCreateContact(); }} />
|
||||
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { newMenu.closeContextMenu(); onCreateGroup(); }} />
|
||||
{onCreateAddressBook && (
|
||||
<ContextMenuItem icon={BookPlus} label={t("address_books.create")} onClick={() => { newMenu.closeContextMenu(); onCreateAddressBook(); }} />
|
||||
)}
|
||||
{onImport && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Upload} label={t("import.title")} onClick={() => { newMenu.closeContextMenu(); onImport(); }} />
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -336,12 +307,12 @@ export function ContactsSidebar({
|
||||
<button
|
||||
onClick={() => onSelectCategory("all")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
|
||||
isAllActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
|
||||
>
|
||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{t("tabs.all")}</span>
|
||||
@@ -355,12 +326,12 @@ export function ContactsSidebar({
|
||||
<button
|
||||
onClick={() => onSelectCategory("directory")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
|
||||
activeCategory === "directory"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
|
||||
data-testid="contacts-directory-entry"
|
||||
>
|
||||
<Building2 className="w-4 h-4 flex-shrink-0" />
|
||||
@@ -413,7 +384,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 +403,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>
|
||||
@@ -459,17 +430,16 @@ export function ContactsSidebar({
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
<IconButton
|
||||
label={t("address_books.manage")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
router.push('/settings/contacts');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
<Settings className="text-muted-foreground" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||
<AddressBookItem
|
||||
@@ -479,7 +449,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 +477,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>
|
||||
@@ -553,12 +517,12 @@ export function ContactsSidebar({
|
||||
<button
|
||||
onClick={() => onSelectCategory("uncategorized")}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors min-h-8 pointer-coarse:min-h-11",
|
||||
activeCategory === "uncategorized"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="truncate italic">{t("no_category")}</span>
|
||||
@@ -576,7 +540,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 })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -603,17 +569,16 @@ export function ContactsSidebar({
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
<IconButton
|
||||
label={t("address_books.manage")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
try { localStorage.setItem('settings-active-tab', 'contacts'); } catch { /* ignore */ }
|
||||
router.push('/settings');
|
||||
router.push('/settings/contacts');
|
||||
}}
|
||||
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted"
|
||||
title={t("address_books.manage")}
|
||||
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
<Settings className="text-muted-foreground" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||
<AddressBookItem
|
||||
@@ -623,7 +588,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 +670,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 +751,84 @@ export function ContactsSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)" } 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(
|
||||
// 32px rows, 44px on touch (R1).
|
||||
"group w-full flex items-center ps-5 pe-1 text-sm transition-colors select-none min-h-8 pointer-coarse:min-h-11",
|
||||
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 +836,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 +858,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 +874,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 +896,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 +918,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 +933,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 ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function DirectoryBrowser({
|
||||
{pageAllSelected ? <CheckSquare className="w-4 h-4" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute start-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
type="text"
|
||||
@@ -169,7 +169,7 @@ export function DirectoryBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setQuery(""); setPage(0); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute end-2 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={tContacts("clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -258,7 +258,7 @@ export function DirectoryBrowser({
|
||||
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label={t("previous_page")}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="tabular-nums">{t("page", { page: currentPage + 1, count: pageCount })}</span>
|
||||
<button
|
||||
@@ -268,7 +268,7 @@ export function DirectoryBrowser({
|
||||
className="p-1.5 rounded-md hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
|
||||
aria-label={t("next_page")}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { FileBrowser } from '../file-browser';
|
||||
import { useFileStore, type FileResource } from '@/stores/file-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useRegisteredCommandGroups, type CommandGroup } from '@/lib/command-registry';
|
||||
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
|
||||
@@ -132,7 +135,10 @@ function list() {
|
||||
}
|
||||
|
||||
describe('FileBrowser', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
act(() => { useSettingsStore.setState({ filesSettings: DEFAULT_FILES_SETTINGS }); });
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { useFileStore.setState({ client: null, sharedRoots: [], allFolders: [] }); });
|
||||
});
|
||||
@@ -228,7 +234,7 @@ describe('FileBrowser', () => {
|
||||
});
|
||||
|
||||
it('starts in the default view and sort from the settings', () => {
|
||||
localStorage.setItem('files-settings', JSON.stringify({ defaultSortKey: 'name', defaultSortDir: 'desc' }));
|
||||
act(() => { useSettingsStore.setState({ filesSettings: { ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'name', defaultSortDir: 'desc' } }); });
|
||||
const files = ['a.txt', 'c.txt', 'b.txt'].map((n, i) => resource(node(`f${i}`, n, null, { blobId: `b${i}`, type: 'text/plain', size: 1 })));
|
||||
renderBrowser({ resources: files, allFolders: [] });
|
||||
const names = within(list()).getAllByRole('row').slice(1).map((r) => r.getAttribute('data-resource'));
|
||||
@@ -334,4 +340,134 @@ describe('FileBrowser', () => {
|
||||
expect(within(bar).getByRole('button', { name: 'download' })).toBeInTheDocument();
|
||||
expect(within(bar).getByRole('button', { name: 'delete' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("takes the closed folder drawer out of reach, so it can't cover the nav rail or catch Tab", () => {
|
||||
renderBrowser();
|
||||
const drawer = screen.getByTestId('files-sidebar-drawer');
|
||||
expect(drawer).toHaveAttribute('inert');
|
||||
expect(drawer.className).toContain('invisible');
|
||||
tree();
|
||||
expect(drawer).not.toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it("renames in a dialog that selects the name without its extension and closes on Escape", async () => {
|
||||
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
|
||||
fireEvent.keyDown(window, { key: 'F2' });
|
||||
const dialog = screen.getByRole('dialog', { name: 'rename_title' });
|
||||
const input = within(dialog).getByRole('textbox') as HTMLInputElement;
|
||||
await waitFor(() => expect([input.selectionStart, input.selectionEnd]).toEqual([0, 5]));
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: 'rename_title' })).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes the selection with Backspace, as Mail does, and goes up a folder only with Alt+Up", () => {
|
||||
const onDelete = vi.fn(async () => {});
|
||||
const onNavigate = vi.fn();
|
||||
renderBrowser({ props: { currentPath: '/Documents', selectedResources: new Set(['notes.txt']), onDelete, onNavigate } });
|
||||
fireEvent.keyDown(window, { key: 'Backspace' });
|
||||
expect(onDelete).toHaveBeenCalledWith('notes.txt');
|
||||
expect(onNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowUp', altKey: true });
|
||||
expect(onNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the selection when Escape closes an item's menu", () => {
|
||||
const onClearSelection = vi.fn();
|
||||
renderBrowser({ props: { selectedResources: new Set(['notes.txt', 'Documents']), onClearSelection } });
|
||||
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document.activeElement ?? window, { key: 'Escape' });
|
||||
expect(screen.queryByRole('menu')).toBeNull();
|
||||
expect(onClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers Restore and Delete permanently in the trash, and Empty trash in its toolbar", () => {
|
||||
const onRestore = vi.fn(async () => {});
|
||||
const onEmptyTrash = vi.fn();
|
||||
const trashed = { ...resource(OWN[1]), trash: { deletedAt: '2026-10-20T10:00:00Z', fromParentId: null, batchId: 'b1' } };
|
||||
act(() => { useFileStore.setState({ trashCount: 1 }); });
|
||||
renderBrowser({ resources: [trashed], props: { currentPath: '/.Trash', onRestore, onEmptyTrash } });
|
||||
act(() => { useFileStore.setState({ currentParentId: '*trash', trashCount: 1 }); });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'empty_trash' }));
|
||||
expect(onEmptyTrash).toHaveBeenCalled();
|
||||
|
||||
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
|
||||
expect(screen.queryByText('rename')).toBeNull();
|
||||
fireEvent.click(screen.getByText('restore'));
|
||||
expect(onRestore).toHaveBeenCalledWith(['notes.txt']);
|
||||
expect(screen.getByRole('columnheader', { name: 'deleted' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the trash in the sidebar", () => {
|
||||
const { onNavigate } = renderBrowser();
|
||||
fireEvent.click(within(tree()).getByRole('button', { name: /trash/ }));
|
||||
expect(onNavigate).toHaveBeenCalledWith('/.Trash', '*trash');
|
||||
});
|
||||
|
||||
it("moves an item to a folder picked in Move to…, without dragging", () => {
|
||||
const onMoveTo = vi.fn(async () => {});
|
||||
renderBrowser({ props: { onMoveTo } });
|
||||
fireEvent.contextMenu(within(list()).getByText('notes.txt'));
|
||||
fireEvent.click(screen.getByText('move_to'));
|
||||
const dialog = screen.getByRole('dialog', { name: 'move_to_title' });
|
||||
expect(within(dialog).getByRole('button', { name: 'move_here' })).toBeDisabled();
|
||||
fireEvent.click(within(dialog).getByRole('treeitem', { name: /Documents/ }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'move_here' }));
|
||||
expect(onMoveTo).toHaveBeenCalledWith(['notes.txt'], 'f-docs');
|
||||
});
|
||||
|
||||
it("leaves a folder out of its own Move to… picker", () => {
|
||||
renderBrowser();
|
||||
fireEvent.contextMenu(within(list()).getByText('Documents'));
|
||||
fireEvent.click(screen.getByText('move_to'));
|
||||
const dialog = screen.getByRole('dialog', { name: 'move_to_title' });
|
||||
expect(within(dialog).queryByRole('treeitem', { name: /Documents/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("downloads a folder as a zip named after it", () => {
|
||||
const onDownloadZip = vi.fn(async () => {});
|
||||
renderBrowser({ props: { onDownloadZip } });
|
||||
fireEvent.contextMenu(within(list()).getByText('Documents'));
|
||||
fireEvent.click(screen.getByText('download_zip'));
|
||||
expect(onDownloadZip).toHaveBeenCalledWith(['f-docs'], 'Documents.zip');
|
||||
});
|
||||
|
||||
it("adds Upload, New folder and Trash to the command palette, and focuses search with /", () => {
|
||||
renderBrowser();
|
||||
let groups: CommandGroup[] = [];
|
||||
function Probe() {
|
||||
groups = useRegisteredCommandGroups();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
const files = groups.find((g) => g.label === 'files');
|
||||
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash']));
|
||||
|
||||
fireEvent.keyDown(document.body, { key: '/' });
|
||||
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
|
||||
});
|
||||
|
||||
it("lists a folder's actions in the same order in the list and in the tree", () => {
|
||||
renderBrowser({ props: { onDownloadZip: vi.fn(async () => {}), onMoveNode: vi.fn(async () => {}) } });
|
||||
const labels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => (i.textContent ?? '').replace(/(F2|Del|Ctrl\+[A-Z]|⌘[A-Z])$/, ''));
|
||||
fireEvent.contextMenu(within(list()).getByText('Documents'));
|
||||
const inList = labels();
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
fireEvent.contextMenu(within(tree()).getByText('Documents'));
|
||||
const inTree = labels();
|
||||
const shared = inTree.filter((l) => inList.includes(l));
|
||||
expect(shared).toEqual(inList.filter((l) => inTree.includes(l)));
|
||||
expect(shared).toEqual(expect.arrayContaining(['open', 'star', 'rename', 'move_to', 'download_zip', 'delete']));
|
||||
});
|
||||
|
||||
it("closes the details panel over a narrow list with Escape", async () => {
|
||||
renderBrowser({ props: { selectedResources: new Set(['notes.txt']) } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'details' }));
|
||||
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(screen.getByRole('button', { name: 'details' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
@@ -46,7 +46,12 @@ describe('FilePreviewModal HTML attachments', () => {
|
||||
getFileContent={async () => ({ blob: new Blob([HTML], { type: 'text/html' }), contentType: 'text/html' })}
|
||||
/>,
|
||||
);
|
||||
const frame = (await screen.findByTitle('invoice.html')) as HTMLIFrameElement;
|
||||
// The header shows the name as its title too; the frame is the iframe.
|
||||
const frame = await waitFor(() => {
|
||||
const found = screen.getAllByTitle('invoice.html').find((el) => el.tagName === 'IFRAME');
|
||||
if (!found) throw new Error('no frame yet');
|
||||
return found as HTMLIFrameElement;
|
||||
});
|
||||
expect(created).toEqual([]);
|
||||
expect(frame.getAttribute('src')).toBeNull();
|
||||
expect(frame.getAttribute('sandbox')).toBe('');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_FILES_SETTINGS } from '@/lib/files-settings';
|
||||
import { getFilesSettings, migrateLegacyFilesSettings } from '../files-settings';
|
||||
|
||||
describe('Files settings in the synced settings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
useSettingsStore.setState({ explicitSettings: [] });
|
||||
});
|
||||
|
||||
it('are part of the settings export and import', () => {
|
||||
useSettingsStore.getState().updateSetting('filesSettings', { ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' });
|
||||
const exported = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
expect(exported.filesSettings.defaultViewMode).toBe('grid');
|
||||
|
||||
useSettingsStore.getState().resetToDefaults();
|
||||
expect(useSettingsStore.getState().filesSettings).toEqual(DEFAULT_FILES_SETTINGS);
|
||||
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ filesSettings: { defaultViewMode: 'grid', showIcons: 'yes' } }));
|
||||
expect(useSettingsStore.getState().filesSettings).toEqual({ ...DEFAULT_FILES_SETTINGS, defaultViewMode: 'grid' });
|
||||
});
|
||||
|
||||
it('take over what an older build kept on this device, once', () => {
|
||||
localStorage.setItem('files-settings', JSON.stringify({ ...DEFAULT_FILES_SETTINGS, showHiddenFiles: true }));
|
||||
expect(getFilesSettings().showHiddenFiles).toBe(true);
|
||||
expect(localStorage.getItem('files-settings')).toBeNull();
|
||||
expect(useSettingsStore.getState().explicitSettings).toContain('filesSettings');
|
||||
});
|
||||
|
||||
it('keep a choice already synced over an old device copy', () => {
|
||||
useSettingsStore.getState().updateSetting('filesSettings', { ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'size' });
|
||||
localStorage.setItem('files-settings', JSON.stringify({ ...DEFAULT_FILES_SETTINGS, defaultSortKey: 'modified' }));
|
||||
migrateLegacyFilesSettings();
|
||||
expect(useSettingsStore.getState().filesSettings.defaultSortKey).toBe('size');
|
||||
expect(localStorage.getItem('files-settings')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -55,4 +55,16 @@ describe('ImagePreviewModal', () => {
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes on Back instead of leaving Files, like the other previews', async () => {
|
||||
const push = vi.spyOn(window.history, 'pushState');
|
||||
const { onClose } = await renderViewer();
|
||||
// The history entry is added a tick after opening.
|
||||
await act(async () => { await new Promise((r) => setTimeout(r, 5)); });
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => { window.dispatchEvent(new PopStateEvent('popstate', { state: null })); });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
push.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { FilePreviewModal } from '../file-preview-modal';
|
||||
import { ImagePreviewModal } from '../image-preview-modal';
|
||||
|
||||
vi.mock('@/lib/standalone-display', () => ({ canOpenUrlInNewWindow: () => false }));
|
||||
|
||||
describe('one preview frame for images and other files', () => {
|
||||
it('has the same header buttons in both, and ←/→ go to the next and previous file', async () => {
|
||||
const onGo = vi.fn();
|
||||
const neighbours = { previous: 'a.txt', next: 'c.png', onGo };
|
||||
const { unmount } = render(
|
||||
<FilePreviewModal name="b.txt" onClose={vi.fn()} onDownload={vi.fn()} getFileContent={() => new Promise(() => {})} neighbours={neighbours} />,
|
||||
);
|
||||
const textButtons = screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' });
|
||||
expect(onGo).toHaveBeenLastCalledWith('c.png');
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' });
|
||||
expect(onGo).toHaveBeenLastCalledWith('a.txt');
|
||||
unmount();
|
||||
|
||||
render(<ImagePreviewModal name="c.png" onClose={vi.fn()} onDownload={async () => {}} getImageUrl={() => new Promise(() => {})} neighbours={neighbours} />);
|
||||
expect(screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(textButtons);
|
||||
});
|
||||
});
|
||||
+279
-115
@@ -5,20 +5,23 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
|
||||
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File,
|
||||
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
|
||||
} from "@/components/icons";
|
||||
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
|
||||
import { fileNameProblem } from "@/lib/file-name-rules";
|
||||
import { EmptyFolder, NoSearchResults } from "@/components/files/file-empty-state";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { isAudioFile, isImageFile, isPdfFile, isTextFile, isVideoFile } from "@/components/files/file-icons";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings } from "@/components/files/files-settings";
|
||||
import { useFilesSettings } from "@/components/files/files-settings";
|
||||
import { FilesSidebar, type CreateKind } from "@/components/files/files-sidebar";
|
||||
import { FilesHeader, type Crumb, type SortDir, type SortKey, type ViewMode } from "@/components/files/files-header";
|
||||
import { FileGridView, FileListView, type FileItemHandlers } from "@/components/files/file-views";
|
||||
@@ -27,6 +30,7 @@ import { INTERNAL_DRAG_TYPE, anchorEvent, isInternalDrag, readDraggedNames } fro
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
||||
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
|
||||
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
||||
@@ -61,11 +65,18 @@ interface FileBrowserProps {
|
||||
onUploadFiles: (files: File[]) => Promise<void>;
|
||||
onUploadFolder: (files: File[]) => Promise<void>;
|
||||
onCancelUpload: () => void;
|
||||
/** Delete: into the trash, or for good when in the trash or a shared drive. */
|
||||
onDelete: (name: string) => Promise<void>;
|
||||
onBatchDelete: (names: string[]) => Promise<void>;
|
||||
/** In the trash: put items back where they came from. */
|
||||
onRestore?: (names: string[]) => Promise<void>;
|
||||
/** In the trash: delete everything in it for good. */
|
||||
onEmptyTrash?: () => void;
|
||||
onRename: (oldName: string, newName: string) => Promise<void>;
|
||||
onDownload: (name: string) => Promise<void>;
|
||||
onBatchDownload: (names: string[]) => Promise<void>;
|
||||
/** Download items by id as one zip, e.g. a folder. */
|
||||
onDownloadZip?: (ids: string[], zipName: string) => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
onToggleSelect: (name: string) => void;
|
||||
onClearSelection: () => void;
|
||||
@@ -75,6 +86,8 @@ interface FileBrowserProps {
|
||||
onPaste: () => Promise<void>;
|
||||
/** Move listed items into a folder by id (null = the root). */
|
||||
onMoveTo: (names: string[], targetId: string | null) => Promise<void>;
|
||||
/** Move a folder of the tree, which need not be listed, into a folder. */
|
||||
onMoveNode?: (id: string, targetId: string | null) => Promise<void>;
|
||||
onPreviewImage: (name: string) => void;
|
||||
onPreviewFile: (name: string) => void;
|
||||
/** WOPI document editing (#425): whether a file can open in the document editor. */
|
||||
@@ -119,6 +132,16 @@ type MenuData = { kind: "item"; resource: FileResource } | { kind: "empty" } | {
|
||||
|
||||
const SIDEBAR_DEFAULT_WIDTH = 256;
|
||||
|
||||
/** A folder's path ("/Documents/Taxes") from the folder list. */
|
||||
function pathOfFolder(folders: FileResource[], folder: FileResource): string {
|
||||
const names = [folder.name];
|
||||
const byId = new Map(folders.map((f) => [f.id, f]));
|
||||
for (let cur = folder.parentId ? byId.get(folder.parentId) : undefined; cur && names.length < 64; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
|
||||
names.unshift(cur.name);
|
||||
}
|
||||
return "/" + names.join("/");
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
@@ -140,9 +163,12 @@ export function FileBrowser({
|
||||
onCancelUpload,
|
||||
onDelete,
|
||||
onBatchDelete,
|
||||
onRestore,
|
||||
onEmptyTrash,
|
||||
onRename,
|
||||
onDownload,
|
||||
onBatchDownload,
|
||||
onDownloadZip,
|
||||
onRefresh,
|
||||
onToggleSelect,
|
||||
onClearSelection,
|
||||
@@ -151,6 +177,7 @@ export function FileBrowser({
|
||||
onCopy,
|
||||
onPaste,
|
||||
onMoveTo,
|
||||
onMoveNode,
|
||||
onPreviewImage,
|
||||
onPreviewFile,
|
||||
isOfficeEditable,
|
||||
@@ -197,37 +224,26 @@ export function FileBrowser({
|
||||
const hasListed = useFileStore((s) => s.hasListed);
|
||||
const currentParentId = useFileStore((s) => s.currentParentId);
|
||||
const pathStack = useFileStore((s) => s.pathStack);
|
||||
const trashCount = useFileStore((s) => s.trashCount);
|
||||
// The trash lists deleted items: they can be restored, previewed or
|
||||
// deleted for good, nothing else.
|
||||
const inTrash = isTrashLocation(currentParentId);
|
||||
useEffect(() => {
|
||||
if (storeClient) void loadSharedRoots();
|
||||
}, [storeClient, loadSharedRoots]);
|
||||
|
||||
// ---- Settings --------------------------------------------------------------
|
||||
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
|
||||
const [settings, updateSettings] = useFilesSettings();
|
||||
const [sortKey, setSortKey] = useState<SortKey>(() => settings.defaultSortKey);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(() => settings.defaultSortDir);
|
||||
// A new default order (Settings > Files, another device) applies at once.
|
||||
useEffect(() => {
|
||||
const reload = () => {
|
||||
const next = loadFilesSettings();
|
||||
setSettings(next);
|
||||
setSortKey(next.defaultSortKey);
|
||||
setSortDir(next.defaultSortDir);
|
||||
};
|
||||
const handleStorage = (e: StorageEvent) => { if (e.key === "files-settings") reload(); };
|
||||
// StorageEvent fires cross-tab, the custom event same-tab
|
||||
window.addEventListener("storage", handleStorage);
|
||||
window.addEventListener("files-settings-changed", reload);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
window.removeEventListener("files-settings-changed", reload);
|
||||
};
|
||||
}, []);
|
||||
setSortKey(settings.defaultSortKey);
|
||||
setSortDir(settings.defaultSortDir);
|
||||
}, [settings.defaultSortKey, settings.defaultSortDir]);
|
||||
const viewMode: ViewMode = settings.defaultViewMode;
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
||||
// The toggle and Settings > Files edit the same preference.
|
||||
const next = { ...loadFilesSettings(), defaultViewMode: mode };
|
||||
setSettings(next);
|
||||
saveFilesSettings(next);
|
||||
}, []);
|
||||
// The toggle and Settings > Files edit the same preference.
|
||||
const handleViewModeChange = useCallback((mode: ViewMode) => updateSettings({ defaultViewMode: mode }), [updateSettings]);
|
||||
const iconOptions = useMemo(() => ({
|
||||
showIcons: settings.showIcons,
|
||||
coloredIcons: settings.coloredIcons,
|
||||
@@ -242,6 +258,11 @@ export function FileBrowser({
|
||||
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
||||
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
||||
useEffect(() => { setNarrowSidebarOpen(false); }, [currentPath]);
|
||||
// Anything picked in the drawer closes it (R7).
|
||||
const fromDrawer = <A extends unknown[], R>(action: (...args: A) => R) => (...args: A): R => {
|
||||
setNarrowSidebarOpen(false);
|
||||
return action(...args);
|
||||
};
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = Number(localStorage.getItem("files-sidebar-width"));
|
||||
@@ -268,6 +289,8 @@ export function FileBrowser({
|
||||
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
||||
const [renameFolder, setRenameFolder] = useState<FileResource | null>(null);
|
||||
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
|
||||
// "Move to…": listed items by name, or a folder of the tree by id.
|
||||
const [moveTarget, setMoveTarget] = useState<{ names: string[] } | { folder: FileResource } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [focusedName, setFocusedName] = useState<string | null>(null);
|
||||
// The cursor outline follows a click or the arrow keys, but goes away once
|
||||
@@ -342,12 +365,15 @@ export function FileBrowser({
|
||||
// Whether the user may add items to the folder on screen. Nothing can be
|
||||
// put on a page listing files shared on their own.
|
||||
const currentFolder = currentParentId ? allFolders.find((f) => f.id === currentParentId) : null;
|
||||
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId)
|
||||
const canCreate = !accountPickerMode && !isSharedFilesPage(currentParentId) && !inTrash
|
||||
&& (currentFolder?.myRights?.mayAddChildren ?? true);
|
||||
|
||||
// ---- Listing ---------------------------------------------------------------
|
||||
const displayResources = useMemo(() => {
|
||||
let list = resources;
|
||||
// In the trash the date column is when an item was deleted.
|
||||
let list = inTrash
|
||||
? resources.map((r) => (r.trash?.deletedAt ? { ...r, lastModified: r.trash.deletedAt } : r))
|
||||
: resources;
|
||||
if (!settings.showHiddenFiles) list = list.filter((r) => !r.name.startsWith("."));
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -371,7 +397,7 @@ export function FileBrowser({
|
||||
if (cmp === 0) cmp = a.name.localeCompare(b.name);
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [resources, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
|
||||
}, [resources, inTrash, settings.showHiddenFiles, searchQuery, sortKey, sortDir]);
|
||||
|
||||
// Folders other principals shared with the user, listed at the root next to
|
||||
// the user's own (#1181).
|
||||
@@ -403,12 +429,15 @@ export function FileBrowser({
|
||||
// Inside a folder someone shared with the user, the trail starts there.
|
||||
const insideShared = !!pathStack[1]?.id?.includes(":");
|
||||
const list: Crumb[] = [{ id: null, name: insideShared ? t("shared_with_me") : t("my_files"), path: "/" }];
|
||||
if (inTrash) return [...list, { id: TRASH_LOCATION, name: t("trash"), path: TRASH_PATH }];
|
||||
segments.forEach((name, i) => {
|
||||
list.push({ id: pathStack[i + 1]?.id ?? null, name, path: "/" + segments.slice(0, i + 1).join("/") });
|
||||
});
|
||||
return list;
|
||||
}, [currentPath, pathStack, t]);
|
||||
}, [currentPath, pathStack, inTrash, t]);
|
||||
const folderName = crumbs[crumbs.length - 1].name;
|
||||
const folderNameRef = useRef(folderName);
|
||||
folderNameRef.current = folderName;
|
||||
|
||||
const navigateCrumb = useCallback((crumb: Crumb) => {
|
||||
if (crumb.path === "/") {
|
||||
@@ -434,6 +463,8 @@ export function FileBrowser({
|
||||
const openResource = useCallback((resource: FileResource) => {
|
||||
// A symlink has nothing to open (Stalwart 1.0).
|
||||
if (resource.isSymlink) return;
|
||||
// A deleted folder is restored before it is opened; a deleted file can be looked at.
|
||||
if (resource.trash && (resource.isDirectory || !isPreviewable(resource.name))) return;
|
||||
if (resource.isDirectory) {
|
||||
onNavigate(pathOf(resource), resource.id);
|
||||
} else if (isOfficeEditable?.(resource.name) && onEditFile) {
|
||||
@@ -452,6 +483,15 @@ export function FileBrowser({
|
||||
else onPreviewFile(resource.name);
|
||||
}, [onPreviewImage, onPreviewFile]);
|
||||
|
||||
// Where a deleted item came from, as its row's tooltip in the trash.
|
||||
const trashNote = useCallback((r: FileResource): string | undefined => {
|
||||
if (!r.trash) return undefined;
|
||||
const from = r.trash.fromParentId;
|
||||
const folder = from ? allFolders.find((f) => f.id === from) : null;
|
||||
const path = from === null ? t("my_files") : folder ? pathOfFolder(allFolders, folder) : null;
|
||||
return path ? t("deleted_from", { folder: path }) : undefined;
|
||||
}, [allFolders, t]);
|
||||
|
||||
// ---- Selection -------------------------------------------------------------
|
||||
const selectRange = useCallback((from: string, to: string, additive: boolean) => {
|
||||
const names = displayResources.map((r) => r.name);
|
||||
@@ -499,10 +539,17 @@ export function FileBrowser({
|
||||
}, [onDelete, onBatchDelete]);
|
||||
|
||||
const downloadNames = useCallback((names: string[]) => {
|
||||
const items = names.map((n) => resources.find((r) => r.name === n)).filter((r): r is FileResource => !!r && !r.isSymlink);
|
||||
// Folders come as one zip, with any files picked next to them.
|
||||
if (onDownloadZip && items.some((r) => r.isDirectory)) {
|
||||
const zipName = items.length === 1 ? items[0].name : folderNameRef.current;
|
||||
void onDownloadZip(items.map((r) => r.id), `${zipName}.zip`);
|
||||
return;
|
||||
}
|
||||
const files = names.filter(hasContent);
|
||||
if (files.length === 1) void onDownload(files[0]);
|
||||
else if (files.length > 1) void onBatchDownload(files);
|
||||
}, [hasContent, onDownload, onBatchDownload]);
|
||||
}, [resources, hasContent, onDownload, onBatchDownload, onDownloadZip]);
|
||||
|
||||
// ---- Drag and drop ---------------------------------------------------------
|
||||
const moveNames = useCallback((names: string[], targetId: string | null) => {
|
||||
@@ -510,7 +557,7 @@ export function FileBrowser({
|
||||
}, [onMoveTo]);
|
||||
|
||||
const itemDragProps = useCallback((resource: FileResource) => ({
|
||||
draggable: !touch,
|
||||
draggable: !touch && !inTrash,
|
||||
onDragStart: (e: React.DragEvent) => {
|
||||
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
||||
e.dataTransfer.setData(INTERNAL_DRAG_TYPE, JSON.stringify(names));
|
||||
@@ -532,7 +579,7 @@ export function FileBrowser({
|
||||
const names = readDraggedNames(e).filter((n) => n !== resource.name);
|
||||
if (names.length > 0) moveNames(names, resource.id);
|
||||
},
|
||||
}), [touch, selectedResources, moveNames]);
|
||||
}), [touch, inTrash, selectedResources, moveNames]);
|
||||
|
||||
const runUpload = useCallback(async (files: File[], asFolder: boolean) => {
|
||||
if (files.length === 0) return;
|
||||
@@ -662,14 +709,15 @@ export function FileBrowser({
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isTypingTarget(e.target)) return;
|
||||
if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId) return;
|
||||
if (showNewFolder || showNewTextFile || renameTarget || renameFolder || shareTargetId || moveTarget) return;
|
||||
// A preview, editor or confirmation is open on top of the list.
|
||||
if (document.querySelector('[role="dialog"], [role="alertdialog"]')) return;
|
||||
if (accountPickerMode) return;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const selected = selectedList.map((r) => r.name);
|
||||
|
||||
if (mod && e.key.toLowerCase() === "f") {
|
||||
// "/" as in Mail and Contacts; Ctrl/⌘+F as in a file manager.
|
||||
if ((mod && e.key.toLowerCase() === "f") || (e.key === "/" && !mod && !e.altKey)) {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
return;
|
||||
@@ -679,6 +727,7 @@ export function FileBrowser({
|
||||
selectAllVisible();
|
||||
return;
|
||||
}
|
||||
if (inTrash && (mod || e.key === "F2") && ["c", "x", "v", "f2"].includes(e.key.toLowerCase())) return;
|
||||
if (mod && e.key.toLowerCase() === "c" && selected.length > 0) {
|
||||
if (window.getSelection()?.toString()) return;
|
||||
e.preventDefault();
|
||||
@@ -696,11 +745,14 @@ export function FileBrowser({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery) setSearchQuery("");
|
||||
// The details panel over the list closes first (innermost layer, R8).
|
||||
if (showDetails && isNarrow) toggleDetails(false);
|
||||
else if (searchQuery) setSearchQuery("");
|
||||
else if (selected.length > 0) onClearSelection();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Delete" && selected.length > 0) {
|
||||
// Backspace deletes too, as in Mail (⌘⌫ on a Mac); Alt+↑ goes up.
|
||||
if ((e.key === "Delete" || e.key === "Backspace") && selected.length > 0) {
|
||||
e.preventDefault();
|
||||
deleteNames(selected);
|
||||
return;
|
||||
@@ -720,7 +772,7 @@ export function FileBrowser({
|
||||
onToggleSelect(focusedName);
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Backspace" || (e.altKey && e.key === "ArrowUp")) && currentPath !== "/") {
|
||||
if (e.altKey && e.key === "ArrowUp" && currentPath !== "/") {
|
||||
e.preventDefault();
|
||||
navigateUp();
|
||||
return;
|
||||
@@ -745,7 +797,8 @@ export function FileBrowser({
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [
|
||||
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, accountPickerMode, selectedList, singleSelected,
|
||||
showNewFolder, showNewTextFile, renameTarget, renameFolder, shareTargetId, moveTarget, accountPickerMode, selectedList, singleSelected, inTrash,
|
||||
showDetails, isNarrow, toggleDetails,
|
||||
selectAllVisible, onCopy, onCut, onPaste, clipboard, searchQuery, onClearSelection, deleteNames, openResource,
|
||||
focusedName, onToggleSelect, currentPath, navigateUp, viewMode, gridColumns, moveFocus,
|
||||
]);
|
||||
@@ -773,18 +826,48 @@ export function FileBrowser({
|
||||
? selectedList
|
||||
: menuData?.kind === "item" ? [menuData.resource] : [];
|
||||
|
||||
// Items move within the own drive; a shared drive's folders are not in the picker.
|
||||
const canMove = !inTrash && !accountPickerMode && !currentParentId?.includes(":");
|
||||
|
||||
const renderItemMenu = (resource: FileResource) => {
|
||||
if (inTrash) {
|
||||
const names = menuSelection.map((r) => r.name);
|
||||
const one = menuSelection.length === 1 ? menuSelection[0] : null;
|
||||
return (
|
||||
<>
|
||||
{onRestore && (
|
||||
<ContextMenuItem icon={RotateCcw} label={t("restore")} onClick={() => { closeMenu(); void onRestore(names); }} />
|
||||
)}
|
||||
{one && !one.isDirectory && !one.isSymlink && isPreviewable(one.name) && (
|
||||
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(one); }} />
|
||||
)}
|
||||
{one && !one.isDirectory && !one.isSymlink && (
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(one.name); }} />
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete_forever")} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (menuSelection.length > 1) {
|
||||
const names = menuSelection.map((r) => r.name);
|
||||
const fileCount = menuSelection.filter((r) => !r.isDirectory && !r.isSymlink).length;
|
||||
const withFolders = !!onDownloadZip && menuSelection.some((r) => r.isDirectory);
|
||||
return (
|
||||
<>
|
||||
{fileCount > 0 && (
|
||||
<ContextMenuItem icon={Download} label={t("download_count", { count: fileCount })} onClick={() => { closeMenu(); downloadNames(names); }} />
|
||||
{(fileCount > 0 || withFolders) && (
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={withFolders ? t("download_zip") : t("download_count", { count: fileCount })}
|
||||
onClick={() => { closeMenu(); downloadNames(names); }}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut(names); }} />
|
||||
{canMove && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names }); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut(names); }} />
|
||||
{menuSelection.some((r) => !r.isSymlink) && (
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy(names); }} />
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy(names); }} />
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete_count", { count: names.length })} destructive onClick={() => { closeMenu(); deleteNames(names); }} />
|
||||
@@ -796,17 +879,18 @@ export function FileBrowser({
|
||||
const starred = starredPaths.has(path);
|
||||
return (
|
||||
<>
|
||||
{/* R6 order, the same as the folder tree's menu: Open, Edit, Share,
|
||||
Star, Rename, Duplicate, Move to…, Cut, Copy, Download, Details, Delete. */}
|
||||
{resource.isDirectory ? (
|
||||
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
|
||||
) : resource.isSymlink ? null : (
|
||||
<>
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
{isPreviewable(resource.name) && (
|
||||
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(resource); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
|
||||
@@ -825,9 +909,18 @@ export function FileBrowser({
|
||||
{!resource.isDirectory && !resource.isSymlink && (
|
||||
<ContextMenuItem icon={CopyPlus} label={t("duplicate")} onClick={() => { closeMenu(); void onDuplicate(resource.name); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut="Ctrl+X" onClick={() => { closeMenu(); onCut([resource.name]); }} />
|
||||
{canMove && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => { closeMenu(); setMoveTarget({ names: [resource.name] }); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Scissors} label={t("cut")} shortcut={formatShortcutCombo("Mod + X")} onClick={() => { closeMenu(); onCut([resource.name]); }} />
|
||||
{!resource.isSymlink && (
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut="Ctrl+C" onClick={() => { closeMenu(); onCopy([resource.name]); }} />
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
|
||||
)}
|
||||
{resource.isDirectory && onDownloadZip && (
|
||||
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
|
||||
)}
|
||||
{!resource.isDirectory && !resource.isSymlink && (
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
|
||||
<ContextMenuSeparator />
|
||||
@@ -879,6 +972,18 @@ export function FileBrowser({
|
||||
else folderInputRef.current?.click();
|
||||
}, [currentParentId, onNavigate]);
|
||||
|
||||
// ---- Command palette ---------------------------------------------------------
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
useRegisterCommands(tSidebar("files"), [
|
||||
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
|
||||
{ id: "new-text-file", label: t("new_text_file"), icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
|
||||
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
|
||||
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
|
||||
{ id: "refresh", label: t("refresh"), icon: RefreshCw, run: () => void onRefresh() },
|
||||
]);
|
||||
|
||||
// ---- Rendering -------------------------------------------------------------
|
||||
const sidebar = !accountPickerMode && (
|
||||
<FilesSidebar
|
||||
@@ -891,20 +996,25 @@ export function FileBrowser({
|
||||
canCreate={canCreate}
|
||||
onNavigate={onNavigate}
|
||||
onOpenRecent={onOpenRecent}
|
||||
onUploadFiles={() => fileInputRef.current?.click()}
|
||||
onUploadFolder={() => folderInputRef.current?.click()}
|
||||
onNewFolder={() => setShowNewFolder(true)}
|
||||
onNewTextFile={() => setShowNewTextFile(true)}
|
||||
onUploadFiles={fromDrawer(() => fileInputRef.current?.click())}
|
||||
onUploadFolder={fromDrawer(() => folderInputRef.current?.click())}
|
||||
onNewFolder={fromDrawer(() => setShowNewFolder(true))}
|
||||
onNewTextFile={fromDrawer(() => setShowNewTextFile(true))}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDropItems={moveNames}
|
||||
canShare={canShare}
|
||||
onShare={(r) => setShareTargetId(r.id)}
|
||||
onCreateIn={createIn}
|
||||
onRenameFolder={setRenameFolder}
|
||||
onDeleteFolder={(folder) => void onDeleteNode(folder.id, folder.name)}
|
||||
onRevealRecent={(id) => void onRevealNode(id)}
|
||||
onDownloadRecent={(id) => void onDownloadNode(id)}
|
||||
onShare={fromDrawer((r: FileResource) => setShareTargetId(r.id))}
|
||||
onCreateIn={fromDrawer(createIn)}
|
||||
onRenameFolder={fromDrawer(setRenameFolder)}
|
||||
onDownloadFolder={onDownloadZip ? (folder: FileResource) => void onDownloadZip([folder.id], `${folder.name}.zip`) : undefined}
|
||||
onMoveFolder={onMoveNode ? fromDrawer((folder: FileResource) => setMoveTarget({ folder })) : undefined}
|
||||
onDeleteFolder={fromDrawer((folder: FileResource) => void onDeleteNode(folder.id, folder.name))}
|
||||
onRevealRecent={fromDrawer((id: string) => void onRevealNode(id))}
|
||||
onDownloadRecent={fromDrawer((id: string) => void onDownloadNode(id))}
|
||||
onRemoveRecent={onRemoveRecent}
|
||||
trashCount={trashCount}
|
||||
onOpenTrash={fromDrawer(() => onNavigate(TRASH_PATH, TRASH_LOCATION))}
|
||||
onDropToTrash={(names) => deleteNames(names)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -943,6 +1053,9 @@ export function FileBrowser({
|
||||
// Until the first listing is in, an empty list means "not loaded yet".
|
||||
if ((isLoading || !hasListed) && resources.length === 0) return <ListSkeleton />;
|
||||
if (showEmpty && searchQuery) return <NoSearchResults query={searchQuery} onClear={() => setSearchQuery("")} />;
|
||||
if (showEmpty && inTrash) {
|
||||
return <EmptyState icon={Trash2} title={t("trash_empty_title")} description={t("trash_empty_hint")} />;
|
||||
}
|
||||
if (showEmpty) {
|
||||
return (
|
||||
<EmptyFolder
|
||||
@@ -965,6 +1078,7 @@ export function FileBrowser({
|
||||
iconOptions,
|
||||
getImageUrl,
|
||||
handlers,
|
||||
itemNote: inTrash ? trashNote : undefined,
|
||||
};
|
||||
return (
|
||||
<>
|
||||
@@ -979,6 +1093,7 @@ export function FileBrowser({
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
onToggleAll={() => (allSelected ? onClearSelection() : selectAllVisible())}
|
||||
dateLabel={inTrash ? t("deleted") : undefined}
|
||||
/>
|
||||
))}
|
||||
{(sharedAtRoot.length > 0 || sharedPagesAtRoot.length > 0) && (
|
||||
@@ -1020,9 +1135,27 @@ export function FileBrowser({
|
||||
/>
|
||||
);
|
||||
|
||||
const selectionActions = selectedList.length > 0 ? {
|
||||
const selectionActions = selectedList.length > 0 && inTrash ? {
|
||||
count: selectedList.length,
|
||||
fileCount: selectedList.filter((r) => !r.isDirectory && !r.isSymlink).length,
|
||||
fileCount: 0,
|
||||
canOpen: false,
|
||||
canRename: false,
|
||||
canShare: false,
|
||||
trash: true,
|
||||
onRestore: onRestore ? () => void onRestore(selectedList.map((r) => r.name)) : undefined,
|
||||
onOpen: () => {},
|
||||
onDownload: () => {},
|
||||
onShare: () => {},
|
||||
onRename: () => {},
|
||||
onCut: () => {},
|
||||
onCopy: () => {},
|
||||
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
|
||||
onClear: onClearSelection,
|
||||
pluginFiles: [],
|
||||
} : selectedList.length > 0 ? {
|
||||
count: selectedList.length,
|
||||
// Folders download as a zip.
|
||||
fileCount: selectedList.filter((r) => !r.isSymlink && (!r.isDirectory || !!onDownloadZip)).length,
|
||||
canOpen: !!singleSelected && !singleSelected.isSymlink
|
||||
&& (singleSelected.isDirectory || isPreviewable(singleSelected.name) || !!isOfficeEditable?.(singleSelected.name)),
|
||||
canRename: !!singleSelected,
|
||||
@@ -1031,6 +1164,7 @@ export function FileBrowser({
|
||||
onDownload: () => downloadNames(selectedList.map((r) => r.name)),
|
||||
onShare: () => singleSelected && setShareTargetId(singleSelected.id),
|
||||
onRename: () => singleSelected && setRenameTarget(singleSelected.name),
|
||||
onMoveTo: canMove ? () => setMoveTarget({ names: selectedList.map((r) => r.name) }) : undefined,
|
||||
onCut: () => onCut(selectedList.map((r) => r.name)),
|
||||
onCopy: () => onCopy(selectedList.map((r) => r.name)),
|
||||
onDelete: () => deleteNames(selectedList.map((r) => r.name)),
|
||||
@@ -1040,19 +1174,22 @@ export function FileBrowser({
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
// Clips the closed drawer, so it never sits over the nav rail.
|
||||
<div className="relative flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Folder sidebar: in flow on desktop, a drawer on narrow panes */}
|
||||
{sidebar && isNarrow && narrowSidebarOpen && (
|
||||
<div className="absolute inset-0 bg-black/50 z-40" onClick={() => setNarrowSidebarOpen(false)} />
|
||||
)}
|
||||
{sidebar && (isNarrow ? (
|
||||
<div
|
||||
data-testid="files-sidebar-drawer"
|
||||
className={cn(
|
||||
"absolute inset-y-0 start-0 z-50 w-72 border-e border-border",
|
||||
"transform transition-transform duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full",
|
||||
"transform transition-[transform,visibility] duration-300 ease-in-out",
|
||||
!narrowSidebarOpen && "invisible -translate-x-full rtl:translate-x-full",
|
||||
)}
|
||||
aria-hidden={!narrowSidebarOpen}
|
||||
// Closed, the drawer is out of reach: no clicks, no Tab stops.
|
||||
inert={!narrowSidebarOpen}
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
@@ -1102,13 +1239,14 @@ export function FileBrowser({
|
||||
compact={isMobile}
|
||||
toolsHidden={accountPickerMode}
|
||||
selection={selectionActions}
|
||||
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div role="alert" className="mx-4 mt-3 px-3 py-2 rounded-md bg-destructive/10 text-destructive text-sm flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1">{error}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-destructive hover:text-destructive shrink-0" onClick={() => void onRefresh()}>
|
||||
<Button variant="ghost" size="xs" className="text-destructive hover:text-destructive shrink-0" onClick={() => void onRefresh()}>
|
||||
<RefreshCw className="w-3.5 h-3.5 me-1" />
|
||||
{t("retry")}
|
||||
</Button>
|
||||
@@ -1159,12 +1297,16 @@ export function FileBrowser({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Over the list on a narrow pane: a backdrop that closes it, as for the drawer. */}
|
||||
{detailsPanel && isNarrow && (
|
||||
<div className="absolute inset-0 z-20 bg-black/30" aria-hidden onClick={() => toggleDetails(false)} />
|
||||
)}
|
||||
{detailsPanel}
|
||||
|
||||
{isNarrow && canCreate && !selectionActions && (
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute bottom-4 end-4 z-20 h-12 w-12 rounded-full shadow-lg"
|
||||
size="icon-lg"
|
||||
className="absolute bottom-4 end-4 z-20 rounded-full shadow-lg"
|
||||
aria-label={t("new")}
|
||||
onClick={(e) => menu.openContextMenu(anchorEvent(e.currentTarget, "end"), { kind: "new" })}
|
||||
>
|
||||
@@ -1193,66 +1335,88 @@ export function FileBrowser({
|
||||
{clipboard && canCreate && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Clipboard} label={t("paste")} shortcut="Ctrl+V" onClick={() => { closeMenu(); void onPaste(); }} />
|
||||
<ContextMenuItem icon={Clipboard} label={t("paste")} shortcut={formatShortcutCombo("Mod + V")} onClick={() => { closeMenu(); void onPaste(); }} />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
{displayResources.length > 0 && (
|
||||
<ContextMenuItem icon={CheckSquare} label={t("select_all")} shortcut="Ctrl+A" onClick={() => { closeMenu(); selectAllVisible(); }} />
|
||||
<ContextMenuItem icon={CheckSquare} label={t("select_all")} shortcut={formatShortcutCombo("Mod + A")} onClick={() => { closeMenu(); selectAllVisible(); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { closeMenu(); void onRefresh(); }} />
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
|
||||
{showNewFolder && (
|
||||
<NewFolderDialog
|
||||
onConfirm={async (name: string) => {
|
||||
await onCreateFolder(name);
|
||||
setShowNewFolder(false);
|
||||
}}
|
||||
onCancel={() => setShowNewFolder(false)}
|
||||
validate={validateFileName}
|
||||
/>
|
||||
)}
|
||||
<PromptDialog
|
||||
isOpen={showNewFolder}
|
||||
onClose={() => setShowNewFolder(false)}
|
||||
title={t("new_folder")}
|
||||
placeholder={t("new_folder_name")}
|
||||
confirmText={t("create")}
|
||||
cancelText={t("cancel")}
|
||||
validate={validateFileName}
|
||||
onSubmit={(name) => void onCreateFolder(name)}
|
||||
/>
|
||||
|
||||
{showNewTextFile && (
|
||||
<RenameDialog
|
||||
currentName="new-file.txt"
|
||||
title={t("new_text_file")}
|
||||
label={t("file_name")}
|
||||
onConfirm={async (name: string) => {
|
||||
await onCreateTextFile(name);
|
||||
setShowNewTextFile(false);
|
||||
}}
|
||||
onCancel={() => setShowNewTextFile(false)}
|
||||
validate={validateFileName}
|
||||
/>
|
||||
)}
|
||||
<PromptDialog
|
||||
isOpen={showNewTextFile}
|
||||
onClose={() => setShowNewTextFile(false)}
|
||||
title={t("new_text_file")}
|
||||
placeholder={t("file_name")}
|
||||
defaultValue="new-file.txt"
|
||||
selectBaseName
|
||||
confirmText={t("create")}
|
||||
cancelText={t("cancel")}
|
||||
validate={validateFileName}
|
||||
onSubmit={(name) => void onCreateTextFile(name)}
|
||||
/>
|
||||
|
||||
{renameTarget && (
|
||||
<RenameDialog
|
||||
currentName={renameTarget}
|
||||
onConfirm={async (newName: string) => {
|
||||
await onRename(renameTarget, newName);
|
||||
setRenameTarget(null);
|
||||
}}
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
validate={validateFileName}
|
||||
/>
|
||||
)}
|
||||
<PromptDialog
|
||||
isOpen={renameTarget !== null}
|
||||
onClose={() => setRenameTarget(null)}
|
||||
title={t("rename_title")}
|
||||
placeholder={t("new_name")}
|
||||
defaultValue={renameTarget ?? ""}
|
||||
// A file keeps its extension unless the user means to change it.
|
||||
selectBaseName={!resources.find((r) => r.name === renameTarget)?.isDirectory}
|
||||
confirmText={t("save")}
|
||||
cancelText={t("cancel")}
|
||||
validate={validateFileName}
|
||||
onSubmit={(newName) => {
|
||||
if (renameTarget !== null && newName !== renameTarget) void onRename(renameTarget, newName);
|
||||
}}
|
||||
/>
|
||||
|
||||
{renameFolder && (
|
||||
<RenameDialog
|
||||
currentName={renameFolder.name}
|
||||
onConfirm={async (newName: string) => {
|
||||
await onRenameNode(renameFolder.id, newName);
|
||||
setRenameFolder(null);
|
||||
}}
|
||||
onCancel={() => setRenameFolder(null)}
|
||||
validate={validateFileName}
|
||||
/>
|
||||
)}
|
||||
<PromptDialog
|
||||
isOpen={renameFolder !== null}
|
||||
onClose={() => setRenameFolder(null)}
|
||||
title={t("rename_title")}
|
||||
placeholder={t("new_name")}
|
||||
defaultValue={renameFolder?.name ?? ""}
|
||||
confirmText={t("save")}
|
||||
cancelText={t("cancel")}
|
||||
validate={validateFileName}
|
||||
onSubmit={(newName) => {
|
||||
if (renameFolder && newName !== renameFolder.name) void onRenameNode(renameFolder.id, newName);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FolderPickerDialog
|
||||
isOpen={moveTarget !== null}
|
||||
onClose={() => setMoveTarget(null)}
|
||||
title={t("move_to_title")}
|
||||
confirmText={t("move_here")}
|
||||
folders={allFolders.filter((f) => !f.isShared && !f.id.includes(":"))}
|
||||
excludeIds={new Set(moveTarget && "folder" in moveTarget
|
||||
? [moveTarget.folder.id]
|
||||
: (moveTarget?.names ?? []).map((n) => resources.find((r) => r.name === n)).filter((r) => r?.isDirectory).map((r) => r!.id))}
|
||||
currentId={moveTarget && "folder" in moveTarget ? moveTarget.folder.parentId ?? null : currentParentId}
|
||||
onPick={(targetId) => {
|
||||
if (!moveTarget) return;
|
||||
if ("folder" in moveTarget) void onMoveNode?.(moveTarget.folder.id, targetId);
|
||||
else void onMoveTo(moveTarget.names, targetId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{shareTarget && client && onShare && (
|
||||
<ShareCollectionDialog
|
||||
|
||||
@@ -82,7 +82,7 @@ export function FileDetailsPanel({
|
||||
<div className="aspect-[4/3] rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
{selection.length > 1
|
||||
? <Files className="w-14 h-14 text-muted-foreground" />
|
||||
: <FolderOpen className="w-14 h-14 text-blue-500" />}
|
||||
: <FolderOpen className="w-14 h-14 text-primary" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium break-words">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderPlus, FolderOpen, SearchX } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
interface EmptyFolderProps {
|
||||
isRoot: boolean;
|
||||
@@ -15,39 +16,35 @@ interface EmptyFolderProps {
|
||||
export function EmptyFolder({ isRoot, canCreate, onUpload, onCreateFolder }: EmptyFolderProps) {
|
||||
const t = useTranslations("files");
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full min-h-[18rem] p-8">
|
||||
<div className="flex flex-col items-center text-center max-w-sm">
|
||||
<div className="w-14 h-14 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
{isRoot ? <Upload className="w-7 h-7 text-muted-foreground" /> : <FolderOpen className="w-7 h-7 text-muted-foreground" />}
|
||||
</div>
|
||||
<h3 className="text-base font-medium">{isRoot ? t("empty_state_title") : t("empty_folder_title")}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{canCreate ? t("empty_folder_description") : t("empty_folder_read_only")}
|
||||
</p>
|
||||
{canCreate && (
|
||||
<div className="flex gap-2 flex-wrap justify-center mt-5">
|
||||
<Button size="sm" onClick={onUpload} className="gap-1.5">
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("upload_files")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
{t("new_folder")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
className="h-full"
|
||||
icon={isRoot ? Upload : FolderOpen}
|
||||
title={isRoot ? t("empty_state_title") : t("empty_folder_title")}
|
||||
description={canCreate ? t("empty_folder_description") : t("empty_folder_read_only")}
|
||||
actions={canCreate ? (
|
||||
<>
|
||||
<Button size="sm" onClick={onUpload} className="gap-1.5">
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("upload_files")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onCreateFolder} className="gap-1.5">
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
{t("new_folder")}
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoSearchResults({ query, onClear }: { query: string; onClear: () => void }) {
|
||||
const t = useTranslations("files");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center h-full min-h-[14rem] p-8">
|
||||
<SearchX className="w-10 h-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm font-medium">{t("no_results_for", { query })}</p>
|
||||
<Button variant="ghost" size="sm" className="mt-2" onClick={onClear}>{t("clear_search")}</Button>
|
||||
</div>
|
||||
<EmptyState
|
||||
className="h-full"
|
||||
icon={SearchX}
|
||||
title={t("no_results_for", { query })}
|
||||
actions={<Button variant="outline" size="sm" onClick={onClear}>{t("clear_search")}</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,5 +156,5 @@ export function getSymlinkIcon(size: FileIconSize, label: string) {
|
||||
}
|
||||
|
||||
export function getFolderIcon(size: FileIconSize, colored = true) {
|
||||
return <Folder className={`${ICON_SIZE_CLASSES[size]} shrink-0 ${colored ? "text-blue-500" : "text-muted-foreground"}`} />;
|
||||
return <Folder className={`${ICON_SIZE_CLASSES[size]} shrink-0 ${colored ? "text-primary" : "text-muted-foreground"}`} />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { getFileIconByName } from "@/components/files/file-icons";
|
||||
import { loadFilesSettings } from "@/components/files/files-settings";
|
||||
import { getFilesSettings } from "@/components/files/files-settings";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
|
||||
@@ -51,7 +51,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
const [trail, setTrail] = useState<{ id: string; name: string }[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Map<string, FileNode>>(() => new Map());
|
||||
const [showHidden] = useState(() => loadFilesSettings().showHiddenFiles);
|
||||
const [showHidden] = useState(() => getFilesSettings().showHiddenFiles);
|
||||
|
||||
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
|
||||
|
||||
@@ -144,7 +144,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
<span className="block truncate">{name}</span>
|
||||
{detail && <span className="block truncate text-xs text-muted-foreground">{detail}</span>}
|
||||
</span>
|
||||
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground rtl:rotate-180" />
|
||||
<ChevronRight className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -159,7 +159,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
onClick={() => toggle(node)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-start text-sm transition-colors",
|
||||
checked ? "bg-primary/10" : "hover:bg-muted",
|
||||
checked ? "bg-selection" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -210,7 +210,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
{here === null && !needle && sharedRoots.length > 0 && folderRow(
|
||||
SHARED_WITH_ME,
|
||||
t("shared_with_me"),
|
||||
<Users className="w-5 h-5 shrink-0 text-blue-500" />,
|
||||
<Users className="w-5 h-5 shrink-0 text-primary" />,
|
||||
() => openFolder(SHARED_WITH_ME, t("shared_with_me")),
|
||||
)}
|
||||
{entries.map((node) =>
|
||||
@@ -218,7 +218,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
? folderRow(
|
||||
node.id,
|
||||
node.name,
|
||||
<Folder className="w-5 h-5 shrink-0 text-blue-500" />,
|
||||
<Folder className="w-5 h-5 shrink-0 text-primary" />,
|
||||
() => openFolder(node.id, node.name),
|
||||
sharedBy(node),
|
||||
)
|
||||
@@ -276,7 +276,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
|
||||
</button>
|
||||
{trail.map((crumb, i) => (
|
||||
<span key={crumb.id} className="flex items-center gap-1 min-w-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground rtl:rotate-180" />
|
||||
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrail(trail.slice(0, i + 1))}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { X, Download, Loader2, ExternalLink } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink } from "@/components/icons";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
|
||||
import dynamic from "next/dynamic";
|
||||
@@ -13,6 +14,7 @@ import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin
|
||||
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
|
||||
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,8 @@ interface FilePreviewModalProps {
|
||||
getFileContent: () => Promise<{ blob: Blob; contentType: string }>;
|
||||
/** The previewed file as plugins see it (file-preview-section). */
|
||||
file?: FileResourceView | null;
|
||||
/** The previous and next file of the folder, for ←/→. */
|
||||
neighbours?: PreviewNeighbours;
|
||||
}
|
||||
|
||||
function SimpleMarkdown({ content }: { content: string }) {
|
||||
@@ -138,11 +142,7 @@ function processInline(text: string): React.ReactNode {
|
||||
return parts.length === 1 ? parts[0] : <>{parts}</>;
|
||||
}
|
||||
|
||||
// Closing one preview can leave an asynchronous Back traversal in flight.
|
||||
// A replacement must not install its popstate listener until that completes.
|
||||
let pendingPreviewHistoryBack: Promise<void> | null = null;
|
||||
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file }: FilePreviewModalProps) {
|
||||
export function FilePreviewModal({ name, onClose, onDownload, getFileContent, file, neighbours }: FilePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
@@ -182,43 +182,8 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep the latest onClose for the back-button handler without re-subscribing.
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Defer history setup so Strict Mode's setup/cleanup/setup replay can
|
||||
// cancel the first setup without scheduling a browser Back traversal.
|
||||
useEffect(() => {
|
||||
const entryId = generateUUID();
|
||||
let cancelled = false;
|
||||
let started = false;
|
||||
let poppedByBack = false;
|
||||
const onPop = () => {
|
||||
poppedByBack = true;
|
||||
onCloseRef.current();
|
||||
};
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (pendingPreviewHistoryBack) await pendingPreviewHistoryBack;
|
||||
if (cancelled) return;
|
||||
window.history.pushState({ ...window.history.state, __filePreview: entryId }, "");
|
||||
started = true;
|
||||
window.addEventListener("popstate", onPop);
|
||||
}, 0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("popstate", onPop);
|
||||
if (started && !poppedByBack && window.history.state?.__filePreview === entryId) {
|
||||
pendingPreviewHistoryBack = new Promise<void>(resolve => {
|
||||
window.addEventListener("popstate", () => {
|
||||
pendingPreviewHistoryBack = null;
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Back closes the preview instead of leaving Files.
|
||||
useCloseOnBack(onClose);
|
||||
|
||||
const fileType = resolvedFileType;
|
||||
|
||||
@@ -298,57 +263,22 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
};
|
||||
}, [getFileContent, name, blockRemote]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-label={name} className="fixed inset-0 z-50 flex flex-col bg-black/80" onClick={onClose}>
|
||||
{/* The dialog is `fixed inset-0` under `viewport-fit=cover`, so in an
|
||||
installed iOS PWA (no browser chrome above it) the header would sit
|
||||
underneath the status bar and its buttons - including the close
|
||||
button - became unreachable (#936). Pad the bar itself so its
|
||||
background still bleeds behind the status bar / notch. The insets are
|
||||
physical, so pl/pr (not ps/pe) is correct in RTL too. */}
|
||||
<div className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-sm font-medium truncate">{name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* An installed app hands the new window to the system browser,
|
||||
which cannot load our blob: URL (#865): no dead button there. */}
|
||||
{objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title={t("open_in_new_tab")}
|
||||
aria-label={t("open_in_new_tab")}
|
||||
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PreviewShell
|
||||
name={name}
|
||||
onClose={onClose}
|
||||
onDownload={() => void onDownload()}
|
||||
neighbours={neighbours}
|
||||
// An installed app hands the new window to the system browser, which
|
||||
// cannot load our blob: URL (#865): no dead button there.
|
||||
actions={objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) ? (
|
||||
<IconButton label={t("open_in_new_tab")} onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center overflow-auto p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))]">
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{loading && <Spinner label={t("loading")} className="w-8 h-8 text-white/80" />}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{t("preview_error")}</p>
|
||||
@@ -434,6 +364,6 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
|
||||
)}
|
||||
</div>
|
||||
<FilePreviewPluginSection file={file} />
|
||||
</div>
|
||||
</PreviewShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowUp, ArrowDown, MoreHorizontal, Download, Share2, Star, Check } from "@/components/icons";
|
||||
import { ArrowUp, ArrowDown, MoreVertical, Download, Share2, Star, Check } from "@/components/icons";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import type { FileResource } from "@/stores/file-store";
|
||||
import {
|
||||
ResourceIcon, ShareBadge, Thumbnail, canShowThumbnail, formatFullDate, formatShortDate,
|
||||
@@ -36,6 +37,8 @@ export interface FileViewProps {
|
||||
iconOptions: IconOptions;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
handlers: FileItemHandlers;
|
||||
/** A line about an item, as its tooltip (where a deleted item came from). */
|
||||
itemNote?: (resource: FileResource) => string | undefined;
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 450;
|
||||
@@ -102,6 +105,8 @@ function SelectBox({ checked, visible, onToggle, label }: {
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-opacity",
|
||||
// A 44px hit area on touch, as every Button has (R1).
|
||||
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2",
|
||||
checked ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background",
|
||||
visible || checked ? "opacity-100" : "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
@@ -138,7 +143,7 @@ const LIST_COLUMNS = "grid grid-cols-[auto_minmax(0,1fr)_auto] sm:grid-cols-[aut
|
||||
|
||||
export function FileListView({
|
||||
resources, selected, focusedName, dropTarget, cutNames, starredPaths, pathOf, touch, iconOptions, getImageUrl, handlers,
|
||||
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll,
|
||||
sortKey, sortDir, onSort, allSelected, someSelected, onToggleAll, itemNote, dateLabel,
|
||||
}: FileViewProps & {
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
@@ -146,6 +151,8 @@ export function FileListView({
|
||||
allSelected: boolean;
|
||||
someSelected: boolean;
|
||||
onToggleAll: () => void;
|
||||
/** The date column's heading, "Modified" unless told otherwise. */
|
||||
dateLabel?: string;
|
||||
}) {
|
||||
const t = useTranslations("files");
|
||||
const selecting = selected.size > 0;
|
||||
@@ -165,7 +172,7 @@ export function FileListView({
|
||||
{someSelected && !allSelected && <span className="sr-only">{t("selected_count", { count: selected.size })}</span>}
|
||||
</div>
|
||||
<SortHeader label={t("name")} column="name" sortKey={sortKey} sortDir={sortDir} onSort={onSort} />
|
||||
<SortHeader label={t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
|
||||
<SortHeader label={dateLabel ?? t("modified")} column="modified" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden md:block" />
|
||||
<SortHeader label={t("size")} column="size" sortKey={sortKey} sortDir={sortDir} onSort={onSort} className="hidden sm:block text-end" />
|
||||
<div role="columnheader" className="w-[4.75rem]"><span className="sr-only">{t("context_menu")}</span></div>
|
||||
</div>
|
||||
@@ -183,13 +190,14 @@ export function FileListView({
|
||||
iconOptions={iconOptions}
|
||||
getImageUrl={getImageUrl}
|
||||
handlers={handlers}
|
||||
note={itemNote?.(resource)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers }: {
|
||||
function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isStarred, selecting, touch, iconOptions, getImageUrl, handlers, note }: {
|
||||
resource: FileResource;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
@@ -201,6 +209,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
iconOptions: IconOptions;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
handlers: FileItemHandlers;
|
||||
note?: string;
|
||||
}) {
|
||||
const t = useTranslations("files");
|
||||
const touchProps = useTouchPress(touch, selecting, resource, handlers);
|
||||
@@ -220,7 +229,7 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
LIST_COLUMNS,
|
||||
"group px-4 border-b border-border/60 cursor-default select-none transition-colors outline-none",
|
||||
touch ? "min-h-[52px] py-1.5" : "h-10",
|
||||
isSelected ? "bg-primary/10" : isDropTarget ? "bg-primary/10 ring-1 ring-inset ring-primary/50" : "hover:bg-muted/60",
|
||||
isSelected ? "bg-selection" : isDropTarget ? "bg-primary/10 ring-1 ring-inset ring-primary/50" : "hover:bg-muted/60",
|
||||
isFocused && "ring-1 ring-inset ring-ring/60",
|
||||
isCut && "opacity-50",
|
||||
)}
|
||||
@@ -251,15 +260,16 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
}}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn("truncate text-start hover:underline underline-offset-2 decoration-muted-foreground/50", resource.isDirectory && "font-medium")}
|
||||
title={resource.name}
|
||||
title={note ? `${resource.name}\n${note}` : resource.name}
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-500 fill-current" aria-label={t("favorites")} />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
</div>
|
||||
{touch && (
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{note && <>{note} · </>}
|
||||
{formatShortDate(resource.lastModified)}
|
||||
{!resource.isDirectory && !resource.isSymlink && <> · {formatFileSize(resource.contentLength)}</>}
|
||||
</div>
|
||||
@@ -279,17 +289,16 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
{!touch && handlers.canShare(resource) && (
|
||||
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
data-item-action
|
||||
aria-label={t("more_actions", { name: resource.name })}
|
||||
label={t("more_actions", { name: resource.name })}
|
||||
title={t("context_menu")}
|
||||
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-colors"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -297,17 +306,15 @@ function FileListRow({ resource, isSelected, isFocused, isDropTarget, isCut, isS
|
||||
|
||||
function RowAction({ icon: Icon, label, onClick }: { icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
data-item-action
|
||||
aria-label={label}
|
||||
title={label}
|
||||
label={label}
|
||||
onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 transition-all opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
className="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,7 +395,7 @@ function FolderTile({ resource, isSelected, isFocused, isDropTarget, isCut, isSt
|
||||
{...touchProps}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2.5 h-11 ps-3 pe-1 rounded-lg border cursor-default select-none transition-colors",
|
||||
isSelected ? "border-primary/60 bg-primary/10" : isDropTarget ? "border-primary bg-primary/10" : "border-border bg-background hover:bg-muted/60",
|
||||
isSelected ? "border-primary/60 bg-selection" : isDropTarget ? "border-primary bg-primary/10" : "border-border bg-background hover:bg-muted/60",
|
||||
isFocused && "ring-2 ring-ring/50",
|
||||
isCut && "opacity-50",
|
||||
)}
|
||||
@@ -409,18 +416,17 @@ function FolderTile({ resource, isSelected, isFocused, isDropTarget, isCut, isSt
|
||||
>
|
||||
{resource.name}
|
||||
</button>
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-500 fill-current" aria-label={t("favorites")} />}
|
||||
{isStarred && <Star className="w-3.5 h-3.5 shrink-0 text-amber-400 fill-amber-400" aria-label={t("favorites")} />}
|
||||
<ShareBadge resource={resource} t={t} />
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
data-item-action
|
||||
aria-label={t("more_actions", { name: resource.name })}
|
||||
label={t("more_actions", { name: resource.name })}
|
||||
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn("p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10", !touch && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")}
|
||||
className={cn("text-muted-foreground hover:text-foreground", !touch && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100")}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -441,7 +447,7 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
|
||||
{...touchProps}
|
||||
className={cn(
|
||||
"group relative flex flex-col rounded-lg border overflow-hidden cursor-default select-none transition-colors",
|
||||
isSelected ? "border-primary/60 bg-primary/10" : "border-border bg-background hover:bg-muted/60",
|
||||
isSelected ? "border-primary/60 bg-selection" : "border-border bg-background hover:bg-muted/60",
|
||||
isFocused && "ring-2 ring-ring/50",
|
||||
isCut && "opacity-50",
|
||||
)}
|
||||
@@ -481,16 +487,15 @@ function FileTile({ resource, isSelected, isFocused, isCut, selecting, touch, ic
|
||||
<ShareBadge resource={resource} t={t} className="w-3 h-3" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
data-item-action
|
||||
aria-label={t("more_actions", { name: resource.name })}
|
||||
label={t("more_actions", { name: resource.name })}
|
||||
onClick={(e) => { e.stopPropagation(); handlers.onContextMenu(e, resource); }}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
className={cn("p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-foreground/10 shrink-0", !touch && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100")}
|
||||
className={cn("text-muted-foreground hover:text-foreground shrink-0", !touch && "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100")}
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+185
-46
@@ -7,7 +7,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useFileStore, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
|
||||
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
|
||||
import { isTrashLocation } from "@/lib/files-trash";
|
||||
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
@@ -88,7 +89,9 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
createTextFile,
|
||||
duplicateResource,
|
||||
downloadResources,
|
||||
downloadZip,
|
||||
moveToFolderId,
|
||||
moveNode,
|
||||
cutResources,
|
||||
copyResources,
|
||||
pasteResources,
|
||||
@@ -109,6 +112,11 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
lastAction,
|
||||
shareResource,
|
||||
openSharedNode,
|
||||
trashResources,
|
||||
trashNode,
|
||||
restoreResources,
|
||||
purgeResources,
|
||||
emptyTrash,
|
||||
} = useFileStore();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
@@ -397,42 +405,6 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
}, [uploadFolder, t, maxSizeUpload, showUploadReport, failureReason]);
|
||||
|
||||
const handleDelete = useCallback(async (name: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm_message", { name }),
|
||||
confirmText: t("delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
if (!await deleteResource(name)) return;
|
||||
toast.success(t("delete_success"));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [deleteResource, confirmDialog, t, failureReason]);
|
||||
|
||||
const handleBatchDelete = useCallback(async (names: string[]) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("batch_delete_confirm_message", { count: names.length }),
|
||||
confirmText: t("delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
if (!await deleteResources(names)) return;
|
||||
toast.success(t("batch_delete_success", { count: names.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to batch delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [deleteResources, confirmDialog, t, failureReason]);
|
||||
|
||||
const handleUndo = useCallback(async () => {
|
||||
try {
|
||||
await undoLastAction();
|
||||
@@ -443,6 +415,93 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
}, [undoLastAction, t, failureReason]);
|
||||
|
||||
// Delete (R3): in the own drive items go to the trash, with Undo and no
|
||||
// question; in the trash and in a drive someone shared they are gone for
|
||||
// good, so that asks first.
|
||||
const currentParentIdForDelete = useFileStore((s) => s.currentParentId);
|
||||
const handleDeleteNames = useCallback(async (names: string[]) => {
|
||||
if (names.length === 0) return;
|
||||
if (isTrashLocation(currentParentIdForDelete)) {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_forever_title", { count: names.length }),
|
||||
message: names.length === 1
|
||||
? t("delete_forever_message", { name: names[0] })
|
||||
: t("delete_forever_message_count", { count: names.length }),
|
||||
confirmText: t("delete_forever"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const count = await purgeResources(names);
|
||||
if (count > 0) toast.success(t("deleted_forever", { count }));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (deletesToTrash(currentParentIdForDelete)) {
|
||||
try {
|
||||
const count = await trashResources(names);
|
||||
if (count > 0) {
|
||||
toast.success(t("moved_to_trash", { count }), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_forever_title", { count: names.length }),
|
||||
message: names.length === 1
|
||||
? t("delete_forever_message", { name: names[0] })
|
||||
: t("delete_forever_message_count", { count: names.length }),
|
||||
confirmText: t("delete_forever"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
if (names.length === 1 ? !await deleteResource(names[0]) : !await deleteResources(names)) return;
|
||||
toast.success(t("deleted_forever", { count: names.length }));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [currentParentIdForDelete, confirmDialog, t, purgeResources, trashResources, deleteResource, deleteResources, handleUndo, failureReason]);
|
||||
const handleDelete = useCallback((name: string) => handleDeleteNames([name]), [handleDeleteNames]);
|
||||
const handleBatchDelete = handleDeleteNames;
|
||||
|
||||
const handleRestore = useCallback(async (names: string[]) => {
|
||||
try {
|
||||
const count = await restoreResources(names);
|
||||
if (count > 0) toast.success(t("restored", { count }));
|
||||
} catch (err) {
|
||||
console.error("Failed to restore:", err);
|
||||
toast.error(t("restore_error"), failureReason(err));
|
||||
}
|
||||
}, [restoreResources, t, failureReason]);
|
||||
|
||||
const trashCount = useFileStore((s) => s.trashCount);
|
||||
const handleEmptyTrash = useCallback(async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("empty_trash_title"),
|
||||
message: t("empty_trash_message", { count: trashCount }),
|
||||
confirmText: t("empty_trash"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await emptyTrash();
|
||||
toast.success(t("trash_emptied"));
|
||||
} catch (err) {
|
||||
console.error("Failed to empty the trash:", err);
|
||||
toast.error(t("empty_trash_error"), failureReason(err));
|
||||
}
|
||||
}, [confirmDialog, t, trashCount, emptyTrash, failureReason]);
|
||||
|
||||
const handleRename = useCallback(async (oldName: string, newName: string) => {
|
||||
try {
|
||||
if (!await renameResource(oldName, newName)) return;
|
||||
@@ -479,6 +538,31 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
}, [downloadResources, t]);
|
||||
|
||||
// A folder (or a selection with folders) comes as a zip built here; a
|
||||
// progress toast shows how far it is and can cancel it.
|
||||
const handleDownloadZip = useCallback(async (ids: string[], zipName: string) => {
|
||||
const controller = new AbortController();
|
||||
const toastId = toast.progress(t("zipping", { name: zipName }), {
|
||||
progress: 0,
|
||||
action: { label: t("cancel"), onClick: () => controller.abort() },
|
||||
});
|
||||
try {
|
||||
await downloadZip(ids, zipName, {
|
||||
signal: controller.signal,
|
||||
onProgress: ({ progress, current }) => toast.update(toastId, { progress, message: current }),
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
} catch (err) {
|
||||
toast.dismiss(toastId);
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
toast.info(t("download_canceled"));
|
||||
return;
|
||||
}
|
||||
console.error("Failed to download the zip:", err);
|
||||
toast.error(t("zip_error", { name: zipName }), failureReason(err));
|
||||
}
|
||||
}, [downloadZip, t, failureReason]);
|
||||
|
||||
const handleCreateTextFile = useCallback(async (name: string) => {
|
||||
try {
|
||||
await createTextFile(name);
|
||||
@@ -511,6 +595,18 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
}
|
||||
}, [moveToFolderId, t, handleUndo, failureReason]);
|
||||
|
||||
const handleMoveNode = useCallback(async (id: string, targetId: string | null) => {
|
||||
try {
|
||||
if (!await moveNode(id, targetId)) return;
|
||||
toast.success(t("move_success", { count: 1 }), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to move:", err);
|
||||
toast.error(t("move_error"), failureReason(err));
|
||||
}
|
||||
}, [moveNode, t, handleUndo, failureReason]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
try {
|
||||
if (!await pasteResources()) return;
|
||||
@@ -541,26 +637,39 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to rename:", err);
|
||||
toast.error(t("rename_error"));
|
||||
toast.error(t("rename_error"), failureReason(err));
|
||||
}
|
||||
}, [renameNode, t, handleUndo]);
|
||||
}, [renameNode, t, handleUndo, failureReason]);
|
||||
|
||||
const handleDeleteNode = useCallback(async (id: string, name: string) => {
|
||||
// A folder of the own drive goes to the trash; one of a shared drive is gone for good.
|
||||
if (deletesToTrash(id)) {
|
||||
try {
|
||||
if (!await trashNode(id)) return;
|
||||
toast.success(t("moved_to_trash", { count: 1 }), {
|
||||
action: { label: t("undo"), onClick: handleUndo },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("delete_confirm_title"),
|
||||
message: t("delete_confirm_message", { name }),
|
||||
confirmText: t("delete"),
|
||||
title: t("delete_forever_title", { count: 1 }),
|
||||
message: t("delete_forever_message", { name }),
|
||||
confirmText: t("delete_forever"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
if (!await deleteNode(id)) return;
|
||||
toast.success(t("delete_success"));
|
||||
toast.success(t("deleted_forever", { count: 1 }));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
toast.error(t("delete_error"));
|
||||
toast.error(t("delete_error"), failureReason(err));
|
||||
}
|
||||
}, [deleteNode, confirmDialog, t]);
|
||||
}, [deleteNode, trashNode, confirmDialog, handleUndo, t, failureReason]);
|
||||
|
||||
const handleDownloadNode = useCallback(async (id: string) => {
|
||||
try {
|
||||
@@ -600,6 +709,29 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
|
||||
// The previewed file as plugins see it (file-preview-section).
|
||||
const previewedName = previewFile ?? previewImage;
|
||||
// ←/→ in a preview go through the folder's other previewable files.
|
||||
const previewNeighbours = useMemo(() => {
|
||||
if (!previewedName) return undefined;
|
||||
const names = resources
|
||||
.filter((r) => !r.isDirectory && !r.isSymlink && (isImageFile(r.name) || isFilePreviewable(r.name)))
|
||||
.map((r) => r.name);
|
||||
const i = names.indexOf(previewedName);
|
||||
if (i === -1) return undefined;
|
||||
return {
|
||||
previous: names[i - 1] ?? null,
|
||||
next: names[i + 1] ?? null,
|
||||
onGo: (name: string) => {
|
||||
addRecentFile(name, resources.find((r) => r.name === name)?.id ?? name);
|
||||
if (isImageFile(name)) {
|
||||
setPreviewFile(null);
|
||||
setPreviewImage(name);
|
||||
} else {
|
||||
setPreviewImage(null);
|
||||
setPreviewFile(name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [previewedName, resources, addRecentFile]);
|
||||
const previewedView = useMemo(() => {
|
||||
const resource = previewedName ? resources.find((r) => r.name === previewedName) : undefined;
|
||||
return resource ? resourceToView(resource, currentPath) : null;
|
||||
@@ -691,7 +823,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
{!filesEnabled ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="max-w-lg text-center space-y-3 px-4">
|
||||
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
|
||||
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
|
||||
<p className="text-sm font-medium">{t("disabled_title")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
|
||||
</div>
|
||||
@@ -716,9 +848,12 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onCancelUpload={cancelUpload}
|
||||
onDelete={handleDelete}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onRestore={handleRestore}
|
||||
onEmptyTrash={() => void handleEmptyTrash()}
|
||||
onRename={handleRename}
|
||||
onDownload={handleDownload}
|
||||
onBatchDownload={handleBatchDownload}
|
||||
onDownloadZip={handleDownloadZip}
|
||||
onRefresh={refresh}
|
||||
onToggleSelect={toggleSelect}
|
||||
onClearSelection={clearSelection}
|
||||
@@ -727,6 +862,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onCopy={copyResources}
|
||||
onPaste={handlePaste}
|
||||
onMoveTo={handleMoveTo}
|
||||
onMoveNode={handleMoveNode}
|
||||
onPreviewImage={handlePreviewImage}
|
||||
onPreviewFile={handlePreviewFile}
|
||||
isOfficeEditable={isOfficeEditable}
|
||||
@@ -776,6 +912,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onDownload={handleDownload}
|
||||
getImageUrl={getImageUrl}
|
||||
file={previewedView}
|
||||
neighbours={previewNeighbours}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -787,6 +924,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
onDownload={() => handleDownload(previewFile)}
|
||||
getFileContent={() => getFileContent(previewFile)}
|
||||
file={previewedView}
|
||||
neighbours={previewNeighbours}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -806,6 +944,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
|
||||
target={{ kind: "file", id: node.id, name: editResource.name }}
|
||||
accountId={node.accountId ?? filesAccountId}
|
||||
slot={slot}
|
||||
closeOnBack
|
||||
onClose={() => {
|
||||
setEditFile(null);
|
||||
// The editor may have saved new content - pick up size/mtime.
|
||||
|
||||
@@ -5,9 +5,10 @@ import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
|
||||
PanelRight, Download, Trash2, Scissors, Copy, Pencil, Share2, Clipboard,
|
||||
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw,
|
||||
MoreHorizontal, FolderOpen, Folder, ArrowUp, ArrowDown, RefreshCw, RotateCcw, FolderInput,
|
||||
} from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
|
||||
@@ -38,6 +39,8 @@ interface SelectionActions {
|
||||
onDownload: () => void;
|
||||
onShare: () => void;
|
||||
onRename: () => void;
|
||||
/** Move to… a folder picked from the tree; undefined where items can't move. */
|
||||
onMoveTo?: () => void;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -46,6 +49,9 @@ interface SelectionActions {
|
||||
pluginFiles: FileResourceView[];
|
||||
/** The one selected item's type, so plugins can offer type-specific actions; undefined for several. */
|
||||
pluginMimeType?: string;
|
||||
/** Items in the trash: Restore and Delete permanently, nothing else. */
|
||||
trash?: boolean;
|
||||
onRestore?: () => void;
|
||||
}
|
||||
|
||||
interface FilesHeaderProps {
|
||||
@@ -75,6 +81,8 @@ interface FilesHeaderProps {
|
||||
/** Hide the tools that only apply to a folder listing (Pro account picker). */
|
||||
toolsHidden?: boolean;
|
||||
selection: SelectionActions | null;
|
||||
/** In the trash: the Empty trash button. */
|
||||
emptyTrash?: { onEmpty: () => void; disabled: boolean };
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_CRUMBS = 3;
|
||||
@@ -104,6 +112,7 @@ export function FilesHeader({
|
||||
compact,
|
||||
toolsHidden,
|
||||
selection,
|
||||
emptyTrash,
|
||||
}: FilesHeaderProps) {
|
||||
const t = useTranslations("files");
|
||||
const sortMenu = useContextMenu<null>();
|
||||
@@ -116,32 +125,47 @@ export function FilesHeader({
|
||||
if (selection) {
|
||||
return (
|
||||
<div role="toolbar" aria-label={t("selection_actions")} className={cn(headerClass, "bg-accent/40")}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 -ms-1" onClick={selection.onClear} aria-label={t("clear_selection")} title={t("clear_selection")}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<IconButton label={t("clear_selection")} className="-ms-1" onClick={selection.onClear}>
|
||||
<X />
|
||||
</IconButton>
|
||||
<span className="text-sm font-medium me-2 whitespace-nowrap" aria-live="polite">
|
||||
{t("selected_count", { count: selection.count })}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 min-w-0 overflow-x-auto">
|
||||
{selection.trash ? (
|
||||
<>
|
||||
{selection.onRestore && (
|
||||
<ToolButton icon={RotateCcw} label={t("restore")} onClick={selection.onRestore} compact />
|
||||
)}
|
||||
<ToolButton icon={Trash2} label={t("delete_forever")} onClick={selection.onDelete} compact destructive />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* All icons with a tooltip, as in Mail's selection bar: one kind of button. */}
|
||||
{selection.canOpen && (
|
||||
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact={compact} />
|
||||
<ToolButton icon={FolderOpen} label={t("open")} onClick={selection.onOpen} compact />
|
||||
)}
|
||||
{selection.fileCount > 0 && (
|
||||
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact={compact} />
|
||||
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
|
||||
)}
|
||||
{selection.canShare && (
|
||||
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact={compact} />
|
||||
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
|
||||
)}
|
||||
{selection.canRename && (
|
||||
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
|
||||
)}
|
||||
{selection.onMoveTo && (
|
||||
<ToolButton icon={FolderInput} label={t("move_to")} onClick={selection.onMoveTo} compact />
|
||||
)}
|
||||
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
|
||||
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
|
||||
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
|
||||
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
|
||||
{compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
|
||||
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
|
||||
<div className="flex-1" />
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
</div>
|
||||
@@ -172,7 +196,7 @@ export function FilesHeader({
|
||||
const isLast = crumb === crumbs[crumbs.length - 1];
|
||||
return (
|
||||
<li key={`${crumb.id ?? "root"}:${crumb.path}`} className="flex items-center min-w-0 shrink-0 last:shrink">
|
||||
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />}
|
||||
{index > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigateCrumb(crumb)}
|
||||
@@ -235,16 +259,14 @@ export function FilesHeader({
|
||||
return (
|
||||
<div role="toolbar" aria-label={t("toolbar")} className={headerClass}>
|
||||
{onMenuClick && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -ms-1 shrink-0"
|
||||
<IconButton
|
||||
label={t("open_folder_tree")}
|
||||
className="-ms-1 shrink-0"
|
||||
onClick={onMenuClick}
|
||||
aria-label={t("open_folder_tree")}
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
<Menu className="w-4 h-4" />
|
||||
</Button>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<nav aria-label={t("breadcrumbs")} className="flex-1 min-w-0">
|
||||
@@ -252,7 +274,7 @@ export function FilesHeader({
|
||||
{visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
|
||||
{accountLabel && (
|
||||
<li className="flex items-center min-w-0 shrink-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateAccountRoot}
|
||||
@@ -264,7 +286,7 @@ export function FilesHeader({
|
||||
)}
|
||||
{hidden.length > 0 && (
|
||||
<li className="flex items-center shrink-0">
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 rtl:rotate-180" aria-hidden />
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
@@ -282,23 +304,33 @@ export function FilesHeader({
|
||||
|
||||
{!toolsHidden && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{emptyTrash && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1.5 text-destructive hover:text-destructive"
|
||||
onClick={emptyTrash.onEmpty}
|
||||
disabled={emptyTrash.disabled}
|
||||
title={t("empty_trash")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span className={cn(compact && "sr-only")}>{t("empty_trash")}</span>
|
||||
</Button>
|
||||
)}
|
||||
{clipboardCount > 0 && (
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={onPaste} title={t("paste_here")}>
|
||||
<Button variant="outline" size="xs" className="gap-1.5" onClick={onPaste} title={t("paste_here")}>
|
||||
<Clipboard className="w-4 h-4" />
|
||||
<span className={cn(compact && "sr-only")}>{t("paste_count", { count: clipboardCount })}</span>
|
||||
</Button>
|
||||
)}
|
||||
{compact ? (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSearchOpen(true)} aria-label={t("search_placeholder")}>
|
||||
<Search className="w-4 h-4" />
|
||||
</Button>
|
||||
<IconButton label={t("search_placeholder")} onClick={() => setSearchOpen(true)}>
|
||||
<Search />
|
||||
</IconButton>
|
||||
) : searchField}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", sortMenu.contextMenu.isOpen && "bg-accent")}
|
||||
aria-label={t("sort_by")}
|
||||
title={t("sort_by")}
|
||||
<IconButton
|
||||
label={t("sort_by")}
|
||||
className={cn(sortMenu.contextMenu.isOpen && "bg-accent")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={sortMenu.contextMenu.isOpen}
|
||||
onMouseDown={(e) => { if (sortMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
@@ -307,8 +339,8 @@ export function FilesHeader({
|
||||
else sortMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
}}
|
||||
>
|
||||
<ArrowUpDown className="w-4 h-4" />
|
||||
</Button>
|
||||
<ArrowUpDown />
|
||||
</IconButton>
|
||||
<div className="flex items-center rounded-md border border-border p-0.5" role="radiogroup" aria-label={t("view_mode")}>
|
||||
{([["list", LayoutList, t("list_view")], ["grid", LayoutGrid, t("grid_view")]] as const).map(([mode, Icon, label]) => (
|
||||
<button
|
||||
@@ -321,6 +353,8 @@ export function FilesHeader({
|
||||
onClick={() => onViewModeChange(mode)}
|
||||
className={cn(
|
||||
"h-6 w-7 flex items-center justify-center rounded transition-colors",
|
||||
// A 44px hit area on touch, as every Button has (R1).
|
||||
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2",
|
||||
viewMode === mode ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
@@ -329,12 +363,18 @@ export function FilesHeader({
|
||||
))}
|
||||
</div>
|
||||
{!compact && (
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onRefresh} disabled={isLoading} aria-label={t("refresh")} title={t("refresh")}>
|
||||
<RefreshCw className={cn("w-4 h-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
<IconButton label={t("refresh")} onClick={onRefresh} disabled={isLoading}>
|
||||
<RefreshCw className={cn(isLoading && "animate-spin")} />
|
||||
</IconButton>
|
||||
)}
|
||||
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
|
||||
<PhoneHeaderActions below="md" />
|
||||
<PhoneHeaderActions
|
||||
below="md"
|
||||
menuItems={(close) => (
|
||||
// The phone header has no room for Refresh as a button.
|
||||
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -386,8 +426,8 @@ function ToolButton({ icon: Icon, label, onClick, compact, destructive }: {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={compact ? "icon" : "sm"}
|
||||
className={cn("h-8 shrink-0", compact ? "w-8" : "gap-1.5 px-2.5", destructive && "text-destructive hover:text-destructive hover:bg-destructive/10")}
|
||||
size={compact ? "icon-sm" : "xs"}
|
||||
className={cn("shrink-0", !compact && "gap-1.5", destructive && "text-destructive hover:text-destructive hover:bg-destructive/10")}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
@@ -400,17 +440,14 @@ function ToolButton({ icon: Icon, label, onClick, compact, destructive }: {
|
||||
|
||||
function DetailsToggle({ showDetails, onToggle, label }: { showDetails: boolean; onToggle: () => void; label: string }) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-8 w-8 shrink-0", showDetails && "bg-accent text-accent-foreground")}
|
||||
<IconButton
|
||||
label={label}
|
||||
className={cn("shrink-0", showDetails && "bg-accent text-accent-foreground")}
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
aria-pressed={showDetails}
|
||||
title={label}
|
||||
>
|
||||
<PanelRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<PanelRight />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,46 @@
|
||||
// Files app preferences, kept in localStorage and edited in Settings > Files.
|
||||
// Files app preferences: in the synced settings store, edited in Settings > Files
|
||||
// and by the Files toolbar.
|
||||
|
||||
export interface FilesSettings {
|
||||
defaultViewMode: "list" | "grid";
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
defaultSortKey: "name" | "size" | "modified";
|
||||
defaultSortDir: "asc" | "desc";
|
||||
showHiddenFiles: boolean;
|
||||
showThumbnails: boolean;
|
||||
}
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from "@/lib/files-settings";
|
||||
|
||||
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
|
||||
defaultViewMode: "list",
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
defaultSortKey: "name",
|
||||
defaultSortDir: "asc",
|
||||
showHiddenFiles: false,
|
||||
showThumbnails: true,
|
||||
};
|
||||
export { DEFAULT_FILES_SETTINGS, type FilesSettings };
|
||||
|
||||
export function loadFilesSettings(): FilesSettings {
|
||||
if (typeof window === "undefined") return DEFAULT_FILES_SETTINGS;
|
||||
/** Where older builds kept the Files settings, on this device only. */
|
||||
export const LEGACY_FILES_SETTINGS_KEY = "files-settings";
|
||||
|
||||
/**
|
||||
* Moves Files settings an older build left in localStorage into the synced
|
||||
* store, once. A choice already made in the synced settings (on this or
|
||||
* another device) wins; the old copy is removed either way.
|
||||
*/
|
||||
export function migrateLegacyFilesSettings(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const raw = localStorage.getItem("files-settings");
|
||||
if (raw) return { ...DEFAULT_FILES_SETTINGS, ...JSON.parse(raw) };
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_FILES_SETTINGS;
|
||||
const raw = localStorage.getItem(LEGACY_FILES_SETTINGS_KEY);
|
||||
if (raw === null) return;
|
||||
const store = useSettingsStore.getState();
|
||||
if (!store.explicitSettings.includes("filesSettings")) {
|
||||
store.updateSetting("filesSettings", normalizeFilesSettings(JSON.parse(raw)));
|
||||
}
|
||||
localStorage.removeItem(LEGACY_FILES_SETTINGS_KEY);
|
||||
} catch { /* unreadable: the defaults stand */ }
|
||||
}
|
||||
|
||||
export function saveFilesSettings(settings: FilesSettings) {
|
||||
localStorage.setItem("files-settings", JSON.stringify(settings));
|
||||
// Dispatch custom event for same-tab listeners (StorageEvent only fires cross-tab)
|
||||
window.dispatchEvent(new CustomEvent("files-settings-changed"));
|
||||
/** The Files settings as they are now, outside React. */
|
||||
export function getFilesSettings(): FilesSettings {
|
||||
migrateLegacyFilesSettings();
|
||||
return useSettingsStore.getState().filesSettings;
|
||||
}
|
||||
|
||||
/** The Files settings, and a setter that changes some of them. */
|
||||
export function useFilesSettings(): [FilesSettings, (patch: Partial<FilesSettings>) => void] {
|
||||
useEffect(() => { migrateLegacyFilesSettings(); }, []);
|
||||
const settings = useSettingsStore((s) => s.filesSettings);
|
||||
const update = useCallback((patch: Partial<FilesSettings>) => {
|
||||
const { filesSettings, updateSetting } = useSettingsStore.getState();
|
||||
updateSetting("filesSettings", { ...filesSettings, ...patch });
|
||||
}, []);
|
||||
return [settings, update];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
Plus, HardDrive, File, Folder, FolderOpen, ChevronRight, ChevronDown, Star, StarOff,
|
||||
Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch,
|
||||
Clock, Share2, Upload, FolderUp, FolderPlus, FilePlus, Pencil, Trash2, Download, Eye, X, FolderSearch, FolderInput,
|
||||
} from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { isTrashLocation } from "@/lib/files-trash";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -26,11 +27,12 @@ function loadCollapsed(): Record<string, boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)", minHeight: "32px" } as const;
|
||||
const ROW_STYLE = { paddingBlock: "var(--density-sidebar-py, 4px)" } as const;
|
||||
|
||||
function rowClass(active: boolean, dropTarget: boolean) {
|
||||
return cn(
|
||||
"group w-full flex items-center gap-2 pe-3 text-sm transition-colors text-start",
|
||||
// 32px rows, 44px on touch (R1).
|
||||
"group w-full flex items-center gap-2 pe-3 text-sm transition-colors text-start min-h-8 pointer-coarse:min-h-11",
|
||||
active
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: dropTarget
|
||||
@@ -72,11 +74,20 @@ export interface FilesSidebarProps {
|
||||
/** Open a folder, then start creating or uploading there. */
|
||||
onCreateIn: (path: string, id: string | null, kind: CreateKind) => void;
|
||||
onRenameFolder: (folder: FileResource) => void;
|
||||
/** Move to… for a folder of the own drive. */
|
||||
onMoveFolder?: (folder: FileResource) => void;
|
||||
/** Download a folder as a zip. */
|
||||
onDownloadFolder?: (folder: FileResource) => void;
|
||||
onDeleteFolder: (folder: FileResource) => void;
|
||||
/** Open the folder holding a recent file, with the file selected. */
|
||||
onRevealRecent: (id: string) => void;
|
||||
onDownloadRecent: (id: string) => void;
|
||||
onRemoveRecent: (id: string) => void;
|
||||
/** Items in the trash; the entry shows their number. */
|
||||
trashCount?: number;
|
||||
onOpenTrash?: () => void;
|
||||
/** Delete items dragged onto the trash entry. */
|
||||
onDropToTrash?: (names: string[]) => void;
|
||||
}
|
||||
|
||||
export function FilesSidebar({
|
||||
@@ -99,10 +110,15 @@ export function FilesSidebar({
|
||||
onShare,
|
||||
onCreateIn,
|
||||
onRenameFolder,
|
||||
onMoveFolder,
|
||||
onDownloadFolder,
|
||||
onDeleteFolder,
|
||||
onRevealRecent,
|
||||
onDownloadRecent,
|
||||
onRemoveRecent,
|
||||
trashCount = 0,
|
||||
onOpenTrash,
|
||||
onDropToTrash,
|
||||
}: FilesSidebarProps) {
|
||||
const t = useTranslations("files");
|
||||
const newMenu = useContextMenu<null>();
|
||||
@@ -199,7 +215,7 @@ export function FilesSidebar({
|
||||
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
|
||||
aria-label={isOpen ? t("collapse_folder", { name: folder.name }) : t("expand_folder", { name: folder.name })}
|
||||
>
|
||||
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5 rtl:rotate-180" />}
|
||||
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5 -ms-1 shrink-0" />
|
||||
@@ -229,7 +245,7 @@ export function FilesSidebar({
|
||||
aria-expanded={!collapsed[key]}
|
||||
>
|
||||
{collapsed[key]
|
||||
? <ChevronRight className="w-3 h-3 text-muted-foreground rtl:rotate-180" />
|
||||
? <ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
: <ChevronDown className="w-3 h-3 text-muted-foreground" />}
|
||||
{icon}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
|
||||
@@ -265,13 +281,10 @@ export function FilesSidebar({
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
{canCreate && (
|
||||
<Button
|
||||
<IconButton
|
||||
ref={newButtonRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
aria-label={t("new")}
|
||||
title={t("new")}
|
||||
label={t("new")}
|
||||
className="flex-shrink-0"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={newMenu.contextMenu.isOpen}
|
||||
onMouseDown={(e) => { if (newMenu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
@@ -280,8 +293,8 @@ export function FilesSidebar({
|
||||
else newMenu.openContextMenu(anchorEvent(e.currentTarget, "end"), null);
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -300,7 +313,7 @@ export function FilesSidebar({
|
||||
className="p-0.5 -ms-1 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
|
||||
aria-label={collapsed.tree ? t("expand_folder", { name: t("my_files") }) : t("collapse_folder", { name: t("my_files") })}
|
||||
>
|
||||
{collapsed.tree ? <ChevronRight className="w-3.5 h-3.5 rtl:rotate-180" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
{collapsed.tree ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5 -ms-1 shrink-0" />
|
||||
@@ -330,7 +343,7 @@ export function FilesSidebar({
|
||||
style={{ ...ROW_STYLE, paddingInlineStart: "26px" }}
|
||||
title={path}
|
||||
>
|
||||
<Star className="w-4 h-4 shrink-0 text-amber-500 fill-current" />
|
||||
<Star className="w-4 h-4 shrink-0 text-amber-400 fill-amber-400" />
|
||||
<span className="truncate">{path === "/" ? t("my_files") : path.split("/").pop()}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -386,6 +399,37 @@ export function FilesSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onOpenTrash && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTrash}
|
||||
aria-current={isTrashLocation(currentParentId) ? "page" : undefined}
|
||||
className={rowClass(isTrashLocation(currentParentId), dropTarget === "__trash__")}
|
||||
style={{ ...ROW_STYLE, paddingInlineStart: "12px" }}
|
||||
onDragOver={(e) => {
|
||||
if (!isInternalDrag(e) || !onDropToTrash || isTrashLocation(currentParentId)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDropTarget("__trash__");
|
||||
}}
|
||||
onDragLeave={() => setDropTarget((cur) => (cur === "__trash__" ? null : cur))}
|
||||
onDrop={(e) => {
|
||||
if (!isInternalDrag(e) || !onDropToTrash) return;
|
||||
e.preventDefault();
|
||||
setDropTarget(null);
|
||||
const names = readDraggedNames(e);
|
||||
if (names.length > 0) onDropToTrash(names);
|
||||
}}
|
||||
>
|
||||
<span className="w-3.5 -ms-1 shrink-0" />
|
||||
<Trash2 className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate flex-1">{t("trash")}</span>
|
||||
{trashCount > 0 && <span className="text-xs text-muted-foreground tabular-nums">{trashCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recent.length === 0 && favorites.length === 0 && (
|
||||
<p className="px-4 pt-4 text-xs text-muted-foreground flex items-start gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||
@@ -420,7 +464,12 @@ export function FilesSidebar({
|
||||
<>
|
||||
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => run(() => onNavigate(path, folder.id))} />
|
||||
{writable && createItems(path, folder.id)}
|
||||
{/* The list's folder menu in the same order; Cut, Copy and
|
||||
Details act on the list and are left out here (R6). */}
|
||||
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
|
||||
{canShare(folder) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
|
||||
)}
|
||||
{!folder.isShared && (
|
||||
<ContextMenuItem
|
||||
icon={starred ? StarOff : Star}
|
||||
@@ -428,13 +477,17 @@ export function FilesSidebar({
|
||||
onClick={() => run(() => onToggleFavorite(path))}
|
||||
/>
|
||||
)}
|
||||
{canShare(folder) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
|
||||
)}
|
||||
{(mayRename || mayDelete) && <ContextMenuSeparator />}
|
||||
<ContextMenuSeparator />
|
||||
{mayRename && (
|
||||
<ContextMenuItem icon={Pencil} label={t("rename")} onClick={() => run(() => onRenameFolder(folder))} />
|
||||
)}
|
||||
{onMoveFolder && !folder.isShared && mayRename && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("move_to")} onClick={() => run(() => onMoveFolder(folder))} />
|
||||
)}
|
||||
{onDownloadFolder && (
|
||||
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => run(() => onDownloadFolder(folder))} />
|
||||
)}
|
||||
{mayDelete && <ContextMenuSeparator />}
|
||||
{mayDelete && (
|
||||
<ContextMenuItem icon={Trash2} label={t("delete")} destructive onClick={() => run(() => onDeleteFolder(folder))} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, HardDrive } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface PickerFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface FolderPickerDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Called with the chosen folder; null is the root ("My files"). */
|
||||
onPick: (folderId: string | null) => void;
|
||||
title: string;
|
||||
confirmText: string;
|
||||
/** Every folder of the drive; the tree is built from their parent links. */
|
||||
folders: PickerFolder[];
|
||||
/** Folders that can't be picked, with what is below them (what is being moved). */
|
||||
excludeIds?: Set<string>;
|
||||
/** The folder that is pre-selected and can't be picked (where the items already are). */
|
||||
currentId?: string | null;
|
||||
}
|
||||
|
||||
const ROOT = "";
|
||||
|
||||
/**
|
||||
* Picks a folder of the user's drive, e.g. to move items there: the folder
|
||||
* tree with My files at the top, browsed with the mouse or the arrow keys.
|
||||
*/
|
||||
export function FolderPickerDialog({
|
||||
isOpen, onClose, onPick, title, confirmText, folders, excludeIds, currentId = null,
|
||||
}: FolderPickerDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set([ROOT]));
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const childrenOf = useMemo(() => {
|
||||
const map = new Map<string, PickerFolder[]>();
|
||||
for (const folder of folders) {
|
||||
if (excludeIds?.has(folder.id)) continue;
|
||||
const key = folder.parentId ?? ROOT;
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(folder);
|
||||
map.set(key, list);
|
||||
}
|
||||
for (const list of map.values()) list.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
return map;
|
||||
}, [folders, excludeIds]);
|
||||
|
||||
// The rows on screen: the root, then every folder whose parents are open.
|
||||
const rows = useMemo(() => {
|
||||
const out: { id: string; name: string; depth: number; hasChildren: boolean }[] = [
|
||||
{ id: ROOT, name: t("my_files"), depth: 0, hasChildren: (childrenOf.get(ROOT)?.length ?? 0) > 0 },
|
||||
];
|
||||
const walk = (parent: string, depth: number) => {
|
||||
if (!expanded.has(parent)) return;
|
||||
for (const folder of childrenOf.get(parent) ?? []) {
|
||||
out.push({ id: folder.id, name: folder.name, depth, hasChildren: (childrenOf.get(folder.id)?.length ?? 0) > 0 });
|
||||
walk(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(ROOT, 1);
|
||||
return out;
|
||||
}, [childrenOf, expanded, t]);
|
||||
|
||||
const current = currentId ?? ROOT;
|
||||
const canPick = selected !== null && selected !== current;
|
||||
|
||||
const toggle = (id: string, open?: boolean) => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open ?? !next.has(id)) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const focusRow = (id: string) => {
|
||||
setSelected(id);
|
||||
listRef.current?.querySelector<HTMLElement>(`[data-folder-id="${CSS.escape(id)}"]`)?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
const index = rows.findIndex((r) => r.id === (selected ?? current));
|
||||
const row = rows[index];
|
||||
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
||||
const openKey = rtl ? "ArrowLeft" : "ArrowRight";
|
||||
const closeKey = rtl ? "ArrowRight" : "ArrowLeft";
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const next = rows[Math.max(0, Math.min(rows.length - 1, index + (e.key === "ArrowDown" ? 1 : -1)))];
|
||||
if (next) focusRow(next.id);
|
||||
} else if (e.key === openKey && row?.hasChildren) {
|
||||
e.preventDefault();
|
||||
toggle(row.id, true);
|
||||
} else if (e.key === closeKey && row) {
|
||||
e.preventDefault();
|
||||
if (row.hasChildren && expanded.has(row.id)) toggle(row.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const pick = () => {
|
||||
if (!canPick) return;
|
||||
onPick(selected === ROOT ? null : selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
onSubmit={(e) => { e.preventDefault(); pick(); }}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>{t("cancel")}</Button>
|
||||
<Button type="submit" disabled={!canPick}>{confirmText}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
ref={listRef}
|
||||
role="tree"
|
||||
aria-label={t("folders")}
|
||||
onKeyDown={onKeyDown}
|
||||
className="mt-4 max-h-80 overflow-y-auto rounded-md border border-border py-1"
|
||||
>
|
||||
{rows.map((row) => {
|
||||
const isOpen = expanded.has(row.id);
|
||||
const isSelected = (selected ?? current) === row.id;
|
||||
const isCurrent = row.id === current;
|
||||
const Icon = row.id === ROOT ? HardDrive : isOpen && row.hasChildren ? FolderOpen : Folder;
|
||||
return (
|
||||
<div
|
||||
key={row.id || "root"}
|
||||
role="treeitem"
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={row.hasChildren ? isOpen : undefined}
|
||||
aria-disabled={isCurrent || undefined}
|
||||
data-folder-id={row.id}
|
||||
tabIndex={isSelected ? 0 : -1}
|
||||
onClick={() => setSelected(row.id)}
|
||||
onDoubleClick={() => { if (row.id !== current) { onPick(row.id === ROOT ? null : row.id); onClose(); } }}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 pe-3 h-9 text-sm cursor-default select-none outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
isSelected ? "bg-selection" : "hover:bg-muted",
|
||||
isCurrent && "text-muted-foreground",
|
||||
)}
|
||||
style={{ paddingInlineStart: `${8 + row.depth * 16}px` }}
|
||||
>
|
||||
{row.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={isOpen ? t("collapse_folder", { name: row.name }) : t("expand_folder", { name: row.name })}
|
||||
onClick={(e) => { e.stopPropagation(); toggle(row.id); }}
|
||||
className="p-0.5 rounded hover:bg-foreground/10 text-muted-foreground shrink-0"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-[18px] shrink-0" />
|
||||
)}
|
||||
<Icon className="w-4 h-4 shrink-0 text-primary" />
|
||||
<span className="truncate" title={row.name}>{row.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Download } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { PreviewShell, type PreviewNeighbours } from "@/components/files/preview-shell";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ZoomableImage } from "@/components/files/zoomable-image";
|
||||
import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section";
|
||||
import type { FileResourceView } from "@/lib/plugin-types";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
name: string;
|
||||
@@ -15,14 +16,19 @@ interface ImagePreviewModalProps {
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
/** The previewed file as plugins see it (file-preview-section). */
|
||||
file?: FileResourceView | null;
|
||||
/** The previous and next file of the folder, for ←/→. */
|
||||
neighbours?: PreviewNeighbours;
|
||||
}
|
||||
|
||||
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file }: ImagePreviewModalProps) {
|
||||
export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file, neighbours }: ImagePreviewModalProps) {
|
||||
const t = useTranslations("files");
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
// Back closes the preview instead of leaving Files, as for every other preview.
|
||||
useCloseOnBack(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
let revoke: string | null = null;
|
||||
let cancelled = false;
|
||||
@@ -49,61 +55,12 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
};
|
||||
}, [name, getImageUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
return () => window.removeEventListener("keydown", onKey, { capture: true });
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/90"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Same safe-area handling as FilePreviewModal: full-screen overlay +
|
||||
`viewport-fit=cover` puts this bar under the iOS status bar in an
|
||||
installed PWA (#936). */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(0.5rem+env(safe-area-inset-right))] pt-[calc(0.5rem+env(safe-area-inset-top))] pb-2 text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-sm font-medium truncate min-w-0">{name}</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
|
||||
onClick={() => void onDownload(name)}
|
||||
aria-label={t("download")}
|
||||
title={t("download")}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-white hover:bg-white/15 hover:text-white"
|
||||
onClick={onClose}
|
||||
aria-label={t("close_preview")}
|
||||
title={t("close_preview")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PreviewShell name={name} onClose={onClose} onDownload={() => void onDownload(name)} neighbours={neighbours}>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-10 h-10 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<Spinner label={t("loading")} className="w-8 h-8 text-white/80" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
@@ -116,6 +73,6 @@ export function ImagePreviewModal({ name, onClose, onDownload, getImageUrl, file
|
||||
)}
|
||||
</div>
|
||||
<FilePreviewPluginSection file={file} />
|
||||
</div>
|
||||
</PreviewShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface NewFolderDialogProps {
|
||||
onConfirm: (name: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
/** Returns an error message for a name the server would refuse. */
|
||||
validate?: (name: string) => string | null;
|
||||
}
|
||||
|
||||
export function NewFolderDialog({ onConfirm, onCancel, validate }: NewFolderDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const [name, setName] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || validate?.(trimmed)) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm(trimmed);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">{t("new_folder")}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("new_folder_name")}
|
||||
className={nameError ? "mb-1" : "mb-4"}
|
||||
aria-invalid={!!nameError}
|
||||
/>
|
||||
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
export interface PreviewNeighbours {
|
||||
/** The previous and next previewable file of the folder, if any. */
|
||||
previous?: string | null;
|
||||
next?: string | null;
|
||||
onGo: (name: string) => void;
|
||||
}
|
||||
|
||||
interface PreviewShellProps {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onDownload: () => void;
|
||||
/** Extra header buttons before Download (e.g. "Open in new tab"). */
|
||||
actions?: ReactNode;
|
||||
neighbours?: PreviewNeighbours;
|
||||
/** Click on the dark area around the content closes the preview. */
|
||||
closeOnBackdrop?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one frame both file previews use (images and everything else): the
|
||||
* same dim backdrop, a header with the name, Download and Close, ←/→ to the
|
||||
* previous and next file of the folder, Escape to close.
|
||||
*/
|
||||
export function PreviewShell({ name, onClose, onDownload, actions, neighbours, closeOnBackdrop = true, children }: PreviewShellProps) {
|
||||
const t = useTranslations("files");
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
};
|
||||
// The arrows after the content's own keys: a zoomed image pans with them
|
||||
// and takes the key (preventDefault), the frame goes to the next file.
|
||||
const onArrow = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || !neighbours || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (target?.closest("input, textarea, [contenteditable=true]")) return;
|
||||
const rtl = document.dir === "rtl";
|
||||
const forward = rtl ? "ArrowLeft" : "ArrowRight";
|
||||
const back = rtl ? "ArrowRight" : "ArrowLeft";
|
||||
if (e.key === forward && neighbours.next) {
|
||||
e.preventDefault();
|
||||
neighbours.onGo(neighbours.next);
|
||||
} else if (e.key === back && neighbours.previous) {
|
||||
e.preventDefault();
|
||||
neighbours.onGo(neighbours.previous);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, { capture: true });
|
||||
window.addEventListener("keydown", onArrow);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, { capture: true });
|
||||
window.removeEventListener("keydown", onArrow);
|
||||
};
|
||||
}, [onClose, neighbours]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/80"
|
||||
onClick={closeOnBackdrop ? onClose : undefined}
|
||||
>
|
||||
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
|
||||
the iOS status bar (#936). The insets are physical, so pl/pr. */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{neighbours && (
|
||||
<>
|
||||
<IconButton label={t("previous_file")} disabled={!neighbours.previous} onClick={() => neighbours.previous && neighbours.onGo(neighbours.previous)}>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
<IconButton label={t("next_file")} disabled={!neighbours.next} onClick={() => neighbours.next && neighbours.onGo(neighbours.next)}>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
{actions}
|
||||
<IconButton label={t("download")} onClick={onDownload}>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton label={t("close_preview")} onClick={onClose}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface RenameDialogProps {
|
||||
currentName: string;
|
||||
title?: string;
|
||||
label?: string;
|
||||
onConfirm: (newName: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
/** Returns an error message for a name the server would refuse. */
|
||||
validate?: (name: string) => string | null;
|
||||
}
|
||||
|
||||
export function RenameDialog({ currentName, title, label, onConfirm, onCancel, validate }: RenameDialogProps) {
|
||||
const t = useTranslations("files");
|
||||
const [name, setName] = useState(currentName);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const nameError = name.trim() ? validate?.(name.trim()) ?? null : null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || validate?.(trimmed)) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm(trimmed);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-background border border-border rounded-lg shadow-lg p-6 w-full max-w-sm mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">{title || t("rename_title")}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={label || t("new_name")}
|
||||
className={nameError ? "mb-1" : "mb-4"}
|
||||
aria-invalid={!!nameError}
|
||||
/>
|
||||
{nameError && <p className="text-xs text-destructive mb-3">{nameError}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || !!nameError || isSubmitting}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useLocale, useTranslations } from "next-intl";
|
||||
import { Loader2, X } from "@/components/icons";
|
||||
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
|
||||
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
|
||||
export type WopiDocument =
|
||||
@@ -21,6 +22,8 @@ interface WopiEditorProps {
|
||||
*/
|
||||
slot?: number | null;
|
||||
onClose: () => void;
|
||||
/** Back closes the editor (Files); Mail keeps its own history. */
|
||||
closeOnBack?: boolean;
|
||||
}
|
||||
|
||||
interface LaunchData {
|
||||
@@ -36,8 +39,9 @@ interface LaunchData {
|
||||
* a form POST of the access token to the editor URL, targeted at an iframe.
|
||||
* Mail attachments open in the same overlay as a read-only viewer (#1047).
|
||||
*/
|
||||
export function WopiEditor({ target, accountId, slot, onClose }: WopiEditorProps) {
|
||||
export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = false }: WopiEditorProps) {
|
||||
const t = useTranslations("files");
|
||||
useCloseOnBack(onClose, closeOnBack);
|
||||
const locale = useLocale();
|
||||
const [launch, setLaunch] = useState<LaunchData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -1425,6 +1425,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
|
||||
const handleMailtoProtocolRequest = useCallback(async (pending: ParsedMailto) => {
|
||||
if (!(await mailtoAllowed(pending))) return;
|
||||
// From another app of this webmail: the account in use, no question.
|
||||
if (pending.inApp) {
|
||||
openMailtoDraft(pending);
|
||||
return;
|
||||
}
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
|
||||
@@ -160,3 +160,12 @@ describe('ShareCollectionDialog directory', () => {
|
||||
expect(screen.queryByText('no_principals')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShareCollectionDialog focus', () => {
|
||||
it('takes the focus when it opens and has a named close button', async () => {
|
||||
renderDialog(fullDirectory());
|
||||
const dialog = screen.getByRole('dialog');
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(screen.getAllByRole('button', { name: 'close' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { SettingsSection } from "./settings-section";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AddressBook, AddressBookRights } from "@/lib/jmap/types";
|
||||
import { ShareCollectionDialog } from "./share-collection-dialog";
|
||||
import { RenameDialog } from "@/components/files/rename-dialog";
|
||||
import { PromptDialog } from "@/components/ui/prompt-dialog";
|
||||
|
||||
function AddressBookEditRow({
|
||||
initial,
|
||||
@@ -93,7 +93,6 @@ export function AddressBookManagementSettings() {
|
||||
try {
|
||||
await createAddressBook(client, name);
|
||||
await fetchAddressBooks(client);
|
||||
setCreating(false);
|
||||
toast.success(t("created"));
|
||||
} catch {
|
||||
toast.error(t("create_failed"));
|
||||
@@ -335,15 +334,14 @@ export function AddressBookManagementSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<RenameDialog
|
||||
currentName=""
|
||||
title={t("create")}
|
||||
label={t("name_label")}
|
||||
onCancel={() => setCreating(false)}
|
||||
onConfirm={handleCreate}
|
||||
/>
|
||||
)}
|
||||
<PromptDialog
|
||||
isOpen={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
title={t("create")}
|
||||
placeholder={t("name_label")}
|
||||
confirmText={t("create_confirm")}
|
||||
onSubmit={(name) => void handleCreate(name)}
|
||||
/>
|
||||
|
||||
{sharingId && client && (() => {
|
||||
const book = addressBooks.find((b) => b.id === sharingId);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Folder, FileText, FileCode, ImageIcon, FileAudio, File } from "@/components/icons";
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings } from "@/components/files/files-settings";
|
||||
import { useFilesSettings, type FilesSettings } from "@/components/files/files-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
@@ -156,26 +156,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
|
||||
export function FilesSettingsComponent() {
|
||||
const t = useTranslations("settings.files");
|
||||
const [settings, setSettings] = useState<FilesSettings>(loadFilesSettings);
|
||||
|
||||
// Listen for external changes (e.g. if file-browser updates settings)
|
||||
useEffect(() => {
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "files-settings") {
|
||||
setSettings(loadFilesSettings());
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const update = useCallback((patch: Partial<FilesSettings>) => {
|
||||
setSettings(prev => {
|
||||
const next = { ...prev, ...patch };
|
||||
saveFilesSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const [settings, update] = useFilesSettings();
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "@/components/icons";
|
||||
import { Loader2, UserPlus, Trash2, ChevronDown, Share2 } from "@/components/icons";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Principal, PrincipalDirectoryStatus, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
|
||||
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
|
||||
@@ -151,7 +152,6 @@ export function ShareCollectionDialog({
|
||||
}: ShareCollectionDialogProps) {
|
||||
const t = useTranslations("sharing");
|
||||
const tCommon = useTranslations("common");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [allPrincipals, setAllPrincipals] = useState<Principal[]>([]);
|
||||
const [directoryStatus, setDirectoryStatus] = useState<PrincipalDirectoryStatus>("ok");
|
||||
const [directoryQueriesOff, setDirectoryQueriesOff] = useState(false);
|
||||
@@ -189,15 +189,6 @@ export function ShareCollectionDialog({
|
||||
return allPrincipals.filter((p) => p.id !== ownAccountId && !existing.has(p.id));
|
||||
}, [allPrincipals, ownAccountId, shareWith]);
|
||||
|
||||
// Close on Escape, focus trap, click outside
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSetRights = async (principalId: string, preset: RolePreset) => {
|
||||
if (preset === "custom") return; // custom is read-only here
|
||||
const rights = presetRights(kind, preset);
|
||||
@@ -260,30 +251,17 @@ export function ShareCollectionDialog({
|
||||
: ["read", "readWrite", "manager"] as const;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
ref={modalRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("title", { name: collectionName })}
|
||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 animate-in zoom-in-95 duration-200 max-h-[85vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold">{t("title", { name: collectionName })}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
aria-label={tCommon("close")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 space-y-4 overflow-y-auto">
|
||||
// The shared dialog shell (R4): focus moves in and comes back, Escape
|
||||
// and the close X work from anywhere in it.
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t("title", { name: collectionName })}
|
||||
icon={<Share2 className="w-5 h-5 text-primary shrink-0 mt-1" />}
|
||||
size="lg"
|
||||
footer={<Button onClick={onClose}>{tCommon("close")}</Button>}
|
||||
>
|
||||
<div className="pt-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("description")}</p>
|
||||
|
||||
{sharedEntries.length === 0 && !showAdd && (
|
||||
@@ -440,10 +418,6 @@ export function ShareCollectionDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||
<Button onClick={onClose}>{tCommon("close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
// Closing one layer can leave an asynchronous Back traversal in flight. The
|
||||
// next layer must not install its popstate listener until that completes, or
|
||||
// the traversal would close it at once.
|
||||
let pendingHistoryBack: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Lets the browser's Back (and the phone's back gesture) close an overlay
|
||||
* instead of leaving the page: while `active`, the overlay holds a history
|
||||
* entry of its own, and Back calls `onBack`. Closed any other way, the entry
|
||||
* is taken back off, so the history ends where it was.
|
||||
*
|
||||
* `onBack` runs after the entry is gone. A layer that decides to stay open
|
||||
* (an editor that asked "Discard changes?" and heard "Keep editing") calls
|
||||
* the returned `rearm` to hold an entry again. `armed` tells whether the
|
||||
* entry is there now, e.g. before a `router.back()` that must leave for real.
|
||||
*/
|
||||
export function useCloseOnBack(onBack: () => void, active = true): { rearm: () => void; armed: () => boolean } {
|
||||
const onBackRef = useRef(onBack);
|
||||
onBackRef.current = onBack;
|
||||
const rearmRef = useRef<() => void>(() => {});
|
||||
const armedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
let cancelled = false;
|
||||
let entryId: string | null = null;
|
||||
let poppedByBack = false;
|
||||
|
||||
const onPop = () => {
|
||||
if (!entryId) return;
|
||||
entryId = null;
|
||||
armedRef.current = false;
|
||||
poppedByBack = true;
|
||||
window.removeEventListener("popstate", onPop);
|
||||
onBackRef.current();
|
||||
};
|
||||
const push = () => {
|
||||
if (cancelled || entryId) return;
|
||||
entryId = generateUUID();
|
||||
armedRef.current = true;
|
||||
poppedByBack = false;
|
||||
window.history.pushState({ ...window.history.state, __filePreview: entryId }, "");
|
||||
window.addEventListener("popstate", onPop);
|
||||
};
|
||||
rearmRef.current = push;
|
||||
|
||||
// Deferred, so Strict Mode's setup/cleanup/setup replay can cancel the
|
||||
// first setup without scheduling a browser Back traversal.
|
||||
const timer = window.setTimeout(async () => {
|
||||
if (pendingHistoryBack) await pendingHistoryBack;
|
||||
push();
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
rearmRef.current = () => {};
|
||||
armedRef.current = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("popstate", onPop);
|
||||
const ours = entryId;
|
||||
if (ours && !poppedByBack && window.history.state?.__filePreview === ours) {
|
||||
pendingHistoryBack = new Promise<void>((resolve) => {
|
||||
window.addEventListener("popstate", () => {
|
||||
pendingHistoryBack = null;
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
window.history.back();
|
||||
}
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
// The same object every render, so callers can list it as a dependency.
|
||||
const [api] = useState(() => ({ rearm: () => rearmRef.current(), armed: () => armedRef.current }));
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatContactDate } from '../contact-dates';
|
||||
|
||||
describe('contact dates in the UI language', () => {
|
||||
it('writes a birthday the German way in German, not "Mar 15 1988"', () => {
|
||||
expect(formatContactDate({ year: 1988, month: 3, day: 15 }, 'de')).toBe('15. März 1988');
|
||||
expect(formatContactDate('1988-03-15', 'de')).toBe('15. März 1988');
|
||||
});
|
||||
|
||||
it('leaves out what the date does not name', () => {
|
||||
expect(formatContactDate('--03-15', 'en')).toBe('March 15');
|
||||
expect(formatContactDate({ year: 1988 }, 'en')).toBe('1988');
|
||||
expect(formatContactDate('--02-29', 'fr')).toBe('29 février');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import JSZip from 'jszip';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { buildZip, planZip } from '../files-zip';
|
||||
|
||||
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
|
||||
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
|
||||
});
|
||||
const file = (id: string, name: string, parentId: string | null, size = 4): FileNode => ({
|
||||
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size, created: '', modified: '2026-10-01T10:00:00Z',
|
||||
});
|
||||
|
||||
const DRIVE = [
|
||||
dir('taxes', 'Taxes', null),
|
||||
dir('y25', '2025', 'taxes'),
|
||||
dir('empty', 'Drafts', 'taxes'),
|
||||
file('ret', 'return.pdf', 'y25'),
|
||||
file('note', 'notes.txt', 'taxes'),
|
||||
file('other', 'other.txt', null),
|
||||
];
|
||||
|
||||
const fetchFile = async (node: FileNode) => new Blob([`body of ${node.id}`]);
|
||||
|
||||
describe('folder zip', () => {
|
||||
it('packs a folder with everything in it under its own name, empty folders included', () => {
|
||||
const plan = planZip(DRIVE, ['taxes']);
|
||||
expect(plan.files.map(f => f.path).sort()).toEqual(['Taxes/2025/return.pdf', 'Taxes/notes.txt']);
|
||||
expect(plan.emptyFolders).toEqual(['Taxes/Drafts']);
|
||||
expect(plan.totalBytes).toBe(8);
|
||||
});
|
||||
|
||||
it('numbers two picked items of the same name instead of overwriting one', () => {
|
||||
const plan = planZip([file('a', 'x.txt', null), file('b', 'x.txt', 'taxes'), dir('taxes', 'Taxes', null)], ['a', 'b']);
|
||||
expect(plan.files.map(f => f.path)).toEqual(['x.txt', 'x (1).txt']);
|
||||
});
|
||||
|
||||
it('builds a zip that holds the files', async () => {
|
||||
const progress: number[] = [];
|
||||
const blob = await buildZip(planZip(DRIVE, ['taxes', 'other']), fetchFile, { onProgress: (p) => progress.push(p.progress) });
|
||||
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
||||
expect(await zip.file('Taxes/2025/return.pdf')!.async('string')).toBe('body of ret');
|
||||
expect(await zip.file('other.txt')!.async('string')).toBe('body of other');
|
||||
expect(zip.folder('Taxes/Drafts')).not.toBeNull();
|
||||
expect(progress[progress.length - 1]).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
it('stops when canceled', async () => {
|
||||
const controller = new AbortController();
|
||||
const plan = planZip(DRIVE, ['taxes']);
|
||||
const slowFetch = async (node: FileNode) => {
|
||||
controller.abort();
|
||||
return fetchFile(node);
|
||||
};
|
||||
await expect(buildZip(plan, slowFetch, { signal: controller.signal })).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
|
||||
/**
|
||||
* A contact's date (birthday, anniversary, created) in the UI language:
|
||||
* "15. März 1988", "March 15", "1988". A date without a year (vCard
|
||||
* "--03-15") or without a day shows only what it has.
|
||||
*/
|
||||
export function formatContactDate(dateInput: AnniversaryDate | string, locale: string): string {
|
||||
const parts = contactDateParts(dateInput);
|
||||
const { year, month, day } = parts;
|
||||
try {
|
||||
if (month && day) {
|
||||
// A leap year, so 29 February survives without a year.
|
||||
const d = new Date(Date.UTC(year ?? 2000, month - 1, day));
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", day: "numeric", ...(year ? { year: "numeric" } : {}) }).format(d);
|
||||
}
|
||||
if (month) {
|
||||
const d = new Date(Date.UTC(year ?? 2000, month - 1, 1));
|
||||
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", ...(year ? { year: "numeric" } : {}) }).format(d);
|
||||
}
|
||||
if (year) return String(year);
|
||||
} catch { /* an unknown locale: below */ }
|
||||
return typeof dateInput === "string" ? dateInput : JSON.stringify(dateInput);
|
||||
}
|
||||
|
||||
/** The year, month and day a contact's date names, whichever it has. */
|
||||
export function contactDateParts(dateInput: AnniversaryDate | string): { year?: number; month?: number; day?: number } {
|
||||
if (typeof dateInput === "object" && dateInput !== null) {
|
||||
if (dateInput["@type"] === "Timestamp" && typeof (dateInput as { utc?: unknown }).utc === "string") {
|
||||
const d = new Date((dateInput as { utc: string }).utc);
|
||||
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
return { year: pd.year, month: pd.month, day: pd.day };
|
||||
}
|
||||
const s = String(dateInput);
|
||||
if (s.startsWith("--")) {
|
||||
const [m, d] = s.substring(2).split("-");
|
||||
return { month: parseInt(m, 10) || undefined, day: d ? parseInt(d, 10) || undefined : undefined };
|
||||
}
|
||||
const iso = /^(\d{4})-(\d{2})(?:-(\d{2}))?/.exec(s);
|
||||
if (iso) return { year: Number(iso[1]), month: Number(iso[2]), day: iso[3] ? Number(iso[3]) : undefined };
|
||||
const d = new Date(s);
|
||||
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||||
}
|
||||
@@ -1408,7 +1408,18 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||||
// Like the server: a folder goes with everything in it (emptying the trash).
|
||||
const idSet = new Set(ids);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const n of this.data.fileNodes) {
|
||||
if (n.parentId && idSet.has(n.parentId) && !idSet.has(n.id)) {
|
||||
idSet.add(n.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id));
|
||||
return { destroyed: ids, notDestroyed: [] };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// The Files app's preferences, part of the synced settings (settings store
|
||||
// key `filesSettings`), so Export, Reset and other devices include them.
|
||||
|
||||
export interface FilesSettings {
|
||||
defaultViewMode: "list" | "grid";
|
||||
showIcons: boolean;
|
||||
coloredIcons: boolean;
|
||||
defaultSortKey: "name" | "size" | "modified";
|
||||
defaultSortDir: "asc" | "desc";
|
||||
showHiddenFiles: boolean;
|
||||
showThumbnails: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_FILES_SETTINGS: FilesSettings = {
|
||||
defaultViewMode: "list",
|
||||
showIcons: true,
|
||||
coloredIcons: true,
|
||||
defaultSortKey: "name",
|
||||
defaultSortDir: "asc",
|
||||
showHiddenFiles: false,
|
||||
showThumbnails: true,
|
||||
};
|
||||
|
||||
const CHOICES: { [K in keyof FilesSettings]: readonly FilesSettings[K][] } = {
|
||||
defaultViewMode: ["list", "grid"],
|
||||
showIcons: [true, false],
|
||||
coloredIcons: [true, false],
|
||||
defaultSortKey: ["name", "size", "modified"],
|
||||
defaultSortDir: ["asc", "desc"],
|
||||
showHiddenFiles: [true, false],
|
||||
showThumbnails: [true, false],
|
||||
};
|
||||
|
||||
/** Files settings from an import, the server or an old build: known keys with valid values, the rest default. */
|
||||
export function normalizeFilesSettings(value: unknown): FilesSettings {
|
||||
const out: FilesSettings = { ...DEFAULT_FILES_SETTINGS };
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(CHOICES) as (keyof FilesSettings)[]) {
|
||||
if ((CHOICES[key] as readonly unknown[]).includes(record[key])) {
|
||||
(out as unknown as Record<string, unknown>)[key] = record[key];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
|
||||
|
||||
/*
|
||||
* The Files trash. Neither Stalwart 0.16 nor 1.0 keeps deleted FileNodes:
|
||||
* FileNode/set destroy removes a node and everything below it for good (1.0
|
||||
* can label a folder `role: "trash"`, 0.16 ignores the role, and neither moves
|
||||
* anything there). So the trash is a folder the app manages: `.Trash` at the
|
||||
* root, hidden from every listing. Each delete puts its items into a folder
|
||||
* of its own inside it, named after when it happened and where the items came
|
||||
* from, so Restore can put them back and two deleted files of the same name
|
||||
* never clash:
|
||||
*
|
||||
* /.Trash/1730112000000~<parentId>/report.pdf
|
||||
*
|
||||
* The name is the record, so a WebDAV client or another device sees the same
|
||||
* trash, and nothing else has to be stored.
|
||||
*/
|
||||
|
||||
export const TRASH_FOLDER_NAME = '.Trash';
|
||||
/** The trash as a place in the Files view. JMAP ids never contain "*". */
|
||||
export const TRASH_LOCATION = '*trash';
|
||||
/** The trash's path, for links and the breadcrumb. */
|
||||
export const TRASH_PATH = `/${TRASH_FOLDER_NAME}`;
|
||||
|
||||
export function isTrashLocation(id: string | null | undefined): boolean {
|
||||
return id === TRASH_LOCATION;
|
||||
}
|
||||
|
||||
/** The trash folder of the account's own drive, if there is one yet. */
|
||||
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
|
||||
return nodes.find(n =>
|
||||
(n.parentId ?? null) === null
|
||||
&& n.name === TRASH_FOLDER_NAME
|
||||
&& !n.isShared
|
||||
&& !n.id.includes(':')
|
||||
&& fileNodeKind(n) === 'directory');
|
||||
}
|
||||
|
||||
/** The name of the folder one delete's items go into. */
|
||||
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
|
||||
return `${deletedAt}~${parentId ?? ''}`;
|
||||
}
|
||||
|
||||
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
|
||||
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
|
||||
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
|
||||
if (!match) return null;
|
||||
return { deletedAt: Number(match[1]), parentId: match[2] || null };
|
||||
}
|
||||
|
||||
/** The trash folder and everything in it, to keep out of normal listings. */
|
||||
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
if (!trashId) return ids;
|
||||
ids.add(trashId);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const n of nodes) {
|
||||
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
|
||||
ids.add(n.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export interface TrashedItem {
|
||||
node: FileNode;
|
||||
/** The delete's folder in the trash; null for something put into the trash directly. */
|
||||
batchId: string | null;
|
||||
deletedAt: number | null;
|
||||
/** Where it came from; null for the root or when unknown. */
|
||||
fromParentId: string | null;
|
||||
}
|
||||
|
||||
/** What the trash holds, one entry per deleted item. */
|
||||
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined): TrashedItem[] {
|
||||
if (!trash) return [];
|
||||
const items: TrashedItem[] = [];
|
||||
for (const child of nodes.filter(n => n.parentId === trash.id)) {
|
||||
const batch = fileNodeKind(child) === 'directory' ? parseTrashBatchName(child.name) : null;
|
||||
if (!batch) {
|
||||
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
|
||||
continue;
|
||||
}
|
||||
for (const node of nodes.filter(n => n.parentId === child.id)) {
|
||||
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
|
||||
|
||||
/*
|
||||
* Downloading folders: the server has no archive download, so the browser
|
||||
* fetches every file below the folders and packs them into one zip (jszip),
|
||||
* with the folders' own names at the top.
|
||||
*/
|
||||
|
||||
export interface ZipEntry {
|
||||
/** Path inside the zip, e.g. "Taxes/2025/return.pdf". */
|
||||
path: string;
|
||||
node: FileNode;
|
||||
}
|
||||
|
||||
export interface ZipPlan {
|
||||
files: ZipEntry[];
|
||||
/** Folders with nothing in them, kept so the zip has the same shape. */
|
||||
emptyFolders: string[];
|
||||
/** Bytes to fetch, for the progress. */
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
/** What goes into the zip of some items: files as they are, folders with everything below them. */
|
||||
export function planZip(nodes: FileNode[], ids: string[]): ZipPlan {
|
||||
const byParent = new Map<string | null, FileNode[]>();
|
||||
for (const n of nodes) {
|
||||
const key = n.parentId ?? null;
|
||||
const list = byParent.get(key) ?? [];
|
||||
list.push(n);
|
||||
byParent.set(key, list);
|
||||
}
|
||||
const plan: ZipPlan = { files: [], emptyFolders: [], totalBytes: 0 };
|
||||
const used = new Set<string>();
|
||||
const unique = (path: string) => {
|
||||
if (!used.has(path)) {
|
||||
used.add(path);
|
||||
return path;
|
||||
}
|
||||
const slash = path.lastIndexOf('/');
|
||||
const dir = path.slice(0, slash + 1);
|
||||
const name = path.slice(slash + 1);
|
||||
const dot = name.lastIndexOf('.');
|
||||
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||
const ext = dot > 0 ? name.slice(dot) : '';
|
||||
let n = 1;
|
||||
while (used.has(`${dir}${base} (${n})${ext}`)) n++;
|
||||
const next = `${dir}${base} (${n})${ext}`;
|
||||
used.add(next);
|
||||
return next;
|
||||
};
|
||||
const seen = new Set<string>();
|
||||
const add = (node: FileNode, prefix: string) => {
|
||||
if (seen.has(node.id)) return;
|
||||
seen.add(node.id);
|
||||
const kind = fileNodeKind(node);
|
||||
// A symlink (Stalwart 1.0) has no content to pack.
|
||||
if (kind === 'symlink') return;
|
||||
const path = unique(prefix + node.name.replace(/[\\/]/g, '_'));
|
||||
if (kind === 'directory') {
|
||||
const children = byParent.get(node.id) ?? [];
|
||||
if (children.length === 0) plan.emptyFolders.push(path);
|
||||
for (const child of children) add(child, `${path}/`);
|
||||
return;
|
||||
}
|
||||
if (!node.blobId) return;
|
||||
plan.files.push({ path, node });
|
||||
plan.totalBytes += node.size || 0;
|
||||
};
|
||||
for (const id of ids) {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (node) add(node, '');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export interface ZipProgress {
|
||||
/** 0..1 over fetching and packing. */
|
||||
progress: number;
|
||||
/** The file being fetched, while fetching. */
|
||||
current?: string;
|
||||
}
|
||||
|
||||
/** Fetch the plan's files and pack them. Throws an AbortError when `signal` aborts. */
|
||||
export async function buildZip(
|
||||
plan: ZipPlan,
|
||||
fetchFile: (node: FileNode) => Promise<Blob>,
|
||||
options: { signal?: AbortSignal; onProgress?: (p: ZipProgress) => void } = {},
|
||||
): Promise<Blob> {
|
||||
const { signal, onProgress } = options;
|
||||
const { default: JSZip } = await import('jszip');
|
||||
const zip = new JSZip();
|
||||
const abortIfAsked = () => {
|
||||
if (signal?.aborted) throw new DOMException('The download was canceled', 'AbortError');
|
||||
};
|
||||
for (const folder of plan.emptyFolders) zip.folder(folder);
|
||||
// Fetching is most of the work; packing gets the last tenth.
|
||||
let fetched = 0;
|
||||
for (const entry of plan.files) {
|
||||
abortIfAsked();
|
||||
onProgress?.({ progress: plan.totalBytes > 0 ? (fetched / plan.totalBytes) * 0.9 : 0, current: entry.path });
|
||||
zip.file(entry.path, await fetchFile(entry.node), { date: entry.node.modified ? new Date(entry.node.modified) : undefined });
|
||||
fetched += entry.node.size || 0;
|
||||
}
|
||||
abortIfAsked();
|
||||
const blob = await zip.generateAsync(
|
||||
// Photos, videos and office files are compressed already; a light level
|
||||
// keeps big folders quick.
|
||||
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 1 } },
|
||||
(meta) => onProgress?.({ progress: 0.9 + (meta.percent / 100) * 0.1 }),
|
||||
);
|
||||
abortIfAsked();
|
||||
return blob;
|
||||
}
|
||||
|
||||
/** Hand a blob to the browser as a download. */
|
||||
export function saveBlob(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}
|
||||
@@ -1,6 +1,32 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// contacts's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [];
|
||||
// Contacts' keys (components/contacts/contact-list.tsx handles them).
|
||||
export const CONTACTS_SECTIONS: readonly ShortcutSection[] = [
|
||||
{
|
||||
id: "list",
|
||||
title: "contacts.shortcuts.list",
|
||||
entries: [
|
||||
{ key: "↓ / ↑", description: "contacts.shortcuts.move" },
|
||||
{ key: "Home / End", description: "contacts.shortcuts.first_last" },
|
||||
{ key: "Shift + ↓ / ↑", description: "contacts.shortcuts.extend" },
|
||||
{ key: "Enter", description: "contacts.shortcuts.open" },
|
||||
{ key: "Space", description: "contacts.shortcuts.toggle" },
|
||||
{ key: "Mod + A", description: "contacts.shortcuts.select_all" },
|
||||
{ key: "Esc", description: "contacts.shortcuts.clear" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
title: "contacts.shortcuts.actions",
|
||||
entries: [
|
||||
{ key: "Del / Backspace", description: "contacts.shortcuts.delete" },
|
||||
{ key: "Shift + F10", description: "contacts.shortcuts.menu" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "global",
|
||||
title: "shortcuts.sections.global",
|
||||
wide: true,
|
||||
entries: [{ key: "/", description: "contacts.shortcuts.search" }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import type { ShortcutSection } from "./types";
|
||||
|
||||
// files's keys for the help modal: one or more sections like MAIL_SECTIONS
|
||||
// in ./mail.ts. Empty sections are not drawn, and an app without any rows
|
||||
// gets no tab.
|
||||
export const FILES_SECTIONS: readonly ShortcutSection[] = [];
|
||||
// Files' keys (components/files/file-browser.tsx handles them).
|
||||
export const FILES_SECTIONS: readonly ShortcutSection[] = [
|
||||
{
|
||||
id: "list",
|
||||
title: "files.shortcuts.list",
|
||||
entries: [
|
||||
{ key: "↓ / ↑", description: "files.shortcuts.move" },
|
||||
{ key: "← / →", description: "files.shortcuts.move_grid" },
|
||||
{ key: "Home / End", description: "files.shortcuts.first_last" },
|
||||
{ key: "Shift + ↓ / ↑", description: "files.shortcuts.extend" },
|
||||
{ key: "Enter", description: "files.shortcuts.open" },
|
||||
{ key: "Space", description: "files.shortcuts.toggle" },
|
||||
{ key: "Mod + A", description: "files.shortcuts.select_all" },
|
||||
{ key: "Esc", description: "files.shortcuts.clear" },
|
||||
{ key: "Alt + ↑", description: "files.shortcuts.up" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
title: "files.shortcuts.actions",
|
||||
entries: [
|
||||
{ key: "F2", description: "files.shortcuts.rename" },
|
||||
{ key: "Del / Backspace", description: "files.shortcuts.delete" },
|
||||
{ key: "Mod + C", description: "files.shortcuts.copy" },
|
||||
{ key: "Mod + X", description: "files.shortcuts.cut" },
|
||||
{ key: "Mod + V", description: "files.shortcuts.paste" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "global",
|
||||
title: "shortcuts.sections.global",
|
||||
wide: true,
|
||||
entries: [{ key: "/ / Mod + F", description: "files.shortcuts.search" }],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface ParsedMailto {
|
||||
htmlBody?: string;
|
||||
/** Only from api.compose.open: files the plugin uploaded, attached as they are. */
|
||||
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>;
|
||||
/**
|
||||
* Started in the app itself (Contacts' "Send email"), not by the OS: it is
|
||||
* written from the account in use, without asking which one.
|
||||
*/
|
||||
inApp?: boolean;
|
||||
}
|
||||
|
||||
const MAX_RECIPIENTS = 200;
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "إضافة إلى «نسخة»",
|
||||
"add_bcc": "إضافة إلى «نسخة مخفية»",
|
||||
"select_hint": "اختر شخصًا لعرض تفاصيله",
|
||||
"write_email": "كتابة رسالة",
|
||||
"write_email": "إرسال بريد إلكتروني",
|
||||
"add_to_contacts": "إضافة إلى جهات الاتصال",
|
||||
"email": "البريد",
|
||||
"read_only_note": "تأتي إدخالات الدليل من خادم البريد ولا يمكن تعديلها هنا.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "جهات الاتصال",
|
||||
"disabled_title": "عطّل المسؤول جهات الاتصال",
|
||||
"disabled_description": "تواصل مع المسؤول إذا كنت بحاجة إليها.",
|
||||
"drag_count": "{count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
|
||||
"select_contact": "تحديد {name}",
|
||||
"new": "جديد",
|
||||
"search_placeholder": "بحث في جهات الاتصال...",
|
||||
"create_new": "جهة اتصال جديدة",
|
||||
"no_category": "بلا فئة",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "لا توجد جهات اتصال بعد",
|
||||
"empty_state_title": "لا توجد جهات اتصال بعد",
|
||||
"empty_state_subtitle": "أنشئ أول جهة اتصال أو استوردها من ملف vCard",
|
||||
"empty_view_title": "لا توجد جهات اتصال هنا",
|
||||
"empty_view_hint": "أضف جهة اتصال أو استورد ملف vCard.",
|
||||
"empty_search": "لا توجد جهات اتصال مطابقة لبحثك",
|
||||
"empty_search_hint": "جرّب مصطلح بحث مختلف",
|
||||
"empty_filtered": "لا توجد جهات اتصال مطابقة لمرشحاتك",
|
||||
@@ -2952,6 +2963,7 @@
|
||||
"back_to_contacts": "العودة إلى جهات الاتصال",
|
||||
"back_to_email": "العودة إلى البريد",
|
||||
"open_categories": "فتح الفئات",
|
||||
"row_actions": "إجراءات {name}",
|
||||
"tabs": {
|
||||
"all": "الكل",
|
||||
"groups": "المجموعات"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "دفتر عناوين جديد",
|
||||
"create_confirm": "إنشاء",
|
||||
"created": "تم إنشاء دفتر العناوين",
|
||||
"create_failed": "فشل إنشاء دفتر العناوين",
|
||||
"title": "دفاتر عناويني",
|
||||
@@ -2983,7 +2996,10 @@
|
||||
"delete": "حذف دفتر العناوين",
|
||||
"confirm_delete": "حذف \"{name}\"؟ ستتم إزالة جميع جهات الاتصال في دفتر العناوين هذا.",
|
||||
"deleted": "تم حذف دفتر العناوين",
|
||||
"delete_failed": "فشل حذف دفتر العناوين"
|
||||
"delete_failed": "فشل حذف دفتر العناوين",
|
||||
"move_to": "نقل إلى…",
|
||||
"move_title": "نقل إلى دفتر عناوين",
|
||||
"move_here": "نقل إلى هنا"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "عناوين البريد الإلكتروني",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "تم استيراد الشهادة إلى مخزن S/MIME",
|
||||
"cert_import_failed": "فشل استيراد الشهادة",
|
||||
"no_contact_selected": "اختر جهة اتصال لعرض التفاصيل",
|
||||
"compose_email": "إنشاء رسالة",
|
||||
"compose_email": "إرسال بريد إلكتروني",
|
||||
"copy_email": "نسخ البريد الإلكتروني",
|
||||
"copy_phone": "نسخ رقم الهاتف",
|
||||
"copy_url": "نسخ الرابط",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "جهة اتصال جديدة",
|
||||
"edit_title": "تعديل جهة الاتصال",
|
||||
"section_address_book": "الدليل",
|
||||
"select_address_book": "اختر دليلًا...",
|
||||
"section_address_book": "دفتر العناوين",
|
||||
"select_address_book": "دفتر العناوين",
|
||||
"section_identity": "الاسم والهوية",
|
||||
"contact_type": "نوع جهة الاتصال",
|
||||
"type_person": "شخص",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "إضافة هاتف",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"discard_title": "تجاهل التغييرات؟",
|
||||
"discard_message": "لم يتم حفظ تغييراتك.",
|
||||
"discard": "تجاهل",
|
||||
"keep_editing": "متابعة التعديل",
|
||||
"remove_entry": "إزالة",
|
||||
"close": "إغلاق",
|
||||
"remove_category": "إزالة {name}",
|
||||
"creating": "جارٍ الإنشاء...",
|
||||
"updating": "جارٍ التحديث...",
|
||||
"name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "مجموعة جديدة",
|
||||
"delete": "حذف المجموعة",
|
||||
"remove_member": "إزالة {name} من المجموعة",
|
||||
"edit": "تعديل المجموعة",
|
||||
"empty": "لا توجد مجموعات بعد",
|
||||
"delete_confirm_title": "حذف المجموعة",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "بحث عن جهات اتصال لإضافتها...",
|
||||
"no_members": "لا يوجد أعضاء في هذه المجموعة",
|
||||
"member_count": "{count, plural, =0 {لا يوجد أعضاء} one {عضو واحد} other {# أعضاء}}",
|
||||
"send_email": "إرسال بريد إلكتروني إلى المجموعة",
|
||||
"send_email": "إرسال بريد إلكتروني",
|
||||
"send_email_to": "إلى",
|
||||
"send_email_cc": "نسخة",
|
||||
"send_email_bcc": "نسخة مخفية",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {تم حذف جهة اتصال واحدة} other {تم حذف # جهة اتصال}}",
|
||||
"add_to_group": "إضافة إلى مجموعة",
|
||||
"choose_group": "اختر مجموعة",
|
||||
"new_group": "مجموعة جديدة…",
|
||||
"adding_contacts": "جارٍ إضافة {count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
|
||||
"added_to_group": "تمت إضافة جهات الاتصال إلى المجموعة",
|
||||
"export": "تصدير",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "الترتيب حسب",
|
||||
"sort_first_name": "الاسم الأول",
|
||||
"sort_last_name": "اسم العائلة"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "القائمة",
|
||||
"actions": "الإجراءات",
|
||||
"first_last": "الأول أو الأخير",
|
||||
"extend": "توسيع التحديد",
|
||||
"toggle": "تحديد أو إلغاء التحديد",
|
||||
"select_all": "تحديد الكل",
|
||||
"clear": "مسح التحديد أو البحث",
|
||||
"delete": "حذف",
|
||||
"search": "البحث",
|
||||
"move": "جهة الاتصال التالية أو السابقة",
|
||||
"open": "فتح جهة الاتصال",
|
||||
"menu": "فتح قائمة جهة الاتصال"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "لا يمكن أن تحتوي الأسماء على أحرف تحكم.",
|
||||
"name_too_long": "هذا الاسم طويل جدًا بالنسبة للخادم.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف المورد",
|
||||
"delete_confirm_message": "هل أنت متأكد أنك تريد حذف \"{name}\"؟ لا يمكن التراجع عن هذا.",
|
||||
"download": "تنزيل",
|
||||
"name": "الاسم",
|
||||
"size": "الحجم",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "بقي جزء من النسخة في مكانه.",
|
||||
"create_folder_success": "تم إنشاء المجلد",
|
||||
"create_folder_error": "فشل إنشاء المجلد",
|
||||
"delete_success": "تم الحذف بنجاح",
|
||||
"delete_error": "فشل الحذف",
|
||||
"trash": "سلة المهملات",
|
||||
"restore": "استعادة",
|
||||
"restored": "{count, plural, one {تمت استعادة العنصر} other {تمت استعادة # عنصر}}",
|
||||
"restore_error": "تعذّرت الاستعادة",
|
||||
"moved_to_trash": "{count, plural, one {نُقل إلى سلة المهملات} other {نُقل # عنصر إلى سلة المهملات}}",
|
||||
"delete_forever": "حذف نهائي",
|
||||
"delete_forever_title": "{count, plural, one {حذف نهائي؟} other {حذف # عنصر نهائيًا؟}}",
|
||||
"delete_forever_message": "سيُحذف \"{name}\". لا يمكنك التراجع عن ذلك.",
|
||||
"delete_forever_message_count": "{count, plural, one {سيُحذف عنصر واحد} other {سيُحذف # عنصر}}. لا يمكنك التراجع عن ذلك.",
|
||||
"deleted_forever": "{count, plural, one {حُذف نهائيًا} other {حُذف # عنصر نهائيًا}}",
|
||||
"empty_trash": "إفراغ سلة المهملات",
|
||||
"empty_trash_title": "إفراغ سلة المهملات؟",
|
||||
"empty_trash_message": "{count, plural, one {سيُحذف العنصر الموجود في سلة المهملات} other {ستُحذف جميع العناصر (#) في سلة المهملات}} نهائيًا. لا يمكنك التراجع عن ذلك.",
|
||||
"trash_emptied": "أُفرغت سلة المهملات",
|
||||
"empty_trash_error": "تعذّر إفراغ سلة المهملات",
|
||||
"trash_empty_title": "سلة المهملات فارغة",
|
||||
"trash_empty_hint": "تبقى الملفات المحذوفة هنا حتى تُفرغ سلة المهملات.",
|
||||
"deleted": "تاريخ الحذف",
|
||||
"deleted_from": "حُذف من {folder}",
|
||||
"shortcuts": {
|
||||
"list": "القائمة",
|
||||
"actions": "الإجراءات",
|
||||
"first_last": "الأول أو الأخير",
|
||||
"extend": "توسيع التحديد",
|
||||
"toggle": "تحديد أو إلغاء التحديد",
|
||||
"select_all": "تحديد الكل",
|
||||
"clear": "مسح التحديد أو البحث",
|
||||
"delete": "حذف",
|
||||
"search": "البحث",
|
||||
"move": "العنصر التالي أو السابق",
|
||||
"move_grid": "العنصر المجاور في الشبكة",
|
||||
"open": "فتح",
|
||||
"up": "مجلد واحد للأعلى",
|
||||
"rename": "إعادة التسمية",
|
||||
"copy": "نسخ",
|
||||
"cut": "قص",
|
||||
"paste": "لصق"
|
||||
},
|
||||
"rename_success": "تمت إعادة التسمية بنجاح",
|
||||
"rename_error": "فشلت إعادة التسمية",
|
||||
"download_error": "فشل التنزيل",
|
||||
"download_zip": "تنزيل كملف ZIP",
|
||||
"zipping": "جارٍ تجهيز {name}",
|
||||
"download_canceled": "أُلغي التنزيل",
|
||||
"zip_error": "تعذّر تنزيل {name}",
|
||||
"not_available": "تخزين الملفات غير متاح على هذا الخادم",
|
||||
"cancel": "إلغاء",
|
||||
"create": "إنشاء",
|
||||
"save": "حفظ",
|
||||
"no_results": "لا توجد ملفات مطابقة لبحثك",
|
||||
"batch_delete_confirm_message": "هل أنت متأكد أنك تريد حذف {count, plural, one {عنصر واحد} other {# عنصر}}؟ لا يمكن التراجع عن هذا.",
|
||||
"batch_delete_success": "{count, plural, one {تم حذف عنصر واحد} other {تم حذف # عنصر}}",
|
||||
"grid_view": "عرض شبكي",
|
||||
"list_view": "عرض قائمة",
|
||||
"details": "التفاصيل",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "لصق",
|
||||
"move_success": "{count, plural, one {تم نقل عنصر واحد} other {تم نقل # عنصر}}",
|
||||
"move_error": "فشل النقل",
|
||||
"move_to": "نقل إلى…",
|
||||
"move_to_title": "نقل إلى",
|
||||
"move_here": "نقل إلى هنا",
|
||||
"paste_success": "تم اللصق بنجاح",
|
||||
"paste_error": "فشل اللصق",
|
||||
"new_text_file": "ملف نصي جديد",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "تصغير",
|
||||
"zoom_reset": "ملاءمة الشاشة",
|
||||
"rotate": "تدوير",
|
||||
"close_preview": "إغلاق"
|
||||
"close_preview": "إغلاق",
|
||||
"previous_file": "الملف السابق",
|
||||
"next_file": "الملف التالي",
|
||||
"loading": "جارٍ التحميل…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "شهاداتك",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Afegeix a A/c",
|
||||
"add_bcc": "Afegeix a C/o",
|
||||
"select_hint": "Selecciona algú per veure'n els detalls",
|
||||
"write_email": "Escriu un correu",
|
||||
"write_email": "Envia un correu",
|
||||
"add_to_contacts": "Afegeix als contactes",
|
||||
"email": "Correu",
|
||||
"read_only_note": "Les entrades del directori vénen del teu servidor de correu i no es poden editar aquí.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contactes",
|
||||
"disabled_title": "L'administrador ha desactivat els contactes",
|
||||
"disabled_description": "Contacteu amb l'administrador si els necessiteu.",
|
||||
"drag_count": "{count, plural, one {1 contacte} other {# contactes}}",
|
||||
"select_contact": "Selecciona {name}",
|
||||
"new": "Nou",
|
||||
"search_placeholder": "Cerca contactes...",
|
||||
"create_new": "Contacte nou",
|
||||
"no_category": "Sense categoria",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Encara no hi ha contactes",
|
||||
"empty_state_subtitle": "Creeu el primer contacte o importeu-lo des d'un fitxer vCard",
|
||||
"empty_view_title": "No hi ha contactes aquí",
|
||||
"empty_view_hint": "Afegiu un contacte o importeu un fitxer vCard.",
|
||||
"empty_search": "Cap contacte coincideix amb la cerca",
|
||||
"empty_search_hint": "Proveu amb un altre terme de cerca",
|
||||
"empty_filtered": "Cap contacte coincideix amb els filtres",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Llibreta d'adreces nova",
|
||||
"create_confirm": "Crea",
|
||||
"created": "Llibreta d'adreces creada",
|
||||
"create_failed": "No s'ha pogut crear la llibreta d'adreces",
|
||||
"title": "Les meves llibretes d'adreces",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Certificat importat a l'emmagatzematge S/MIME",
|
||||
"cert_import_failed": "No s'ha pogut importar el certificat",
|
||||
"no_contact_selected": "Seleccioneu un contacte per veure'n els detalls",
|
||||
"compose_email": "Redacta un correu",
|
||||
"compose_email": "Envia un correu",
|
||||
"copy_email": "Copia el correu electrònic",
|
||||
"copy_phone": "Copia el número de telèfon",
|
||||
"copy_url": "Copia l'URL",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "Contacte nou",
|
||||
"edit_title": "Edita el contacte",
|
||||
"section_address_book": "Directori",
|
||||
"select_address_book": "Seleccioneu un directori...",
|
||||
"section_address_book": "Llibreta d'adreces",
|
||||
"select_address_book": "Llibreta d'adreces",
|
||||
"section_identity": "Nom i identitat",
|
||||
"contact_type": "Tipus de contacte",
|
||||
"type_person": "Persona",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Afegeix un telèfon",
|
||||
"save": "Desa",
|
||||
"cancel": "Cancel·la",
|
||||
"discard_title": "Voleu descartar els canvis?",
|
||||
"discard_message": "Els canvis no s'han desat.",
|
||||
"discard": "Descarta",
|
||||
"keep_editing": "Continua editant",
|
||||
"remove_entry": "Elimina",
|
||||
"close": "Tanca",
|
||||
"remove_category": "Elimina {name}",
|
||||
"creating": "Creant...",
|
||||
"updating": "Actualitzant...",
|
||||
"name_required": "Introduïu un nom, un cognom o una organització",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Grup nou",
|
||||
"delete": "Suprimeix el grup",
|
||||
"remove_member": "Treu {name} del grup",
|
||||
"edit": "Edita el grup",
|
||||
"empty": "Encara no hi ha grups",
|
||||
"delete_confirm_title": "Suprimeix el grup",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Cerca contactes per afegir...",
|
||||
"no_members": "Cap membre en aquest grup",
|
||||
"member_count": "{count, plural, =0 {Cap membre} one {1 membre} other {# membres}}",
|
||||
"send_email": "Envia un correu al grup",
|
||||
"send_email": "Envia un correu",
|
||||
"send_email_to": "Per a",
|
||||
"send_email_cc": "CC",
|
||||
"send_email_bcc": "CCO",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 contacte suprimit} other {# contactes suprimits}}",
|
||||
"add_to_group": "Afegeix al grup",
|
||||
"choose_group": "Trieu un grup",
|
||||
"new_group": "Grup nou…",
|
||||
"adding_contacts": "Afegint {count, plural, one {1 contacte} other {# contactes}}",
|
||||
"added_to_group": "Contactes afegits al grup",
|
||||
"export": "Exporta",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Ordena per",
|
||||
"sort_first_name": "Nom",
|
||||
"sort_last_name": "Cognom"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Llista",
|
||||
"actions": "Accions",
|
||||
"first_last": "Primer o darrer",
|
||||
"extend": "Amplia la selecció",
|
||||
"toggle": "Selecciona o desselecciona",
|
||||
"select_all": "Selecciona-ho tot",
|
||||
"clear": "Esborra la selecció o la cerca",
|
||||
"delete": "Suprimeix",
|
||||
"search": "Cerca",
|
||||
"move": "Contacte següent o anterior",
|
||||
"open": "Obre el contacte",
|
||||
"menu": "Obre el menú del contacte"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Els noms no poden contenir caràcters de control.",
|
||||
"name_too_long": "Aquest nom és massa llarg per al servidor.",
|
||||
"delete": "Suprimeix",
|
||||
"delete_confirm_title": "Suprimeix el recurs",
|
||||
"delete_confirm_message": "Segur que voleu suprimir «{name}»? Això no es pot desfer.",
|
||||
"download": "Baixa",
|
||||
"name": "Nom",
|
||||
"size": "Mida",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "Part de la còpia s'ha quedat al seu lloc.",
|
||||
"create_folder_success": "Carpeta creada",
|
||||
"create_folder_error": "No s'ha pogut crear la carpeta",
|
||||
"delete_success": "Suprimit correctament",
|
||||
"delete_error": "No s'ha pogut suprimir",
|
||||
"trash": "Paperera",
|
||||
"restore": "Restaura",
|
||||
"restored": "{count, plural, one {S'ha restaurat l'element} other {S'han restaurat # elements}}",
|
||||
"restore_error": "No s'ha pogut restaurar",
|
||||
"moved_to_trash": "{count, plural, one {S'ha mogut a la paperera} other {S'han mogut # elements a la paperera}}",
|
||||
"delete_forever": "Suprimeix definitivament",
|
||||
"delete_forever_title": "{count, plural, one {Voleu suprimir-lo definitivament?} other {Voleu suprimir # elements definitivament?}}",
|
||||
"delete_forever_message": "Se suprimirà «{name}». No es pot desfer.",
|
||||
"delete_forever_message_count": "{count, plural, one {Se suprimirà 1 element} other {Se suprimiran # elements}}. No es pot desfer.",
|
||||
"deleted_forever": "{count, plural, one {S'ha suprimit definitivament} other {S'han suprimit # elements definitivament}}",
|
||||
"empty_trash": "Buida la paperera",
|
||||
"empty_trash_title": "Voleu buidar la paperera?",
|
||||
"empty_trash_message": "{count, plural, one {L'element de la paperera se suprimirà} other {Els # elements de la paperera se suprimiran}} definitivament. No es pot desfer.",
|
||||
"trash_emptied": "S'ha buidat la paperera",
|
||||
"empty_trash_error": "No s'ha pogut buidar la paperera",
|
||||
"trash_empty_title": "La paperera és buida",
|
||||
"trash_empty_hint": "Els fitxers suprimits es queden aquí fins que buideu la paperera.",
|
||||
"deleted": "Suprimit",
|
||||
"deleted_from": "Suprimit de {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Llista",
|
||||
"actions": "Accions",
|
||||
"first_last": "Primer o darrer",
|
||||
"extend": "Amplia la selecció",
|
||||
"toggle": "Selecciona o desselecciona",
|
||||
"select_all": "Selecciona-ho tot",
|
||||
"clear": "Esborra la selecció o la cerca",
|
||||
"delete": "Suprimeix",
|
||||
"search": "Cerca",
|
||||
"move": "Element següent o anterior",
|
||||
"move_grid": "Element del costat a la quadrícula",
|
||||
"open": "Obre",
|
||||
"up": "Una carpeta amunt",
|
||||
"rename": "Canvia el nom",
|
||||
"copy": "Copia",
|
||||
"cut": "Retalla",
|
||||
"paste": "Enganxa"
|
||||
},
|
||||
"rename_success": "Nom canviat correctament",
|
||||
"rename_error": "No s'ha pogut canviar el nom",
|
||||
"download_error": "No s'ha pogut baixar",
|
||||
"download_zip": "Baixa com a ZIP",
|
||||
"zipping": "S'està preparant {name}",
|
||||
"download_canceled": "S'ha cancel·lat la baixada",
|
||||
"zip_error": "No s'ha pogut baixar {name}",
|
||||
"not_available": "L'emmagatzematge de fitxers no està disponible en aquest servidor",
|
||||
"cancel": "Cancel·la",
|
||||
"create": "Crea",
|
||||
"save": "Desa",
|
||||
"no_results": "Cap fitxer coincideix amb la cerca",
|
||||
"batch_delete_confirm_message": "Segur que voleu suprimir {count, plural, one {1 element} other {# elements}}? Això no es pot desfer.",
|
||||
"batch_delete_success": "{count, plural, one {1 element suprimit} other {# elements suprimits}}",
|
||||
"grid_view": "Vista de quadrícula",
|
||||
"list_view": "Vista de llista",
|
||||
"details": "Detalls",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Enganxa",
|
||||
"move_success": "{count, plural, one {1 element mogut} other {# elements moguts}}",
|
||||
"move_error": "No s'ha pogut moure",
|
||||
"move_to": "Mou a…",
|
||||
"move_to_title": "Mou a",
|
||||
"move_here": "Mou aquí",
|
||||
"paste_success": "Enganxat correctament",
|
||||
"paste_error": "No s'ha pogut enganxar",
|
||||
"new_text_file": "Fitxer de text nou",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Redueix",
|
||||
"zoom_reset": "Ajusta a la pantalla",
|
||||
"rotate": "Gira",
|
||||
"close_preview": "Tanca"
|
||||
"close_preview": "Tanca",
|
||||
"previous_file": "Fitxer anterior",
|
||||
"next_file": "Fitxer següent",
|
||||
"loading": "S'està carregant…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Els vostres certificats",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Přidat do Kopie",
|
||||
"add_bcc": "Přidat do Skrytá kopie",
|
||||
"select_hint": "Vyberte osobu a zobrazte podrobnosti",
|
||||
"write_email": "Napsat e-mail",
|
||||
"write_email": "Poslat e-mail",
|
||||
"add_to_contacts": "Přidat do kontaktů",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Položky adresáře pocházejí z vašeho poštovního serveru a nelze je zde upravit.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakty",
|
||||
"disabled_title": "Kontakty vypnul správce",
|
||||
"disabled_description": "Pokud je potřebujete, obraťte se na správce.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}",
|
||||
"select_contact": "Vybrat {name}",
|
||||
"new": "Nový",
|
||||
"search_placeholder": "Hledat kontakty...",
|
||||
"create_new": "Nový kontakt",
|
||||
"no_category": "Bez kategorie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Žádné kontakty",
|
||||
"empty_state_subtitle": "Vytvořte první kontakt nebo importujte ze souboru vCard",
|
||||
"empty_view_title": "Nejsou zde žádné kontakty",
|
||||
"empty_view_hint": "Přidejte kontakt nebo importujte soubor vCard.",
|
||||
"empty_search": "Hledání neodpovídají žádné kontakty",
|
||||
"empty_search_hint": "Zkuste použít jiný hledaný výraz",
|
||||
"empty_filtered": "Žádné kontakty neodpovídají filtrům",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nový adresář",
|
||||
"create_confirm": "Vytvořit",
|
||||
"created": "Adresář vytvořen",
|
||||
"create_failed": "Nepodařilo se vytvořit adresář",
|
||||
"title": "Moje adresáře",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Certifikát byl importován do úložiště S/MIME",
|
||||
"cert_import_failed": "Import certifikátu selhal",
|
||||
"no_contact_selected": "Vyberte kontakt pro zobrazení podrobností",
|
||||
"compose_email": "Napsat e-mail",
|
||||
"compose_email": "Poslat e-mail",
|
||||
"copy_email": "Kopírovat e-mail",
|
||||
"copy_phone": "Kopírovat telefon",
|
||||
"copy_url": "Kopírovat URL",
|
||||
@@ -3066,7 +3081,7 @@
|
||||
"create_title": "Nový kontakt",
|
||||
"edit_title": "Upravit kontakt",
|
||||
"section_address_book": "Adresář",
|
||||
"select_address_book": "Vyberte adresář...",
|
||||
"select_address_book": "Adresář",
|
||||
"section_identity": "Jméno a identita",
|
||||
"contact_type": "Typ kontaktu",
|
||||
"type_person": "Osoba",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Přidat telefon",
|
||||
"save": "Uložit",
|
||||
"cancel": "Zrušit",
|
||||
"discard_title": "Zahodit změny?",
|
||||
"discard_message": "Vaše změny nebyly uloženy.",
|
||||
"discard": "Zahodit",
|
||||
"keep_editing": "Pokračovat v úpravách",
|
||||
"remove_entry": "Odebrat",
|
||||
"close": "Zavřít",
|
||||
"remove_category": "Odebrat {name}",
|
||||
"creating": "Vytváření...",
|
||||
"updating": "Aktualizování...",
|
||||
"name_required": "Zadejte jméno, příjmení nebo organizaci",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nová skupina",
|
||||
"delete": "Smazat skupinu",
|
||||
"remove_member": "Odebrat {name} ze skupiny",
|
||||
"edit": "Upravit skupinu",
|
||||
"empty": "Žádné skupiny",
|
||||
"delete_confirm_title": "Odstranit skupinu",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Hledat kontakty k přidání...",
|
||||
"no_members": "Tato skupina nemá žádné členy",
|
||||
"member_count": "{count, plural, =0 {Žádní členové} one {1 člen} few {# členové} other {# členů}}",
|
||||
"send_email": "Poslat e-mail skupině",
|
||||
"send_email": "Poslat e-mail",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {Odstraněn 1 kontakt} few {Odstraněny # kontakty} other {Odstraněno # kontaktů}}",
|
||||
"add_to_group": "Přidat do skupiny",
|
||||
"choose_group": "Vyberte skupinu",
|
||||
"new_group": "Nová skupina…",
|
||||
"adding_contacts": "Přidávání {count, plural, one {1 kontaktu} few {# kontaktů} other {# kontaktů}}",
|
||||
"added_to_group": "Kontakty byly přidány do skupiny",
|
||||
"export": "Exportovat",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Jméno",
|
||||
"sort_last_name": "Příjmení"
|
||||
},
|
||||
"open_categories": "Otevřít kategorie"
|
||||
"shortcuts": {
|
||||
"list": "Seznam",
|
||||
"actions": "Akce",
|
||||
"first_last": "První nebo poslední",
|
||||
"extend": "Rozšířit výběr",
|
||||
"toggle": "Vybrat nebo zrušit výběr",
|
||||
"select_all": "Vybrat vše",
|
||||
"clear": "Zrušit výběr nebo hledání",
|
||||
"delete": "Smazat",
|
||||
"search": "Hledat",
|
||||
"move": "Další nebo předchozí kontakt",
|
||||
"open": "Otevřít kontakt",
|
||||
"menu": "Otevřít nabídku kontaktu"
|
||||
},
|
||||
"open_categories": "Otevřít kategorie",
|
||||
"row_actions": "Akce pro {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendář",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Názvy nesmí obsahovat řídicí znaky.",
|
||||
"name_too_long": "Tento název je pro server příliš dlouhý.",
|
||||
"delete": "Odstranit",
|
||||
"delete_confirm_title": "Odstranit položku",
|
||||
"delete_confirm_message": "Opravdu chcete odstranit \"{name}\"? Tuto akci nelze vrátit zpět.",
|
||||
"download": "Stáhnout",
|
||||
"name": "Název",
|
||||
"size": "Velikost",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Část kopie zůstala na místě.",
|
||||
"create_folder_success": "Složka byla vytvořena",
|
||||
"create_folder_error": "Vytvoření složky selhalo",
|
||||
"delete_success": "Úspěšně odstraněno",
|
||||
"delete_error": "Odstranění selhalo",
|
||||
"trash": "Koš",
|
||||
"restore": "Obnovit",
|
||||
"restored": "{count, plural, one {Položka obnovena} few {Obnoveny # položky} other {Obnoveno # položek}}",
|
||||
"restore_error": "Obnovení se nezdařilo",
|
||||
"moved_to_trash": "{count, plural, one {Přesunuto do koše} few {# položky přesunuty do koše} other {# položek přesunuto do koše}}",
|
||||
"delete_forever": "Trvale smazat",
|
||||
"delete_forever_title": "{count, plural, one {Trvale smazat?} few {Trvale smazat # položky?} other {Trvale smazat # položek?}}",
|
||||
"delete_forever_message": "Položka „{name}“ bude smazána. Tuto akci nelze vrátit.",
|
||||
"delete_forever_message_count": "{count, plural, one {Bude smazána 1 položka} few {Budou smazány # položky} other {Bude smazáno # položek}}. Tuto akci nelze vrátit.",
|
||||
"deleted_forever": "{count, plural, one {Trvale smazáno} few {Trvale smazány # položky} other {Trvale smazáno # položek}}",
|
||||
"empty_trash": "Vysypat koš",
|
||||
"empty_trash_title": "Vysypat koš?",
|
||||
"empty_trash_message": "{count, plural, one {Položka v koši bude trvale smazána} few {Všechny # položky v koši budou trvale smazány} other {Všech # položek v koši bude trvale smazáno}}. Tuto akci nelze vrátit.",
|
||||
"trash_emptied": "Koš byl vysypán",
|
||||
"empty_trash_error": "Koš se nepodařilo vysypat",
|
||||
"trash_empty_title": "Koš je prázdný",
|
||||
"trash_empty_hint": "Smazané soubory zůstávají zde, dokud koš nevysypete.",
|
||||
"deleted": "Smazáno",
|
||||
"deleted_from": "Smazáno z {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Seznam",
|
||||
"actions": "Akce",
|
||||
"first_last": "První nebo poslední",
|
||||
"extend": "Rozšířit výběr",
|
||||
"toggle": "Vybrat nebo zrušit výběr",
|
||||
"select_all": "Vybrat vše",
|
||||
"clear": "Zrušit výběr nebo hledání",
|
||||
"delete": "Smazat",
|
||||
"search": "Hledat",
|
||||
"move": "Další nebo předchozí položka",
|
||||
"move_grid": "Sousední položka v mřížce",
|
||||
"open": "Otevřít",
|
||||
"up": "O složku výš",
|
||||
"rename": "Přejmenovat",
|
||||
"copy": "Kopírovat",
|
||||
"cut": "Vyjmout",
|
||||
"paste": "Vložit"
|
||||
},
|
||||
"rename_success": "Úspěšně přejmenováno",
|
||||
"rename_error": "Přejmenování selhalo",
|
||||
"download_error": "Stahování selhalo",
|
||||
"download_zip": "Stáhnout jako ZIP",
|
||||
"zipping": "Připravuje se {name}",
|
||||
"download_canceled": "Stahování zrušeno",
|
||||
"zip_error": "{name} se nepodařilo stáhnout",
|
||||
"not_available": "Úložiště souborů není na tomto serveru k dispozici",
|
||||
"cancel": "Zrušit",
|
||||
"create": "Vytvořit",
|
||||
"save": "Uložit",
|
||||
"no_results": "Hledání neodpovídají žádné soubory",
|
||||
"batch_delete_confirm_message": "Opravdu chcete odstranit {count, plural, one {1 položku} few {# položky} other {# položek}}? Tuto akci nelze vrátit zpět.",
|
||||
"batch_delete_success": "{count, plural, one {Odstraněna 1 položka} few {Odstraněny # položky} other {Odstraněno # položek}}",
|
||||
"grid_view": "Zobrazení mřížky",
|
||||
"list_view": "Zobrazení seznamu",
|
||||
"details": "Podrobnosti",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Vložit",
|
||||
"move_success": "{count, plural, one {Přesunuta 1 položka} few {Přesunuty # položky} other {Přesunuto # položek}}",
|
||||
"move_error": "Přesun selhal",
|
||||
"move_to": "Přesunout do…",
|
||||
"move_to_title": "Přesunout do",
|
||||
"move_here": "Přesunout sem",
|
||||
"paste_success": "Úspěšně vloženo",
|
||||
"paste_error": "Vložení selhalo",
|
||||
"new_text_file": "Nový textový soubor",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Oddálit",
|
||||
"zoom_reset": "Přizpůsobit obrazovce",
|
||||
"rotate": "Otočit",
|
||||
"close_preview": "Zavřít"
|
||||
"close_preview": "Zavřít",
|
||||
"previous_file": "Předchozí soubor",
|
||||
"next_file": "Další soubor",
|
||||
"loading": "Načítání…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Tilføj til Cc",
|
||||
"add_bcc": "Tilføj til Bcc",
|
||||
"select_hint": "Vælg en person for at se detaljer",
|
||||
"write_email": "Skriv e-mail",
|
||||
"write_email": "Send e-mail",
|
||||
"add_to_contacts": "Tilføj til kontakter",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Posterne kommer fra din mailserver og kan ikke redigeres her.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakter",
|
||||
"disabled_title": "Kontakter er slået fra af din administrator",
|
||||
"disabled_description": "Kontakt din administrator, hvis du har brug for dem.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"select_contact": "Vælg {name}",
|
||||
"new": "Ny",
|
||||
"search_placeholder": "Søg efter kontakter...",
|
||||
"create_new": "Ny kontakt",
|
||||
"no_category": "Ingen kategori",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Ingen kontakter endnu",
|
||||
"empty_state_subtitle": "Opret din første kontakt eller importér fra en vCard-fil",
|
||||
"empty_view_title": "Ingen kontakter her",
|
||||
"empty_view_hint": "Tilføj en kontakt, eller importér en vCard-fil.",
|
||||
"empty_search": "Ingen kontakter matcher din søgning",
|
||||
"empty_search_hint": "Prøv en anden søgeterm",
|
||||
"empty_filtered": "Ingen kontakter matcher dine filtre",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Ny adressebog",
|
||||
"create_confirm": "Opret",
|
||||
"created": "Adressebog oprettet",
|
||||
"create_failed": "Kunne ikke oprette adressebog",
|
||||
"title": "Mine adressebøger",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Certifikat importeret til S/MIME-lager",
|
||||
"cert_import_failed": "Kunne ikke importere certifikat",
|
||||
"no_contact_selected": "Vælg en kontakt for at se detaljer",
|
||||
"compose_email": "Skriv e-mail",
|
||||
"compose_email": "Send e-mail",
|
||||
"copy_email": "Kopier e-mail",
|
||||
"copy_phone": "Kopier telefonnummer",
|
||||
"copy_url": "Kopier URL",
|
||||
@@ -3066,7 +3081,7 @@
|
||||
"create_title": "Ny kontakt",
|
||||
"edit_title": "Redigér kontakt",
|
||||
"section_address_book": "Adressebog",
|
||||
"select_address_book": "Vælg en adressebog...",
|
||||
"select_address_book": "Adressebog",
|
||||
"section_identity": "Navn & identitet",
|
||||
"contact_type": "Kontakttype",
|
||||
"type_person": "Person",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Tilføj telefon",
|
||||
"save": "Gem",
|
||||
"cancel": "Annuller",
|
||||
"discard_title": "Kassér ændringer?",
|
||||
"discard_message": "Dine ændringer er ikke gemt.",
|
||||
"discard": "Kassér",
|
||||
"keep_editing": "Fortsæt redigering",
|
||||
"remove_entry": "Fjern",
|
||||
"close": "Luk",
|
||||
"remove_category": "Fjern {name}",
|
||||
"creating": "Opretter...",
|
||||
"updating": "Opdaterer...",
|
||||
"name_required": "Angiv et fornavn, efternavn eller en organisation",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Ny gruppe",
|
||||
"delete": "Slet gruppe",
|
||||
"remove_member": "Fjern {name} fra gruppen",
|
||||
"edit": "Redigér gruppe",
|
||||
"empty": "Ingen grupper endnu",
|
||||
"delete_confirm_title": "Slet gruppe",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Søg efter kontakter at tilføje...",
|
||||
"no_members": "Ingen medlemmer i denne gruppe",
|
||||
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Send e-mail",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
|
||||
"add_to_group": "Tilføj til gruppe",
|
||||
"choose_group": "Vælg en gruppe",
|
||||
"new_group": "Ny gruppe…",
|
||||
"adding_contacts": "Tilføjer {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"added_to_group": "Kontakter tilføjet til gruppe",
|
||||
"export": "Eksportér",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Fornavn",
|
||||
"sort_last_name": "Efternavn"
|
||||
},
|
||||
"open_categories": "Åbn kategorier"
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Handlinger",
|
||||
"first_last": "Første eller sidste",
|
||||
"extend": "Udvid markeringen",
|
||||
"toggle": "Vælg eller fravælg",
|
||||
"select_all": "Vælg alle",
|
||||
"clear": "Ryd markering eller søgning",
|
||||
"delete": "Slet",
|
||||
"search": "Søg",
|
||||
"move": "Næste eller forrige kontakt",
|
||||
"open": "Åbn kontakten",
|
||||
"menu": "Åbn kontaktens menu"
|
||||
},
|
||||
"open_categories": "Åbn kategorier",
|
||||
"row_actions": "Handlinger for {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Navne må ikke indeholde kontroltegn.",
|
||||
"name_too_long": "Dette navn er for langt til serveren.",
|
||||
"delete": "Slet",
|
||||
"delete_confirm_title": "Slet ressource",
|
||||
"delete_confirm_message": "Er du sikker på, at du vil slette \"{name}\"? Dette kan ikke fortrydes.",
|
||||
"download": "Download",
|
||||
"name": "Navn",
|
||||
"size": "Størrelse",
|
||||
@@ -3972,18 +4010,57 @@
|
||||
"error_partial_copy": "En del af kopien blev liggende.",
|
||||
"create_folder_success": "Mappe oprettet",
|
||||
"create_folder_error": "Kunne ikke oprette mappe",
|
||||
"delete_success": "Slettet",
|
||||
"delete_error": "Kunne ikke slette",
|
||||
"trash": "Papirkurv",
|
||||
"restore": "Gendan",
|
||||
"restored": "{count, plural, one {Elementet er gendannet} other {# elementer er gendannet}}",
|
||||
"restore_error": "Kunne ikke gendanne",
|
||||
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
|
||||
"delete_forever": "Slet permanent",
|
||||
"delete_forever_title": "{count, plural, one {Slet permanent?} other {Slet # elementer permanent?}}",
|
||||
"delete_forever_message": "\"{name}\" bliver slettet. Det kan ikke fortrydes.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 element bliver slettet} other {# elementer bliver slettet}}. Det kan ikke fortrydes.",
|
||||
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
|
||||
"empty_trash": "Tøm papirkurven",
|
||||
"empty_trash_title": "Tøm papirkurven?",
|
||||
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} bliver slettet permanent. Det kan ikke fortrydes.",
|
||||
"trash_emptied": "Papirkurven er tømt",
|
||||
"empty_trash_error": "Kunne ikke tømme papirkurven",
|
||||
"trash_empty_title": "Papirkurven er tom",
|
||||
"trash_empty_hint": "Slettede filer bliver her, indtil du tømmer papirkurven.",
|
||||
"deleted": "Slettet",
|
||||
"deleted_from": "Slettet fra {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Handlinger",
|
||||
"first_last": "Første eller sidste",
|
||||
"extend": "Udvid markeringen",
|
||||
"toggle": "Vælg eller fravælg",
|
||||
"select_all": "Vælg alle",
|
||||
"clear": "Ryd markering eller søgning",
|
||||
"delete": "Slet",
|
||||
"search": "Søg",
|
||||
"move": "Næste eller forrige element",
|
||||
"move_grid": "Element ved siden af i gitteret",
|
||||
"open": "Åbn",
|
||||
"up": "En mappe op",
|
||||
"rename": "Omdøb",
|
||||
"copy": "Kopiér",
|
||||
"cut": "Klip",
|
||||
"paste": "Indsæt"
|
||||
},
|
||||
"rename_success": "Omdøbt",
|
||||
"rename_error": "Kunne ikke omdøbe",
|
||||
"download_error": "Kunne ikke downloade",
|
||||
"download_zip": "Download som ZIP",
|
||||
"zipping": "Forbereder {name}",
|
||||
"download_canceled": "Download annulleret",
|
||||
"zip_error": "Kunne ikke downloade {name}",
|
||||
"not_available": "Fillager er ikke tilgængeligt på denne server",
|
||||
"cancel": "Annuller",
|
||||
"create": "Opret",
|
||||
"save": "Gem",
|
||||
"no_results": "Ingen filer matcher din søgning",
|
||||
"batch_delete_confirm_message": "Er du sikker på, at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke fortrydes.",
|
||||
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
|
||||
"grid_view": "Gittervisning",
|
||||
"list_view": "Listevisning",
|
||||
"details": "Detaljer",
|
||||
@@ -3995,6 +4072,9 @@
|
||||
"paste": "Sæt ind",
|
||||
"move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}",
|
||||
"move_error": "Kunne ikke flytte",
|
||||
"move_to": "Flyt til…",
|
||||
"move_to_title": "Flyt til",
|
||||
"move_here": "Flyt hertil",
|
||||
"paste_success": "Indsat",
|
||||
"paste_error": "Kunne ikke indsætte",
|
||||
"new_text_file": "Ny tekstfil",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Zoom ud",
|
||||
"zoom_reset": "Tilpas til skærm",
|
||||
"rotate": "Rotér",
|
||||
"close_preview": "Luk"
|
||||
"close_preview": "Luk",
|
||||
"previous_file": "Forrige fil",
|
||||
"next_file": "Næste fil",
|
||||
"loading": "Indlæser…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine certifikater",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Zu Cc",
|
||||
"add_bcc": "Zu Bcc",
|
||||
"select_hint": "Wählen Sie jemanden aus, um die Details zu sehen",
|
||||
"write_email": "E-Mail schreiben",
|
||||
"write_email": "E-Mail senden",
|
||||
"add_to_contacts": "Zu Kontakten hinzufügen",
|
||||
"email": "E-Mail",
|
||||
"read_only_note": "Verzeichniseinträge kommen von Ihrem Mailserver und können hier nicht bearbeitet werden.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakte",
|
||||
"disabled_title": "Kontakte wurden von Ihrem Administrator deaktiviert",
|
||||
"disabled_description": "Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.",
|
||||
"drag_count": "{count, plural, one {1 Kontakt} other {# Kontakte}}",
|
||||
"select_contact": "{name} auswählen",
|
||||
"new": "Neu",
|
||||
"search_placeholder": "Kontakte suchen...",
|
||||
"create_new": "Neuer Kontakt",
|
||||
"no_category": "Ohne Kategorie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Keine Kontakte",
|
||||
"empty_state_subtitle": "Erstellen Sie Ihren ersten Kontakt oder importieren Sie aus einer vCard-Datei",
|
||||
"empty_view_title": "Hier sind keine Kontakte",
|
||||
"empty_view_hint": "Fügen Sie einen Kontakt hinzu oder importieren Sie eine vCard-Datei.",
|
||||
"empty_search": "Keine Kontakte gefunden",
|
||||
"empty_search_hint": "Versuchen Sie einen anderen Suchbegriff",
|
||||
"empty_filtered": "Keine Kontakte entsprechen Ihren Filtern",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Neues Adressbuch",
|
||||
"create_confirm": "Erstellen",
|
||||
"created": "Adressbuch erstellt",
|
||||
"create_failed": "Adressbuch konnte nicht erstellt werden",
|
||||
"title": "Meine Adressbücher",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Verwandte Kontakte",
|
||||
"crypto_keys": "Kryptografische Schlüssel",
|
||||
"no_contact_selected": "Kontakt auswählen, um Details anzuzeigen",
|
||||
"compose_email": "E-Mail schreiben",
|
||||
"compose_email": "E-Mail senden",
|
||||
"copy_email": "E-Mail kopieren",
|
||||
"copy_phone": "Telefonnummer kopieren",
|
||||
"copy_url": "URL kopieren",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Neuer Kontakt",
|
||||
"edit_title": "Kontakt bearbeiten",
|
||||
"section_address_book": "Verzeichnis",
|
||||
"select_address_book": "Verzeichnis auswählen...",
|
||||
"section_address_book": "Adressbuch",
|
||||
"select_address_book": "Adressbuch",
|
||||
"section_identity": "Name & Identität",
|
||||
"contact_type": "Kontakttyp",
|
||||
"type_person": "Person",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Telefon hinzufügen",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"discard_title": "Änderungen verwerfen?",
|
||||
"discard_message": "Ihre Änderungen wurden nicht gespeichert.",
|
||||
"discard": "Verwerfen",
|
||||
"keep_editing": "Weiter bearbeiten",
|
||||
"remove_entry": "Entfernen",
|
||||
"close": "Schließen",
|
||||
"remove_category": "{name} entfernen",
|
||||
"creating": "Wird erstellt...",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"name_required": "Bitte Vorname, Nachname oder Organisation angeben",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Neue Gruppe",
|
||||
"delete": "Gruppe löschen",
|
||||
"remove_member": "{name} aus der Gruppe entfernen",
|
||||
"edit": "Gruppe bearbeiten",
|
||||
"empty": "Keine Gruppen",
|
||||
"delete_confirm_title": "Gruppe löschen",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Kontakte zum Hinzufügen suchen...",
|
||||
"no_members": "Keine Mitglieder in dieser Gruppe",
|
||||
"member_count": "{count, plural, =0 {Keine Mitglieder} one {1 Mitglied} other {# Mitglieder}}",
|
||||
"send_email": "E-Mail an Gruppe senden",
|
||||
"send_email": "E-Mail senden",
|
||||
"send_email_to": "An",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 Kontakt gelöscht} other {# Kontakte gelöscht}}",
|
||||
"add_to_group": "Zur Gruppe hinzufügen",
|
||||
"choose_group": "Gruppe wählen",
|
||||
"new_group": "Neue Gruppe…",
|
||||
"adding_contacts": "{count, plural, one {1 Kontakt} other {# Kontakte}} hinzufügen",
|
||||
"added_to_group": "Kontakte zur Gruppe hinzugefügt",
|
||||
"export": "Exportieren",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Vorname",
|
||||
"sort_last_name": "Nachname"
|
||||
},
|
||||
"open_categories": "Kategorien öffnen"
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Aktionen",
|
||||
"first_last": "Erstes oder letztes",
|
||||
"extend": "Auswahl erweitern",
|
||||
"toggle": "Auswählen oder abwählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"clear": "Auswahl oder Suche aufheben",
|
||||
"delete": "Löschen",
|
||||
"search": "Suchen",
|
||||
"move": "Nächster oder vorheriger Kontakt",
|
||||
"open": "Kontakt öffnen",
|
||||
"menu": "Kontaktmenü öffnen"
|
||||
},
|
||||
"open_categories": "Kategorien öffnen",
|
||||
"row_actions": "Aktionen für {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Namen dürfen keine Steuerzeichen enthalten.",
|
||||
"name_too_long": "Dieser Name ist für den Server zu lang.",
|
||||
"delete": "Löschen",
|
||||
"delete_confirm_title": "Ressource löschen",
|
||||
"delete_confirm_message": "Möchten Sie \"{name}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"download": "Herunterladen",
|
||||
"name": "Name",
|
||||
"size": "Größe",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Ein Teil der Kopie wurde nicht entfernt.",
|
||||
"create_folder_success": "Ordner erstellt",
|
||||
"create_folder_error": "Ordner konnte nicht erstellt werden",
|
||||
"delete_success": "Erfolgreich gelöscht",
|
||||
"delete_error": "Löschen fehlgeschlagen",
|
||||
"trash": "Papierkorb",
|
||||
"restore": "Wiederherstellen",
|
||||
"restored": "{count, plural, one {Element wiederhergestellt} other {# Elemente wiederhergestellt}}",
|
||||
"restore_error": "Wiederherstellen fehlgeschlagen",
|
||||
"moved_to_trash": "{count, plural, one {In den Papierkorb verschoben} other {# Elemente in den Papierkorb verschoben}}",
|
||||
"delete_forever": "Endgültig löschen",
|
||||
"delete_forever_title": "{count, plural, one {Endgültig löschen?} other {# Elemente endgültig löschen?}}",
|
||||
"delete_forever_message": "„{name}“ wird gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 Element wird gelöscht} other {# Elemente werden gelöscht}}. Das lässt sich nicht rückgängig machen.",
|
||||
"deleted_forever": "{count, plural, one {Endgültig gelöscht} other {# Elemente endgültig gelöscht}}",
|
||||
"empty_trash": "Papierkorb leeren",
|
||||
"empty_trash_title": "Papierkorb leeren?",
|
||||
"empty_trash_message": "{count, plural, one {Das Element im Papierkorb wird} other {Alle # Elemente im Papierkorb werden}} endgültig gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"trash_emptied": "Papierkorb geleert",
|
||||
"empty_trash_error": "Papierkorb konnte nicht geleert werden",
|
||||
"trash_empty_title": "Der Papierkorb ist leer",
|
||||
"trash_empty_hint": "Gelöschte Dateien bleiben hier, bis Sie den Papierkorb leeren.",
|
||||
"deleted": "Gelöscht",
|
||||
"deleted_from": "Gelöscht aus {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Aktionen",
|
||||
"first_last": "Erstes oder letztes",
|
||||
"extend": "Auswahl erweitern",
|
||||
"toggle": "Auswählen oder abwählen",
|
||||
"select_all": "Alle auswählen",
|
||||
"clear": "Auswahl oder Suche aufheben",
|
||||
"delete": "Löschen",
|
||||
"search": "Suchen",
|
||||
"move": "Nächstes oder vorheriges Element",
|
||||
"move_grid": "Element daneben im Raster",
|
||||
"open": "Öffnen",
|
||||
"up": "Einen Ordner nach oben",
|
||||
"rename": "Umbenennen",
|
||||
"copy": "Kopieren",
|
||||
"cut": "Ausschneiden",
|
||||
"paste": "Einfügen"
|
||||
},
|
||||
"rename_success": "Erfolgreich umbenannt",
|
||||
"rename_error": "Umbenennen fehlgeschlagen",
|
||||
"download_error": "Herunterladen fehlgeschlagen",
|
||||
"download_zip": "Als ZIP herunterladen",
|
||||
"zipping": "{name} wird vorbereitet",
|
||||
"download_canceled": "Download abgebrochen",
|
||||
"zip_error": "{name} konnte nicht heruntergeladen werden",
|
||||
"not_available": "Dateispeicher ist auf diesem Server nicht verfügbar",
|
||||
"cancel": "Abbrechen",
|
||||
"create": "Erstellen",
|
||||
"save": "Speichern",
|
||||
"no_results": "Keine Dateien entsprechen Ihrer Suche",
|
||||
"batch_delete_confirm_message": "Möchten Sie wirklich {count, plural, one {1 Element} other {# Elemente}} löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"batch_delete_success": "{count, plural, one {1 Element gelöscht} other {# Elemente gelöscht}}",
|
||||
"grid_view": "Rasteransicht",
|
||||
"list_view": "Listenansicht",
|
||||
"details": "Details",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Einfügen",
|
||||
"move_success": "{count, plural, one {1 Element verschoben} other {# Elemente verschoben}}",
|
||||
"move_error": "Verschieben fehlgeschlagen",
|
||||
"move_to": "Verschieben nach…",
|
||||
"move_to_title": "Verschieben nach",
|
||||
"move_here": "Hierher verschieben",
|
||||
"paste_success": "Erfolgreich eingefügt",
|
||||
"paste_error": "Einfügen fehlgeschlagen",
|
||||
"new_text_file": "Neue Textdatei",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Verkleinern",
|
||||
"zoom_reset": "An Bildschirm anpassen",
|
||||
"rotate": "Drehen",
|
||||
"close_preview": "Schließen"
|
||||
"close_preview": "Schließen",
|
||||
"previous_file": "Vorherige Datei",
|
||||
"next_file": "Nächste Datei",
|
||||
"loading": "Wird geladen…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ihre Zertifikate",
|
||||
|
||||
+149
-66
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Add to Cc",
|
||||
"add_bcc": "Add to Bcc",
|
||||
"select_hint": "Select someone to see their details",
|
||||
"write_email": "Write email",
|
||||
"write_email": "Send email",
|
||||
"add_to_contacts": "Add to contacts",
|
||||
"email": "Email",
|
||||
"read_only_note": "Directory entries come from your mail server and can't be edited here.",
|
||||
@@ -2928,18 +2928,29 @@
|
||||
}
|
||||
},
|
||||
"title": "Contacts",
|
||||
"disabled_title": "Contacts is turned off by your administrator",
|
||||
"disabled_description": "Ask your administrator if you need them.",
|
||||
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
|
||||
"select_contact": "Select {name}",
|
||||
"new": "New",
|
||||
"search_placeholder": "Search contacts...",
|
||||
"create_new": "New Contact",
|
||||
"no_category": "No Category",
|
||||
"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_rename_failed": "Couldn't rename the 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",
|
||||
"empty_state_title": "No contacts yet",
|
||||
"empty_state_subtitle": "Create your first contact or import from a vCard file",
|
||||
"empty_view_title": "No contacts here",
|
||||
"empty_view_hint": "Add a contact or import a vCard file.",
|
||||
"empty_search": "No contacts match your search",
|
||||
"empty_search_hint": "Try a different search term",
|
||||
"empty_filtered": "No contacts match your filters",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,44 +2973,48 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "New address book",
|
||||
"create_confirm": "Create",
|
||||
"created": "Address book created",
|
||||
"create_failed": "Failed to create address book",
|
||||
"title": "My Address Books",
|
||||
"create_failed": "Couldn't create the address book",
|
||||
"title": "My address books",
|
||||
"shared_prefix": "Shared: {name}",
|
||||
"moved": "Contact moved to {name}",
|
||||
"moved_plural": "{count} contacts moved to {name}",
|
||||
"move_failed": "Failed to move contact",
|
||||
"address_book": "Address Book",
|
||||
"move_failed": "Couldn't move the contact",
|
||||
"address_book": "Address book",
|
||||
"rename": "Rename address book",
|
||||
"name_label": "Address book name",
|
||||
"renamed": "Address book renamed",
|
||||
"rename_failed": "Failed to rename address book",
|
||||
"rename_failed": "Couldn't rename the address book",
|
||||
"default": "Default",
|
||||
"set_default": "Set as default",
|
||||
"default_updated": "Default address book updated",
|
||||
"set_default_failed": "Failed to set default address book",
|
||||
"set_default_failed": "Couldn't set the default address book",
|
||||
"manage": "Manage address books",
|
||||
"share": "Share address book",
|
||||
"new_contact_in_book": "New contact in this address book",
|
||||
"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": "Couldn't delete the address book",
|
||||
"move_to": "Move to…",
|
||||
"move_title": "Move to address book",
|
||||
"move_here": "Move here"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
"phones": "Phone Numbers",
|
||||
"emails": "Email addresses",
|
||||
"phones": "Phone numbers",
|
||||
"organizations": "Organizations",
|
||||
"addresses": "Addresses",
|
||||
"notes": "Notes",
|
||||
"titles": "Titles & Roles",
|
||||
"online_services": "Online Services",
|
||||
"titles": "Titles and roles",
|
||||
"online_services": "Online services",
|
||||
"anniversaries": "Anniversaries",
|
||||
"personal_info": "Personal Info",
|
||||
"personal_info": "Personal info",
|
||||
"languages": "Languages",
|
||||
"categories": "Categories",
|
||||
"related_contacts": "Related Contacts",
|
||||
"crypto_keys": "Crypto Keys",
|
||||
"crypto_keys": "Crypto keys",
|
||||
"cert_issuer": "Issuer",
|
||||
"cert_expires": "Expires",
|
||||
"cert_expired": "Expired",
|
||||
@@ -3007,14 +3023,14 @@
|
||||
"import_to_smime": "Import to S/MIME",
|
||||
"cert_already_imported": "Already imported to S/MIME",
|
||||
"cert_imported": "Certificate imported to S/MIME store",
|
||||
"cert_import_failed": "Failed to import certificate",
|
||||
"cert_import_failed": "Couldn't import the certificate",
|
||||
"no_contact_selected": "Select a contact to view details",
|
||||
"compose_email": "Compose email",
|
||||
"compose_email": "Send email",
|
||||
"copy_email": "Copy email",
|
||||
"copy_phone": "Copy phone number",
|
||||
"copy_url": "Copy URL",
|
||||
"copied": "Copied to clipboard",
|
||||
"copy_failed": "Failed to copy to clipboard",
|
||||
"copy_failed": "Couldn't copy to the clipboard",
|
||||
"created": "Created",
|
||||
"updated": "Last updated",
|
||||
"timezone": "Timezone",
|
||||
@@ -3053,26 +3069,26 @@
|
||||
"years_since": "{count, plural, one {1 year} other {# years}}"
|
||||
},
|
||||
"activity": {
|
||||
"recent_emails": "Recent Emails",
|
||||
"upcoming_events": "Upcoming Events",
|
||||
"recent_emails": "Recent emails",
|
||||
"upcoming_events": "Upcoming events",
|
||||
"no_emails": "No recent emails",
|
||||
"no_events": "No upcoming events",
|
||||
"no_subject": "(No subject)",
|
||||
"no_title": "(No title)",
|
||||
"load_failed": "Failed to load",
|
||||
"load_failed": "Couldn't load",
|
||||
"unknown_sender": "Unknown sender",
|
||||
"all_day": "All day"
|
||||
},
|
||||
"form": {
|
||||
"create_title": "New Contact",
|
||||
"edit_title": "Edit Contact",
|
||||
"section_address_book": "Directory",
|
||||
"select_address_book": "Select a directory...",
|
||||
"section_identity": "Name & Identity",
|
||||
"create_title": "New contact",
|
||||
"edit_title": "Edit contact",
|
||||
"section_address_book": "Address book",
|
||||
"select_address_book": "Select an address book…",
|
||||
"section_identity": "Name and identity",
|
||||
"contact_type": "Contact type",
|
||||
"type_person": "Person",
|
||||
"type_organization": "Organization",
|
||||
"section_work": "Work & Organization",
|
||||
"section_work": "Work and organization",
|
||||
"prefix": "Prefix",
|
||||
"prefix_placeholder": "Dr., Mr., Mrs.",
|
||||
"given_name": "First name",
|
||||
@@ -3108,7 +3124,7 @@
|
||||
"region": "State / Region",
|
||||
"postcode": "Postal code",
|
||||
"country": "Country",
|
||||
"online_services": "Online Services",
|
||||
"online_services": "Online services",
|
||||
"add_online_service": "Add online service",
|
||||
"url_placeholder": "https://...",
|
||||
"service_placeholder": "Service",
|
||||
@@ -3118,7 +3134,7 @@
|
||||
"anniversary_wedding": "Anniversary",
|
||||
"anniversary_death": "Memorial",
|
||||
"anniversary_other": "Other",
|
||||
"personal_info": "Personal Info",
|
||||
"personal_info": "Personal info",
|
||||
"add_personal_info": "Add entry",
|
||||
"personal_info_placeholder": "e.g., Photography",
|
||||
"personal_expertise": "Expertise",
|
||||
@@ -3154,12 +3170,19 @@
|
||||
"add_phone": "Add phone",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"discard_title": "Discard changes?",
|
||||
"discard_message": "Your changes haven't been saved.",
|
||||
"discard": "Discard",
|
||||
"keep_editing": "Keep editing",
|
||||
"remove_entry": "Remove",
|
||||
"close": "Close",
|
||||
"remove_category": "Remove {name}",
|
||||
"creating": "Creating...",
|
||||
"updating": "Updating...",
|
||||
"name_required": "Enter a first name, last name, or organization",
|
||||
"email_invalid": "Please enter a valid email address",
|
||||
"email_error_inline": "Invalid email format",
|
||||
"save_failed": "Failed to save contact",
|
||||
"save_failed": "Couldn't save the contact",
|
||||
"delete": "Delete",
|
||||
"upload_photo": "Upload photo",
|
||||
"remove_photo": "Remove photo",
|
||||
@@ -3169,31 +3192,33 @@
|
||||
"change_photo": "Change"
|
||||
},
|
||||
"groups": {
|
||||
"create": "New Group",
|
||||
"edit": "Edit Group",
|
||||
"create": "New group",
|
||||
"delete": "Delete group",
|
||||
"remove_member": "Remove {name} from the group",
|
||||
"edit": "Edit group",
|
||||
"empty": "No groups yet",
|
||||
"delete_confirm_title": "Delete group",
|
||||
"delete_confirm": "Are you sure you want to delete this group?",
|
||||
"name_label": "Group Name",
|
||||
"name_label": "Group name",
|
||||
"name_placeholder": "e.g., Team, Family",
|
||||
"name_required": "Group name is required",
|
||||
"save_failed": "Failed to save group",
|
||||
"save_failed": "Couldn't save the group",
|
||||
"members_label": "Members",
|
||||
"search_members": "Search contacts to add...",
|
||||
"no_members": "No members in this group",
|
||||
"member_count": "{count, plural, =0 {No members} one {1 member} other {# members}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Send email",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
"no_member_emails": "This group has no members with an email address."
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Contacts",
|
||||
"title": "Import contacts",
|
||||
"drop_hint": "Click to select a vCard file",
|
||||
"file_types": ".vcf or .vcard files",
|
||||
"no_contacts": "No contacts found in file",
|
||||
"parse_error": "Failed to parse vCard file",
|
||||
"parse_error": "Couldn't read the vCard file",
|
||||
"found": "{count, plural, one {1 contact found} other {# contacts found}}",
|
||||
"duplicate": "Duplicate",
|
||||
"select_all": "Select all",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 contact deleted} other {# contacts deleted}}",
|
||||
"add_to_group": "Add to group",
|
||||
"choose_group": "Choose a group",
|
||||
"new_group": "New group…",
|
||||
"adding_contacts": "Adding {count, plural, one {1 contact} other {# contacts}}",
|
||||
"added_to_group": "Contacts added to group",
|
||||
"export": "Export",
|
||||
@@ -3229,9 +3255,9 @@
|
||||
"created": "Contact created",
|
||||
"updated": "Contact updated",
|
||||
"deleted": "Contact deleted",
|
||||
"error_create": "Failed to create contact",
|
||||
"error_update": "Failed to update contact",
|
||||
"error_delete": "Failed to delete contact"
|
||||
"error_create": "Couldn't create the contact",
|
||||
"error_update": "Couldn't update the contact",
|
||||
"error_delete": "Couldn't delete the contact"
|
||||
},
|
||||
"context_menu": {
|
||||
"open": "Open",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Sort by",
|
||||
"sort_first_name": "First name",
|
||||
"sort_last_name": "Last name"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "List",
|
||||
"actions": "Actions",
|
||||
"first_last": "First or last",
|
||||
"extend": "Extend the selection",
|
||||
"toggle": "Select or deselect",
|
||||
"select_all": "Select all",
|
||||
"clear": "Clear the selection or search",
|
||||
"delete": "Delete",
|
||||
"search": "Search",
|
||||
"move": "Next or previous contact",
|
||||
"open": "Open the contact",
|
||||
"menu": "Open the contact's menu"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3935,8 +3975,8 @@
|
||||
"title": "Files",
|
||||
"search_placeholder": "Search this folder",
|
||||
"empty_state_title": "No files yet",
|
||||
"upload_files": "Upload Files",
|
||||
"new_folder": "New Folder",
|
||||
"upload_files": "Upload files",
|
||||
"new_folder": "New folder",
|
||||
"new_folder_name": "Folder name",
|
||||
"rename": "Rename",
|
||||
"rename_title": "Rename",
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Names cannot contain control characters.",
|
||||
"name_too_long": "This name is too long for the server.",
|
||||
"delete": "Delete",
|
||||
"delete_confirm_title": "Delete resource",
|
||||
"delete_confirm_message": "Are you sure you want to delete \"{name}\"? This cannot be undone.",
|
||||
"download": "Download",
|
||||
"name": "Name",
|
||||
"size": "Size",
|
||||
@@ -3963,7 +4001,7 @@
|
||||
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
|
||||
"upload_canceled": "Upload canceled",
|
||||
"upload_canceled_partial": "{count, plural, one {1 file was} other {# files were}} uploaded before you canceled",
|
||||
"upload_error": "Failed to upload file",
|
||||
"upload_error": "Couldn't upload the file",
|
||||
"upload_failed": "{count, plural, one {1 file could not be uploaded} other {# files could not be uploaded}}",
|
||||
"error_over_quota": "You have reached the limit on files or folders. Delete some and try again.",
|
||||
"error_too_deep": "Folders cannot be nested this deeply.",
|
||||
@@ -3972,55 +4010,97 @@
|
||||
"error_too_large": "This file is too large for the server.",
|
||||
"error_partial_copy": "Part of the copy was left in place.",
|
||||
"create_folder_success": "Folder created",
|
||||
"create_folder_error": "Failed to create folder",
|
||||
"delete_success": "Deleted successfully",
|
||||
"delete_error": "Failed to delete",
|
||||
"rename_success": "Renamed successfully",
|
||||
"rename_error": "Failed to rename",
|
||||
"download_error": "Failed to download",
|
||||
"create_folder_error": "Couldn't create the folder",
|
||||
"delete_error": "Couldn't delete",
|
||||
"trash": "Trash",
|
||||
"restore": "Restore",
|
||||
"restored": "{count, plural, one {Item restored} other {# items restored}}",
|
||||
"restore_error": "Couldn't restore",
|
||||
"moved_to_trash": "{count, plural, one {Moved to trash} other {# items moved to trash}}",
|
||||
"delete_forever": "Delete permanently",
|
||||
"delete_forever_title": "{count, plural, one {Delete permanently?} other {Delete # items permanently?}}",
|
||||
"delete_forever_message": "\"{name}\" will be deleted. You can't undo this.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 item} other {# items}} will be deleted. You can't undo this.",
|
||||
"deleted_forever": "{count, plural, one {Deleted permanently} other {# items deleted permanently}}",
|
||||
"empty_trash": "Empty trash",
|
||||
"empty_trash_title": "Empty trash?",
|
||||
"empty_trash_message": "{count, plural, one {The item in the trash} other {All # items in the trash}} will be deleted permanently. You can't undo this.",
|
||||
"trash_emptied": "Trash emptied",
|
||||
"empty_trash_error": "Couldn't empty the trash",
|
||||
"trash_empty_title": "Trash is empty",
|
||||
"trash_empty_hint": "Deleted files stay here until you empty the trash.",
|
||||
"deleted": "Deleted",
|
||||
"deleted_from": "Deleted from {folder}",
|
||||
"shortcuts": {
|
||||
"list": "List",
|
||||
"actions": "Actions",
|
||||
"first_last": "First or last",
|
||||
"extend": "Extend the selection",
|
||||
"toggle": "Select or deselect",
|
||||
"select_all": "Select all",
|
||||
"clear": "Clear the selection or search",
|
||||
"delete": "Delete",
|
||||
"search": "Search",
|
||||
"move": "Next or previous item",
|
||||
"move_grid": "Item beside it in the grid",
|
||||
"open": "Open",
|
||||
"up": "Up a folder",
|
||||
"rename": "Rename",
|
||||
"copy": "Copy",
|
||||
"cut": "Cut",
|
||||
"paste": "Paste"
|
||||
},
|
||||
"rename_success": "Renamed",
|
||||
"rename_error": "Couldn't rename",
|
||||
"download_error": "Couldn't download",
|
||||
"download_zip": "Download as zip",
|
||||
"zipping": "Preparing {name}",
|
||||
"download_canceled": "Download canceled",
|
||||
"zip_error": "Couldn't download {name}",
|
||||
"not_available": "File storage is not available on this server",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"save": "Save",
|
||||
"no_results": "No files match your search",
|
||||
"batch_delete_confirm_message": "Are you sure you want to delete {count, plural, one {1 item} other {# items}}? This cannot be undone.",
|
||||
"batch_delete_success": "{count, plural, one {1 item deleted} other {# items deleted}}",
|
||||
"grid_view": "Grid view",
|
||||
"list_view": "List view",
|
||||
"details": "Details",
|
||||
"path": "Path",
|
||||
"preview": "Preview",
|
||||
"preview_error": "Failed to load preview",
|
||||
"preview_error": "Couldn't load the preview",
|
||||
"cut": "Cut",
|
||||
"copy": "Copy",
|
||||
"paste": "Paste",
|
||||
"move_success": "{count, plural, one {1 item moved} other {# items moved}}",
|
||||
"move_error": "Failed to move",
|
||||
"paste_success": "Pasted successfully",
|
||||
"paste_error": "Failed to paste",
|
||||
"new_text_file": "New Text File",
|
||||
"move_error": "Couldn't move",
|
||||
"move_to": "Move to…",
|
||||
"move_to_title": "Move to",
|
||||
"move_here": "Move here",
|
||||
"paste_success": "Pasted",
|
||||
"paste_error": "Couldn't paste",
|
||||
"new_text_file": "New text file",
|
||||
"file_name": "File name",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicate_success": "Duplicated successfully",
|
||||
"duplicate_error": "Failed to duplicate",
|
||||
"duplicate_success": "Duplicated",
|
||||
"duplicate_error": "Couldn't duplicate",
|
||||
"share": "Share",
|
||||
"shared": "Shared",
|
||||
"shared_with_me": "Shared with me",
|
||||
"shared_by": "Shared by {name}",
|
||||
"shared_files_from": "Files from {name}",
|
||||
"create_file_success": "File created",
|
||||
"create_file_error": "Failed to create file",
|
||||
"create_file_error": "Couldn't create the file",
|
||||
"favorites": "Starred",
|
||||
"recent": "Recent",
|
||||
"show_in_folder": "Show in folder",
|
||||
"remove_from_recent": "Remove from recent",
|
||||
"upload_folder": "Upload Folder",
|
||||
"upload_folder": "Upload folder",
|
||||
"file_too_large": "\"{name}\" exceeds the maximum file size ({max})",
|
||||
"undo": "Undo",
|
||||
"undo_success": "Action undone",
|
||||
"undo_error": "Failed to undo",
|
||||
"undo_error": "Couldn't undo",
|
||||
"toolbar": "File actions",
|
||||
"open_folder_tree": "Open folder tree",
|
||||
"file_list": "Files and folders",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Zoom out",
|
||||
"zoom_reset": "Fit to screen",
|
||||
"rotate": "Rotate",
|
||||
"close_preview": "Close"
|
||||
"close_preview": "Close",
|
||||
"previous_file": "Previous file",
|
||||
"next_file": "Next file",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Your Certificates",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Añadir a CC",
|
||||
"add_bcc": "Añadir a CCO",
|
||||
"select_hint": "Selecciona a alguien para ver sus datos",
|
||||
"write_email": "Escribir correo",
|
||||
"write_email": "Enviar correo",
|
||||
"add_to_contacts": "Añadir a contactos",
|
||||
"email": "Correo",
|
||||
"read_only_note": "Las entradas del directorio vienen de tu servidor de correo y no se pueden editar aquí.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contactos",
|
||||
"disabled_title": "Tu administrador ha desactivado los contactos",
|
||||
"disabled_description": "Pídeselos a tu administrador si los necesitas.",
|
||||
"drag_count": "{count, plural, one {1 contacto} other {# contactos}}",
|
||||
"select_contact": "Seleccionar a {name}",
|
||||
"new": "Nuevo",
|
||||
"search_placeholder": "Buscar contactos...",
|
||||
"create_new": "Nuevo contacto",
|
||||
"no_category": "Sin categoría",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Sin contactos",
|
||||
"empty_state_subtitle": "Crea tu primer contacto o importa desde un archivo vCard",
|
||||
"empty_view_title": "No hay contactos aquí",
|
||||
"empty_view_hint": "Añade un contacto o importa un archivo vCard.",
|
||||
"empty_search": "Ningún contacto coincide con tu búsqueda",
|
||||
"empty_search_hint": "Prueba con otro término de búsqueda",
|
||||
"empty_filtered": "Ningún contacto coincide con tus filtros",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nueva libreta de direcciones",
|
||||
"create_confirm": "Crear",
|
||||
"created": "Libreta de direcciones creada",
|
||||
"create_failed": "No se pudo crear la libreta de direcciones",
|
||||
"title": "Mis Libretas de Direcciones",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Contactos relacionados",
|
||||
"crypto_keys": "Claves criptográficas",
|
||||
"no_contact_selected": "Selecciona un contacto para ver los detalles",
|
||||
"compose_email": "Escribir correo",
|
||||
"compose_email": "Enviar correo",
|
||||
"copy_email": "Copiar correo",
|
||||
"copy_phone": "Copiar teléfono",
|
||||
"copy_url": "Copiar URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Nuevo contacto",
|
||||
"edit_title": "Editar contacto",
|
||||
"section_address_book": "Directorio",
|
||||
"select_address_book": "Seleccionar un directorio...",
|
||||
"section_address_book": "Libreta de direcciones",
|
||||
"select_address_book": "Libreta de direcciones",
|
||||
"section_identity": "Nombre e identidad",
|
||||
"contact_type": "Tipo de contacto",
|
||||
"type_person": "Persona",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Agregar teléfono",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"discard_title": "¿Descartar los cambios?",
|
||||
"discard_message": "Tus cambios no se han guardado.",
|
||||
"discard": "Descartar",
|
||||
"keep_editing": "Seguir editando",
|
||||
"remove_entry": "Quitar",
|
||||
"close": "Cerrar",
|
||||
"remove_category": "Quitar {name}",
|
||||
"creating": "Creando...",
|
||||
"updating": "Actualizando...",
|
||||
"name_required": "Introduce un nombre, un apellido o una organización",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuevo grupo",
|
||||
"delete": "Eliminar grupo",
|
||||
"remove_member": "Quitar a {name} del grupo",
|
||||
"edit": "Editar grupo",
|
||||
"empty": "No hay grupos",
|
||||
"delete_confirm_title": "Eliminar grupo",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Buscar contactos para agregar...",
|
||||
"no_members": "No hay miembros en este grupo",
|
||||
"member_count": "{count, plural, =0 {Sin miembros} one {1 miembro} other {# miembros}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Enviar correo",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 contacto eliminado} other {# contactos eliminados}}",
|
||||
"add_to_group": "Agregar al grupo",
|
||||
"choose_group": "Elegir un grupo",
|
||||
"new_group": "Nuevo grupo…",
|
||||
"adding_contacts": "Agregando {count, plural, one {1 contacto} other {# contactos}}",
|
||||
"added_to_group": "Contactos agregados al grupo",
|
||||
"export": "Exportar",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Nombre",
|
||||
"sort_last_name": "Apellido"
|
||||
},
|
||||
"open_categories": "Abrir categorías"
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Acciones",
|
||||
"first_last": "Primero o último",
|
||||
"extend": "Ampliar la selección",
|
||||
"toggle": "Seleccionar o deseleccionar",
|
||||
"select_all": "Seleccionar todo",
|
||||
"clear": "Borrar la selección o la búsqueda",
|
||||
"delete": "Eliminar",
|
||||
"search": "Buscar",
|
||||
"move": "Contacto siguiente o anterior",
|
||||
"open": "Abrir el contacto",
|
||||
"menu": "Abrir el menú del contacto"
|
||||
},
|
||||
"open_categories": "Abrir categorías",
|
||||
"row_actions": "Acciones para {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Los nombres no pueden contener caracteres de control.",
|
||||
"name_too_long": "Este nombre es demasiado largo para el servidor.",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar recurso",
|
||||
"delete_confirm_message": "¿Está seguro de que desea eliminar \"{name}\"? Esta acción no se puede deshacer.",
|
||||
"download": "Descargar",
|
||||
"name": "Nombre",
|
||||
"size": "Tamaño",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Una parte de la copia se ha quedado en su lugar.",
|
||||
"create_folder_success": "Carpeta creada",
|
||||
"create_folder_error": "Error al crear la carpeta",
|
||||
"delete_success": "Eliminado correctamente",
|
||||
"delete_error": "Error al eliminar",
|
||||
"trash": "Papelera",
|
||||
"restore": "Restaurar",
|
||||
"restored": "{count, plural, one {Elemento restaurado} other {# elementos restaurados}}",
|
||||
"restore_error": "No se ha podido restaurar",
|
||||
"moved_to_trash": "{count, plural, one {Movido a la papelera} other {# elementos movidos a la papelera}}",
|
||||
"delete_forever": "Eliminar definitivamente",
|
||||
"delete_forever_title": "{count, plural, one {¿Eliminar definitivamente?} other {¿Eliminar # elementos definitivamente?}}",
|
||||
"delete_forever_message": "Se eliminará «{name}». No se puede deshacer.",
|
||||
"delete_forever_message_count": "{count, plural, one {Se eliminará 1 elemento} other {Se eliminarán # elementos}}. No se puede deshacer.",
|
||||
"deleted_forever": "{count, plural, one {Eliminado definitivamente} other {# elementos eliminados definitivamente}}",
|
||||
"empty_trash": "Vaciar la papelera",
|
||||
"empty_trash_title": "¿Vaciar la papelera?",
|
||||
"empty_trash_message": "{count, plural, one {El elemento de la papelera se eliminará} other {Los # elementos de la papelera se eliminarán}} definitivamente. No se puede deshacer.",
|
||||
"trash_emptied": "Papelera vaciada",
|
||||
"empty_trash_error": "No se ha podido vaciar la papelera",
|
||||
"trash_empty_title": "La papelera está vacía",
|
||||
"trash_empty_hint": "Los archivos eliminados se quedan aquí hasta que vacíes la papelera.",
|
||||
"deleted": "Eliminado",
|
||||
"deleted_from": "Eliminado de {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Acciones",
|
||||
"first_last": "Primero o último",
|
||||
"extend": "Ampliar la selección",
|
||||
"toggle": "Seleccionar o deseleccionar",
|
||||
"select_all": "Seleccionar todo",
|
||||
"clear": "Borrar la selección o la búsqueda",
|
||||
"delete": "Eliminar",
|
||||
"search": "Buscar",
|
||||
"move": "Elemento siguiente o anterior",
|
||||
"move_grid": "Elemento contiguo en la cuadrícula",
|
||||
"open": "Abrir",
|
||||
"up": "Subir una carpeta",
|
||||
"rename": "Cambiar nombre",
|
||||
"copy": "Copiar",
|
||||
"cut": "Cortar",
|
||||
"paste": "Pegar"
|
||||
},
|
||||
"rename_success": "Renombrado correctamente",
|
||||
"rename_error": "Error al renombrar",
|
||||
"download_error": "Error al descargar",
|
||||
"download_zip": "Descargar como ZIP",
|
||||
"zipping": "Preparando {name}",
|
||||
"download_canceled": "Descarga cancelada",
|
||||
"zip_error": "No se ha podido descargar {name}",
|
||||
"not_available": "El almacenamiento de archivos no está disponible en este servidor",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Crear",
|
||||
"save": "Guardar",
|
||||
"no_results": "Ningún archivo coincide con su búsqueda",
|
||||
"batch_delete_confirm_message": "¿Está seguro de que desea eliminar {count, plural, one {1 elemento} other {# elementos}}? Esta acción no se puede deshacer.",
|
||||
"batch_delete_success": "{count, plural, one {1 elemento eliminado} other {# elementos eliminados}}",
|
||||
"grid_view": "Vista de cuadrícula",
|
||||
"list_view": "Vista de lista",
|
||||
"details": "Detalles",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Pegar",
|
||||
"move_success": "{count, plural, one {1 elemento movido} other {# elementos movidos}}",
|
||||
"move_error": "Error al mover",
|
||||
"move_to": "Mover a…",
|
||||
"move_to_title": "Mover a",
|
||||
"move_here": "Mover aquí",
|
||||
"paste_success": "Pegado correctamente",
|
||||
"paste_error": "Error al pegar",
|
||||
"new_text_file": "Nuevo archivo de texto",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Alejar",
|
||||
"zoom_reset": "Ajustar a la pantalla",
|
||||
"rotate": "Girar",
|
||||
"close_preview": "Cerrar"
|
||||
"close_preview": "Cerrar",
|
||||
"previous_file": "Archivo anterior",
|
||||
"next_file": "Archivo siguiente",
|
||||
"loading": "Cargando…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tus certificados",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Lisa CC-sse",
|
||||
"add_bcc": "Lisa BCC-sse",
|
||||
"select_hint": "Vali keegi, et näha tema andmeid",
|
||||
"write_email": "Kirjuta kiri",
|
||||
"write_email": "Saada kiri",
|
||||
"add_to_contacts": "Lisa kontaktidesse",
|
||||
"email": "E-post",
|
||||
"read_only_note": "Kataloogi kirjed tulevad sinu meiliserverist ja neid ei saa siin muuta.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontaktid",
|
||||
"disabled_title": "Administraator on kontaktid välja lülitanud",
|
||||
"disabled_description": "Kui neid vajate, pöörduge administraatori poole.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} other {# kontakti}}",
|
||||
"select_contact": "Vali {name}",
|
||||
"new": "Uus",
|
||||
"search_placeholder": "Otsi kontakte...",
|
||||
"create_new": "Uus kontakt",
|
||||
"no_category": "Kategooria puudub",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Kontakte veel pole",
|
||||
"empty_state_subtitle": "Loo oma esimene kontakt või impordi vCard-failist",
|
||||
"empty_view_title": "Siin pole kontakte",
|
||||
"empty_view_hint": "Lisage kontakt või importige vCard-fail.",
|
||||
"empty_search": "Otsingule ei vastanud ükski kontakt",
|
||||
"empty_search_hint": "Proovi teistsugust otsisõna",
|
||||
"empty_filtered": "Filtritele ei vastanud ükski kontakt",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Uus aadressiraamat",
|
||||
"create_confirm": "Loo",
|
||||
"created": "Aadressiraamat loodud",
|
||||
"create_failed": "Aadressiraamatu loomine ebaõnnestus",
|
||||
"title": "Minu aadressiraamatud",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Sertifikaat imporditud S/MIME hoidlasse",
|
||||
"cert_import_failed": "Sertifikaadi importimine ebaõnnestus",
|
||||
"no_contact_selected": "Vali kontakt, et vaadata üksikasju",
|
||||
"compose_email": "Koosta kiri",
|
||||
"compose_email": "Saada kiri",
|
||||
"copy_email": "Kopeeri e-posti aadress",
|
||||
"copy_phone": "Kopeeri telefoninumber",
|
||||
"copy_url": "Kopeeri URL",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "Uus kontakt",
|
||||
"edit_title": "Muuda kontakti",
|
||||
"section_address_book": "Kataloog",
|
||||
"select_address_book": "Vali kataloog...",
|
||||
"section_address_book": "Aadressiraamat",
|
||||
"select_address_book": "Aadressiraamat",
|
||||
"section_identity": "Nimi ja identiteet",
|
||||
"contact_type": "Kontakti tüüp",
|
||||
"type_person": "Isik",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Lisa telefon",
|
||||
"save": "Salvesta",
|
||||
"cancel": "Tühista",
|
||||
"discard_title": "Kas loobuda muudatustest?",
|
||||
"discard_message": "Teie muudatusi pole salvestatud.",
|
||||
"discard": "Loobu",
|
||||
"keep_editing": "Jätka muutmist",
|
||||
"remove_entry": "Eemalda",
|
||||
"close": "Sulge",
|
||||
"remove_category": "Eemalda {name}",
|
||||
"creating": "Loomine...",
|
||||
"updating": "Uuendamine...",
|
||||
"name_required": "Sisesta eesnimi, perekonnanimi või organisatsioon",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Uus grupp",
|
||||
"delete": "Kustuta rühm",
|
||||
"remove_member": "Eemalda {name} rühmast",
|
||||
"edit": "Muuda gruppi",
|
||||
"empty": "Gruppe veel pole",
|
||||
"delete_confirm_title": "Kustuta grupp",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Otsi lisatavaid kontakte...",
|
||||
"no_members": "Selles grupis pole liikmeid",
|
||||
"member_count": "{count, plural, =0 {Liikmeid pole} one {1 liige} other {# liiget}}",
|
||||
"send_email": "Saada kiri grupile",
|
||||
"send_email": "Saada kiri",
|
||||
"send_email_to": "Kellele",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {Kustutati 1 kontakt} other {Kustutati # kontakti}}",
|
||||
"add_to_group": "Lisa gruppi",
|
||||
"choose_group": "Vali grupp",
|
||||
"new_group": "Uus rühm…",
|
||||
"adding_contacts": "Lisamine: {count, plural, one {1 kontakt} other {# kontakti}}",
|
||||
"added_to_group": "Kontaktid lisati gruppi",
|
||||
"export": "Eksport",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Sordi",
|
||||
"sort_first_name": "Eesnimi",
|
||||
"sort_last_name": "Perekonnanimi"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Loend",
|
||||
"actions": "Toimingud",
|
||||
"first_last": "Esimene või viimane",
|
||||
"extend": "Laienda valikut",
|
||||
"toggle": "Vali või tühista valik",
|
||||
"select_all": "Vali kõik",
|
||||
"clear": "Tühjenda valik või otsing",
|
||||
"delete": "Kustuta",
|
||||
"search": "Otsi",
|
||||
"move": "Järgmine või eelmine kontakt",
|
||||
"open": "Ava kontakt",
|
||||
"menu": "Ava kontakti menüü"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Nimed ei tohi sisaldada juhtmärke.",
|
||||
"name_too_long": "See nimi on serveri jaoks liiga pikk.",
|
||||
"delete": "Kustuta",
|
||||
"delete_confirm_title": "Kustuta ressurss",
|
||||
"delete_confirm_message": "Kas oled kindel, et soovid kustutada \"{name}\"? Seda ei saa tagasi võtta.",
|
||||
"download": "Laadi alla",
|
||||
"name": "Nimi",
|
||||
"size": "Suurus",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "Osa koopiast jäi alles.",
|
||||
"create_folder_success": "Kaust loodud",
|
||||
"create_folder_error": "Kausta loomine ebaõnnestus",
|
||||
"delete_success": "Edukalt kustutatud",
|
||||
"delete_error": "Kustutamine ebaõnnestus",
|
||||
"trash": "Prügikast",
|
||||
"restore": "Taasta",
|
||||
"restored": "{count, plural, one {Üksus taastati} other {# üksust taastati}}",
|
||||
"restore_error": "Taastamine ebaõnnestus",
|
||||
"moved_to_trash": "{count, plural, one {Teisaldati prügikasti} other {# üksust teisaldati prügikasti}}",
|
||||
"delete_forever": "Kustuta jäädavalt",
|
||||
"delete_forever_title": "{count, plural, one {Kas kustutada jäädavalt?} other {Kas kustutada # üksust jäädavalt?}}",
|
||||
"delete_forever_message": "„{name}“ kustutatakse. Seda ei saa tagasi võtta.",
|
||||
"delete_forever_message_count": "{count, plural, one {Kustutatakse 1 üksus} other {Kustutatakse # üksust}}. Seda ei saa tagasi võtta.",
|
||||
"deleted_forever": "{count, plural, one {Kustutati jäädavalt} other {# üksust kustutati jäädavalt}}",
|
||||
"empty_trash": "Tühjenda prügikast",
|
||||
"empty_trash_title": "Kas tühjendada prügikast?",
|
||||
"empty_trash_message": "{count, plural, one {Prügikastis olev üksus} other {Kõik # prügikastis olevat üksust}} kustutatakse jäädavalt. Seda ei saa tagasi võtta.",
|
||||
"trash_emptied": "Prügikast tühjendati",
|
||||
"empty_trash_error": "Prügikasti ei õnnestunud tühjendada",
|
||||
"trash_empty_title": "Prügikast on tühi",
|
||||
"trash_empty_hint": "Kustutatud failid jäävad siia, kuni prügikasti tühjendate.",
|
||||
"deleted": "Kustutatud",
|
||||
"deleted_from": "Kustutatud kaustast {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Loend",
|
||||
"actions": "Toimingud",
|
||||
"first_last": "Esimene või viimane",
|
||||
"extend": "Laienda valikut",
|
||||
"toggle": "Vali või tühista valik",
|
||||
"select_all": "Vali kõik",
|
||||
"clear": "Tühjenda valik või otsing",
|
||||
"delete": "Kustuta",
|
||||
"search": "Otsi",
|
||||
"move": "Järgmine või eelmine üksus",
|
||||
"move_grid": "Kõrvalolev üksus ruudustikus",
|
||||
"open": "Ava",
|
||||
"up": "Kaust üles",
|
||||
"rename": "Nimeta ümber",
|
||||
"copy": "Kopeeri",
|
||||
"cut": "Lõika",
|
||||
"paste": "Kleebi"
|
||||
},
|
||||
"rename_success": "Edukalt ümber nimetatud",
|
||||
"rename_error": "Ümbernimetamine ebaõnnestus",
|
||||
"download_error": "Allalaadimine ebaõnnestus",
|
||||
"download_zip": "Laadi alla ZIP-failina",
|
||||
"zipping": "{name} valmistamine",
|
||||
"download_canceled": "Allalaadimine tühistati",
|
||||
"zip_error": "Faili {name} ei õnnestunud alla laadida",
|
||||
"not_available": "Failihoidla ei ole selles serveris saadaval",
|
||||
"cancel": "Tühista",
|
||||
"create": "Loo",
|
||||
"save": "Salvesta",
|
||||
"no_results": "Otsingule vastavaid faile ei leitud",
|
||||
"batch_delete_confirm_message": "Kas oled kindel, et soovid kustutada {count, plural, one {1 üksuse} other {# üksust}}? Seda ei saa tagasi võtta.",
|
||||
"batch_delete_success": "{count, plural, one {1 üksus kustutatud} other {# üksust kustutatud}}",
|
||||
"grid_view": "Ruudustikvaade",
|
||||
"list_view": "Loendivaade",
|
||||
"details": "Üksikasjad",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Kleebi",
|
||||
"move_success": "{count, plural, one {1 üksus liigutatud} other {# üksust liigutatud}}",
|
||||
"move_error": "Liigutamine ebaõnnestus",
|
||||
"move_to": "Teisalda…",
|
||||
"move_to_title": "Teisalda",
|
||||
"move_here": "Teisalda siia",
|
||||
"paste_success": "Edukalt kleebitud",
|
||||
"paste_error": "Kleepimine ebaõnnestus",
|
||||
"new_text_file": "Uus tekstifail",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Suumi välja",
|
||||
"zoom_reset": "Sobita ekraanile",
|
||||
"rotate": "Pööra",
|
||||
"close_preview": "Sulge"
|
||||
"close_preview": "Sulge",
|
||||
"previous_file": "Eelmine fail",
|
||||
"next_file": "Järgmine fail",
|
||||
"loading": "Laadimine…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Sinu sertifikaadid",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "افزودن به «رونوشت»",
|
||||
"add_bcc": "افزودن به «رونوشت پنهان»",
|
||||
"select_hint": "برای دیدن جزئیات، یک نفر را انتخاب کنید",
|
||||
"write_email": "نوشتن ایمیل",
|
||||
"write_email": "ارسال ایمیل",
|
||||
"add_to_contacts": "افزودن به مخاطبان",
|
||||
"email": "ایمیل",
|
||||
"read_only_note": "ورودیهای فهرست از سرور ایمیل شما میآیند و اینجا قابل ویرایش نیستند.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "مخاطبین",
|
||||
"disabled_title": "مدیر شما مخاطبان را غیرفعال کرده است",
|
||||
"disabled_description": "اگر به آنها نیاز دارید، با مدیر تماس بگیرید.",
|
||||
"drag_count": "{count, plural, one {۱ مخاطب} other {# مخاطب}}",
|
||||
"select_contact": "انتخاب {name}",
|
||||
"new": "جدید",
|
||||
"search_placeholder": "جستجوی مخاطبین...",
|
||||
"create_new": "مخاطب جدید",
|
||||
"no_category": "بدون دستهبندی",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "هنوز مخاطبی نیست",
|
||||
"empty_state_title": "هنوز مخاطبی نیست",
|
||||
"empty_state_subtitle": "اولین مخاطب خود را ایجاد کنید یا از فایل vCard وارد کنید",
|
||||
"empty_view_title": "مخاطبی اینجا نیست",
|
||||
"empty_view_hint": "مخاطبی اضافه کنید یا فایل vCard را وارد کنید.",
|
||||
"empty_search": "مخاطبی با این جستجو مطابقت ندارد",
|
||||
"empty_search_hint": "عبارت جستجوی دیگری را امتحان کنید",
|
||||
"empty_filtered": "مخاطبی با این فیلترها مطابقت ندارد",
|
||||
@@ -2952,6 +2963,7 @@
|
||||
"back_to_contacts": "بازگشت به مخاطبین",
|
||||
"back_to_email": "بازگشت به ایمیل",
|
||||
"open_categories": "باز کردن دستهبندیها",
|
||||
"row_actions": "کنشهای {name}",
|
||||
"tabs": {
|
||||
"all": "همه",
|
||||
"groups": "گروهها"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "دفترچه آدرس جدید",
|
||||
"create_confirm": "ایجاد",
|
||||
"created": "دفترچه آدرس ایجاد شد",
|
||||
"create_failed": "ایجاد دفترچه آدرس ناموفق بود",
|
||||
"title": "دفترچههای آدرس من",
|
||||
@@ -2983,7 +2996,10 @@
|
||||
"delete": "حذف دفترچه آدرس",
|
||||
"confirm_delete": "حذف \"{name}\"؟ همه مخاطبین این دفترچه آدرس حذف خواهند شد.",
|
||||
"deleted": "دفترچه آدرس حذف شد",
|
||||
"delete_failed": "حذف دفترچه آدرس ناموفق بود"
|
||||
"delete_failed": "حذف دفترچه آدرس ناموفق بود",
|
||||
"move_to": "انتقال به…",
|
||||
"move_title": "انتقال به دفترچه نشانی",
|
||||
"move_here": "انتقال به اینجا"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "آدرسهای ایمیل",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "گواهی به مخزن S/MIME وارد شد",
|
||||
"cert_import_failed": "وارد کردن گواهی ناموفق بود",
|
||||
"no_contact_selected": "یک مخاطب برای مشاهده جزئیات انتخاب کنید",
|
||||
"compose_email": "نوشتن ایمیل",
|
||||
"compose_email": "ارسال ایمیل",
|
||||
"copy_email": "کپی ایمیل",
|
||||
"copy_phone": "کپی شماره تلفن",
|
||||
"copy_url": "کپی آدرس",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "مخاطب جدید",
|
||||
"edit_title": "ویرایش مخاطب",
|
||||
"section_address_book": "دفترچه",
|
||||
"select_address_book": "انتخاب دفترچه...",
|
||||
"section_address_book": "دفترچه آدرس",
|
||||
"select_address_book": "دفترچه آدرس",
|
||||
"section_identity": "نام و هویت",
|
||||
"contact_type": "نوع مخاطب",
|
||||
"type_person": "شخص",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "افزودن تلفن",
|
||||
"save": "ذخیره",
|
||||
"cancel": "انصراف",
|
||||
"discard_title": "تغییرات دور انداخته شوند؟",
|
||||
"discard_message": "تغییرات شما ذخیره نشده است.",
|
||||
"discard": "دور انداختن",
|
||||
"keep_editing": "ادامه ویرایش",
|
||||
"remove_entry": "حذف",
|
||||
"close": "بستن",
|
||||
"remove_category": "حذف {name}",
|
||||
"creating": "در حال ایجاد...",
|
||||
"updating": "در حال بهروزرسانی...",
|
||||
"name_required": "نام، نام خانوادگی یا سازمان را وارد کنید",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "گروه جدید",
|
||||
"delete": "حذف گروه",
|
||||
"remove_member": "حذف {name} از گروه",
|
||||
"edit": "ویرایش گروه",
|
||||
"empty": "هنوز گروهی نیست",
|
||||
"delete_confirm_title": "حذف گروه",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "جستجوی مخاطبین برای افزودن...",
|
||||
"no_members": "عضوی در این گروه نیست",
|
||||
"member_count": "{count, plural, =0 {بدون عضو} one {۱ عضو} other {# عضو}}",
|
||||
"send_email": "ارسال ایمیل به گروه",
|
||||
"send_email": "ارسال ایمیل",
|
||||
"send_email_to": "به",
|
||||
"send_email_cc": "رونوشت",
|
||||
"send_email_bcc": "رونوشت مخفی",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {۱ مخاطب حذف شد} other {# مخاطب حذف شد}}",
|
||||
"add_to_group": "افزودن به گروه",
|
||||
"choose_group": "انتخاب گروه",
|
||||
"new_group": "گروه جدید…",
|
||||
"adding_contacts": "افزودن {count, plural, one {۱ مخاطب} other {# مخاطب}}",
|
||||
"added_to_group": "مخاطبین به گروه اضافه شدند",
|
||||
"export": "خروجی",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "مرتبسازی بر اساس",
|
||||
"sort_first_name": "نام",
|
||||
"sort_last_name": "نام خانوادگی"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "فهرست",
|
||||
"actions": "کنشها",
|
||||
"first_last": "اولین یا آخرین",
|
||||
"extend": "گسترش انتخاب",
|
||||
"toggle": "انتخاب یا لغو انتخاب",
|
||||
"select_all": "انتخاب همه",
|
||||
"clear": "پاک کردن انتخاب یا جستجو",
|
||||
"delete": "حذف",
|
||||
"search": "جستجو",
|
||||
"move": "مخاطب بعدی یا قبلی",
|
||||
"open": "باز کردن مخاطب",
|
||||
"menu": "باز کردن منوی مخاطب"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "نامها نمیتوانند نویسههای کنترلی داشته باشند.",
|
||||
"name_too_long": "این نام برای سرور بیش از حد طولانی است.",
|
||||
"delete": "حذف",
|
||||
"delete_confirm_title": "حذف منبع",
|
||||
"delete_confirm_message": "آیا از حذف \"{name}\" اطمینان دارید؟ این عملیات قابل بازگشت نیست.",
|
||||
"download": "دانلود",
|
||||
"name": "نام",
|
||||
"size": "حجم",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "بخشی از رونوشت در جای خود باقی ماند.",
|
||||
"create_folder_success": "پوشه ایجاد شد",
|
||||
"create_folder_error": "ایجاد پوشه ناموفق بود",
|
||||
"delete_success": "با موفقیت حذف شد",
|
||||
"delete_error": "حذف ناموفق بود",
|
||||
"trash": "سطل زباله",
|
||||
"restore": "بازیابی",
|
||||
"restored": "{count, plural, one {مورد بازیابی شد} other {# مورد بازیابی شد}}",
|
||||
"restore_error": "بازیابی انجام نشد",
|
||||
"moved_to_trash": "{count, plural, one {به سطل زباله منتقل شد} other {# مورد به سطل زباله منتقل شد}}",
|
||||
"delete_forever": "حذف دائمی",
|
||||
"delete_forever_title": "{count, plural, one {برای همیشه حذف شود؟} other {# مورد برای همیشه حذف شود؟}}",
|
||||
"delete_forever_message": "«{name}» حذف میشود. این کار قابل بازگشت نیست.",
|
||||
"delete_forever_message_count": "{count, plural, one {۱ مورد حذف میشود} other {# مورد حذف میشود}}. این کار قابل بازگشت نیست.",
|
||||
"deleted_forever": "{count, plural, one {برای همیشه حذف شد} other {# مورد برای همیشه حذف شد}}",
|
||||
"empty_trash": "خالی کردن سطل زباله",
|
||||
"empty_trash_title": "سطل زباله خالی شود؟",
|
||||
"empty_trash_message": "{count, plural, one {مورد داخل سطل زباله} other {همه # مورد داخل سطل زباله}} برای همیشه حذف میشود. این کار قابل بازگشت نیست.",
|
||||
"trash_emptied": "سطل زباله خالی شد",
|
||||
"empty_trash_error": "سطل زباله خالی نشد",
|
||||
"trash_empty_title": "سطل زباله خالی است",
|
||||
"trash_empty_hint": "فایلهای حذفشده تا زمانی که سطل زباله را خالی کنید اینجا میمانند.",
|
||||
"deleted": "حذفشده",
|
||||
"deleted_from": "حذفشده از {folder}",
|
||||
"shortcuts": {
|
||||
"list": "فهرست",
|
||||
"actions": "کنشها",
|
||||
"first_last": "اولین یا آخرین",
|
||||
"extend": "گسترش انتخاب",
|
||||
"toggle": "انتخاب یا لغو انتخاب",
|
||||
"select_all": "انتخاب همه",
|
||||
"clear": "پاک کردن انتخاب یا جستجو",
|
||||
"delete": "حذف",
|
||||
"search": "جستجو",
|
||||
"move": "مورد بعدی یا قبلی",
|
||||
"move_grid": "مورد کناری در جدول",
|
||||
"open": "باز کردن",
|
||||
"up": "یک پوشه بالاتر",
|
||||
"rename": "تغییر نام",
|
||||
"copy": "کپی",
|
||||
"cut": "برش",
|
||||
"paste": "چسباندن"
|
||||
},
|
||||
"rename_success": "تغییر نام با موفقیت انجام شد",
|
||||
"rename_error": "تغییر نام ناموفق بود",
|
||||
"download_error": "دانلود ناموفق بود",
|
||||
"download_zip": "دانلود به صورت ZIP",
|
||||
"zipping": "در حال آمادهسازی {name}",
|
||||
"download_canceled": "دانلود لغو شد",
|
||||
"zip_error": "{name} دانلود نشد",
|
||||
"not_available": "ذخیرهسازی فایل روی این سرور در دسترس نیست",
|
||||
"cancel": "انصراف",
|
||||
"create": "ایجاد",
|
||||
"save": "ذخیره",
|
||||
"no_results": "فایلی با این جستجو مطابقت ندارد",
|
||||
"batch_delete_confirm_message": "آیا از حذف {count, plural, one {۱ مورد} other {# مورد}} اطمینان دارید؟",
|
||||
"batch_delete_success": "{count, plural, one {۱ مورد حذف شد} other {# مورد حذف شد}}",
|
||||
"grid_view": "نمای شبکهای",
|
||||
"list_view": "نمای لیست",
|
||||
"details": "جزئیات",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "چسباندن",
|
||||
"move_success": "{count, plural, one {۱ مورد منتقل شد} other {# مورد منتقل شد}}",
|
||||
"move_error": "انتقال ناموفق بود",
|
||||
"move_to": "انتقال به…",
|
||||
"move_to_title": "انتقال به",
|
||||
"move_here": "انتقال به اینجا",
|
||||
"paste_success": "با موفقیت چسبانده شد",
|
||||
"paste_error": "چسباندن ناموفق بود",
|
||||
"new_text_file": "فایل متنی جدید",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "کوچکنمایی",
|
||||
"zoom_reset": "متناسب با صفحه",
|
||||
"rotate": "چرخش",
|
||||
"close_preview": "بستن"
|
||||
"close_preview": "بستن",
|
||||
"previous_file": "فایل قبلی",
|
||||
"next_file": "فایل بعدی",
|
||||
"loading": "در حال بارگیری…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "گواهیهای شما",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Ajouter à Cc",
|
||||
"add_bcc": "Ajouter à Cci",
|
||||
"select_hint": "Sélectionnez une personne pour voir ses détails",
|
||||
"write_email": "Écrire un e-mail",
|
||||
"write_email": "Envoyer un courriel",
|
||||
"add_to_contacts": "Ajouter aux contacts",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Les entrées de l'annuaire proviennent de votre serveur de messagerie et ne peuvent pas être modifiées ici.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contacts",
|
||||
"disabled_title": "Les contacts ont été désactivés par votre administrateur",
|
||||
"disabled_description": "Contactez votre administrateur si vous en avez besoin.",
|
||||
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
|
||||
"select_contact": "Sélectionner {name}",
|
||||
"new": "Nouveau",
|
||||
"search_placeholder": "Rechercher des contacts...",
|
||||
"create_new": "Nouveau contact",
|
||||
"no_category": "Sans catégorie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Aucun contact",
|
||||
"empty_state_subtitle": "Créez votre premier contact ou importez depuis un fichier vCard",
|
||||
"empty_view_title": "Aucun contact ici",
|
||||
"empty_view_hint": "Ajoutez un contact ou importez un fichier vCard.",
|
||||
"empty_search": "Aucun contact ne correspond à votre recherche",
|
||||
"empty_search_hint": "Essayez un autre terme de recherche",
|
||||
"empty_filtered": "Aucun contact ne correspond à vos filtres",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nouveau carnet d'adresses",
|
||||
"create_confirm": "Créer",
|
||||
"created": "Carnet d'adresses créé",
|
||||
"create_failed": "Échec de la création du carnet d'adresses",
|
||||
"title": "Mes Carnets d'adresses",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Contacts associés",
|
||||
"crypto_keys": "Clés cryptographiques",
|
||||
"no_contact_selected": "Sélectionnez un contact pour voir les détails",
|
||||
"compose_email": "Écrire un e-mail",
|
||||
"compose_email": "Envoyer un courriel",
|
||||
"copy_email": "Copier l'e-mail",
|
||||
"copy_phone": "Copier le numéro",
|
||||
"copy_url": "Copier l'URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Nouveau contact",
|
||||
"edit_title": "Modifier le contact",
|
||||
"section_address_book": "Répertoire",
|
||||
"select_address_book": "Sélectionner un répertoire...",
|
||||
"section_address_book": "Carnet d'adresses",
|
||||
"select_address_book": "Carnet d'adresses",
|
||||
"section_identity": "Nom et identité",
|
||||
"contact_type": "Type de contact",
|
||||
"type_person": "Personne",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Ajouter un téléphone",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"discard_title": "Abandonner les modifications ?",
|
||||
"discard_message": "Vos modifications n'ont pas été enregistrées.",
|
||||
"discard": "Abandonner",
|
||||
"keep_editing": "Continuer l'édition",
|
||||
"remove_entry": "Retirer",
|
||||
"close": "Fermer",
|
||||
"remove_category": "Retirer {name}",
|
||||
"creating": "Création...",
|
||||
"updating": "Mise à jour...",
|
||||
"name_required": "Saisissez un prénom, un nom ou une organisation",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nouveau groupe",
|
||||
"delete": "Supprimer le groupe",
|
||||
"remove_member": "Retirer {name} du groupe",
|
||||
"edit": "Modifier le groupe",
|
||||
"empty": "Aucun groupe",
|
||||
"delete_confirm_title": "Supprimer le groupe",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Rechercher des contacts à ajouter...",
|
||||
"no_members": "Aucun membre dans ce groupe",
|
||||
"member_count": "{count, plural, =0 {Aucun membre} one {1 membre} other {# membres}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Envoyer un courriel",
|
||||
"send_email_to": "À",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Cci",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 contact supprimé} other {# contacts supprimés}}",
|
||||
"add_to_group": "Ajouter au groupe",
|
||||
"choose_group": "Choisir un groupe",
|
||||
"new_group": "Nouveau groupe…",
|
||||
"adding_contacts": "Ajout de {count, plural, one {1 contact} other {# contacts}}",
|
||||
"added_to_group": "Contacts ajoutés au groupe",
|
||||
"export": "Exporter",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Prénom",
|
||||
"sort_last_name": "Nom de famille"
|
||||
},
|
||||
"open_categories": "Ouvrir les catégories"
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Actions",
|
||||
"first_last": "Premier ou dernier",
|
||||
"extend": "Étendre la sélection",
|
||||
"toggle": "Sélectionner ou désélectionner",
|
||||
"select_all": "Tout sélectionner",
|
||||
"clear": "Effacer la sélection ou la recherche",
|
||||
"delete": "Supprimer",
|
||||
"search": "Rechercher",
|
||||
"move": "Contact suivant ou précédent",
|
||||
"open": "Ouvrir le contact",
|
||||
"menu": "Ouvrir le menu du contact"
|
||||
},
|
||||
"open_categories": "Ouvrir les catégories",
|
||||
"row_actions": "Actions pour {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendrier",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Les noms ne peuvent pas contenir de caractères de contrôle.",
|
||||
"name_too_long": "Ce nom est trop long pour le serveur.",
|
||||
"delete": "Supprimer",
|
||||
"delete_confirm_title": "Supprimer la ressource",
|
||||
"delete_confirm_message": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
|
||||
"download": "Télécharger",
|
||||
"name": "Nom",
|
||||
"size": "Taille",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Une partie de la copie est restée en place.",
|
||||
"create_folder_success": "Dossier créé",
|
||||
"create_folder_error": "Échec de la création du dossier",
|
||||
"delete_success": "Supprimé avec succès",
|
||||
"delete_error": "Échec de la suppression",
|
||||
"trash": "Corbeille",
|
||||
"restore": "Restaurer",
|
||||
"restored": "{count, plural, one {Élément restauré} other {# éléments restaurés}}",
|
||||
"restore_error": "Impossible de restaurer",
|
||||
"moved_to_trash": "{count, plural, one {Placé dans la corbeille} other {# éléments placés dans la corbeille}}",
|
||||
"delete_forever": "Supprimer définitivement",
|
||||
"delete_forever_title": "{count, plural, one {Supprimer définitivement ?} other {Supprimer définitivement # éléments ?}}",
|
||||
"delete_forever_message": "« {name} » sera supprimé. Cette action est irréversible.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 élément sera supprimé} other {# éléments seront supprimés}}. Cette action est irréversible.",
|
||||
"deleted_forever": "{count, plural, one {Supprimé définitivement} other {# éléments supprimés définitivement}}",
|
||||
"empty_trash": "Vider la corbeille",
|
||||
"empty_trash_title": "Vider la corbeille ?",
|
||||
"empty_trash_message": "{count, plural, one {L'élément de la corbeille sera supprimé} other {Les # éléments de la corbeille seront supprimés}} définitivement. Cette action est irréversible.",
|
||||
"trash_emptied": "Corbeille vidée",
|
||||
"empty_trash_error": "Impossible de vider la corbeille",
|
||||
"trash_empty_title": "La corbeille est vide",
|
||||
"trash_empty_hint": "Les fichiers supprimés restent ici jusqu'à ce que vous vidiez la corbeille.",
|
||||
"deleted": "Supprimé le",
|
||||
"deleted_from": "Supprimé de {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Actions",
|
||||
"first_last": "Premier ou dernier",
|
||||
"extend": "Étendre la sélection",
|
||||
"toggle": "Sélectionner ou désélectionner",
|
||||
"select_all": "Tout sélectionner",
|
||||
"clear": "Effacer la sélection ou la recherche",
|
||||
"delete": "Supprimer",
|
||||
"search": "Rechercher",
|
||||
"move": "Élément suivant ou précédent",
|
||||
"move_grid": "Élément voisin dans la grille",
|
||||
"open": "Ouvrir",
|
||||
"up": "Remonter d'un dossier",
|
||||
"rename": "Renommer",
|
||||
"copy": "Copier",
|
||||
"cut": "Couper",
|
||||
"paste": "Coller"
|
||||
},
|
||||
"rename_success": "Renommé avec succès",
|
||||
"rename_error": "Échec du renommage",
|
||||
"download_error": "Échec du téléchargement",
|
||||
"download_zip": "Télécharger en ZIP",
|
||||
"zipping": "Préparation de {name}",
|
||||
"download_canceled": "Téléchargement annulé",
|
||||
"zip_error": "Impossible de télécharger {name}",
|
||||
"not_available": "Le stockage de fichiers n'est pas disponible sur ce serveur",
|
||||
"cancel": "Annuler",
|
||||
"create": "Créer",
|
||||
"save": "Enregistrer",
|
||||
"no_results": "Aucun fichier ne correspond à votre recherche",
|
||||
"batch_delete_confirm_message": "Êtes-vous sûr de vouloir supprimer {count, plural, one {1 élément} other {# éléments}} ? Cette action est irréversible.",
|
||||
"batch_delete_success": "{count, plural, one {1 élément supprimé} other {# éléments supprimés}}",
|
||||
"grid_view": "Vue en grille",
|
||||
"list_view": "Vue en liste",
|
||||
"details": "Détails",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Coller",
|
||||
"move_success": "{count, plural, one {1 élément déplacé} other {# éléments déplacés}}",
|
||||
"move_error": "Échec du déplacement",
|
||||
"move_to": "Déplacer vers…",
|
||||
"move_to_title": "Déplacer vers",
|
||||
"move_here": "Déplacer ici",
|
||||
"paste_success": "Collé avec succès",
|
||||
"paste_error": "Échec du collage",
|
||||
"new_text_file": "Nouveau fichier texte",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Zoom arrière",
|
||||
"zoom_reset": "Ajuster à l'écran",
|
||||
"rotate": "Pivoter",
|
||||
"close_preview": "Fermer"
|
||||
"close_preview": "Fermer",
|
||||
"previous_file": "Fichier précédent",
|
||||
"next_file": "Fichier suivant",
|
||||
"loading": "Chargement…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vos certificats",
|
||||
|
||||
+95
-12
@@ -2833,7 +2833,7 @@
|
||||
"add_cc": "הוספה לעותק",
|
||||
"add_bcc": "הוספה לעותק מוסתר",
|
||||
"select_hint": "בחר אדם כדי לראות את פרטיו",
|
||||
"write_email": "כתיבת הודעה",
|
||||
"write_email": "שלח דוא״ל",
|
||||
"add_to_contacts": "הוספה לאנשי קשר",
|
||||
"email": "דוא״ל",
|
||||
"read_only_note": "רשומות הספרייה מגיעות משרת הדואר ואי אפשר לערוך אותן כאן.",
|
||||
@@ -2846,6 +2846,11 @@
|
||||
}
|
||||
},
|
||||
"title": "אנשי קשר",
|
||||
"disabled_title": "מנהל המערכת השבית את אנשי הקשר",
|
||||
"disabled_description": "פנה למנהל המערכת אם אתה צריך אותם.",
|
||||
"drag_count": "{count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
|
||||
"select_contact": "בחר את {name}",
|
||||
"new": "חדש",
|
||||
"search_placeholder": "חפש אנשי קשר...",
|
||||
"create_new": "איש קשר חדש",
|
||||
"no_category": "אין קטגוריה",
|
||||
@@ -2853,11 +2858,17 @@
|
||||
"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": "עדיין אין אנשי קשר",
|
||||
"empty_state_title": "עדיין אין אנשי קשר",
|
||||
"empty_state_subtitle": "צור את איש הקשר הראשון שלך או ייבא מקובץ vCard",
|
||||
"empty_view_title": "אין כאן אנשי קשר",
|
||||
"empty_view_hint": "הוסף איש קשר או ייבא קובץ vCard.",
|
||||
"empty_search": "אין אנשי קשר שתואמים לחיפוש שלך",
|
||||
"empty_search_hint": "נסה מונח חיפוש אחר",
|
||||
"clear_search": "נקה חיפוש",
|
||||
@@ -2890,6 +2901,7 @@
|
||||
"set_default_failed": "נכשל בהגדרת פנקס כתובות ברירת מחדל",
|
||||
"manage": "ניהול פנקסי כתובות",
|
||||
"create": "ספר כתובות חדש",
|
||||
"create_confirm": "צור",
|
||||
"created": "ספר כתובות נוצר",
|
||||
"create_failed": "נכשל ביצירת ספר כתובות",
|
||||
"share": "שתף ספר כתובות",
|
||||
@@ -2897,7 +2909,10 @@
|
||||
"delete": "מחק ספר כתובות",
|
||||
"confirm_delete": "מחק \"{name}\"? כל אנשי הקשר בספר כתובות זה יוסרו.",
|
||||
"deleted": "ספר כתובות נמחק",
|
||||
"delete_failed": "נכשל במחיקת ספר כתובות"
|
||||
"delete_failed": "נכשל במחיקת ספר כתובות",
|
||||
"move_to": "העבר אל…",
|
||||
"move_title": "העבר לפנקס כתובות",
|
||||
"move_here": "העבר לכאן"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "כתובות אימייל",
|
||||
@@ -2923,7 +2938,7 @@
|
||||
"cert_imported": "אישור מיובא לחנות S/MIME",
|
||||
"cert_import_failed": "ייבוא האישור נכשל",
|
||||
"no_contact_selected": "בחר איש קשר כדי להציג פרטים",
|
||||
"compose_email": "כתוב אימייל",
|
||||
"compose_email": "שלח דוא״ל",
|
||||
"copy_email": "העתק אימייל",
|
||||
"copy_phone": "העתק את מספר הטלפון",
|
||||
"copy_url": "העתק כתובת URL",
|
||||
@@ -2969,8 +2984,8 @@
|
||||
"form": {
|
||||
"create_title": "איש קשר חדש",
|
||||
"edit_title": "ערוך איש קשר",
|
||||
"section_address_book": "ספרייה",
|
||||
"select_address_book": "בחר ספרייה...",
|
||||
"section_address_book": "ספר כתובות",
|
||||
"select_address_book": "ספר כתובות",
|
||||
"section_identity": "שם וזהות",
|
||||
"contact_type": "סוג איש קשר",
|
||||
"type_person": "אדם",
|
||||
@@ -3057,6 +3072,13 @@
|
||||
"add_phone": "הוסף טלפון",
|
||||
"save": "שמור",
|
||||
"cancel": "לְבַטֵל",
|
||||
"discard_title": "לבטל את השינויים?",
|
||||
"discard_message": "השינויים שלך לא נשמרו.",
|
||||
"discard": "בטל",
|
||||
"keep_editing": "המשך לערוך",
|
||||
"remove_entry": "הסר",
|
||||
"close": "סגור",
|
||||
"remove_category": "הסר את {name}",
|
||||
"creating": "יוצר...",
|
||||
"updating": "מעדכן...",
|
||||
"name_required": "יש להזין שם פרטי, שם משפחה או ארגון",
|
||||
@@ -3073,6 +3095,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "קבוצה חדשה",
|
||||
"delete": "מחק קבוצה",
|
||||
"remove_member": "הסר את {name} מהקבוצה",
|
||||
"edit": "ערוך קבוצה",
|
||||
"empty": "עדיין אין קבוצות",
|
||||
"delete_confirm_title": "מחק את הקבוצה",
|
||||
@@ -3085,7 +3109,7 @@
|
||||
"search_members": "חפש אנשי קשר להוספה...",
|
||||
"no_members": "אין חברים בקבוצה זו",
|
||||
"member_count": "{count, plural, =0 {אין חברים} one {חבר אחד} other {# חברים}}",
|
||||
"send_email": "שלח דוא״ל לקבוצה",
|
||||
"send_email": "שלח דוא״ל",
|
||||
"send_email_to": "אל",
|
||||
"send_email_cc": "עותק",
|
||||
"send_email_bcc": "עותק נסתר",
|
||||
@@ -3123,6 +3147,7 @@
|
||||
"deleted": "{count, plural, one {נמחק איש קשר אחד} other {נמחקו # אנשי קשר}}",
|
||||
"add_to_group": "הוסף לקבוצה",
|
||||
"choose_group": "בחר קבוצה",
|
||||
"new_group": "קבוצה חדשה…",
|
||||
"adding_contacts": "מוסיף {count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
|
||||
"added_to_group": "אנשי קשר נוספו לקבוצה",
|
||||
"export": "ייצוא",
|
||||
@@ -3140,6 +3165,7 @@
|
||||
"empty_filtered_hint": "נסה להתאים או להביא סננים",
|
||||
"back_to_email": "חזור לדוא״ל",
|
||||
"open_categories": "קטגוריות פתוחות",
|
||||
"row_actions": "פעולות עבור {name}",
|
||||
"activity": {
|
||||
"recent_emails": "דוא״ל אחרונים",
|
||||
"upcoming_events": "אירועים קרובים",
|
||||
@@ -3184,6 +3210,20 @@
|
||||
"sort_by": "מיון לפי",
|
||||
"sort_first_name": "שם פרטי",
|
||||
"sort_last_name": "שם משפחה"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "רשימה",
|
||||
"actions": "פעולות",
|
||||
"first_last": "הראשון או האחרון",
|
||||
"extend": "הרחב את הבחירה",
|
||||
"toggle": "בחר או בטל בחירה",
|
||||
"select_all": "בחר הכול",
|
||||
"clear": "נקה את הבחירה או החיפוש",
|
||||
"delete": "מחק",
|
||||
"search": "חיפוש",
|
||||
"move": "איש הקשר הבא או הקודם",
|
||||
"open": "פתח את איש הקשר",
|
||||
"menu": "פתח את התפריט של איש הקשר"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3817,8 +3857,6 @@
|
||||
"name_control_chars": "שמות אינם יכולים להכיל תווי בקרה.",
|
||||
"name_too_long": "השם הזה ארוך מדי עבור השרת.",
|
||||
"delete": "לִמְחוֹק",
|
||||
"delete_confirm_title": "מחק משאב",
|
||||
"delete_confirm_message": "האם אתה בטוח שברצונך למחוק את \"{name}\"? לא ניתן לבטל זאת.",
|
||||
"download": "הורד",
|
||||
"name": "שֵׁם",
|
||||
"size": "גודל",
|
||||
@@ -3843,18 +3881,57 @@
|
||||
"error_partial_copy": "חלק מההעתק נשאר במקומו.",
|
||||
"create_folder_success": "התיקיה נוצרה",
|
||||
"create_folder_error": "יצירת התיקיה נכשלה",
|
||||
"delete_success": "נמחק בהצלחה",
|
||||
"delete_error": "המחיקה נכשלה",
|
||||
"trash": "אשפה",
|
||||
"restore": "שחזר",
|
||||
"restored": "{count, plural, one {הפריט שוחזר} other {# פריטים שוחזרו}}",
|
||||
"restore_error": "לא ניתן היה לשחזר",
|
||||
"moved_to_trash": "{count, plural, one {הועבר לאשפה} other {# פריטים הועברו לאשפה}}",
|
||||
"delete_forever": "מחק לצמיתות",
|
||||
"delete_forever_title": "{count, plural, one {למחוק לצמיתות?} other {למחוק # פריטים לצמיתות?}}",
|
||||
"delete_forever_message": "\"{name}\" יימחק. לא ניתן לבטל פעולה זו.",
|
||||
"delete_forever_message_count": "{count, plural, one {פריט אחד יימחק} other {# פריטים יימחקו}}. לא ניתן לבטל פעולה זו.",
|
||||
"deleted_forever": "{count, plural, one {נמחק לצמיתות} other {# פריטים נמחקו לצמיתות}}",
|
||||
"empty_trash": "רוקן את האשפה",
|
||||
"empty_trash_title": "לרוקן את האשפה?",
|
||||
"empty_trash_message": "{count, plural, one {הפריט באשפה יימחק} other {כל # הפריטים באשפה יימחקו}} לצמיתות. לא ניתן לבטל פעולה זו.",
|
||||
"trash_emptied": "האשפה רוקנה",
|
||||
"empty_trash_error": "לא ניתן היה לרוקן את האשפה",
|
||||
"trash_empty_title": "האשפה ריקה",
|
||||
"trash_empty_hint": "קבצים שנמחקו נשארים כאן עד שתרוקן את האשפה.",
|
||||
"deleted": "נמחק",
|
||||
"deleted_from": "נמחק מ-{folder}",
|
||||
"shortcuts": {
|
||||
"list": "רשימה",
|
||||
"actions": "פעולות",
|
||||
"first_last": "הראשון או האחרון",
|
||||
"extend": "הרחב את הבחירה",
|
||||
"toggle": "בחר או בטל בחירה",
|
||||
"select_all": "בחר הכול",
|
||||
"clear": "נקה את הבחירה או החיפוש",
|
||||
"delete": "מחק",
|
||||
"search": "חיפוש",
|
||||
"move": "הפריט הבא או הקודם",
|
||||
"move_grid": "הפריט הסמוך ברשת",
|
||||
"open": "פתח",
|
||||
"up": "תיקייה אחת למעלה",
|
||||
"rename": "שנה שם",
|
||||
"copy": "העתק",
|
||||
"cut": "גזור",
|
||||
"paste": "הדבק"
|
||||
},
|
||||
"rename_success": "השם שונה בהצלחה",
|
||||
"rename_error": "שינוי השם נכשל",
|
||||
"download_error": "ההורדה נכשלה",
|
||||
"download_zip": "הורד כקובץ ZIP",
|
||||
"zipping": "מכין את {name}",
|
||||
"download_canceled": "ההורדה בוטלה",
|
||||
"zip_error": "לא ניתן היה להוריד את {name}",
|
||||
"not_available": "אחסון קבצים אינו זמין בשרת זה",
|
||||
"cancel": "לְבַטֵל",
|
||||
"create": "צור",
|
||||
"save": "שמור",
|
||||
"no_results": "אין קבצים שתואמים לחיפוש שלך",
|
||||
"batch_delete_confirm_message": "האם למחוק {count, plural, one {פריט אחד} other {# פריטים}}? לא ניתן לבטל זאת.",
|
||||
"batch_delete_success": "{count, plural, one {פריט אחד נמחק} other {# פריטים נמחקו}}",
|
||||
"grid_view": "תצוגת רשת",
|
||||
"list_view": "תצוגת רשימה",
|
||||
"details": "פרטים",
|
||||
@@ -3866,6 +3943,9 @@
|
||||
"paste": "הדבק",
|
||||
"move_success": "{count, plural, one {פריט אחד הועבר} other {# פריטים הועברו}}",
|
||||
"move_error": "לא הצליח לזוז",
|
||||
"move_to": "העבר אל…",
|
||||
"move_to_title": "העבר אל",
|
||||
"move_here": "העבר לכאן",
|
||||
"paste_success": "הודבק בהצלחה",
|
||||
"paste_error": "ההדבקה נכשלה",
|
||||
"new_text_file": "קובץ טקסט חדש",
|
||||
@@ -3947,7 +4027,10 @@
|
||||
"zoom_out": "הקטן",
|
||||
"zoom_reset": "התאם למסך",
|
||||
"rotate": "סובב",
|
||||
"close_preview": "סגור"
|
||||
"close_preview": "סגור",
|
||||
"previous_file": "הקובץ הקודם",
|
||||
"next_file": "הקובץ הבא",
|
||||
"loading": "טוען…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "התעודות שלך",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Hozzáadás: Másolat",
|
||||
"add_bcc": "Hozzáadás: Titkos másolat",
|
||||
"select_hint": "Válassz ki valakit a részletekhez",
|
||||
"write_email": "E-mail írása",
|
||||
"write_email": "E-mail küldése",
|
||||
"add_to_contacts": "Hozzáadás a névjegyekhez",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "A címtár bejegyzései a levelezőszerverről származnak, itt nem szerkeszthetők.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Névjegyek",
|
||||
"disabled_title": "A névjegyeket a rendszergazda kikapcsolta",
|
||||
"disabled_description": "Ha szüksége van rájuk, forduljon a rendszergazdához.",
|
||||
"drag_count": "{count, plural, one {1 névjegy} other {# névjegy}}",
|
||||
"select_contact": "{name} kijelölése",
|
||||
"new": "Új",
|
||||
"search_placeholder": "Névjegyek keresése...",
|
||||
"create_new": "Új névjegy",
|
||||
"no_category": "Nincs kategória",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Még nincsenek névjegyek",
|
||||
"empty_state_subtitle": "Hozd létre az első névjegyed vagy importálj vCard fájlból",
|
||||
"empty_view_title": "Itt nincsenek névjegyek",
|
||||
"empty_view_hint": "Adjon hozzá egy névjegyet, vagy importáljon egy vCard-fájlt.",
|
||||
"empty_search": "Nincs a keresésnek megfelelő névjegy",
|
||||
"empty_search_hint": "Próbálj meg más keresési kifejezést",
|
||||
"empty_filtered": "Nincs a szűrőknek megfelelő névjegy",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Új címjegyzék",
|
||||
"create_confirm": "Létrehozás",
|
||||
"created": "Címjegyzék létrehozva",
|
||||
"create_failed": "Nem sikerült létrehozni a címjegyzéket",
|
||||
"title": "Címjegyzékeim",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Tanúsítvány importálva az S/MIME tárolóba",
|
||||
"cert_import_failed": "Nem sikerült importálni a tanúsítványt",
|
||||
"no_contact_selected": "Válassz ki egy névjegyet a részletek megtekintéséhez",
|
||||
"compose_email": "E-mail írása",
|
||||
"compose_email": "E-mail küldése",
|
||||
"copy_email": "E-mail másolása",
|
||||
"copy_phone": "Telefonszám másolása",
|
||||
"copy_url": "URL másolása",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "Új névjegy",
|
||||
"edit_title": "Névjegy szerkesztése",
|
||||
"section_address_book": "Címtár",
|
||||
"select_address_book": "Címtár kiválasztása...",
|
||||
"section_address_book": "Címjegyzék",
|
||||
"select_address_book": "Címjegyzék",
|
||||
"section_identity": "Név és azonosság",
|
||||
"contact_type": "Névjegy típusa",
|
||||
"type_person": "Személy",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Telefon hozzáadása",
|
||||
"save": "Mentés",
|
||||
"cancel": "Mégse",
|
||||
"discard_title": "Elveti a változtatásokat?",
|
||||
"discard_message": "A változtatásai nincsenek mentve.",
|
||||
"discard": "Elvetés",
|
||||
"keep_editing": "Szerkesztés folytatása",
|
||||
"remove_entry": "Eltávolítás",
|
||||
"close": "Bezárás",
|
||||
"remove_category": "{name} eltávolítása",
|
||||
"creating": "Létrehozás...",
|
||||
"updating": "Frissítés...",
|
||||
"name_required": "Adjon meg egy keresztnevet, vezetéknevet vagy szervezetet",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Új csoport",
|
||||
"delete": "Csoport törlése",
|
||||
"remove_member": "{name} eltávolítása a csoportból",
|
||||
"edit": "Csoport szerkesztése",
|
||||
"empty": "Még nincsenek csoportok",
|
||||
"delete_confirm_title": "Csoport törlése",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Névjegyek keresése a hozzáadáshoz...",
|
||||
"no_members": "Nincs tag ebben a csoportban",
|
||||
"member_count": "{count, plural, =0 {Nincs tag} one {1 tag} other {# tag}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "E-mail küldése",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 névjegy törölve} other {# névjegy törölve}}",
|
||||
"add_to_group": "Hozzáadás a csoporthoz",
|
||||
"choose_group": "Csoport kiválasztása",
|
||||
"new_group": "Új csoport…",
|
||||
"adding_contacts": "{count, plural, one {1 névjegy} other {# névjegy}} hozzáadása",
|
||||
"added_to_group": "Névjegyek hozzáadva a csoporthoz",
|
||||
"export": "Exportálás",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Rendezés",
|
||||
"sort_first_name": "Keresztnév",
|
||||
"sort_last_name": "Vezetéknév"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Műveletek",
|
||||
"first_last": "Első vagy utolsó",
|
||||
"extend": "Kijelölés bővítése",
|
||||
"toggle": "Kijelölés vagy kijelölés megszüntetése",
|
||||
"select_all": "Összes kijelölése",
|
||||
"clear": "Kijelölés vagy keresés törlése",
|
||||
"delete": "Törlés",
|
||||
"search": "Keresés",
|
||||
"move": "Következő vagy előző névjegy",
|
||||
"open": "Névjegy megnyitása",
|
||||
"menu": "Névjegy menüjének megnyitása"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "A nevek nem tartalmazhatnak vezérlőkaraktereket.",
|
||||
"name_too_long": "Ez a név túl hosszú a kiszolgáló számára.",
|
||||
"delete": "Törlés",
|
||||
"delete_confirm_title": "Erőforrás törlése",
|
||||
"delete_confirm_message": "Biztosan törölni szeretnéd a \"{name}\" elemet? Ez nem vonható vissza.",
|
||||
"download": "Letöltés",
|
||||
"name": "Név",
|
||||
"size": "Méret",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "A másolat egy része a helyén maradt.",
|
||||
"create_folder_success": "Mappa létrehozva",
|
||||
"create_folder_error": "Nem sikerült létrehozni a mappát",
|
||||
"delete_success": "Sikeresen törölve",
|
||||
"delete_error": "Nem sikerült törölni",
|
||||
"trash": "Kuka",
|
||||
"restore": "Visszaállítás",
|
||||
"restored": "{count, plural, one {Elem visszaállítva} other {# elem visszaállítva}}",
|
||||
"restore_error": "Nem sikerült visszaállítani",
|
||||
"moved_to_trash": "{count, plural, one {Áthelyezve a kukába} other {# elem áthelyezve a kukába}}",
|
||||
"delete_forever": "Végleges törlés",
|
||||
"delete_forever_title": "{count, plural, one {Véglegesen törli?} other {Véglegesen törli a(z) # elemet?}}",
|
||||
"delete_forever_message": "A(z) „{name}” törlődik. Ez nem vonható vissza.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 elem törlődik} other {# elem törlődik}}. Ez nem vonható vissza.",
|
||||
"deleted_forever": "{count, plural, one {Véglegesen törölve} other {# elem véglegesen törölve}}",
|
||||
"empty_trash": "Kuka ürítése",
|
||||
"empty_trash_title": "Kiüríti a kukát?",
|
||||
"empty_trash_message": "{count, plural, one {A kukában lévő elem} other {A kukában lévő mind a(z) # elem}} véglegesen törlődik. Ez nem vonható vissza.",
|
||||
"trash_emptied": "Kuka kiürítve",
|
||||
"empty_trash_error": "Nem sikerült kiüríteni a kukát",
|
||||
"trash_empty_title": "A kuka üres",
|
||||
"trash_empty_hint": "A törölt fájlok itt maradnak, amíg ki nem üríti a kukát.",
|
||||
"deleted": "Törölve",
|
||||
"deleted_from": "Törölve innen: {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Műveletek",
|
||||
"first_last": "Első vagy utolsó",
|
||||
"extend": "Kijelölés bővítése",
|
||||
"toggle": "Kijelölés vagy kijelölés megszüntetése",
|
||||
"select_all": "Összes kijelölése",
|
||||
"clear": "Kijelölés vagy keresés törlése",
|
||||
"delete": "Törlés",
|
||||
"search": "Keresés",
|
||||
"move": "Következő vagy előző elem",
|
||||
"move_grid": "Szomszédos elem a rácsban",
|
||||
"open": "Megnyitás",
|
||||
"up": "Egy mappával feljebb",
|
||||
"rename": "Átnevezés",
|
||||
"copy": "Másolás",
|
||||
"cut": "Kivágás",
|
||||
"paste": "Beillesztés"
|
||||
},
|
||||
"rename_success": "Sikeresen átnevezve",
|
||||
"rename_error": "Nem sikerült átnevezni",
|
||||
"download_error": "Nem sikerült letölteni",
|
||||
"download_zip": "Letöltés ZIP-ként",
|
||||
"zipping": "{name} előkészítése",
|
||||
"download_canceled": "Letöltés megszakítva",
|
||||
"zip_error": "Nem sikerült letölteni: {name}",
|
||||
"not_available": "A fájltárolás nem elérhető ezen a szerveren",
|
||||
"cancel": "Mégse",
|
||||
"create": "Létrehozás",
|
||||
"save": "Mentés",
|
||||
"no_results": "Nincs a keresésnek megfelelő fájl",
|
||||
"batch_delete_confirm_message": "Biztosan törölni szeretnéd {count, plural, one {az 1 elemet} other {a # elemet}}? Ez nem vonható vissza.",
|
||||
"batch_delete_success": "{count, plural, one {1 elem törölve} other {# elem törölve}}",
|
||||
"grid_view": "Rács nézet",
|
||||
"list_view": "Lista nézet",
|
||||
"details": "Részletek",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Beillesztés",
|
||||
"move_success": "{count, plural, one {1 elem áthelyezve} other {# elem áthelyezve}}",
|
||||
"move_error": "Nem sikerült áthelyezni",
|
||||
"move_to": "Áthelyezés ide…",
|
||||
"move_to_title": "Áthelyezés ide",
|
||||
"move_here": "Áthelyezés ide",
|
||||
"paste_success": "Sikeresen beillesztve",
|
||||
"paste_error": "Nem sikerült beilleszteni",
|
||||
"new_text_file": "Új szöveges fájl",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Kicsinyítés",
|
||||
"zoom_reset": "Igazítás a képernyőhöz",
|
||||
"rotate": "Forgatás",
|
||||
"close_preview": "Bezárás"
|
||||
"close_preview": "Bezárás",
|
||||
"previous_file": "Előző fájl",
|
||||
"next_file": "Következő fájl",
|
||||
"loading": "Betöltés…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Tanúsítványaid",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Aggiungi a Cc",
|
||||
"add_bcc": "Aggiungi a Ccn",
|
||||
"select_hint": "Seleziona una persona per vederne i dettagli",
|
||||
"write_email": "Scrivi email",
|
||||
"write_email": "Invia email",
|
||||
"add_to_contacts": "Aggiungi ai contatti",
|
||||
"email": "Email",
|
||||
"read_only_note": "Le voci provengono dal tuo server di posta e non possono essere modificate qui.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contatti",
|
||||
"disabled_title": "I contatti sono stati disattivati dall'amministratore",
|
||||
"disabled_description": "Rivolgiti all'amministratore se ti servono.",
|
||||
"drag_count": "{count, plural, one {1 contatto} other {# contatti}}",
|
||||
"select_contact": "Seleziona {name}",
|
||||
"new": "Nuovo",
|
||||
"search_placeholder": "Cerca contatti...",
|
||||
"create_new": "Nuovo contatto",
|
||||
"no_category": "Senza categoria",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Nessun contatto",
|
||||
"empty_state_subtitle": "Crea il tuo primo contatto o importa da un file vCard",
|
||||
"empty_view_title": "Nessun contatto qui",
|
||||
"empty_view_hint": "Aggiungi un contatto o importa un file vCard.",
|
||||
"empty_search": "Nessun contatto corrisponde alla ricerca",
|
||||
"empty_search_hint": "Prova con un altro termine di ricerca",
|
||||
"empty_filtered": "Nessun contatto corrisponde ai filtri",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nuova rubrica",
|
||||
"create_confirm": "Crea",
|
||||
"created": "Rubrica creata",
|
||||
"create_failed": "Impossibile creare la rubrica",
|
||||
"title": "Le mie Rubriche",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Contatti correlati",
|
||||
"crypto_keys": "Chiavi crittografiche",
|
||||
"no_contact_selected": "Seleziona un contatto per vedere i dettagli",
|
||||
"compose_email": "Scrivi email",
|
||||
"compose_email": "Invia email",
|
||||
"copy_email": "Copia email",
|
||||
"copy_phone": "Copia telefono",
|
||||
"copy_url": "Copia URL",
|
||||
@@ -3066,7 +3081,7 @@
|
||||
"create_title": "Nuovo contatto",
|
||||
"edit_title": "Modifica contatto",
|
||||
"section_address_book": "Rubrica",
|
||||
"select_address_book": "Seleziona una rubrica...",
|
||||
"select_address_book": "Rubrica",
|
||||
"section_identity": "Nome e identità",
|
||||
"contact_type": "Tipo di contatto",
|
||||
"type_person": "Persona",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Aggiungi telefono",
|
||||
"save": "Salva",
|
||||
"cancel": "Annulla",
|
||||
"discard_title": "Eliminare le modifiche?",
|
||||
"discard_message": "Le modifiche non sono state salvate.",
|
||||
"discard": "Elimina",
|
||||
"keep_editing": "Continua a modificare",
|
||||
"remove_entry": "Rimuovi",
|
||||
"close": "Chiudi",
|
||||
"remove_category": "Rimuovi {name}",
|
||||
"creating": "Creazione...",
|
||||
"updating": "Aggiornamento...",
|
||||
"name_required": "Inserisci un nome, un cognome o un'organizzazione",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nuovo gruppo",
|
||||
"delete": "Elimina gruppo",
|
||||
"remove_member": "Rimuovi {name} dal gruppo",
|
||||
"edit": "Modifica gruppo",
|
||||
"empty": "Nessun gruppo",
|
||||
"delete_confirm_title": "Elimina gruppo",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Cerca contatti da aggiungere...",
|
||||
"no_members": "Nessun membro in questo gruppo",
|
||||
"member_count": "{count, plural, =0 {Nessun membro} one {1 membro} other {# membri}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Invia email",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 contatto eliminato} other {# contatti eliminati}}",
|
||||
"add_to_group": "Aggiungi al gruppo",
|
||||
"choose_group": "Scegli un gruppo",
|
||||
"new_group": "Nuovo gruppo…",
|
||||
"adding_contacts": "Aggiunta di {count, plural, one {1 contatto} other {# contatti}}",
|
||||
"added_to_group": "Contatti aggiunti al gruppo",
|
||||
"export": "Esporta",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Nome",
|
||||
"sort_last_name": "Cognome"
|
||||
},
|
||||
"open_categories": "Apri categorie"
|
||||
"shortcuts": {
|
||||
"list": "Elenco",
|
||||
"actions": "Azioni",
|
||||
"first_last": "Primo o ultimo",
|
||||
"extend": "Estendi la selezione",
|
||||
"toggle": "Seleziona o deseleziona",
|
||||
"select_all": "Seleziona tutto",
|
||||
"clear": "Cancella la selezione o la ricerca",
|
||||
"delete": "Elimina",
|
||||
"search": "Cerca",
|
||||
"move": "Contatto successivo o precedente",
|
||||
"open": "Apri il contatto",
|
||||
"menu": "Apri il menu del contatto"
|
||||
},
|
||||
"open_categories": "Apri categorie",
|
||||
"row_actions": "Azioni per {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendario",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "I nomi non possono contenere caratteri di controllo.",
|
||||
"name_too_long": "Questo nome è troppo lungo per il server.",
|
||||
"delete": "Elimina",
|
||||
"delete_confirm_title": "Elimina risorsa",
|
||||
"delete_confirm_message": "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
|
||||
"download": "Scarica",
|
||||
"name": "Nome",
|
||||
"size": "Dimensione",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Una parte della copia è rimasta al suo posto.",
|
||||
"create_folder_success": "Cartella creata",
|
||||
"create_folder_error": "Creazione della cartella non riuscita",
|
||||
"delete_success": "Eliminato con successo",
|
||||
"delete_error": "Eliminazione non riuscita",
|
||||
"trash": "Cestino",
|
||||
"restore": "Ripristina",
|
||||
"restored": "{count, plural, one {Elemento ripristinato} other {# elementi ripristinati}}",
|
||||
"restore_error": "Impossibile ripristinare",
|
||||
"moved_to_trash": "{count, plural, one {Spostato nel cestino} other {# elementi spostati nel cestino}}",
|
||||
"delete_forever": "Elimina definitivamente",
|
||||
"delete_forever_title": "{count, plural, one {Eliminare definitivamente?} other {Eliminare definitivamente # elementi?}}",
|
||||
"delete_forever_message": "«{name}» verrà eliminato. L'operazione non può essere annullata.",
|
||||
"delete_forever_message_count": "{count, plural, one {Verrà eliminato 1 elemento} other {Verranno eliminati # elementi}}. L'operazione non può essere annullata.",
|
||||
"deleted_forever": "{count, plural, one {Eliminato definitivamente} other {# elementi eliminati definitivamente}}",
|
||||
"empty_trash": "Svuota il cestino",
|
||||
"empty_trash_title": "Svuotare il cestino?",
|
||||
"empty_trash_message": "{count, plural, one {L'elemento nel cestino verrà eliminato} other {Tutti i # elementi nel cestino verranno eliminati}} definitivamente. L'operazione non può essere annullata.",
|
||||
"trash_emptied": "Cestino svuotato",
|
||||
"empty_trash_error": "Impossibile svuotare il cestino",
|
||||
"trash_empty_title": "Il cestino è vuoto",
|
||||
"trash_empty_hint": "I file eliminati restano qui finché non svuoti il cestino.",
|
||||
"deleted": "Eliminato",
|
||||
"deleted_from": "Eliminato da {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Elenco",
|
||||
"actions": "Azioni",
|
||||
"first_last": "Primo o ultimo",
|
||||
"extend": "Estendi la selezione",
|
||||
"toggle": "Seleziona o deseleziona",
|
||||
"select_all": "Seleziona tutto",
|
||||
"clear": "Cancella la selezione o la ricerca",
|
||||
"delete": "Elimina",
|
||||
"search": "Cerca",
|
||||
"move": "Elemento successivo o precedente",
|
||||
"move_grid": "Elemento accanto nella griglia",
|
||||
"open": "Apri",
|
||||
"up": "Su di una cartella",
|
||||
"rename": "Rinomina",
|
||||
"copy": "Copia",
|
||||
"cut": "Taglia",
|
||||
"paste": "Incolla"
|
||||
},
|
||||
"rename_success": "Rinominato con successo",
|
||||
"rename_error": "Rinominazione non riuscita",
|
||||
"download_error": "Download non riuscito",
|
||||
"download_zip": "Scarica come ZIP",
|
||||
"zipping": "Preparazione di {name}",
|
||||
"download_canceled": "Download annullato",
|
||||
"zip_error": "Impossibile scaricare {name}",
|
||||
"not_available": "L'archiviazione file non è disponibile su questo server",
|
||||
"cancel": "Annulla",
|
||||
"create": "Crea",
|
||||
"save": "Salva",
|
||||
"no_results": "Nessun file corrisponde alla ricerca",
|
||||
"batch_delete_confirm_message": "Sei sicuro di voler eliminare {count, plural, one {1 elemento} other {# elementi}}? Questa azione non può essere annullata.",
|
||||
"batch_delete_success": "{count, plural, one {1 elemento eliminato} other {# elementi eliminati}}",
|
||||
"grid_view": "Vista a griglia",
|
||||
"list_view": "Vista a elenco",
|
||||
"details": "Dettagli",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Incolla",
|
||||
"move_success": "{count, plural, one {1 elemento spostato} other {# elementi spostati}}",
|
||||
"move_error": "Spostamento non riuscito",
|
||||
"move_to": "Sposta in…",
|
||||
"move_to_title": "Sposta in",
|
||||
"move_here": "Sposta qui",
|
||||
"paste_success": "Incollato con successo",
|
||||
"paste_error": "Incollaggio non riuscito",
|
||||
"new_text_file": "Nuovo file di testo",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Riduci",
|
||||
"zoom_reset": "Adatta allo schermo",
|
||||
"rotate": "Ruota",
|
||||
"close_preview": "Chiudi"
|
||||
"close_preview": "Chiudi",
|
||||
"previous_file": "File precedente",
|
||||
"next_file": "File successivo",
|
||||
"loading": "Caricamento…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "I tuoi certificati",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "CC に追加",
|
||||
"add_bcc": "BCC に追加",
|
||||
"select_hint": "詳細を表示するには相手を選択してください",
|
||||
"write_email": "メールを作成",
|
||||
"write_email": "メール送信",
|
||||
"add_to_contacts": "連絡先に追加",
|
||||
"email": "メール",
|
||||
"read_only_note": "ディレクトリの項目はメールサーバーから取得され、ここでは編集できません。",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "連絡先",
|
||||
"disabled_title": "連絡先は管理者によって無効にされています",
|
||||
"disabled_description": "必要な場合は管理者にお問い合わせください。",
|
||||
"drag_count": "{count, plural, other {#件の連絡先}}",
|
||||
"select_contact": "{name} を選択",
|
||||
"new": "新規",
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"no_category": "カテゴリなし",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "連絡先がありません",
|
||||
"empty_state_title": "連絡先がありません",
|
||||
"empty_state_subtitle": "最初の連絡先を作成するか、vCardファイルからインポートしてください",
|
||||
"empty_view_title": "ここに連絡先はありません",
|
||||
"empty_view_hint": "連絡先を追加するか、vCard ファイルを読み込んでください。",
|
||||
"empty_search": "検索に一致する連絡先がありません",
|
||||
"empty_search_hint": "別の検索語をお試しください",
|
||||
"empty_filtered": "フィルターに一致する連絡先がありません",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "新しいアドレス帳",
|
||||
"create_confirm": "作成",
|
||||
"created": "アドレス帳を作成しました",
|
||||
"create_failed": "アドレス帳の作成に失敗しました",
|
||||
"title": "マイアドレス帳",
|
||||
@@ -2982,7 +2994,10 @@
|
||||
"delete": "アドレス帳を削除",
|
||||
"confirm_delete": "「{name}」を削除しますか?このアドレス帳のすべての連絡先が削除されます。",
|
||||
"deleted": "アドレス帳を削除しました",
|
||||
"delete_failed": "アドレス帳の削除に失敗しました"
|
||||
"delete_failed": "アドレス帳の削除に失敗しました",
|
||||
"move_to": "移動先…",
|
||||
"move_title": "アドレス帳に移動",
|
||||
"move_here": "ここに移動"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "関連する連絡先",
|
||||
"crypto_keys": "暗号鍵",
|
||||
"no_contact_selected": "連絡先を選択して詳細を表示",
|
||||
"compose_email": "メールを作成",
|
||||
"compose_email": "メール送信",
|
||||
"copy_email": "メールをコピー",
|
||||
"copy_phone": "電話番号をコピー",
|
||||
"copy_url": "URLをコピー",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "新しい連絡先",
|
||||
"edit_title": "連絡先を編集",
|
||||
"section_address_book": "ディレクトリ",
|
||||
"select_address_book": "ディレクトリを選択...",
|
||||
"section_address_book": "アドレス帳",
|
||||
"select_address_book": "アドレス帳",
|
||||
"section_identity": "名前と識別情報",
|
||||
"contact_type": "連絡先の種類",
|
||||
"type_person": "個人",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "電話追加",
|
||||
"save": "保存",
|
||||
"cancel": "キャンセル",
|
||||
"discard_title": "変更を破棄しますか?",
|
||||
"discard_message": "変更は保存されていません。",
|
||||
"discard": "破棄",
|
||||
"keep_editing": "編集を続ける",
|
||||
"remove_entry": "削除",
|
||||
"close": "閉じる",
|
||||
"remove_category": "{name} を削除",
|
||||
"creating": "作成中...",
|
||||
"updating": "更新中...",
|
||||
"name_required": "名、姓、または組織を入力してください",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "新しいグループ",
|
||||
"delete": "グループを削除",
|
||||
"remove_member": "{name} をグループから削除",
|
||||
"edit": "グループを編集",
|
||||
"empty": "グループがありません",
|
||||
"delete_confirm_title": "グループを削除",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "追加する連絡先を検索...",
|
||||
"no_members": "このグループにメンバーがいません",
|
||||
"member_count": "{count, plural, =0 {メンバーなし} other {#人のメンバー}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "メール送信",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, other {#件の連絡先を削除しました}}",
|
||||
"add_to_group": "グループに追加",
|
||||
"choose_group": "グループを選択",
|
||||
"new_group": "新しいグループ…",
|
||||
"adding_contacts": "{count, plural, other {#件の連絡先}}を追加",
|
||||
"added_to_group": "連絡先をグループに追加しました",
|
||||
"export": "エクスポート",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "名",
|
||||
"sort_last_name": "姓"
|
||||
},
|
||||
"open_categories": "カテゴリを開く"
|
||||
"shortcuts": {
|
||||
"list": "リスト",
|
||||
"actions": "操作",
|
||||
"first_last": "最初または最後",
|
||||
"extend": "選択範囲を広げる",
|
||||
"toggle": "選択または選択解除",
|
||||
"select_all": "すべて選択",
|
||||
"clear": "選択または検索を解除",
|
||||
"delete": "削除",
|
||||
"search": "検索",
|
||||
"move": "次または前の連絡先",
|
||||
"open": "連絡先を開く",
|
||||
"menu": "連絡先のメニューを開く"
|
||||
},
|
||||
"open_categories": "カテゴリを開く",
|
||||
"row_actions": "{name} の操作"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "カレンダー",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "名前に制御文字は使用できません。",
|
||||
"name_too_long": "この名前はサーバーには長すぎます。",
|
||||
"delete": "削除",
|
||||
"delete_confirm_title": "リソースを削除",
|
||||
"delete_confirm_message": "\"{name}\"を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"download": "ダウンロード",
|
||||
"name": "名前",
|
||||
"size": "サイズ",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "コピーの一部は削除されずに残っています。",
|
||||
"create_folder_success": "フォルダーを作成しました",
|
||||
"create_folder_error": "フォルダーの作成に失敗しました",
|
||||
"delete_success": "正常に削除しました",
|
||||
"delete_error": "削除に失敗しました",
|
||||
"trash": "ゴミ箱",
|
||||
"restore": "復元",
|
||||
"restored": "{count, plural, other {#件のアイテムを復元しました}}",
|
||||
"restore_error": "復元できませんでした",
|
||||
"moved_to_trash": "{count, plural, other {#件のアイテムをゴミ箱に移動しました}}",
|
||||
"delete_forever": "完全に削除",
|
||||
"delete_forever_title": "{count, plural, other {#件のアイテムを完全に削除しますか?}}",
|
||||
"delete_forever_message": "「{name}」は削除されます。この操作は元に戻せません。",
|
||||
"delete_forever_message_count": "{count, plural, other {#件のアイテムが削除されます}}。この操作は元に戻せません。",
|
||||
"deleted_forever": "{count, plural, other {#件のアイテムを完全に削除しました}}",
|
||||
"empty_trash": "ゴミ箱を空にする",
|
||||
"empty_trash_title": "ゴミ箱を空にしますか?",
|
||||
"empty_trash_message": "{count, plural, other {ゴミ箱内の#件のアイテムがすべて完全に削除されます}}。この操作は元に戻せません。",
|
||||
"trash_emptied": "ゴミ箱を空にしました",
|
||||
"empty_trash_error": "ゴミ箱を空にできませんでした",
|
||||
"trash_empty_title": "ゴミ箱は空です",
|
||||
"trash_empty_hint": "削除したファイルはゴミ箱を空にするまでここに残ります。",
|
||||
"deleted": "削除日",
|
||||
"deleted_from": "{folder} から削除",
|
||||
"shortcuts": {
|
||||
"list": "リスト",
|
||||
"actions": "操作",
|
||||
"first_last": "最初または最後",
|
||||
"extend": "選択範囲を広げる",
|
||||
"toggle": "選択または選択解除",
|
||||
"select_all": "すべて選択",
|
||||
"clear": "選択または検索を解除",
|
||||
"delete": "削除",
|
||||
"search": "検索",
|
||||
"move": "次または前の項目",
|
||||
"move_grid": "グリッドの隣の項目",
|
||||
"open": "開く",
|
||||
"up": "1つ上のフォルダー",
|
||||
"rename": "名前を変更",
|
||||
"copy": "コピー",
|
||||
"cut": "切り取り",
|
||||
"paste": "貼り付け"
|
||||
},
|
||||
"rename_success": "正常に名前を変更しました",
|
||||
"rename_error": "名前の変更に失敗しました",
|
||||
"download_error": "ダウンロードに失敗しました",
|
||||
"download_zip": "ZIPでダウンロード",
|
||||
"zipping": "{name} を準備しています",
|
||||
"download_canceled": "ダウンロードをキャンセルしました",
|
||||
"zip_error": "{name} をダウンロードできませんでした",
|
||||
"not_available": "ファイルストレージはこのサーバーで利用できません",
|
||||
"cancel": "キャンセル",
|
||||
"create": "作成",
|
||||
"save": "保存",
|
||||
"no_results": "検索に一致するファイルがありません",
|
||||
"batch_delete_confirm_message": "{count, plural, other {#件のアイテム}}を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"batch_delete_success": "{count, plural, other {#件のアイテムを削除しました}}",
|
||||
"grid_view": "グリッド表示",
|
||||
"list_view": "リスト表示",
|
||||
"details": "詳細",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "貼り付け",
|
||||
"move_success": "{count, plural, other {#件のアイテムを移動しました}}",
|
||||
"move_error": "移動に失敗しました",
|
||||
"move_to": "移動先…",
|
||||
"move_to_title": "移動先",
|
||||
"move_here": "ここに移動",
|
||||
"paste_success": "正常に貼り付けました",
|
||||
"paste_error": "貼り付けに失敗しました",
|
||||
"new_text_file": "新規テキストファイル",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "縮小",
|
||||
"zoom_reset": "画面に合わせる",
|
||||
"rotate": "回転",
|
||||
"close_preview": "閉じる"
|
||||
"close_preview": "閉じる",
|
||||
"previous_file": "前のファイル",
|
||||
"next_file": "次のファイル",
|
||||
"loading": "読み込み中…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "あなたの証明書",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "참조에 추가",
|
||||
"add_bcc": "숨은 참조에 추가",
|
||||
"select_hint": "세부 정보를 보려면 사람을 선택하세요",
|
||||
"write_email": "메일 쓰기",
|
||||
"write_email": "이메일 보내기",
|
||||
"add_to_contacts": "연락처에 추가",
|
||||
"email": "이메일",
|
||||
"read_only_note": "디렉터리 항목은 메일 서버에서 가져오며 여기에서 편집할 수 없습니다.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "연락처",
|
||||
"disabled_title": "관리자가 연락처를 사용 중지했습니다",
|
||||
"disabled_description": "필요하면 관리자에게 문의하세요.",
|
||||
"drag_count": "{count, plural, other {연락처 #개}}",
|
||||
"select_contact": "{name} 선택",
|
||||
"new": "새로 만들기",
|
||||
"search_placeholder": "연락처 검색...",
|
||||
"create_new": "새 연락처",
|
||||
"no_category": "카테고리 없음",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "아직 연락처가 없어요",
|
||||
"empty_state_title": "아직 연락처가 없어요",
|
||||
"empty_state_subtitle": "새 연락처를 만들거나 vCard 파일에서 가져와 보세요",
|
||||
"empty_view_title": "여기에 연락처가 없습니다",
|
||||
"empty_view_hint": "연락처를 추가하거나 vCard 파일을 가져오세요.",
|
||||
"empty_search": "검색된 연락처가 없어요",
|
||||
"empty_search_hint": "다른 검색어로 다시 시도해 보세요",
|
||||
"empty_filtered": "필터에 일치하는 연락처가 없어요",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "새 주소록",
|
||||
"create_confirm": "만들기",
|
||||
"created": "주소록이 생성되었습니다",
|
||||
"create_failed": "주소록을 만들지 못했습니다",
|
||||
"title": "내 주소록",
|
||||
@@ -2982,7 +2994,10 @@
|
||||
"delete": "주소록 삭제",
|
||||
"confirm_delete": "\"{name}\"을(를) 삭제하시겠습니까? 이 주소록의 모든 연락처가 삭제됩니다.",
|
||||
"deleted": "주소록이 삭제되었습니다",
|
||||
"delete_failed": "주소록 삭제에 실패했습니다"
|
||||
"delete_failed": "주소록 삭제에 실패했습니다",
|
||||
"move_to": "이동…",
|
||||
"move_title": "주소록으로 이동",
|
||||
"move_here": "여기로 이동"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "이메일",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "인증서가 S/MIME 저장소에 추가되었어요",
|
||||
"cert_import_failed": "인증서를 가져오지 못했어요",
|
||||
"no_contact_selected": "세부 정보를 보려면 연락처를 선택해 주세요",
|
||||
"compose_email": "메일 쓰기",
|
||||
"compose_email": "이메일 보내기",
|
||||
"copy_email": "이메일 복사",
|
||||
"copy_phone": "전화번호 복사",
|
||||
"copy_url": "URL 복사",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "새 연락처",
|
||||
"edit_title": "연락처 수정",
|
||||
"section_address_book": "디렉터리",
|
||||
"select_address_book": "디렉터리 선택...",
|
||||
"section_address_book": "주소록",
|
||||
"select_address_book": "주소록",
|
||||
"section_identity": "이름 및 신원",
|
||||
"contact_type": "연락처 유형",
|
||||
"type_person": "개인",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "전화번호 추가",
|
||||
"save": "저장",
|
||||
"cancel": "취소",
|
||||
"discard_title": "변경 사항을 삭제할까요?",
|
||||
"discard_message": "변경 사항이 저장되지 않았습니다.",
|
||||
"discard": "삭제",
|
||||
"keep_editing": "계속 편집",
|
||||
"remove_entry": "제거",
|
||||
"close": "닫기",
|
||||
"remove_category": "{name} 제거",
|
||||
"creating": "만드는 중...",
|
||||
"updating": "업데이트 중...",
|
||||
"name_required": "이름, 성 또는 조직을 입력하세요",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "새 그룹",
|
||||
"delete": "그룹 삭제",
|
||||
"remove_member": "그룹에서 {name} 제거",
|
||||
"edit": "그룹 수정",
|
||||
"empty": "아직 그룹이 없어요",
|
||||
"delete_confirm_title": "그룹 삭제",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "추가할 연락처 검색...",
|
||||
"no_members": "이 그룹에는 멤버가 없어요",
|
||||
"member_count": "멤버 {count}명",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "이메일 보내기",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count}개의 연락처가 삭제되었어요",
|
||||
"add_to_group": "그룹에 추가",
|
||||
"choose_group": "그룹 선택",
|
||||
"new_group": "새 그룹…",
|
||||
"adding_contacts": "{count}개의 연락처를 추가하는 중...",
|
||||
"added_to_group": "연락처가 그룹에 추가되었어요",
|
||||
"export": "내보내기",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "이름",
|
||||
"sort_last_name": "성"
|
||||
},
|
||||
"open_categories": "카테고리 열기"
|
||||
"shortcuts": {
|
||||
"list": "목록",
|
||||
"actions": "작업",
|
||||
"first_last": "처음 또는 마지막",
|
||||
"extend": "선택 확장",
|
||||
"toggle": "선택 또는 선택 해제",
|
||||
"select_all": "모두 선택",
|
||||
"clear": "선택 또는 검색 지우기",
|
||||
"delete": "삭제",
|
||||
"search": "검색",
|
||||
"move": "다음 또는 이전 연락처",
|
||||
"open": "연락처 열기",
|
||||
"menu": "연락처 메뉴 열기"
|
||||
},
|
||||
"open_categories": "카테고리 열기",
|
||||
"row_actions": "{name} 작업"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "캘린더",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "이름에 제어 문자를 사용할 수 없습니다.",
|
||||
"name_too_long": "이 이름은 서버에서 사용하기에 너무 깁니다.",
|
||||
"delete": "삭제",
|
||||
"delete_confirm_title": "항목 삭제",
|
||||
"delete_confirm_message": "정말 \"{name}\"을(를) 삭제할까요? 이 작업은 되돌릴 수 없어요.",
|
||||
"download": "다운로드",
|
||||
"name": "이름",
|
||||
"size": "크기",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "복사본의 일부가 그대로 남아 있어요.",
|
||||
"create_folder_success": "폴더가 만들어졌어요",
|
||||
"create_folder_error": "폴더를 만들지 못했어요",
|
||||
"delete_success": "성공적으로 삭제되었어요",
|
||||
"delete_error": "삭제에 실패했어요",
|
||||
"trash": "휴지통",
|
||||
"restore": "복원",
|
||||
"restored": "{count, plural, other {항목 #개를 복원했습니다}}",
|
||||
"restore_error": "복원하지 못했습니다",
|
||||
"moved_to_trash": "{count, plural, other {항목 #개를 휴지통으로 이동했습니다}}",
|
||||
"delete_forever": "영구 삭제",
|
||||
"delete_forever_title": "{count, plural, other {항목 #개를 영구 삭제할까요?}}",
|
||||
"delete_forever_message": "\"{name}\" 항목이 삭제됩니다. 되돌릴 수 없습니다.",
|
||||
"delete_forever_message_count": "{count, plural, other {항목 #개가 삭제됩니다}}. 되돌릴 수 없습니다.",
|
||||
"deleted_forever": "{count, plural, other {항목 #개를 영구 삭제했습니다}}",
|
||||
"empty_trash": "휴지통 비우기",
|
||||
"empty_trash_title": "휴지통을 비울까요?",
|
||||
"empty_trash_message": "{count, plural, other {휴지통의 항목 #개가 모두 영구 삭제됩니다}}. 되돌릴 수 없습니다.",
|
||||
"trash_emptied": "휴지통을 비웠습니다",
|
||||
"empty_trash_error": "휴지통을 비우지 못했습니다",
|
||||
"trash_empty_title": "휴지통이 비어 있습니다",
|
||||
"trash_empty_hint": "삭제한 파일은 휴지통을 비울 때까지 여기에 남아 있습니다.",
|
||||
"deleted": "삭제한 날짜",
|
||||
"deleted_from": "{folder}에서 삭제됨",
|
||||
"shortcuts": {
|
||||
"list": "목록",
|
||||
"actions": "작업",
|
||||
"first_last": "처음 또는 마지막",
|
||||
"extend": "선택 확장",
|
||||
"toggle": "선택 또는 선택 해제",
|
||||
"select_all": "모두 선택",
|
||||
"clear": "선택 또는 검색 지우기",
|
||||
"delete": "삭제",
|
||||
"search": "검색",
|
||||
"move": "다음 또는 이전 항목",
|
||||
"move_grid": "격자에서 옆 항목",
|
||||
"open": "열기",
|
||||
"up": "상위 폴더로",
|
||||
"rename": "이름 바꾸기",
|
||||
"copy": "복사",
|
||||
"cut": "잘라내기",
|
||||
"paste": "붙여넣기"
|
||||
},
|
||||
"rename_success": "이름이 변경되었어요",
|
||||
"rename_error": "이름을 바꾸지 못했어요",
|
||||
"download_error": "다운로드에 실패했어요",
|
||||
"download_zip": "ZIP으로 다운로드",
|
||||
"zipping": "{name} 준비 중",
|
||||
"download_canceled": "다운로드를 취소했습니다",
|
||||
"zip_error": "{name}을(를) 다운로드하지 못했습니다",
|
||||
"not_available": "이 서버에서는 파일 저장소를 사용할 수 없어요",
|
||||
"cancel": "취소",
|
||||
"create": "만들기",
|
||||
"save": "저장",
|
||||
"no_results": "검색된 파일이 없어요",
|
||||
"batch_delete_confirm_message": "선택한 {count}개의 항목을 정말 삭제할까요? 이 작업은 되돌릴 수 없어요.",
|
||||
"batch_delete_success": "{count}개의 항목이 삭제되었어요",
|
||||
"grid_view": "그리드 뷰",
|
||||
"list_view": "리스트 뷰",
|
||||
"details": "세부 정보",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "붙여넣기",
|
||||
"move_success": "{count}개의 항목이 이동되었어요",
|
||||
"move_error": "이동에 실패했어요",
|
||||
"move_to": "이동…",
|
||||
"move_to_title": "이동",
|
||||
"move_here": "여기로 이동",
|
||||
"paste_success": "붙여넣기 성공",
|
||||
"paste_error": "붙여넣기에 실패했어요",
|
||||
"new_text_file": "새 텍스트 파일",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "축소",
|
||||
"zoom_reset": "화면에 맞추기",
|
||||
"rotate": "회전",
|
||||
"close_preview": "닫기"
|
||||
"close_preview": "닫기",
|
||||
"previous_file": "이전 파일",
|
||||
"next_file": "다음 파일",
|
||||
"loading": "불러오는 중…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "내 인증서",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Pievienot laukam Kopija",
|
||||
"add_bcc": "Pievienot laukam Slēptā kopija",
|
||||
"select_hint": "Atlasiet personu, lai redzētu informāciju",
|
||||
"write_email": "Rakstīt e-pastu",
|
||||
"write_email": "Sūtīt e-pastu",
|
||||
"add_to_contacts": "Pievienot kontaktiem",
|
||||
"email": "E-pasts",
|
||||
"read_only_note": "Kataloga ieraksti nāk no jūsu pasta servera, un tos šeit nevar rediģēt.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakti",
|
||||
"disabled_title": "Administrators ir izslēdzis kontaktpersonas",
|
||||
"disabled_description": "Ja tās ir nepieciešamas, sazinieties ar administratoru.",
|
||||
"drag_count": "{count, plural, one {1 kontaktpersona} other {# kontaktpersonas}}",
|
||||
"select_contact": "Atlasīt {name}",
|
||||
"new": "Jauns",
|
||||
"search_placeholder": "Meklēt kontaktus...",
|
||||
"create_new": "Jauns kontakts",
|
||||
"no_category": "Bez kategorijas",
|
||||
@@ -2936,6 +2941,8 @@
|
||||
"empty_state": "Kontaktu vēl nav",
|
||||
"empty_state_title": "Kontaktu vēl nav",
|
||||
"empty_state_subtitle": "Izveidojiet pirmo kontaktu vai importējiet no vCard faila",
|
||||
"empty_view_title": "Šeit nav kontaktpersonu",
|
||||
"empty_view_hint": "Pievienojiet kontaktpersonu vai importējiet vCard failu.",
|
||||
"empty_search": "Nav kontaktu, kas atbilstu vaicājumam",
|
||||
"empty_search_hint": "Mēģiniet citu meklēšanas vaicājumu",
|
||||
"empty_filtered": "Nav kontaktu, kas atbilstu filtriem",
|
||||
@@ -2956,6 +2963,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Jauna adrešu grāmata",
|
||||
"create_confirm": "Izveidot",
|
||||
"created": "Adrešu grāmata izveidota",
|
||||
"create_failed": "Neizdevās izveidot adrešu grāmatu",
|
||||
"title": "Manas adrešu grāmatas",
|
||||
@@ -2978,7 +2986,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",
|
||||
@@ -3004,7 +3015,7 @@
|
||||
"cert_imported": "Sertifikāts importēts S/MIME krātuvē",
|
||||
"cert_import_failed": "Neizdevās importēt sertifikātu",
|
||||
"no_contact_selected": "Atlasiet kontaktu, lai skatītu informāciju",
|
||||
"compose_email": "Rakstīt e-pastu",
|
||||
"compose_email": "Sūtīt e-pastu",
|
||||
"copy_email": "Kopēt e-pastu",
|
||||
"copy_phone": "Kopēt tālruni",
|
||||
"copy_url": "Kopēt URL",
|
||||
@@ -3061,8 +3072,8 @@
|
||||
"form": {
|
||||
"create_title": "Jauns kontakts",
|
||||
"edit_title": "Rediģēt kontaktu",
|
||||
"section_address_book": "Katalogs",
|
||||
"select_address_book": "Izvēlieties katalogu...",
|
||||
"section_address_book": "Adrešu grāmata",
|
||||
"select_address_book": "Adrešu grāmata",
|
||||
"section_identity": "Vārds un identitāte",
|
||||
"contact_type": "Kontakta veids",
|
||||
"type_person": "Persona",
|
||||
@@ -3149,6 +3160,13 @@
|
||||
"add_phone": "Pievienot tālruni",
|
||||
"save": "Saglabāt",
|
||||
"cancel": "Atcelt",
|
||||
"discard_title": "Atmest izmaiņas?",
|
||||
"discard_message": "Jūsu izmaiņas nav saglabātas.",
|
||||
"discard": "Atmest",
|
||||
"keep_editing": "Turpināt rediģēšanu",
|
||||
"remove_entry": "Noņemt",
|
||||
"close": "Aizvērt",
|
||||
"remove_category": "Noņemt {name}",
|
||||
"creating": "Izveido...",
|
||||
"updating": "Atjaunina...",
|
||||
"name_required": "Ievadiet vārdu, uzvārdu vai organizāciju",
|
||||
@@ -3165,6 +3183,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Jauna grupa",
|
||||
"delete": "Dzēst grupu",
|
||||
"remove_member": "Noņemt {name} no grupas",
|
||||
"edit": "Rediģēt grupu",
|
||||
"empty": "Grupu vēl nav",
|
||||
"delete_confirm_title": "Dzēst grupu",
|
||||
@@ -3177,7 +3197,7 @@
|
||||
"search_members": "Meklēt kontaktus, ko pievienot...",
|
||||
"no_members": "Šajā grupā nav dalībnieku",
|
||||
"member_count": "{count, plural, =0 {Nav dalībnieku} one {1 dalībnieks} other {# dalībnieki}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Sūtīt e-pastu",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3215,6 +3235,7 @@
|
||||
"deleted": "Izdzēsts {count, plural, one {1 kontakts} other {# kontakti}}",
|
||||
"add_to_group": "Pievienot grupai",
|
||||
"choose_group": "Izvēlēties grupu",
|
||||
"new_group": "Jauna grupa…",
|
||||
"adding_contacts": "Pievieno {count, plural, one {1 kontaktu} other {# kontaktus}}",
|
||||
"added_to_group": "Kontakti pievienoti grupai",
|
||||
"export": "Eksportēt",
|
||||
@@ -3232,6 +3253,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",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Vārds",
|
||||
"sort_last_name": "Uzvārds"
|
||||
},
|
||||
"open_categories": "Atvērt kategorijas"
|
||||
"shortcuts": {
|
||||
"list": "Saraksts",
|
||||
"actions": "Darbības",
|
||||
"first_last": "Pirmais vai pēdējais",
|
||||
"extend": "Paplašināt atlasi",
|
||||
"toggle": "Atlasīt vai noņemt atlasi",
|
||||
"select_all": "Atlasīt visu",
|
||||
"clear": "Notīrīt atlasi vai meklēšanu",
|
||||
"delete": "Dzēst",
|
||||
"search": "Meklēt",
|
||||
"move": "Nākamā vai iepriekšējā kontaktpersona",
|
||||
"open": "Atvērt kontaktpersonu",
|
||||
"menu": "Atvērt kontaktpersonas izvēlni"
|
||||
},
|
||||
"open_categories": "Atvērt kategorijas",
|
||||
"row_actions": "Darbības: {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendārs",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Nosaukumos nedrīkst būt vadības rakstzīmes.",
|
||||
"name_too_long": "Šis nosaukums serverim ir pārāk garš.",
|
||||
"delete": "Dzēst",
|
||||
"delete_confirm_title": "Dzēst resursu",
|
||||
"delete_confirm_message": "Vai tiešām vēlaties dzēst «{name}»? Šo darbību nevar atcelt.",
|
||||
"download": "Lejupielādēt",
|
||||
"name": "Nosaukums",
|
||||
"size": "Izmērs",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Daļa kopijas palika savā vietā.",
|
||||
"create_folder_success": "Mape izveidota",
|
||||
"create_folder_error": "Neizdevās izveidot mapi",
|
||||
"delete_success": "Sekmīgi izdzēsts",
|
||||
"delete_error": "Neizdevās izdzēst",
|
||||
"trash": "Miskaste",
|
||||
"restore": "Atjaunot",
|
||||
"restored": "{count, plural, one {Vienums atjaunots} other {Atjaunoti # vienumi}}",
|
||||
"restore_error": "Neizdevās atjaunot",
|
||||
"moved_to_trash": "{count, plural, one {Pārvietots uz miskasti} other {# vienumi pārvietoti uz miskasti}}",
|
||||
"delete_forever": "Dzēst neatgriezeniski",
|
||||
"delete_forever_title": "{count, plural, one {Dzēst neatgriezeniski?} other {Dzēst # vienumus neatgriezeniski?}}",
|
||||
"delete_forever_message": "“{name}” tiks dzēsts. To nevar atsaukt.",
|
||||
"delete_forever_message_count": "{count, plural, one {Tiks dzēsts 1 vienums} other {Tiks dzēsti # vienumi}}. To nevar atsaukt.",
|
||||
"deleted_forever": "{count, plural, one {Dzēsts neatgriezeniski} other {# vienumi dzēsti neatgriezeniski}}",
|
||||
"empty_trash": "Iztukšot miskasti",
|
||||
"empty_trash_title": "Iztukšot miskasti?",
|
||||
"empty_trash_message": "{count, plural, one {Miskastē esošais vienums tiks dzēsts} other {Visi # miskastē esošie vienumi tiks dzēsti}} neatgriezeniski. To nevar atsaukt.",
|
||||
"trash_emptied": "Miskaste iztukšota",
|
||||
"empty_trash_error": "Neizdevās iztukšot miskasti",
|
||||
"trash_empty_title": "Miskaste ir tukša",
|
||||
"trash_empty_hint": "Dzēstie faili paliek šeit, līdz iztukšojat miskasti.",
|
||||
"deleted": "Dzēsts",
|
||||
"deleted_from": "Dzēsts no {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Saraksts",
|
||||
"actions": "Darbības",
|
||||
"first_last": "Pirmais vai pēdējais",
|
||||
"extend": "Paplašināt atlasi",
|
||||
"toggle": "Atlasīt vai noņemt atlasi",
|
||||
"select_all": "Atlasīt visu",
|
||||
"clear": "Notīrīt atlasi vai meklēšanu",
|
||||
"delete": "Dzēst",
|
||||
"search": "Meklēt",
|
||||
"move": "Nākamais vai iepriekšējais vienums",
|
||||
"move_grid": "Blakus esošais vienums režģī",
|
||||
"open": "Atvērt",
|
||||
"up": "Mapi uz augšu",
|
||||
"rename": "Pārdēvēt",
|
||||
"copy": "Kopēt",
|
||||
"cut": "Izgriezt",
|
||||
"paste": "Ielīmēt"
|
||||
},
|
||||
"rename_success": "Sekmīgi pārdēvēts",
|
||||
"rename_error": "Neizdevās pārdēvēt",
|
||||
"download_error": "Neizdevās lejupielādēt",
|
||||
"download_zip": "Lejupielādēt kā ZIP",
|
||||
"zipping": "Sagatavo {name}",
|
||||
"download_canceled": "Lejupielāde atcelta",
|
||||
"zip_error": "Neizdevās lejupielādēt {name}",
|
||||
"not_available": "Failu krātuve šajā serverī nav pieejama",
|
||||
"cancel": "Atcelt",
|
||||
"create": "Izveidot",
|
||||
"save": "Saglabāt",
|
||||
"no_results": "Nav failu, kas atbilstu vaicājumam",
|
||||
"batch_delete_confirm_message": "Vai tiešām vēlaties dzēst {count, plural, one {1 elementu} other {# elementus}}?",
|
||||
"batch_delete_success": "Izdzēsts {count, plural, one {1 elements} other {# elementi}}",
|
||||
"grid_view": "Režģis",
|
||||
"list_view": "Saraksts",
|
||||
"details": "Sīkāka informācija",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Ielīmēt",
|
||||
"move_success": "Pārvietots {count, plural, one {1 elements} other {# elementi}}",
|
||||
"move_error": "Neizdevās pārvietot",
|
||||
"move_to": "Pārvietot uz…",
|
||||
"move_to_title": "Pārvietot uz",
|
||||
"move_here": "Pārvietot šeit",
|
||||
"paste_success": "Ielīmēts sekmīgi",
|
||||
"paste_error": "Neizdevās ielīmēt",
|
||||
"new_text_file": "Jauns teksta fails",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Tālināt",
|
||||
"zoom_reset": "Ietilpināt ekrānā",
|
||||
"rotate": "Pagriezt",
|
||||
"close_preview": "Aizvērt"
|
||||
"close_preview": "Aizvērt",
|
||||
"previous_file": "Iepriekšējais fails",
|
||||
"next_file": "Nākamais fails",
|
||||
"loading": "Ielādē…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Jūsu sertifikāti",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Cc-д нэмэх",
|
||||
"add_bcc": "Bcc-д нэмэх",
|
||||
"select_hint": "Дэлгэрэнгүйг харахын тулд хэн нэгнийг сонгоно уу",
|
||||
"write_email": "Имэйл бичих",
|
||||
"write_email": "Имэйл илгээх",
|
||||
"add_to_contacts": "Харилцагчид нэмэх",
|
||||
"email": "Имэйл",
|
||||
"read_only_note": "Лавлахын бичлэгүүд таны шуудангийн серверээс ирдэг тул энд засах боломжгүй.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Харилцах утас",
|
||||
"disabled_title": "Таны администратор харилцагчдыг унтраасан",
|
||||
"disabled_description": "Хэрэгтэй бол администратортаа хандана уу.",
|
||||
"drag_count": "{count, plural, one {1 харилцагч} other {# харилцагч}}",
|
||||
"select_contact": "{name}-г сонгох",
|
||||
"new": "Шинэ",
|
||||
"search_placeholder": "Харилцагч хайх...",
|
||||
"create_new": "Шинэ холбоо барих",
|
||||
"no_category": "Ангилалгүй",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "Харилцагч байхгүй",
|
||||
"empty_state_title": "Хараахан холбоо барих хүн алга",
|
||||
"empty_state_subtitle": "Анхны харилцагчаа үүсгэх эсвэл vCard файлаас импортлох",
|
||||
"empty_view_title": "Энд харилцагч алга",
|
||||
"empty_view_hint": "Харилцагч нэмэх эсвэл vCard файл импортлоно уу.",
|
||||
"empty_search": "Таны хайлтад тохирох харилцагч алга",
|
||||
"empty_search_hint": "Өөр хайлтын үг хэрэглэж үзнэ үү",
|
||||
"empty_filtered": "Таны шүүлтүүрт тохирох харилцагч алга",
|
||||
@@ -2952,6 +2963,7 @@
|
||||
"back_to_contacts": "Харилцагч руу буцах",
|
||||
"back_to_email": "Имэйл рүү буцах",
|
||||
"open_categories": "Нээлттэй ангилалууд",
|
||||
"row_actions": "{name}-н үйлдлүүд",
|
||||
"tabs": {
|
||||
"all": "Бүгд",
|
||||
"groups": "Бүлэг"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Шинэ хаягийн дэвтэр",
|
||||
"create_confirm": "Үүсгэх",
|
||||
"created": "Хаягийн дэвтэр үүсгэсэн",
|
||||
"create_failed": "Хаягийн дэвтэр үүсгэж чадсангүй",
|
||||
"title": "Миний хаягийн дэвтэр",
|
||||
@@ -2983,7 +2996,10 @@
|
||||
"delete": "Хаягийн дэвтэр устгах",
|
||||
"confirm_delete": "\"{name}\"-г устгах уу? Энэ хаягийн дэвтэр дэх бүх харилцагчийг устгах болно.",
|
||||
"deleted": "Хаягийн дэвтэр устсан",
|
||||
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй"
|
||||
"delete_failed": "Хаягийн дэвтрийг устгаж чадсангүй",
|
||||
"move_to": "Зөөх…",
|
||||
"move_title": "Хаягийн дэвтэр рүү зөөх",
|
||||
"move_here": "Энд зөөх"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Имэйл хаягууд",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Сертификат S/MIME дэлгүүрт импортлогдсон",
|
||||
"cert_import_failed": "Сертификат импортлож чадсангүй",
|
||||
"no_contact_selected": "Дэлгэрэнгүйг харахын тулд харилцагчаа сонгоно уу",
|
||||
"compose_email": "Имэйл бичих",
|
||||
"compose_email": "Имэйл илгээх",
|
||||
"copy_email": "Имэйл хуулах",
|
||||
"copy_phone": "Утасны дугаарыг хуулах",
|
||||
"copy_url": "URL хуулах",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "Шинэ холбоо барих",
|
||||
"edit_title": "Харилцагчийг засах",
|
||||
"section_address_book": "Лавлах",
|
||||
"select_address_book": "Лавлах сонгох...",
|
||||
"section_address_book": "Хаягийн дэвтэр",
|
||||
"select_address_book": "Хаягийн дэвтэр",
|
||||
"section_identity": "Нэр & Хувийн мэдээлэл",
|
||||
"contact_type": "Холбоо барих төрөл",
|
||||
"type_person": "Хүн",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Утас нэмэх",
|
||||
"save": "Хадгалах",
|
||||
"cancel": "Цуцлах",
|
||||
"discard_title": "Өөрчлөлтийг хаях уу?",
|
||||
"discard_message": "Таны өөрчлөлт хадгалагдаагүй байна.",
|
||||
"discard": "Хаях",
|
||||
"keep_editing": "Үргэлжлүүлэн засах",
|
||||
"remove_entry": "Хасах",
|
||||
"close": "Хаах",
|
||||
"remove_category": "{name}-г хасах",
|
||||
"creating": "Бүтээж байна...",
|
||||
"updating": "Шинэчилж байна...",
|
||||
"name_required": "Нэр, овог, байгууллагыг оруулна уу",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Шинэ групп",
|
||||
"delete": "Бүлгийг устгах",
|
||||
"remove_member": "{name}-г бүлгээс хасах",
|
||||
"edit": "Засварлах групп",
|
||||
"empty": "Одоохондоо бүлэг байхгүй",
|
||||
"delete_confirm_title": "Бүлгийг устгах",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Нэмэх харилцагчдыг хайх...",
|
||||
"no_members": "Энэ бүлэгт гишүүн алга",
|
||||
"member_count": "{count, plural, =0 {Гишүүд байхгүй} one {1 гишүүн} other {# гишүүн}}",
|
||||
"send_email": "Групп руу имэйл илгээх",
|
||||
"send_email": "Имэйл илгээх",
|
||||
"send_email_to": "руу",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 контакт устгагдлаа} other {# контакт устгагдлаа}}",
|
||||
"add_to_group": "Бүлэгт нэмэх",
|
||||
"choose_group": "Бүлэг сонгох",
|
||||
"new_group": "Шинэ бүлэг…",
|
||||
"adding_contacts": "{count, plural, one {1 контакт} other {# контакт}} нэмэж байна",
|
||||
"added_to_group": "Харилцагчдыг группт нэмсэн",
|
||||
"export": "Экспорт хийх",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Эрэмбэлэх",
|
||||
"sort_first_name": "Нэр",
|
||||
"sort_last_name": "Овог"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Жагсаалт",
|
||||
"actions": "Үйлдлүүд",
|
||||
"first_last": "Эхний эсвэл сүүлийн",
|
||||
"extend": "Сонголтыг өргөтгөх",
|
||||
"toggle": "Сонгох эсвэл цуцлах",
|
||||
"select_all": "Бүгдийг сонгох",
|
||||
"clear": "Сонголт эсвэл хайлтыг цэвэрлэх",
|
||||
"delete": "Устгах",
|
||||
"search": "Хайх",
|
||||
"move": "Дараах эсвэл өмнөх харилцагч",
|
||||
"open": "Харилцагчийг нээх",
|
||||
"menu": "Харилцагчийн цэсийг нээх"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Нэрэнд удирдах тэмдэгт агуулж болохгүй.",
|
||||
"name_too_long": "Энэ нэр серверийн хувьд хэт урт байна.",
|
||||
"delete": "Устгах",
|
||||
"delete_confirm_title": "Нөөцийг устгах",
|
||||
"delete_confirm_message": "Та \"{name}\"-г устгахдаа итгэлтэй байна уу? Үүнийг буцаах боломжгүй.",
|
||||
"download": "Татаж авах",
|
||||
"name": "Нэр",
|
||||
"size": "Хэмжээ",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "Хуулбарын нэг хэсэг хэвээр үлдлээ.",
|
||||
"create_folder_success": "Хавтас үүсгэсэн",
|
||||
"create_folder_error": "Хавтас үүсгэж чадсангүй",
|
||||
"delete_success": "Амжилттай устгасан",
|
||||
"delete_error": "Устгаж чадсангүй",
|
||||
"trash": "Хогийн сав",
|
||||
"restore": "Сэргээх",
|
||||
"restored": "{count, plural, one {Зүйлийг сэргээлээ} other {# зүйлийг сэргээлээ}}",
|
||||
"restore_error": "Сэргээж чадсангүй",
|
||||
"moved_to_trash": "{count, plural, one {Хогийн сав руу зөөлөө} other {# зүйлийг хогийн сав руу зөөлөө}}",
|
||||
"delete_forever": "Бүрмөсөн устгах",
|
||||
"delete_forever_title": "{count, plural, one {Бүрмөсөн устгах уу?} other {# зүйлийг бүрмөсөн устгах уу?}}",
|
||||
"delete_forever_message": "\"{name}\" устгагдана. Үүнийг буцаах боломжгүй.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 зүйл устгагдана} other {# зүйл устгагдана}}. Үүнийг буцаах боломжгүй.",
|
||||
"deleted_forever": "{count, plural, one {Бүрмөсөн устгалаа} other {# зүйлийг бүрмөсөн устгалаа}}",
|
||||
"empty_trash": "Хогийн савыг хоослох",
|
||||
"empty_trash_title": "Хогийн савыг хоослох уу?",
|
||||
"empty_trash_message": "{count, plural, one {Хогийн саван дахь зүйл} other {Хогийн саван дахь бүх # зүйл}} бүрмөсөн устгагдана. Үүнийг буцаах боломжгүй.",
|
||||
"trash_emptied": "Хогийн савыг хоосоллоо",
|
||||
"empty_trash_error": "Хогийн савыг хоосолж чадсангүй",
|
||||
"trash_empty_title": "Хогийн сав хоосон байна",
|
||||
"trash_empty_hint": "Устгасан файлууд хогийн савыг хоослох хүртэл энд хадгалагдана.",
|
||||
"deleted": "Устгасан",
|
||||
"deleted_from": "{folder}-с устгасан",
|
||||
"shortcuts": {
|
||||
"list": "Жагсаалт",
|
||||
"actions": "Үйлдлүүд",
|
||||
"first_last": "Эхний эсвэл сүүлийн",
|
||||
"extend": "Сонголтыг өргөтгөх",
|
||||
"toggle": "Сонгох эсвэл цуцлах",
|
||||
"select_all": "Бүгдийг сонгох",
|
||||
"clear": "Сонголт эсвэл хайлтыг цэвэрлэх",
|
||||
"delete": "Устгах",
|
||||
"search": "Хайх",
|
||||
"move": "Дараах эсвэл өмнөх зүйл",
|
||||
"move_grid": "Сүлжээн дэх хажуугийн зүйл",
|
||||
"open": "Нээх",
|
||||
"up": "Нэг хавтас дээш",
|
||||
"rename": "Нэр солих",
|
||||
"copy": "Хуулах",
|
||||
"cut": "Хайчлах",
|
||||
"paste": "Буулгах"
|
||||
},
|
||||
"rename_success": "Нэрээ амжилттай өөрчилсөн",
|
||||
"rename_error": "Нэрээ өөрчилж чадсангүй",
|
||||
"download_error": "Татаж авч чадсангүй",
|
||||
"download_zip": "ZIP хэлбэрээр татах",
|
||||
"zipping": "{name}-г бэлдэж байна",
|
||||
"download_canceled": "Татаж авалтыг цуцаллаа",
|
||||
"zip_error": "{name}-г татаж чадсангүй",
|
||||
"not_available": "Энэ сервер дээр файл хадгалах боломжгүй",
|
||||
"cancel": "Цуцлах",
|
||||
"create": "Үүсгэх",
|
||||
"save": "Хадгалах",
|
||||
"no_results": "Таны хайлтад тохирох файл алга",
|
||||
"batch_delete_confirm_message": "{count, plural, one {1 элемент} other {# элемент}} устгахыг баталгаажуулж байна уу? Энэ үйлдлийг буцаах боломжгүй.",
|
||||
"batch_delete_success": "{count, plural, one {1 элемент устгагдлаа} other {# элемент устгагдлаа}}",
|
||||
"grid_view": "Сүлжээ харах",
|
||||
"list_view": "Жагсаалт харах",
|
||||
"details": "Дэлгэрэнгүй мэдээлэл",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Наах",
|
||||
"move_success": "{count, plural, one {1 зүйл зөөгдлөө} other {# зүйл зөөгдлөө}}",
|
||||
"move_error": "Хөдөлж чадсангүй",
|
||||
"move_to": "Зөөх…",
|
||||
"move_to_title": "Зөөх",
|
||||
"move_here": "Энд зөөх",
|
||||
"paste_success": "Амжилттай буулгалаа",
|
||||
"paste_error": "Буулгаж чадсангүй",
|
||||
"new_text_file": "Шинэ текст файл",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Жижигрүүлэх",
|
||||
"zoom_reset": "Дэлгэцэнд тааруулах",
|
||||
"rotate": "Эргүүлэх",
|
||||
"close_preview": "Хаах"
|
||||
"close_preview": "Хаах",
|
||||
"previous_file": "Өмнөх файл",
|
||||
"next_file": "Дараах файл",
|
||||
"loading": "Ачаалж байна…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Таны сертификатууд",
|
||||
|
||||
+94
-11
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Legg til i Kopi",
|
||||
"add_bcc": "Legg til i Blindkopi",
|
||||
"select_hint": "Velg en person for å se detaljer",
|
||||
"write_email": "Skriv e-post",
|
||||
"write_email": "Send e-post",
|
||||
"add_to_contacts": "Legg til i kontakter",
|
||||
"email": "E-post",
|
||||
"read_only_note": "Oppføringene kommer fra e-postserveren din og kan ikke redigeres her.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakter",
|
||||
"disabled_title": "Kontakter er slått av av administratoren",
|
||||
"disabled_description": "Spør administratoren hvis du trenger dem.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"select_contact": "Velg {name}",
|
||||
"new": "Ny",
|
||||
"search_placeholder": "Søk etter kontakter...",
|
||||
"create_new": "Ny kontakt",
|
||||
"no_category": "Ingen kategori",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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å",
|
||||
"empty_state_title": "Ingen kontakter ennå",
|
||||
"empty_state_subtitle": "Opprett din første kontakt eller importer fra en vCard-fil",
|
||||
"empty_view_title": "Ingen kontakter her",
|
||||
"empty_view_hint": "Legg til en kontakt eller importer en vCard-fil.",
|
||||
"empty_search": "Ingen kontakter passer med søket ditt",
|
||||
"empty_search_hint": "Prøv et annet søkeord",
|
||||
"empty_filtered": "Ingen kontakter passer med filtrene dine",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Ny adressebok",
|
||||
"create_confirm": "Opprett",
|
||||
"created": "Adresseboken er opprettet",
|
||||
"create_failed": "Kunne ikke opprette adresseboken",
|
||||
"title": "Mine adressebøker",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Sertifikatet er importert til S/MIME-lageret",
|
||||
"cert_import_failed": "Kunne ikke importere sertifikatet",
|
||||
"no_contact_selected": "Velg en kontakt for å se detaljer",
|
||||
"compose_email": "Skriv en e-post",
|
||||
"compose_email": "Send e-post",
|
||||
"copy_email": "Kopier e-postadresse",
|
||||
"copy_phone": "Kopier telefonnummer",
|
||||
"copy_url": "Kopier nettadresse",
|
||||
@@ -3067,7 +3083,7 @@
|
||||
"create_title": "Ny kontakt",
|
||||
"edit_title": "Rediger kontakt",
|
||||
"section_address_book": "Adressebok",
|
||||
"select_address_book": "Velg en adressebok...",
|
||||
"select_address_book": "Adressebok",
|
||||
"section_identity": "Navn og identitet",
|
||||
"contact_type": "Kontakttype",
|
||||
"type_person": "Person",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Legg til telefonnummer",
|
||||
"save": "Lagre",
|
||||
"cancel": "Avbryt",
|
||||
"discard_title": "Vil du forkaste endringene?",
|
||||
"discard_message": "Endringene dine er ikke lagret.",
|
||||
"discard": "Forkast",
|
||||
"keep_editing": "Fortsett å redigere",
|
||||
"remove_entry": "Fjern",
|
||||
"close": "Lukk",
|
||||
"remove_category": "Fjern {name}",
|
||||
"creating": "Oppretter...",
|
||||
"updating": "Oppdaterer...",
|
||||
"name_required": "Skriv inn et fornavn, etternavn eller organisasjon",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Ny gruppe",
|
||||
"delete": "Slett gruppe",
|
||||
"remove_member": "Fjern {name} fra gruppen",
|
||||
"edit": "Rediger gruppe",
|
||||
"empty": "Ingen grupper ennå",
|
||||
"delete_confirm_title": "Slett gruppe",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Søk etter kontakter som skal legges til...",
|
||||
"no_members": "Ingen medlemmer i denne gruppen",
|
||||
"member_count": "{count, plural, =0 {Ingen medlemmer} one {1 medlem} other {# medlemmer}}",
|
||||
"send_email": "Send e-post til gruppen",
|
||||
"send_email": "Send e-post",
|
||||
"send_email_to": "Til",
|
||||
"send_email_cc": "Kopi",
|
||||
"send_email_bcc": "Blindkopi",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 kontakt slettet} other {# kontakter slettet}}",
|
||||
"add_to_group": "Legg til i gruppe",
|
||||
"choose_group": "Velg en gruppe",
|
||||
"new_group": "Ny gruppe…",
|
||||
"adding_contacts": "Legger til {count, plural, one {1 kontakt} other {# kontakter}}",
|
||||
"added_to_group": "Kontaktene er lagt til i gruppen",
|
||||
"export": "Eksporter",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Sorter etter",
|
||||
"sort_first_name": "Fornavn",
|
||||
"sort_last_name": "Etternavn"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Handlinger",
|
||||
"first_last": "Første eller siste",
|
||||
"extend": "Utvid markeringen",
|
||||
"toggle": "Velg eller fjern valg",
|
||||
"select_all": "Velg alle",
|
||||
"clear": "Fjern markering eller søk",
|
||||
"delete": "Slett",
|
||||
"search": "Søk",
|
||||
"move": "Neste eller forrige kontakt",
|
||||
"open": "Åpne kontakten",
|
||||
"menu": "Åpne kontaktens meny"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Navn kan ikke inneholde kontrolltegn.",
|
||||
"name_too_long": "Dette navnet er for langt for serveren.",
|
||||
"delete": "Slett",
|
||||
"delete_confirm_title": "Slett fil eller mappe",
|
||||
"delete_confirm_message": "Er du sikker på at du vil slette «{name}»? Dette kan ikke angres.",
|
||||
"download": "Last ned",
|
||||
"name": "Navn",
|
||||
"size": "Størrelse",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "En del av kopien ble liggende igjen.",
|
||||
"create_folder_success": "Mappen er opprettet",
|
||||
"create_folder_error": "Kunne ikke opprette mappe",
|
||||
"delete_success": "Elementet er slettet",
|
||||
"delete_error": "Kunne ikke slette elementet",
|
||||
"trash": "Papirkurv",
|
||||
"restore": "Gjenopprett",
|
||||
"restored": "{count, plural, one {Elementet er gjenopprettet} other {# elementer er gjenopprettet}}",
|
||||
"restore_error": "Kunne ikke gjenopprette",
|
||||
"moved_to_trash": "{count, plural, one {Flyttet til papirkurven} other {# elementer flyttet til papirkurven}}",
|
||||
"delete_forever": "Slett permanent",
|
||||
"delete_forever_title": "{count, plural, one {Vil du slette permanent?} other {Vil du slette # elementer permanent?}}",
|
||||
"delete_forever_message": "«{name}» blir slettet. Dette kan ikke angres.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 element blir slettet} other {# elementer blir slettet}}. Dette kan ikke angres.",
|
||||
"deleted_forever": "{count, plural, one {Slettet permanent} other {# elementer slettet permanent}}",
|
||||
"empty_trash": "Tøm papirkurven",
|
||||
"empty_trash_title": "Vil du tømme papirkurven?",
|
||||
"empty_trash_message": "{count, plural, one {Elementet i papirkurven} other {Alle # elementer i papirkurven}} blir slettet permanent. Dette kan ikke angres.",
|
||||
"trash_emptied": "Papirkurven er tømt",
|
||||
"empty_trash_error": "Kunne ikke tømme papirkurven",
|
||||
"trash_empty_title": "Papirkurven er tom",
|
||||
"trash_empty_hint": "Slettede filer blir liggende her til du tømmer papirkurven.",
|
||||
"deleted": "Slettet",
|
||||
"deleted_from": "Slettet fra {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "Handlinger",
|
||||
"first_last": "Første eller siste",
|
||||
"extend": "Utvid markeringen",
|
||||
"toggle": "Velg eller fjern valg",
|
||||
"select_all": "Velg alle",
|
||||
"clear": "Fjern markering eller søk",
|
||||
"delete": "Slett",
|
||||
"search": "Søk",
|
||||
"move": "Neste eller forrige element",
|
||||
"move_grid": "Elementet ved siden av i rutenettet",
|
||||
"open": "Åpne",
|
||||
"up": "En mappe opp",
|
||||
"rename": "Gi nytt navn",
|
||||
"copy": "Kopier",
|
||||
"cut": "Klipp ut",
|
||||
"paste": "Lim inn"
|
||||
},
|
||||
"rename_success": "Navnet er endret",
|
||||
"rename_error": "Kunne ikke endre navnet",
|
||||
"download_error": "Kunne ikke laste ned",
|
||||
"download_zip": "Last ned som ZIP",
|
||||
"zipping": "Forbereder {name}",
|
||||
"download_canceled": "Nedlasting avbrutt",
|
||||
"zip_error": "Kunne ikke laste ned {name}",
|
||||
"not_available": "Fillagring er ikke tilgjengelig på denne serveren",
|
||||
"cancel": "Avbryt",
|
||||
"create": "Opprett",
|
||||
"save": "Lagre",
|
||||
"no_results": "Ingen filer passer med søket ditt",
|
||||
"batch_delete_confirm_message": "Er du sikker på at du vil slette {count, plural, one {1 element} other {# elementer}}? Dette kan ikke angres.",
|
||||
"batch_delete_success": "{count, plural, one {1 element slettet} other {# elementer slettet}}",
|
||||
"grid_view": "Rutenettvisning",
|
||||
"list_view": "Listevisning",
|
||||
"details": "Detaljer",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Lim inn",
|
||||
"move_success": "{count, plural, one {1 element flyttet} other {# elementer flyttet}}",
|
||||
"move_error": "Kunne ikke flytte elementet",
|
||||
"move_to": "Flytt til…",
|
||||
"move_to_title": "Flytt til",
|
||||
"move_here": "Flytt hit",
|
||||
"paste_success": "Limt inn",
|
||||
"paste_error": "Kunne ikke lime inn",
|
||||
"new_text_file": "Ny tekstfil",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Zoom ut",
|
||||
"zoom_reset": "Tilpass til skjermen",
|
||||
"rotate": "Roter",
|
||||
"close_preview": "Lukk"
|
||||
"close_preview": "Lukk",
|
||||
"previous_file": "Forrige fil",
|
||||
"next_file": "Neste fil",
|
||||
"loading": "Laster inn…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Dine sertifikater",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Toevoegen aan Cc",
|
||||
"add_bcc": "Toevoegen aan Bcc",
|
||||
"select_hint": "Selecteer iemand om de details te zien",
|
||||
"write_email": "E-mail schrijven",
|
||||
"write_email": "E-mail verzenden",
|
||||
"add_to_contacts": "Toevoegen aan contacten",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Deze gegevens komen van je mailserver en kunnen hier niet worden bewerkt.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contacten",
|
||||
"disabled_title": "Contacten zijn uitgeschakeld door je beheerder",
|
||||
"disabled_description": "Vraag je beheerder als je ze nodig hebt.",
|
||||
"drag_count": "{count, plural, one {1 contact} other {# contacten}}",
|
||||
"select_contact": "{name} selecteren",
|
||||
"new": "Nieuw",
|
||||
"search_placeholder": "Contacten zoeken...",
|
||||
"create_new": "Nieuw contact",
|
||||
"no_category": "Geen categorie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Geen contacten",
|
||||
"empty_state_subtitle": "Maak uw eerste contact aan of importeer vanuit een vCard-bestand",
|
||||
"empty_view_title": "Geen contacten hier",
|
||||
"empty_view_hint": "Voeg een contact toe of importeer een vCard-bestand.",
|
||||
"empty_search": "Geen contacten gevonden",
|
||||
"empty_search_hint": "Probeer een andere zoekterm",
|
||||
"empty_filtered": "Geen contacten voldoen aan uw filters",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nieuw adresboek",
|
||||
"create_confirm": "Aanmaken",
|
||||
"created": "Adresboek aangemaakt",
|
||||
"create_failed": "Kan adresboek niet aanmaken",
|
||||
"title": "Mijn Adresboeken",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Gerelateerde contacten",
|
||||
"crypto_keys": "Cryptografische sleutels",
|
||||
"no_contact_selected": "Selecteer een contact om details te bekijken",
|
||||
"compose_email": "E-mail schrijven",
|
||||
"compose_email": "E-mail verzenden",
|
||||
"copy_email": "E-mail kopiëren",
|
||||
"copy_phone": "Telefoonnummer kopiëren",
|
||||
"copy_url": "URL kopiëren",
|
||||
@@ -3066,7 +3081,7 @@
|
||||
"create_title": "Nieuw contact",
|
||||
"edit_title": "Contact bewerken",
|
||||
"section_address_book": "Adresboek",
|
||||
"select_address_book": "Selecteer een adresboek...",
|
||||
"select_address_book": "Adresboek",
|
||||
"section_identity": "Naam en identiteit",
|
||||
"contact_type": "Contacttype",
|
||||
"type_person": "Persoon",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Telefoon toevoegen",
|
||||
"save": "Opslaan",
|
||||
"cancel": "Annuleren",
|
||||
"discard_title": "Wijzigingen verwerpen?",
|
||||
"discard_message": "Je wijzigingen zijn niet opgeslagen.",
|
||||
"discard": "Verwerpen",
|
||||
"keep_editing": "Doorgaan met bewerken",
|
||||
"remove_entry": "Verwijderen",
|
||||
"close": "Sluiten",
|
||||
"remove_category": "{name} verwijderen",
|
||||
"creating": "Aanmaken...",
|
||||
"updating": "Bijwerken...",
|
||||
"name_required": "Voer een voornaam, achternaam of organisatie in",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nieuwe groep",
|
||||
"delete": "Groep verwijderen",
|
||||
"remove_member": "{name} uit de groep verwijderen",
|
||||
"edit": "Groep bewerken",
|
||||
"empty": "Geen groepen",
|
||||
"delete_confirm_title": "Groep verwijderen",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Contacten zoeken om toe te voegen...",
|
||||
"no_members": "Geen leden in deze groep",
|
||||
"member_count": "{count, plural, =0 {Geen leden} one {1 lid} other {# leden}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "E-mail verzenden",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 contact verwijderd} other {# contacten verwijderd}}",
|
||||
"add_to_group": "Aan groep toevoegen",
|
||||
"choose_group": "Kies een groep",
|
||||
"new_group": "Nieuwe groep…",
|
||||
"adding_contacts": "{count, plural, one {1 contact} other {# contacten}} toevoegen",
|
||||
"added_to_group": "Contacten aan groep toegevoegd",
|
||||
"export": "Exporteren",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Voornaam",
|
||||
"sort_last_name": "Achternaam"
|
||||
},
|
||||
"open_categories": "Categorieën openen"
|
||||
"shortcuts": {
|
||||
"list": "Lijst",
|
||||
"actions": "Acties",
|
||||
"first_last": "Eerste of laatste",
|
||||
"extend": "Selectie uitbreiden",
|
||||
"toggle": "Selecteren of deselecteren",
|
||||
"select_all": "Alles selecteren",
|
||||
"clear": "Selectie of zoekopdracht wissen",
|
||||
"delete": "Verwijderen",
|
||||
"search": "Zoeken",
|
||||
"move": "Volgend of vorig contact",
|
||||
"open": "Contact openen",
|
||||
"menu": "Contactmenu openen"
|
||||
},
|
||||
"open_categories": "Categorieën openen",
|
||||
"row_actions": "Acties voor {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Agenda",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Namen mogen geen besturingstekens bevatten.",
|
||||
"name_too_long": "Deze naam is te lang voor de server.",
|
||||
"delete": "Verwijderen",
|
||||
"delete_confirm_title": "Bron verwijderen",
|
||||
"delete_confirm_message": "Weet u zeker dat u \"{name}\" wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"download": "Downloaden",
|
||||
"name": "Naam",
|
||||
"size": "Grootte",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Een deel van de kopie is blijven staan.",
|
||||
"create_folder_success": "Map aangemaakt",
|
||||
"create_folder_error": "Map aanmaken mislukt",
|
||||
"delete_success": "Succesvol verwijderd",
|
||||
"delete_error": "Verwijderen mislukt",
|
||||
"trash": "Prullenbak",
|
||||
"restore": "Herstellen",
|
||||
"restored": "{count, plural, one {Item hersteld} other {# items hersteld}}",
|
||||
"restore_error": "Kan niet herstellen",
|
||||
"moved_to_trash": "{count, plural, one {Naar de prullenbak verplaatst} other {# items naar de prullenbak verplaatst}}",
|
||||
"delete_forever": "Definitief verwijderen",
|
||||
"delete_forever_title": "{count, plural, one {Definitief verwijderen?} other {# items definitief verwijderen?}}",
|
||||
"delete_forever_message": "'{name}' wordt verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 item wordt verwijderd} other {# items worden verwijderd}}. Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleted_forever": "{count, plural, one {Definitief verwijderd} other {# items definitief verwijderd}}",
|
||||
"empty_trash": "Prullenbak legen",
|
||||
"empty_trash_title": "Prullenbak legen?",
|
||||
"empty_trash_message": "{count, plural, one {Het item in de prullenbak wordt} other {Alle # items in de prullenbak worden}} definitief verwijderd. Dit kan niet ongedaan worden gemaakt.",
|
||||
"trash_emptied": "Prullenbak geleegd",
|
||||
"empty_trash_error": "Kan de prullenbak niet legen",
|
||||
"trash_empty_title": "De prullenbak is leeg",
|
||||
"trash_empty_hint": "Verwijderde bestanden blijven hier tot je de prullenbak leegt.",
|
||||
"deleted": "Verwijderd",
|
||||
"deleted_from": "Verwijderd uit {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Lijst",
|
||||
"actions": "Acties",
|
||||
"first_last": "Eerste of laatste",
|
||||
"extend": "Selectie uitbreiden",
|
||||
"toggle": "Selecteren of deselecteren",
|
||||
"select_all": "Alles selecteren",
|
||||
"clear": "Selectie of zoekopdracht wissen",
|
||||
"delete": "Verwijderen",
|
||||
"search": "Zoeken",
|
||||
"move": "Volgend of vorig item",
|
||||
"move_grid": "Item ernaast in het raster",
|
||||
"open": "Openen",
|
||||
"up": "Een map omhoog",
|
||||
"rename": "Naam wijzigen",
|
||||
"copy": "Kopiëren",
|
||||
"cut": "Knippen",
|
||||
"paste": "Plakken"
|
||||
},
|
||||
"rename_success": "Succesvol hernoemd",
|
||||
"rename_error": "Hernoemen mislukt",
|
||||
"download_error": "Downloaden mislukt",
|
||||
"download_zip": "Downloaden als zip",
|
||||
"zipping": "{name} voorbereiden",
|
||||
"download_canceled": "Download geannuleerd",
|
||||
"zip_error": "Kan {name} niet downloaden",
|
||||
"not_available": "Bestandsopslag is niet beschikbaar op deze server",
|
||||
"cancel": "Annuleren",
|
||||
"create": "Aanmaken",
|
||||
"save": "Opslaan",
|
||||
"no_results": "Geen bestanden komen overeen met uw zoekopdracht",
|
||||
"batch_delete_confirm_message": "Weet u zeker dat u {count, plural, one {1 item} other {# items}} wilt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"batch_delete_success": "{count, plural, one {1 item verwijderd} other {# items verwijderd}}",
|
||||
"grid_view": "Rasterweergave",
|
||||
"list_view": "Lijstweergave",
|
||||
"details": "Details",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Plakken",
|
||||
"move_success": "{count, plural, one {1 item verplaatst} other {# items verplaatst}}",
|
||||
"move_error": "Verplaatsen mislukt",
|
||||
"move_to": "Verplaatsen naar…",
|
||||
"move_to_title": "Verplaatsen naar",
|
||||
"move_here": "Hierheen verplaatsen",
|
||||
"paste_success": "Succesvol geplakt",
|
||||
"paste_error": "Plakken mislukt",
|
||||
"new_text_file": "Nieuw tekstbestand",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Uitzoomen",
|
||||
"zoom_reset": "Aanpassen aan scherm",
|
||||
"rotate": "Draaien",
|
||||
"close_preview": "Sluiten"
|
||||
"close_preview": "Sluiten",
|
||||
"previous_file": "Vorig bestand",
|
||||
"next_file": "Volgend bestand",
|
||||
"loading": "Laden…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Uw certificaten",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Dodaj do DW",
|
||||
"add_bcc": "Dodaj do UDW",
|
||||
"select_hint": "Wybierz osobę, aby zobaczyć szczegóły",
|
||||
"write_email": "Napisz e-mail",
|
||||
"write_email": "Wyślij e-mail",
|
||||
"add_to_contacts": "Dodaj do kontaktów",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Wpisy katalogu pochodzą z Twojego serwera poczty i nie można ich tu edytować.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakty",
|
||||
"disabled_title": "Administrator wyłączył kontakty",
|
||||
"disabled_description": "Jeśli ich potrzebujesz, skontaktuj się z administratorem.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktów}}",
|
||||
"select_contact": "Zaznacz {name}",
|
||||
"new": "Nowy",
|
||||
"search_placeholder": "Szukaj kontaktów...",
|
||||
"create_new": "Nowy kontakt",
|
||||
"no_category": "Brak kategorii",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Brak kontaktów",
|
||||
"empty_state_subtitle": "Utwórz pierwszy kontakt lub zaimportuj z pliku vCard",
|
||||
"empty_view_title": "Brak kontaktów",
|
||||
"empty_view_hint": "Dodaj kontakt lub zaimportuj plik vCard.",
|
||||
"empty_search": "Żadne kontakty nie pasują do wyszukiwania",
|
||||
"empty_search_hint": "Spróbuj użyć innego wyszukiwanego hasła",
|
||||
"empty_filtered": "Żadne kontakty nie pasują do filtrów",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nowa książka adresowa",
|
||||
"create_confirm": "Utwórz",
|
||||
"created": "Utworzono książkę adresową",
|
||||
"create_failed": "Nie udało się utworzyć książki adresowej",
|
||||
"title": "Moje książki adresowe",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Certyfikat zaimportowano do magazynu S/MIME",
|
||||
"cert_import_failed": "Nie udało się zaimportować certyfikatu",
|
||||
"no_contact_selected": "Wybierz kontakt, aby wyświetlić szczegóły",
|
||||
"compose_email": "Napisz e-mail",
|
||||
"compose_email": "Wyślij e-mail",
|
||||
"copy_email": "Kopiuj e-mail",
|
||||
"copy_phone": "Kopiuj numer telefonu",
|
||||
"copy_url": "Kopiuj URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Nowy kontakt",
|
||||
"edit_title": "Edytuj kontakt",
|
||||
"section_address_book": "Katalog",
|
||||
"select_address_book": "Wybierz katalog...",
|
||||
"section_address_book": "Książka adresowa",
|
||||
"select_address_book": "Książka adresowa",
|
||||
"section_identity": "Imię i tożsamość",
|
||||
"contact_type": "Typ kontaktu",
|
||||
"type_person": "Osoba",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Dodaj telefon",
|
||||
"save": "Zapisz",
|
||||
"cancel": "Anuluj",
|
||||
"discard_title": "Odrzucić zmiany?",
|
||||
"discard_message": "Twoje zmiany nie zostały zapisane.",
|
||||
"discard": "Odrzuć",
|
||||
"keep_editing": "Kontynuuj edycję",
|
||||
"remove_entry": "Usuń",
|
||||
"close": "Zamknij",
|
||||
"remove_category": "Usuń {name}",
|
||||
"creating": "Tworzenie...",
|
||||
"updating": "Aktualizowanie...",
|
||||
"name_required": "Podaj imię, nazwisko lub organizację",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nowa grupa",
|
||||
"delete": "Usuń grupę",
|
||||
"remove_member": "Usuń {name} z grupy",
|
||||
"edit": "Edytuj grupę",
|
||||
"empty": "Brak grup",
|
||||
"delete_confirm_title": "Usuń grupę",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Szukaj kontaktów do dodania...",
|
||||
"no_members": "Brak członków w tej grupie",
|
||||
"member_count": "{count, plural, =0 {Brak członków} one {1 członek} other {# członków}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Wyślij e-mail",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {Usunięto 1 kontakt} other {Usunięto # kontaktów}}",
|
||||
"add_to_group": "Dodaj do grupy",
|
||||
"choose_group": "Wybierz grupę",
|
||||
"new_group": "Nowa grupa…",
|
||||
"adding_contacts": "Dodawanie {count, plural, one {1 kontaktu} other {# kontaktów}}",
|
||||
"added_to_group": "Kontakty dodano do grupy",
|
||||
"export": "Eksportuj",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Imię",
|
||||
"sort_last_name": "Nazwisko"
|
||||
},
|
||||
"open_categories": "Otwórz kategorie"
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Działania",
|
||||
"first_last": "Pierwszy lub ostatni",
|
||||
"extend": "Rozszerz zaznaczenie",
|
||||
"toggle": "Zaznacz lub odznacz",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"clear": "Wyczyść zaznaczenie lub wyszukiwanie",
|
||||
"delete": "Usuń",
|
||||
"search": "Szukaj",
|
||||
"move": "Następny lub poprzedni kontakt",
|
||||
"open": "Otwórz kontakt",
|
||||
"menu": "Otwórz menu kontaktu"
|
||||
},
|
||||
"open_categories": "Otwórz kategorie",
|
||||
"row_actions": "Działania dla {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalendarz",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Nazwy nie mogą zawierać znaków sterujących.",
|
||||
"name_too_long": "Ta nazwa jest zbyt długa dla serwera.",
|
||||
"delete": "Usuń",
|
||||
"delete_confirm_title": "Usuń zasób",
|
||||
"delete_confirm_message": "Czy na pewno chcesz usunąć \"{name}\"? Tej operacji nie można cofnąć.",
|
||||
"download": "Pobierz",
|
||||
"name": "Nazwa",
|
||||
"size": "Rozmiar",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Część kopii pozostała na miejscu.",
|
||||
"create_folder_success": "Folder utworzono",
|
||||
"create_folder_error": "Nie udało się utworzyć folderu",
|
||||
"delete_success": "Usunięto pomyślnie",
|
||||
"delete_error": "Nie udało się usunąć",
|
||||
"trash": "Kosz",
|
||||
"restore": "Przywróć",
|
||||
"restored": "{count, plural, one {Przywrócono element} few {Przywrócono # elementy} other {Przywrócono # elementów}}",
|
||||
"restore_error": "Nie udało się przywrócić",
|
||||
"moved_to_trash": "{count, plural, one {Przeniesiono do kosza} few {Przeniesiono # elementy do kosza} other {Przeniesiono # elementów do kosza}}",
|
||||
"delete_forever": "Usuń trwale",
|
||||
"delete_forever_title": "{count, plural, one {Usunąć trwale?} few {Usunąć trwale # elementy?} other {Usunąć trwale # elementów?}}",
|
||||
"delete_forever_message": "„{name}” zostanie usunięty. Tej operacji nie można cofnąć.",
|
||||
"delete_forever_message_count": "{count, plural, one {Zostanie usunięty 1 element} few {Zostaną usunięte # elementy} other {Zostanie usuniętych # elementów}}. Tej operacji nie można cofnąć.",
|
||||
"deleted_forever": "{count, plural, one {Usunięto trwale} few {Usunięto trwale # elementy} other {Usunięto trwale # elementów}}",
|
||||
"empty_trash": "Opróżnij kosz",
|
||||
"empty_trash_title": "Opróżnić kosz?",
|
||||
"empty_trash_message": "{count, plural, one {Element w koszu zostanie usunięty} few {Wszystkie # elementy w koszu zostaną usunięte} other {Wszystkie # elementów w koszu zostanie usuniętych}} trwale. Tej operacji nie można cofnąć.",
|
||||
"trash_emptied": "Kosz opróżniony",
|
||||
"empty_trash_error": "Nie udało się opróżnić kosza",
|
||||
"trash_empty_title": "Kosz jest pusty",
|
||||
"trash_empty_hint": "Usunięte pliki pozostają tutaj, dopóki nie opróżnisz kosza.",
|
||||
"deleted": "Usunięto",
|
||||
"deleted_from": "Usunięto z {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Działania",
|
||||
"first_last": "Pierwszy lub ostatni",
|
||||
"extend": "Rozszerz zaznaczenie",
|
||||
"toggle": "Zaznacz lub odznacz",
|
||||
"select_all": "Zaznacz wszystko",
|
||||
"clear": "Wyczyść zaznaczenie lub wyszukiwanie",
|
||||
"delete": "Usuń",
|
||||
"search": "Szukaj",
|
||||
"move": "Następny lub poprzedni element",
|
||||
"move_grid": "Sąsiedni element w siatce",
|
||||
"open": "Otwórz",
|
||||
"up": "Folder wyżej",
|
||||
"rename": "Zmień nazwę",
|
||||
"copy": "Kopiuj",
|
||||
"cut": "Wytnij",
|
||||
"paste": "Wklej"
|
||||
},
|
||||
"rename_success": "Nazwę zmieniono pomyślnie",
|
||||
"rename_error": "Nie udało się zmienić nazwy",
|
||||
"download_error": "Nie udało się pobrać",
|
||||
"download_zip": "Pobierz jako ZIP",
|
||||
"zipping": "Przygotowywanie {name}",
|
||||
"download_canceled": "Anulowano pobieranie",
|
||||
"zip_error": "Nie udało się pobrać {name}",
|
||||
"not_available": "Przechowywanie plików nie jest dostępne na tym serwerze",
|
||||
"cancel": "Anuluj",
|
||||
"create": "Utwórz",
|
||||
"save": "Zapisz",
|
||||
"no_results": "Żadne pliki nie pasują do wyszukiwania",
|
||||
"batch_delete_confirm_message": "Czy na pewno chcesz usunąć {count, plural, one {1 element} other {# elementów}}? Tej operacji nie można cofnąć.",
|
||||
"batch_delete_success": "{count, plural, one {Usunięto 1 element} other {Usunięto # elementów}}",
|
||||
"grid_view": "Widok siatki",
|
||||
"list_view": "Widok listy",
|
||||
"details": "Szczegóły",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Wklej",
|
||||
"move_success": "{count, plural, one {Przeniesiono 1 element} other {Przeniesiono # elementów}}",
|
||||
"move_error": "Nie udało się przenieść",
|
||||
"move_to": "Przenieś do…",
|
||||
"move_to_title": "Przenieś do",
|
||||
"move_here": "Przenieś tutaj",
|
||||
"paste_success": "Wklejono pomyślnie",
|
||||
"paste_error": "Nie udało się wkleić",
|
||||
"new_text_file": "Nowy plik tekstowy",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Pomniejsz",
|
||||
"zoom_reset": "Dopasuj do ekranu",
|
||||
"rotate": "Obróć",
|
||||
"close_preview": "Zamknij"
|
||||
"close_preview": "Zamknij",
|
||||
"previous_file": "Poprzedni plik",
|
||||
"next_file": "Następny plik",
|
||||
"loading": "Wczytywanie…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Twoje certyfikaty",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Adicionar em Cc",
|
||||
"add_bcc": "Adicionar em Cco",
|
||||
"select_hint": "Selecione alguém para ver os detalhes",
|
||||
"write_email": "Escrever e-mail",
|
||||
"write_email": "Enviar e-mail",
|
||||
"add_to_contacts": "Adicionar aos contatos",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "As entradas do diretório vêm do seu servidor de e-mail e não podem ser editadas aqui.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contatos",
|
||||
"disabled_title": "Os contatos foram desativados pelo administrador",
|
||||
"disabled_description": "Peça ao administrador se precisar deles.",
|
||||
"drag_count": "{count, plural, one {1 contato} other {# contatos}}",
|
||||
"select_contact": "Selecionar {name}",
|
||||
"new": "Novo",
|
||||
"search_placeholder": "Pesquisar contatos...",
|
||||
"create_new": "Novo contato",
|
||||
"no_category": "Sem categoria",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Sem contatos",
|
||||
"empty_state_subtitle": "Crie seu primeiro contato ou importe de um arquivo vCard",
|
||||
"empty_view_title": "Nenhum contato aqui",
|
||||
"empty_view_hint": "Adicione um contato ou importe um arquivo vCard.",
|
||||
"empty_search": "Nenhum contato encontrado",
|
||||
"empty_search_hint": "Tente outro termo de pesquisa",
|
||||
"empty_filtered": "Nenhum contato corresponde aos seus filtros",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Novo livro de endereços",
|
||||
"create_confirm": "Criar",
|
||||
"created": "Livro de endereços criado",
|
||||
"create_failed": "Falha ao criar o livro de endereços",
|
||||
"title": "Meus Catálogos de Endereços",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -2999,7 +3014,7 @@
|
||||
"related_contacts": "Contatos relacionados",
|
||||
"crypto_keys": "Chaves criptográficas",
|
||||
"no_contact_selected": "Selecione um contato para ver os detalhes",
|
||||
"compose_email": "Escrever e-mail",
|
||||
"compose_email": "Enviar e-mail",
|
||||
"copy_email": "Copiar e-mail",
|
||||
"copy_phone": "Copiar telefone",
|
||||
"copy_url": "Copiar URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Novo contato",
|
||||
"edit_title": "Editar contato",
|
||||
"section_address_book": "Diretório",
|
||||
"select_address_book": "Selecionar um diretório...",
|
||||
"section_address_book": "Catálogo de endereços",
|
||||
"select_address_book": "Catálogo de endereços",
|
||||
"section_identity": "Nome e identidade",
|
||||
"contact_type": "Tipo de contacto",
|
||||
"type_person": "Pessoa",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Adicionar telefone",
|
||||
"save": "Salvar",
|
||||
"cancel": "Cancelar",
|
||||
"discard_title": "Descartar alterações?",
|
||||
"discard_message": "Suas alterações não foram salvas.",
|
||||
"discard": "Descartar",
|
||||
"keep_editing": "Continuar editando",
|
||||
"remove_entry": "Remover",
|
||||
"close": "Fechar",
|
||||
"remove_category": "Remover {name}",
|
||||
"creating": "Criando...",
|
||||
"updating": "Atualizando...",
|
||||
"name_required": "Introduza um nome próprio, apelido ou organização",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Novo grupo",
|
||||
"delete": "Excluir grupo",
|
||||
"remove_member": "Remover {name} do grupo",
|
||||
"edit": "Editar grupo",
|
||||
"empty": "Nenhum grupo",
|
||||
"delete_confirm_title": "Excluir grupo",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Pesquisar contatos para adicionar...",
|
||||
"no_members": "Nenhum membro neste grupo",
|
||||
"member_count": "{count, plural, =0 {Nenhum membro} one {1 membro} other {# membros}}",
|
||||
"send_email": "Enviar e-mail para o grupo",
|
||||
"send_email": "Enviar e-mail",
|
||||
"send_email_to": "Para",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Cco",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 contato excluído} other {# contatos excluídos}}",
|
||||
"add_to_group": "Adicionar ao grupo",
|
||||
"choose_group": "Escolher um grupo",
|
||||
"new_group": "Novo grupo…",
|
||||
"adding_contacts": "Adicionando {count, plural, one {1 contato} other {# contatos}}",
|
||||
"added_to_group": "Contatos adicionados ao grupo",
|
||||
"export": "Exportar",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Nome",
|
||||
"sort_last_name": "Sobrenome"
|
||||
},
|
||||
"open_categories": "Abrir categorias"
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Ações",
|
||||
"first_last": "Primeiro ou último",
|
||||
"extend": "Ampliar a seleção",
|
||||
"toggle": "Selecionar ou desmarcar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"clear": "Limpar a seleção ou a pesquisa",
|
||||
"delete": "Excluir",
|
||||
"search": "Pesquisar",
|
||||
"move": "Contato seguinte ou anterior",
|
||||
"open": "Abrir o contato",
|
||||
"menu": "Abrir o menu do contato"
|
||||
},
|
||||
"open_categories": "Abrir categorias",
|
||||
"row_actions": "Ações para {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendário",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Os nomes não podem conter caracteres de controle.",
|
||||
"name_too_long": "Este nome é longo demais para o servidor.",
|
||||
"delete": "Eliminar",
|
||||
"delete_confirm_title": "Eliminar recurso",
|
||||
"delete_confirm_message": "Tem a certeza de que deseja eliminar \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
"download": "Transferir",
|
||||
"name": "Nome",
|
||||
"size": "Tamanho",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Parte da cópia ficou no lugar.",
|
||||
"create_folder_success": "Pasta criada",
|
||||
"create_folder_error": "Falha ao criar a pasta",
|
||||
"delete_success": "Eliminado com sucesso",
|
||||
"delete_error": "Falha ao eliminar",
|
||||
"trash": "Lixeira",
|
||||
"restore": "Restaurar",
|
||||
"restored": "{count, plural, one {Item restaurado} other {# itens restaurados}}",
|
||||
"restore_error": "Não foi possível restaurar",
|
||||
"moved_to_trash": "{count, plural, one {Movido para a lixeira} other {# itens movidos para a lixeira}}",
|
||||
"delete_forever": "Excluir permanentemente",
|
||||
"delete_forever_title": "{count, plural, one {Excluir permanentemente?} other {Excluir # itens permanentemente?}}",
|
||||
"delete_forever_message": "\"{name}\" será excluído. Não é possível desfazer.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 item será excluído} other {# itens serão excluídos}}. Não é possível desfazer.",
|
||||
"deleted_forever": "{count, plural, one {Excluído permanentemente} other {# itens excluídos permanentemente}}",
|
||||
"empty_trash": "Esvaziar lixeira",
|
||||
"empty_trash_title": "Esvaziar a lixeira?",
|
||||
"empty_trash_message": "{count, plural, one {O item da lixeira será excluído} other {Todos os # itens da lixeira serão excluídos}} permanentemente. Não é possível desfazer.",
|
||||
"trash_emptied": "Lixeira esvaziada",
|
||||
"empty_trash_error": "Não foi possível esvaziar a lixeira",
|
||||
"trash_empty_title": "A lixeira está vazia",
|
||||
"trash_empty_hint": "Os arquivos excluídos ficam aqui até você esvaziar a lixeira.",
|
||||
"deleted": "Excluído",
|
||||
"deleted_from": "Excluído de {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Lista",
|
||||
"actions": "Ações",
|
||||
"first_last": "Primeiro ou último",
|
||||
"extend": "Ampliar a seleção",
|
||||
"toggle": "Selecionar ou desmarcar",
|
||||
"select_all": "Selecionar tudo",
|
||||
"clear": "Limpar a seleção ou a pesquisa",
|
||||
"delete": "Excluir",
|
||||
"search": "Pesquisar",
|
||||
"move": "Item seguinte ou anterior",
|
||||
"move_grid": "Item ao lado na grade",
|
||||
"open": "Abrir",
|
||||
"up": "Subir uma pasta",
|
||||
"rename": "Renomear",
|
||||
"copy": "Copiar",
|
||||
"cut": "Recortar",
|
||||
"paste": "Colar"
|
||||
},
|
||||
"rename_success": "Renomeado com sucesso",
|
||||
"rename_error": "Falha ao renomear",
|
||||
"download_error": "Falha ao transferir",
|
||||
"download_zip": "Baixar como ZIP",
|
||||
"zipping": "Preparando {name}",
|
||||
"download_canceled": "Download cancelado",
|
||||
"zip_error": "Não foi possível baixar {name}",
|
||||
"not_available": "O armazenamento de arquivos não está disponível neste servidor",
|
||||
"cancel": "Cancelar",
|
||||
"create": "Criar",
|
||||
"save": "Guardar",
|
||||
"no_results": "Nenhum arquivo corresponde à sua pesquisa",
|
||||
"batch_delete_confirm_message": "Tem a certeza de que deseja eliminar {count, plural, one {1 item} other {# itens}}? Esta ação não pode ser desfeita.",
|
||||
"batch_delete_success": "{count, plural, one {1 item eliminado} other {# itens eliminados}}",
|
||||
"grid_view": "Vista em grelha",
|
||||
"list_view": "Vista em lista",
|
||||
"details": "Detalhes",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Colar",
|
||||
"move_success": "{count, plural, one {1 item movido} other {# itens movidos}}",
|
||||
"move_error": "Falha ao mover",
|
||||
"move_to": "Mover para…",
|
||||
"move_to_title": "Mover para",
|
||||
"move_here": "Mover para cá",
|
||||
"paste_success": "Colado com sucesso",
|
||||
"paste_error": "Falha ao colar",
|
||||
"new_text_file": "Novo arquivo de texto",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Diminuir zoom",
|
||||
"zoom_reset": "Ajustar ao ecrã",
|
||||
"rotate": "Rodar",
|
||||
"close_preview": "Fechar"
|
||||
"close_preview": "Fechar",
|
||||
"previous_file": "Arquivo anterior",
|
||||
"next_file": "Próximo arquivo",
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Seus certificados",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Adaugă la Cc",
|
||||
"add_bcc": "Adaugă la Bcc",
|
||||
"select_hint": "Selectează o persoană pentru detalii",
|
||||
"write_email": "Scrie e-mail",
|
||||
"write_email": "Trimite e-mail",
|
||||
"add_to_contacts": "Adaugă la contacte",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Intrările din director provin de la serverul de e-mail și nu pot fi editate aici.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Contacte",
|
||||
"disabled_title": "Contactele au fost dezactivate de administrator",
|
||||
"disabled_description": "Contactați administratorul dacă aveți nevoie de ele.",
|
||||
"drag_count": "{count, plural, one {1 contact} other {# contacte}}",
|
||||
"select_contact": "Selectați {name}",
|
||||
"new": "Nou",
|
||||
"search_placeholder": "Căutare contacte...",
|
||||
"create_new": "Contact nou",
|
||||
"no_category": "Fără categorie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Nu există încă contacte",
|
||||
"empty_state_subtitle": "Creați primul dvs. contact sau importați-l dintr-un fișier „vCard”",
|
||||
"empty_view_title": "Nu există contacte aici",
|
||||
"empty_view_hint": "Adăugați un contact sau importați un fișier vCard.",
|
||||
"empty_search": "Nu există contacte care să corespundă căutării dvs.",
|
||||
"empty_search_hint": "Încercați un alt termen de căutare",
|
||||
"empty_filtered": "Nu există contacte care să corespundă filtrelor dvs.",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Agendă nouă",
|
||||
"create_confirm": "Creați",
|
||||
"created": "Agendă creată",
|
||||
"create_failed": "Nu s-a putut crea agenda",
|
||||
"title": "Agendele mele de adrese",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Certificat importat în magazinul S/MIME",
|
||||
"cert_import_failed": "Importul certificatului a eșuat",
|
||||
"no_contact_selected": "Selectați un contact pentru a vizualiza detaliile",
|
||||
"compose_email": "Redactați un e-mail",
|
||||
"compose_email": "Trimite e-mail",
|
||||
"copy_email": "Copiați e-mailul",
|
||||
"copy_phone": "Copiați numărul de telefon",
|
||||
"copy_url": "Copiați URL",
|
||||
@@ -3066,8 +3082,8 @@
|
||||
"form": {
|
||||
"create_title": "Contact nou",
|
||||
"edit_title": "Editează contactul",
|
||||
"section_address_book": "Director",
|
||||
"select_address_book": "Selectați un director...",
|
||||
"section_address_book": "Agendă",
|
||||
"select_address_book": "Agendă",
|
||||
"section_identity": "Nume și identitate",
|
||||
"contact_type": "Tip de contact",
|
||||
"type_person": "Persoană",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Adăugați numărul de telefon",
|
||||
"save": "Salvare",
|
||||
"cancel": "Anulează",
|
||||
"discard_title": "Renunțați la modificări?",
|
||||
"discard_message": "Modificările dvs. nu au fost salvate.",
|
||||
"discard": "Renunțați",
|
||||
"keep_editing": "Continuați editarea",
|
||||
"remove_entry": "Eliminați",
|
||||
"close": "Închideți",
|
||||
"remove_category": "Eliminați {name}",
|
||||
"creating": "Se creează...",
|
||||
"updating": "Se actualizează...",
|
||||
"name_required": "Introduceți un prenume, un nume sau o organizație",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Grup nou",
|
||||
"delete": "Ștergeți grupul",
|
||||
"remove_member": "Eliminați {name} din grup",
|
||||
"edit": "Editare grup",
|
||||
"empty": "Nu există încă grupuri",
|
||||
"delete_confirm_title": "Șterge grupul",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Căutați contacte pentru a le adăuga...",
|
||||
"no_members": "Nu există membri în acest grup",
|
||||
"member_count": "{count, plural, =0 {Nu există membri} one {1 membru} few {# membri} other {# de membri}}",
|
||||
"send_email": "Trimite e-mail către grup",
|
||||
"send_email": "Trimite e-mail",
|
||||
"send_email_to": "Către",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {1 contact șters} few {# contacte șterse} other {# de contacte șterse}}",
|
||||
"add_to_group": "Adaugă la grup",
|
||||
"choose_group": "Alegeți un grup",
|
||||
"new_group": "Grup nou…",
|
||||
"adding_contacts": "Adăugare {count, plural, one {1 contact} few {# contacte} other {# de contacte}}",
|
||||
"added_to_group": "Contacte adăugate la grup",
|
||||
"export": "Export",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Sortare după",
|
||||
"sort_first_name": "Prenume",
|
||||
"sort_last_name": "Nume de familie"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Listă",
|
||||
"actions": "Acțiuni",
|
||||
"first_last": "Primul sau ultimul",
|
||||
"extend": "Extindeți selecția",
|
||||
"toggle": "Selectați sau deselectați",
|
||||
"select_all": "Selectați tot",
|
||||
"clear": "Ștergeți selecția sau căutarea",
|
||||
"delete": "Ștergeți",
|
||||
"search": "Căutați",
|
||||
"move": "Contactul următor sau anterior",
|
||||
"open": "Deschideți contactul",
|
||||
"menu": "Deschideți meniul contactului"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Numele nu pot conține caractere de control.",
|
||||
"name_too_long": "Acest nume este prea lung pentru server.",
|
||||
"delete": "Șterge",
|
||||
"delete_confirm_title": "Ștergeți resursa",
|
||||
"delete_confirm_message": "Sunteți sigur că doriți să ștergeți „{name}”? Această acțiune nu poate fi anulată.",
|
||||
"download": "Descărcați",
|
||||
"name": "Nume",
|
||||
"size": "Dimensiune",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "O parte din copie a rămas pe loc.",
|
||||
"create_folder_success": "Folder creat",
|
||||
"create_folder_error": "Nu s-a putut crea folderul",
|
||||
"delete_success": "Șters cu succes",
|
||||
"delete_error": "Nu s-a putut șterge",
|
||||
"trash": "Coș de gunoi",
|
||||
"restore": "Restaurați",
|
||||
"restored": "{count, plural, one {Element restaurat} other {# elemente restaurate}}",
|
||||
"restore_error": "Nu s-a putut restaura",
|
||||
"moved_to_trash": "{count, plural, one {Mutat în coșul de gunoi} other {# elemente mutate în coșul de gunoi}}",
|
||||
"delete_forever": "Ștergeți definitiv",
|
||||
"delete_forever_title": "{count, plural, one {Ștergeți definitiv?} other {Ștergeți definitiv # elemente?}}",
|
||||
"delete_forever_message": "„{name}” va fi șters. Acțiunea nu poate fi anulată.",
|
||||
"delete_forever_message_count": "{count, plural, one {Va fi șters 1 element} other {Vor fi șterse # elemente}}. Acțiunea nu poate fi anulată.",
|
||||
"deleted_forever": "{count, plural, one {Șters definitiv} other {# elemente șterse definitiv}}",
|
||||
"empty_trash": "Goliți coșul de gunoi",
|
||||
"empty_trash_title": "Goliți coșul de gunoi?",
|
||||
"empty_trash_message": "{count, plural, one {Elementul din coșul de gunoi va fi șters} other {Toate cele # elemente din coșul de gunoi vor fi șterse}} definitiv. Acțiunea nu poate fi anulată.",
|
||||
"trash_emptied": "Coșul de gunoi a fost golit",
|
||||
"empty_trash_error": "Nu s-a putut goli coșul de gunoi",
|
||||
"trash_empty_title": "Coșul de gunoi este gol",
|
||||
"trash_empty_hint": "Fișierele șterse rămân aici până când goliți coșul de gunoi.",
|
||||
"deleted": "Șters",
|
||||
"deleted_from": "Șters din {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Listă",
|
||||
"actions": "Acțiuni",
|
||||
"first_last": "Primul sau ultimul",
|
||||
"extend": "Extindeți selecția",
|
||||
"toggle": "Selectați sau deselectați",
|
||||
"select_all": "Selectați tot",
|
||||
"clear": "Ștergeți selecția sau căutarea",
|
||||
"delete": "Ștergeți",
|
||||
"search": "Căutați",
|
||||
"move": "Elementul următor sau anterior",
|
||||
"move_grid": "Elementul alăturat din grilă",
|
||||
"open": "Deschideți",
|
||||
"up": "Un dosar mai sus",
|
||||
"rename": "Redenumiți",
|
||||
"copy": "Copiați",
|
||||
"cut": "Decupați",
|
||||
"paste": "Lipiți"
|
||||
},
|
||||
"rename_success": "Redenumit cu succes",
|
||||
"rename_error": "Nu s-a putut redenumi",
|
||||
"download_error": "Descărcarea a eșuat",
|
||||
"download_zip": "Descărcați ca ZIP",
|
||||
"zipping": "Se pregătește {name}",
|
||||
"download_canceled": "Descărcare anulată",
|
||||
"zip_error": "Nu s-a putut descărca {name}",
|
||||
"not_available": "Stocarea fișierelor nu este disponibilă pe acest server",
|
||||
"cancel": "Anulează",
|
||||
"create": "Creați",
|
||||
"save": "Salvare",
|
||||
"no_results": "Nu există fișiere care să corespundă căutării dvs.",
|
||||
"batch_delete_confirm_message": "Sunteți sigur că doriți să ștergeți {count, plural, one {1 element} few {# elemente} other {# de elemente}}? Această acțiune nu poate fi anulată.",
|
||||
"batch_delete_success": "{count, plural, one {1 element șters} few {# elemente șterse} other {# de elemente șterse}}",
|
||||
"grid_view": "Vizualizare tabelară",
|
||||
"list_view": "Vizualizare listă",
|
||||
"details": "Detalii",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Lipiți",
|
||||
"move_success": "{count, plural, one {1 element mutat} few {# elemente mutate} other {# de elemente mutate}}",
|
||||
"move_error": "Nu s-a putut muta",
|
||||
"move_to": "Mutați în…",
|
||||
"move_to_title": "Mutați în",
|
||||
"move_here": "Mutați aici",
|
||||
"paste_success": "S-a lipit cu succes",
|
||||
"paste_error": "Nu s-a putut insera",
|
||||
"new_text_file": "Fișier text nou",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Micșorează",
|
||||
"zoom_reset": "Potrivește pe ecran",
|
||||
"rotate": "Rotește",
|
||||
"close_preview": "Închide"
|
||||
"close_preview": "Închide",
|
||||
"previous_file": "Fișierul anterior",
|
||||
"next_file": "Fișierul următor",
|
||||
"loading": "Se încarcă…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Certificatele dvs.",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Добавить в «Копия»",
|
||||
"add_bcc": "Добавить в «Скрытая копия»",
|
||||
"select_hint": "Выберите человека, чтобы увидеть подробности",
|
||||
"write_email": "Написать письмо",
|
||||
"write_email": "Отправить письмо",
|
||||
"add_to_contacts": "Добавить в контакты",
|
||||
"email": "Эл. почта",
|
||||
"read_only_note": "Записи справочника приходят с вашего почтового сервера, здесь их нельзя изменить.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Контакты",
|
||||
"disabled_title": "Администратор отключил контакты",
|
||||
"disabled_description": "Обратитесь к администратору, если они вам нужны.",
|
||||
"drag_count": "{count, plural, one {# контакт} few {# контакта} other {# контактов}}",
|
||||
"select_contact": "Выбрать {name}",
|
||||
"new": "Создать",
|
||||
"search_placeholder": "Поиск контактов...",
|
||||
"create_new": "Новый контакт",
|
||||
"no_category": "Без категории",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "Контактов пока нет",
|
||||
"empty_state_title": "Контактов пока нет",
|
||||
"empty_state_subtitle": "Создайте первый контакт или импортируйте из файла vCard",
|
||||
"empty_view_title": "Здесь нет контактов",
|
||||
"empty_view_hint": "Добавьте контакт или импортируйте файл vCard.",
|
||||
"empty_search": "Нет контактов, соответствующих вашему запросу",
|
||||
"empty_search_hint": "Попробуйте другой поисковый запрос",
|
||||
"empty_filtered": "Нет контактов, соответствующих фильтрам",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Новая адресная книга",
|
||||
"create_confirm": "Создать",
|
||||
"created": "Адресная книга создана",
|
||||
"create_failed": "Не удалось создать адресную книгу",
|
||||
"title": "Мои адресные книги",
|
||||
@@ -2982,7 +2994,10 @@
|
||||
"delete": "Удалить адресную книгу",
|
||||
"confirm_delete": "Удалить «{name}»? Все контакты в этой адресной книге будут удалены.",
|
||||
"deleted": "Адресная книга удалена",
|
||||
"delete_failed": "Не удалось удалить адресную книгу"
|
||||
"delete_failed": "Не удалось удалить адресную книгу",
|
||||
"move_to": "Переместить в…",
|
||||
"move_title": "Переместить в адресную книгу",
|
||||
"move_here": "Переместить сюда"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреса электронной почты",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Сертификат импортирован в хранилище S/MIME",
|
||||
"cert_import_failed": "Не удалось импортировать сертификат",
|
||||
"no_contact_selected": "Выберите контакт для просмотра деталей",
|
||||
"compose_email": "Написать письмо",
|
||||
"compose_email": "Отправить письмо",
|
||||
"copy_email": "Скопировать email",
|
||||
"copy_phone": "Скопировать номер телефона",
|
||||
"copy_url": "Скопировать URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Новый контакт",
|
||||
"edit_title": "Редактировать контакт",
|
||||
"section_address_book": "Каталог",
|
||||
"select_address_book": "Выберите каталог...",
|
||||
"section_address_book": "Адресная книга",
|
||||
"select_address_book": "Адресная книга",
|
||||
"section_identity": "Имя и личность",
|
||||
"contact_type": "Тип контакта",
|
||||
"type_person": "Человек",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Добавить телефон",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"discard_title": "Отменить изменения?",
|
||||
"discard_message": "Ваши изменения не сохранены.",
|
||||
"discard": "Отменить",
|
||||
"keep_editing": "Продолжить редактирование",
|
||||
"remove_entry": "Убрать",
|
||||
"close": "Закрыть",
|
||||
"remove_category": "Убрать {name}",
|
||||
"creating": "Создание...",
|
||||
"updating": "Обновление...",
|
||||
"name_required": "Укажите имя, фамилию или организацию",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Новая группа",
|
||||
"delete": "Удалить группу",
|
||||
"remove_member": "Убрать {name} из группы",
|
||||
"edit": "Редактировать группу",
|
||||
"empty": "Групп пока нет",
|
||||
"delete_confirm_title": "Удалить группу",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Поиск контактов для добавления...",
|
||||
"no_members": "В этой группе нет участников",
|
||||
"member_count": "{count, plural, =0 {Нет участников} one {1 участник} other {# участников}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Отправить письмо",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 контакт удалён} other {# контактов удалено}}",
|
||||
"add_to_group": "Добавить в группу",
|
||||
"choose_group": "Выбрать группу",
|
||||
"new_group": "Новая группа…",
|
||||
"adding_contacts": "Добавляем {count, plural, one {1 контакт} other {# контактов}}",
|
||||
"added_to_group": "Контакты добавлены в группу",
|
||||
"export": "Экспорт",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Имя",
|
||||
"sort_last_name": "Фамилия"
|
||||
},
|
||||
"open_categories": "Открыть категории"
|
||||
"shortcuts": {
|
||||
"list": "Список",
|
||||
"actions": "Действия",
|
||||
"first_last": "Первый или последний",
|
||||
"extend": "Расширить выделение",
|
||||
"toggle": "Выбрать или снять выбор",
|
||||
"select_all": "Выбрать все",
|
||||
"clear": "Сбросить выделение или поиск",
|
||||
"delete": "Удалить",
|
||||
"search": "Поиск",
|
||||
"move": "Следующий или предыдущий контакт",
|
||||
"open": "Открыть контакт",
|
||||
"menu": "Открыть меню контакта"
|
||||
},
|
||||
"open_categories": "Открыть категории",
|
||||
"row_actions": "Действия для {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Календарь",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Имена не могут содержать управляющие символы.",
|
||||
"name_too_long": "Это имя слишком длинное для сервера.",
|
||||
"delete": "Удалить",
|
||||
"delete_confirm_title": "Удалить ресурс",
|
||||
"delete_confirm_message": "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
|
||||
"download": "Скачать",
|
||||
"name": "Имя",
|
||||
"size": "Размер",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Часть копии осталась на месте.",
|
||||
"create_folder_success": "Папка создана",
|
||||
"create_folder_error": "Не удалось создать папку",
|
||||
"delete_success": "Успешно удалено",
|
||||
"delete_error": "Не удалось удалить",
|
||||
"trash": "Корзина",
|
||||
"restore": "Восстановить",
|
||||
"restored": "{count, plural, one {Элемент восстановлен} few {Восстановлено # элемента} other {Восстановлено # элементов}}",
|
||||
"restore_error": "Не удалось восстановить",
|
||||
"moved_to_trash": "{count, plural, one {Перемещено в корзину} few {# элемента перемещено в корзину} other {# элементов перемещено в корзину}}",
|
||||
"delete_forever": "Удалить навсегда",
|
||||
"delete_forever_title": "{count, plural, one {Удалить навсегда?} few {Удалить # элемента навсегда?} other {Удалить # элементов навсегда?}}",
|
||||
"delete_forever_message": "«{name}» будет удалён. Это действие нельзя отменить.",
|
||||
"delete_forever_message_count": "{count, plural, one {Будет удалён 1 элемент} few {Будут удалены # элемента} other {Будут удалены # элементов}}. Это действие нельзя отменить.",
|
||||
"deleted_forever": "{count, plural, one {Удалено навсегда} few {# элемента удалено навсегда} other {# элементов удалено навсегда}}",
|
||||
"empty_trash": "Очистить корзину",
|
||||
"empty_trash_title": "Очистить корзину?",
|
||||
"empty_trash_message": "{count, plural, one {Элемент в корзине будет удалён} few {Все # элемента в корзине будут удалены} other {Все # элементов в корзине будут удалены}} навсегда. Это действие нельзя отменить.",
|
||||
"trash_emptied": "Корзина очищена",
|
||||
"empty_trash_error": "Не удалось очистить корзину",
|
||||
"trash_empty_title": "Корзина пуста",
|
||||
"trash_empty_hint": "Удалённые файлы хранятся здесь, пока вы не очистите корзину.",
|
||||
"deleted": "Удалено",
|
||||
"deleted_from": "Удалено из {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Список",
|
||||
"actions": "Действия",
|
||||
"first_last": "Первый или последний",
|
||||
"extend": "Расширить выделение",
|
||||
"toggle": "Выбрать или снять выбор",
|
||||
"select_all": "Выбрать все",
|
||||
"clear": "Сбросить выделение или поиск",
|
||||
"delete": "Удалить",
|
||||
"search": "Поиск",
|
||||
"move": "Следующий или предыдущий элемент",
|
||||
"move_grid": "Соседний элемент в сетке",
|
||||
"open": "Открыть",
|
||||
"up": "На папку выше",
|
||||
"rename": "Переименовать",
|
||||
"copy": "Копировать",
|
||||
"cut": "Вырезать",
|
||||
"paste": "Вставить"
|
||||
},
|
||||
"rename_success": "Успешно переименовано",
|
||||
"rename_error": "Не удалось переименовать",
|
||||
"download_error": "Не удалось скачать",
|
||||
"download_zip": "Скачать как ZIP",
|
||||
"zipping": "Подготовка {name}",
|
||||
"download_canceled": "Скачивание отменено",
|
||||
"zip_error": "Не удалось скачать {name}",
|
||||
"not_available": "Хранилище файлов недоступно на этом сервере",
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать",
|
||||
"save": "Сохранить",
|
||||
"no_results": "Нет файлов, соответствующих вашему запросу",
|
||||
"batch_delete_confirm_message": "Вы уверены, что хотите удалить {count, plural, one {1 элемент} other {# элементов}}? Это действие нельзя отменить.",
|
||||
"batch_delete_success": "{count, plural, one {1 элемент удалён} other {# элементов удалено}}",
|
||||
"grid_view": "Сеткой",
|
||||
"list_view": "Списком",
|
||||
"details": "Подробности",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Вставить",
|
||||
"move_success": "{count, plural, one {1 элемент перемещён} other {# элементов перемещено}}",
|
||||
"move_error": "Не удалось переместить",
|
||||
"move_to": "Переместить в…",
|
||||
"move_to_title": "Переместить в",
|
||||
"move_here": "Переместить сюда",
|
||||
"paste_success": "Вставлено успешно",
|
||||
"paste_error": "Не удалось вставить",
|
||||
"new_text_file": "Новый текстовый файл",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Уменьшить",
|
||||
"zoom_reset": "По размеру экрана",
|
||||
"rotate": "Повернуть",
|
||||
"close_preview": "Закрыть"
|
||||
"close_preview": "Закрыть",
|
||||
"previous_file": "Предыдущий файл",
|
||||
"next_file": "Следующий файл",
|
||||
"loading": "Загрузка…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваши сертификаты",
|
||||
|
||||
+94
-11
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Pridať do Kópia",
|
||||
"add_bcc": "Pridať do Skrytá kópia",
|
||||
"select_hint": "Vyberte osobu a zobrazte podrobnosti",
|
||||
"write_email": "Napísať e-mail",
|
||||
"write_email": "Poslať e-mail",
|
||||
"add_to_contacts": "Pridať do kontaktov",
|
||||
"email": "E-mail",
|
||||
"read_only_note": "Položky adresára pochádzajú z vášho poštového servera a nedajú sa tu upraviť.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kontakty",
|
||||
"disabled_title": "Kontakty vypol správca",
|
||||
"disabled_description": "Ak ich potrebujete, obráťte sa na správcu.",
|
||||
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktov}}",
|
||||
"select_contact": "Vybrať {name}",
|
||||
"new": "Nový",
|
||||
"search_placeholder": "Hľadať kontakty...",
|
||||
"create_new": "Nový kontakt",
|
||||
"no_category": "Bez kategórie",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Žiadne kontakty",
|
||||
"empty_state_subtitle": "Vytvorte prvý kontakt alebo importujte zo súboru vCard",
|
||||
"empty_view_title": "Nie sú tu žiadne kontakty",
|
||||
"empty_view_hint": "Pridajte kontakt alebo importujte súbor vCard.",
|
||||
"empty_search": "Hľadaniu nezodpovedajú žiadne kontakty",
|
||||
"empty_search_hint": "Skúste iný hľadaný výraz",
|
||||
"empty_filtered": "Žiadne kontakty nezodpovedajú filtrom",
|
||||
@@ -2952,6 +2963,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"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Nový adresár",
|
||||
"create_confirm": "Vytvoriť",
|
||||
"created": "Adresár vytvorený",
|
||||
"create_failed": "Nepodarilo sa vytvoriť adresár",
|
||||
"title": "Moje adresáre",
|
||||
@@ -2983,7 +2996,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",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "Certifikát bol importovaný do úložiska S/MIME",
|
||||
"cert_import_failed": "Import certifikátu zlyhal",
|
||||
"no_contact_selected": "Vyberte kontakt na zobrazenie podrobností",
|
||||
"compose_email": "Napísať e-mail",
|
||||
"compose_email": "Poslať e-mail",
|
||||
"copy_email": "Kopírovať e-mail",
|
||||
"copy_phone": "Kopírovať telefón",
|
||||
"copy_url": "Kopírovať URL",
|
||||
@@ -3067,7 +3083,7 @@
|
||||
"create_title": "Nový kontakt",
|
||||
"edit_title": "Upraviť kontakt",
|
||||
"section_address_book": "Adresár",
|
||||
"select_address_book": "Vyberte adresár...",
|
||||
"select_address_book": "Adresár",
|
||||
"section_identity": "Meno a identita",
|
||||
"contact_type": "Typ kontaktu",
|
||||
"type_person": "Osoba",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "Pridať telefón",
|
||||
"save": "Uložiť",
|
||||
"cancel": "Zrušiť",
|
||||
"discard_title": "Zahodiť zmeny?",
|
||||
"discard_message": "Vaše zmeny neboli uložené.",
|
||||
"discard": "Zahodiť",
|
||||
"keep_editing": "Pokračovať v úpravách",
|
||||
"remove_entry": "Odstrániť",
|
||||
"close": "Zavrieť",
|
||||
"remove_category": "Odstrániť {name}",
|
||||
"creating": "Vytváranie...",
|
||||
"updating": "Aktualizovanie...",
|
||||
"name_required": "Zadajte meno, priezvisko alebo organizáciu",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Nová skupina",
|
||||
"delete": "Odstrániť skupinu",
|
||||
"remove_member": "Odstrániť {name} zo skupiny",
|
||||
"edit": "Upraviť skupinu",
|
||||
"empty": "Žiadne skupiny",
|
||||
"delete_confirm_title": "Odstrániť skupinu",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "Hľadať kontakty na pridanie...",
|
||||
"no_members": "Táto skupina nemá žiadnych členov",
|
||||
"member_count": "{count, plural, =0 {Žiadni členovia} one {1 člen} other {# členov}}",
|
||||
"send_email": "Odoslať e-mail skupine",
|
||||
"send_email": "Poslať e-mail",
|
||||
"send_email_to": "Komu",
|
||||
"send_email_cc": "Kópia",
|
||||
"send_email_bcc": "Skrytá kópia",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {Odstránený 1 kontakt} other {Odstránených # kontaktov}}",
|
||||
"add_to_group": "Pridať do skupiny",
|
||||
"choose_group": "Vyberte skupinu",
|
||||
"new_group": "Nová skupina…",
|
||||
"adding_contacts": "Pridávanie {count, plural, one {1 kontaktu} other {# kontaktov}}",
|
||||
"added_to_group": "Kontakty boli pridané do skupiny",
|
||||
"export": "Exportovať",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "Zoradiť podľa",
|
||||
"sort_first_name": "Meno",
|
||||
"sort_last_name": "Priezvisko"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "Zoznam",
|
||||
"actions": "Akcie",
|
||||
"first_last": "Prvý alebo posledný",
|
||||
"extend": "Rozšíriť výber",
|
||||
"toggle": "Vybrať alebo zrušiť výber",
|
||||
"select_all": "Vybrať všetko",
|
||||
"clear": "Zrušiť výber alebo vyhľadávanie",
|
||||
"delete": "Odstrániť",
|
||||
"search": "Hľadať",
|
||||
"move": "Ďalší alebo predchádzajúci kontakt",
|
||||
"open": "Otvoriť kontakt",
|
||||
"menu": "Otvoriť ponuku kontaktu"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Názvy nesmú obsahovať riadiace znaky.",
|
||||
"name_too_long": "Tento názov je pre server príliš dlhý.",
|
||||
"delete": "Odstrániť",
|
||||
"delete_confirm_title": "Odstrániť položku",
|
||||
"delete_confirm_message": "Naozaj chcete odstrániť \"{name}\"? Túto akciu nie je možné vrátiť.",
|
||||
"download": "Stiahnuť",
|
||||
"name": "Názov",
|
||||
"size": "Veľkosť",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "Časť kópie zostala na mieste.",
|
||||
"create_folder_success": "Priečinok bol vytvorený",
|
||||
"create_folder_error": "Vytvorenie priečinka zlyhalo",
|
||||
"delete_success": "Úspešne odstránené",
|
||||
"delete_error": "Odstránenie zlyhalo",
|
||||
"trash": "Kôš",
|
||||
"restore": "Obnoviť",
|
||||
"restored": "{count, plural, one {Položka obnovená} few {Obnovené # položky} other {Obnovených # položiek}}",
|
||||
"restore_error": "Obnovenie sa nepodarilo",
|
||||
"moved_to_trash": "{count, plural, one {Presunuté do koša} few {# položky presunuté do koša} other {# položiek presunutých do koša}}",
|
||||
"delete_forever": "Natrvalo odstrániť",
|
||||
"delete_forever_title": "{count, plural, one {Natrvalo odstrániť?} few {Natrvalo odstrániť # položky?} other {Natrvalo odstrániť # položiek?}}",
|
||||
"delete_forever_message": "„{name}“ sa odstráni. Túto akciu nie je možné vrátiť.",
|
||||
"delete_forever_message_count": "{count, plural, one {Odstráni sa 1 položka} few {Odstránia sa # položky} other {Odstráni sa # položiek}}. Túto akciu nie je možné vrátiť.",
|
||||
"deleted_forever": "{count, plural, one {Natrvalo odstránené} few {Natrvalo odstránené # položky} other {Natrvalo odstránených # položiek}}",
|
||||
"empty_trash": "Vyprázdniť kôš",
|
||||
"empty_trash_title": "Vyprázdniť kôš?",
|
||||
"empty_trash_message": "{count, plural, one {Položka v koši sa natrvalo odstráni} few {Všetky # položky v koši sa natrvalo odstránia} other {Všetkých # položiek v koši sa natrvalo odstráni}}. Túto akciu nie je možné vrátiť.",
|
||||
"trash_emptied": "Kôš bol vyprázdnený",
|
||||
"empty_trash_error": "Kôš sa nepodarilo vyprázdniť",
|
||||
"trash_empty_title": "Kôš je prázdny",
|
||||
"trash_empty_hint": "Odstránené súbory tu zostanú, kým kôš nevyprázdnite.",
|
||||
"deleted": "Odstránené",
|
||||
"deleted_from": "Odstránené z {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Zoznam",
|
||||
"actions": "Akcie",
|
||||
"first_last": "Prvý alebo posledný",
|
||||
"extend": "Rozšíriť výber",
|
||||
"toggle": "Vybrať alebo zrušiť výber",
|
||||
"select_all": "Vybrať všetko",
|
||||
"clear": "Zrušiť výber alebo vyhľadávanie",
|
||||
"delete": "Odstrániť",
|
||||
"search": "Hľadať",
|
||||
"move": "Ďalšia alebo predchádzajúca položka",
|
||||
"move_grid": "Susedná položka v mriežke",
|
||||
"open": "Otvoriť",
|
||||
"up": "O priečinok vyššie",
|
||||
"rename": "Premenovať",
|
||||
"copy": "Kopírovať",
|
||||
"cut": "Vystrihnúť",
|
||||
"paste": "Prilepiť"
|
||||
},
|
||||
"rename_success": "Úspešne premenované",
|
||||
"rename_error": "Premenovanie zlyhalo",
|
||||
"download_error": "Sťahovanie zlyhalo",
|
||||
"download_zip": "Stiahnuť ako ZIP",
|
||||
"zipping": "Pripravuje sa {name}",
|
||||
"download_canceled": "Sťahovanie zrušené",
|
||||
"zip_error": "{name} sa nepodarilo stiahnuť",
|
||||
"not_available": "Úložisko súborov nie je na tomto serveri k dispozícii",
|
||||
"cancel": "Zrušiť",
|
||||
"create": "Vytvoriť",
|
||||
"save": "Uložiť",
|
||||
"no_results": "Hľadaniu nezodpovedajú žiadne súbory",
|
||||
"batch_delete_confirm_message": "Naozaj chcete odstrániť {count, plural, one {1 položku} other {# položiek}}? Túto akciu nie je možné vrátiť.",
|
||||
"batch_delete_success": "{count, plural, one {Odstránená 1 položka} other {Odstránených # položiek}}",
|
||||
"grid_view": "Zobrazenie mriežky",
|
||||
"list_view": "Zobrazenie zoznamu",
|
||||
"details": "Podrobnosti",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "Vložiť",
|
||||
"move_success": "{count, plural, one {1 položka presunutá} other {# položiek presunutých}}",
|
||||
"move_error": "Presun zlyhal",
|
||||
"move_to": "Presunúť do…",
|
||||
"move_to_title": "Presunúť do",
|
||||
"move_here": "Presunúť sem",
|
||||
"paste_success": "Úspešne vložené",
|
||||
"paste_error": "Vloženie zlyhalo",
|
||||
"new_text_file": "Nový textový súbor",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Oddialiť",
|
||||
"zoom_reset": "Prispôsobiť obrazovke",
|
||||
"rotate": "Otočiť",
|
||||
"close_preview": "Zavrieť"
|
||||
"close_preview": "Zavrieť",
|
||||
"previous_file": "Predchádzajúci súbor",
|
||||
"next_file": "Nasledujúci súbor",
|
||||
"loading": "Načítava sa…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Vaše certifikáty",
|
||||
|
||||
+93
-10
@@ -1510,8 +1510,6 @@
|
||||
"settings_section": "เริ่มต้นใช้งาน"
|
||||
},
|
||||
"files": {
|
||||
"batch_delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ {count, plural, other {# รายการ}} ที่เลือกไว้? การดำเนินการนี้ไม่สามารถยกเลิกได้",
|
||||
"batch_delete_success": "{count, plural, other {ลบแล้ว # รายการ}}",
|
||||
"breadcrumb_root": "หน้าแรก",
|
||||
"breadcrumbs": "เส้นทางโฟลเดอร์",
|
||||
"cancel": "ยกเลิก",
|
||||
@@ -1520,6 +1518,9 @@
|
||||
"clear_selection": "ล้างการเลือก",
|
||||
"close_details": "ปิดรายละเอียด",
|
||||
"close_preview": "ปิด",
|
||||
"previous_file": "ไฟล์ก่อนหน้า",
|
||||
"next_file": "ไฟล์ถัดไป",
|
||||
"loading": "กำลังโหลด…",
|
||||
"collapse_folder": "ย่อ {name}",
|
||||
"context_menu": "การดำเนินการ",
|
||||
"copy": "คัดลอก",
|
||||
@@ -1530,11 +1531,46 @@
|
||||
"create_folder_success": "สร้างโฟลเดอร์เรียบร้อยแล้ว",
|
||||
"cut": "ตัด",
|
||||
"delete": "ลบ",
|
||||
"delete_confirm_message": "คุณแน่ใจหรือไม่ว่าต้องการลบ “{name}”? การดำเนินการนี้ไม่สามารถยกเลิกได้",
|
||||
"delete_confirm_title": "ลบรายการ",
|
||||
"delete_count": "{count, plural, other {ลบ # รายการ}}",
|
||||
"delete_error": "ลบไม่สำเร็จ",
|
||||
"delete_success": "ลบเรียบร้อยแล้ว",
|
||||
"trash": "ถังขยะ",
|
||||
"restore": "กู้คืน",
|
||||
"restored": "{count, plural, other {กู้คืน # รายการแล้ว}}",
|
||||
"restore_error": "กู้คืนไม่ได้",
|
||||
"moved_to_trash": "{count, plural, other {ย้าย # รายการไปที่ถังขยะแล้ว}}",
|
||||
"delete_forever": "ลบถาวร",
|
||||
"delete_forever_title": "{count, plural, other {ลบ # รายการถาวรหรือไม่?}}",
|
||||
"delete_forever_message": "\"{name}\" จะถูกลบ คุณไม่สามารถเลิกทำได้",
|
||||
"delete_forever_message_count": "{count, plural, other {จะลบ # รายการ}} คุณไม่สามารถเลิกทำได้",
|
||||
"deleted_forever": "{count, plural, other {ลบ # รายการถาวรแล้ว}}",
|
||||
"empty_trash": "ล้างถังขยะ",
|
||||
"empty_trash_title": "ล้างถังขยะหรือไม่?",
|
||||
"empty_trash_message": "{count, plural, other {ทั้ง # รายการในถังขยะจะถูกลบถาวร}} คุณไม่สามารถเลิกทำได้",
|
||||
"trash_emptied": "ล้างถังขยะแล้ว",
|
||||
"empty_trash_error": "ล้างถังขยะไม่ได้",
|
||||
"trash_empty_title": "ถังขยะว่างเปล่า",
|
||||
"trash_empty_hint": "ไฟล์ที่ลบจะอยู่ที่นี่จนกว่าคุณจะล้างถังขยะ",
|
||||
"deleted": "วันที่ลบ",
|
||||
"deleted_from": "ลบจาก {folder}",
|
||||
"shortcuts": {
|
||||
"list": "รายการ",
|
||||
"actions": "การทำงาน",
|
||||
"first_last": "แรกสุดหรือท้ายสุด",
|
||||
"extend": "ขยายการเลือก",
|
||||
"toggle": "เลือกหรือยกเลิกการเลือก",
|
||||
"select_all": "เลือกทั้งหมด",
|
||||
"clear": "ล้างการเลือกหรือการค้นหา",
|
||||
"delete": "ลบ",
|
||||
"search": "ค้นหา",
|
||||
"move": "รายการถัดไปหรือก่อนหน้า",
|
||||
"move_grid": "รายการข้างเคียงในตาราง",
|
||||
"open": "เปิด",
|
||||
"up": "ขึ้นหนึ่งโฟลเดอร์",
|
||||
"rename": "เปลี่ยนชื่อ",
|
||||
"copy": "คัดลอก",
|
||||
"cut": "ตัด",
|
||||
"paste": "วาง"
|
||||
},
|
||||
"details": "รายละเอียด",
|
||||
"details_hint": "เลือกไฟล์หรือโฟลเดอร์เพื่อดูรายละเอียด",
|
||||
"disabled_description": "การอัปโหลดไฟล์ขนาดใหญ่ผ่าน WebDAV อาจทำให้ฐานข้อมูล Stalwart/RocksDB ไม่เสถียร รวมถึงการใช้หน่วยความจำเกินและพื้นที่ดิสก์ ฟังก์ชันนี้ไม่แนะนำให้เปิดใช้งานในสภาพแวดล้อม Production",
|
||||
@@ -1543,6 +1579,10 @@
|
||||
"download": "ดาวน์โหลด",
|
||||
"download_count": "{count, plural, other {ดาวน์โหลด # ไฟล์}}",
|
||||
"download_error": "ดาวน์โหลดไม่สำเร็จ",
|
||||
"download_zip": "ดาวน์โหลดเป็น ZIP",
|
||||
"zipping": "กำลังเตรียม {name}",
|
||||
"download_canceled": "ยกเลิกการดาวน์โหลดแล้ว",
|
||||
"zip_error": "ดาวน์โหลด {name} ไม่ได้",
|
||||
"drop_to_upload": "วางไฟล์ที่นี่เพื่ออัปโหลดไปยัง {folder}",
|
||||
"duplicate": "สร้างสำเนา",
|
||||
"duplicate_error": "สร้างสำเนาไม่สำเร็จ",
|
||||
@@ -1575,6 +1615,9 @@
|
||||
"modified": "แก้ไขล่าสุด",
|
||||
"more_actions": "การดำเนินการเพิ่มเติมสำหรับ {name}",
|
||||
"move_error": "ย้ายไม่สำเร็จ",
|
||||
"move_to": "ย้ายไปที่…",
|
||||
"move_to_title": "ย้ายไปที่",
|
||||
"move_here": "ย้ายมาที่นี่",
|
||||
"move_success": "{count, plural, other {ย้ายแล้ว # รายการ}}",
|
||||
"my_files": "ไฟล์ของฉัน",
|
||||
"name": "ชื่อ",
|
||||
@@ -2244,7 +2287,7 @@
|
||||
"add_cc": "เพิ่มในช่อง สำเนา (Cc)",
|
||||
"add_bcc": "เพิ่มในช่อง สำเนาลับ (Bcc)",
|
||||
"select_hint": "เลือกรายชื่อเพื่อดูรายละเอียด",
|
||||
"write_email": "เขียนอีเมล",
|
||||
"write_email": "ส่งอีเมล",
|
||||
"add_to_contacts": "เพิ่มในรายชื่อผู้ติดต่อ",
|
||||
"email": "อีเมล",
|
||||
"read_only_note": "ข้อมูลไดเรกทอรีมาจากเมลเซิร์ฟเวอร์ส่วนกลาง ไม่สามารถแก้ไขได้จากที่นี่",
|
||||
@@ -2257,6 +2300,11 @@
|
||||
}
|
||||
},
|
||||
"title": "ผู้ติดต่อ",
|
||||
"disabled_title": "ผู้ดูแลระบบปิดรายชื่อติดต่อ",
|
||||
"disabled_description": "ติดต่อผู้ดูแลระบบหากคุณต้องการใช้",
|
||||
"drag_count": "{count, plural, other {ผู้ติดต่อ # รายการ}}",
|
||||
"select_contact": "เลือก {name}",
|
||||
"new": "ใหม่",
|
||||
"search_placeholder": "ค้นหาผู้ติดต่อ...",
|
||||
"create_new": "ผู้ติดต่อใหม่",
|
||||
"no_category": "ไม่มีหมวดหมู่",
|
||||
@@ -2264,11 +2312,17 @@
|
||||
"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": "ยังไม่มีผู้ติดต่อ",
|
||||
"empty_state_title": "ยังไม่มีผู้ติดต่อ",
|
||||
"empty_state_subtitle": "สร้างผู้ติดต่อแรกของคุณ หรือนำเข้าจากไฟล์ vCard",
|
||||
"empty_view_title": "ไม่มีผู้ติดต่อที่นี่",
|
||||
"empty_view_hint": "เพิ่มผู้ติดต่อหรือนำเข้าไฟล์ vCard",
|
||||
"empty_search": "ไม่พบผู้ติดต่อที่ตรงกับการค้นหา",
|
||||
"empty_search_hint": "ลองค้นหาด้วยคำอื่น",
|
||||
"empty_filtered": "ไม่พบผู้ติดต่อที่ตรงกับตัวกรอง",
|
||||
@@ -2281,6 +2335,7 @@
|
||||
"back_to_contacts": "กลับไปยังผู้ติดต่อ",
|
||||
"back_to_email": "กลับไปยังอีเมล",
|
||||
"open_categories": "เปิดหมวดหมู่",
|
||||
"row_actions": "การทำงานสำหรับ {name}",
|
||||
"tabs": {
|
||||
"all": "ทั้งหมด",
|
||||
"groups": "กลุ่มผู้ติดต่อ"
|
||||
@@ -2290,6 +2345,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "สมุดที่อยู่ใหม่",
|
||||
"create_confirm": "สร้าง",
|
||||
"created": "สร้างสมุดที่อยู่แล้ว",
|
||||
"create_failed": "สร้างสมุดที่อยู่ไม่สำเร็จ",
|
||||
"title": "สมุดที่อยู่ของฉัน",
|
||||
@@ -2312,7 +2368,10 @@
|
||||
"delete": "ลบสมุดที่อยู่",
|
||||
"confirm_delete": "ลบ “{name}” หรือไม่? ผู้ติดต่อทั้งหมดในสมุดที่อยู่นี้จะถูกลบ",
|
||||
"deleted": "ลบสมุดที่อยู่แล้ว",
|
||||
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ"
|
||||
"delete_failed": "ลบสมุดที่อยู่ไม่สำเร็จ",
|
||||
"move_to": "ย้ายไปที่…",
|
||||
"move_title": "ย้ายไปยังสมุดที่อยู่",
|
||||
"move_here": "ย้ายมาที่นี่"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "ที่อยู่อีเมล",
|
||||
@@ -2338,7 +2397,7 @@
|
||||
"cert_imported": "นำเข้าใบรับรองไปยังที่จัดเก็บ S/MIME แล้ว",
|
||||
"cert_import_failed": "นำเข้าใบรับรองไม่สำเร็จ",
|
||||
"no_contact_selected": "เลือกผู้ติดต่อเพื่อดูรายละเอียด",
|
||||
"compose_email": "เขียนอีเมล",
|
||||
"compose_email": "ส่งอีเมล",
|
||||
"copy_email": "คัดลอกอีเมล",
|
||||
"copy_phone": "คัดลอกหมายเลขโทรศัพท์",
|
||||
"copy_url": "คัดลอก URL",
|
||||
@@ -2396,7 +2455,7 @@
|
||||
"create_title": "ผู้ติดต่อใหม่",
|
||||
"edit_title": "แก้ไขผู้ติดต่อ",
|
||||
"section_address_book": "สมุดที่อยู่",
|
||||
"select_address_book": "เลือกสมุดที่อยู่...",
|
||||
"select_address_book": "สมุดที่อยู่",
|
||||
"section_identity": "ชื่อและข้อมูลประจำตัว",
|
||||
"contact_type": "ประเภทผู้ติดต่อ",
|
||||
"type_person": "บุคคล",
|
||||
@@ -2483,6 +2542,13 @@
|
||||
"add_phone": "เพิ่มโทรศัพท์",
|
||||
"save": "บันทึก",
|
||||
"cancel": "ยกเลิก",
|
||||
"discard_title": "ละทิ้งการเปลี่ยนแปลงหรือไม่?",
|
||||
"discard_message": "การเปลี่ยนแปลงของคุณยังไม่ได้บันทึก",
|
||||
"discard": "ละทิ้ง",
|
||||
"keep_editing": "แก้ไขต่อ",
|
||||
"remove_entry": "นำออก",
|
||||
"close": "ปิด",
|
||||
"remove_category": "นำ {name} ออก",
|
||||
"creating": "กำลังสร้าง…",
|
||||
"updating": "กำลังอัปเดต…",
|
||||
"name_required": "กรุณาระบุชื่อ นามสกุล หรือชื่อองค์กร",
|
||||
@@ -2499,6 +2565,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "กลุ่มใหม่",
|
||||
"delete": "ลบกลุ่ม",
|
||||
"remove_member": "นำ {name} ออกจากกลุ่ม",
|
||||
"edit": "แก้ไขกลุ่ม",
|
||||
"empty": "ยังไม่มีกลุ่มผู้ติดต่อ",
|
||||
"delete_confirm_title": "ลบกลุ่ม",
|
||||
@@ -2511,7 +2579,7 @@
|
||||
"search_members": "ค้นหาผู้ติดต่อเพื่อเพิ่มในกลุ่ม...",
|
||||
"no_members": "ยังไม่มีสมาชิกในกลุ่มนี้",
|
||||
"member_count": "{count, plural, =0 {ไม่มีสมาชิก} one {สมาชิก 1 คน} other {สมาชิก # คน}}",
|
||||
"send_email": "ส่งอีเมลถึงกลุ่ม",
|
||||
"send_email": "ส่งอีเมล",
|
||||
"send_email_to": "ถึง (To)",
|
||||
"send_email_cc": "สำเนา (Cc)",
|
||||
"send_email_bcc": "สำเนาลับ (Bcc)",
|
||||
@@ -2549,6 +2617,7 @@
|
||||
"deleted": "{count, plural, one {ลบผู้ติดต่อแล้ว 1 รายการ} other {ลบผู้ติดต่อแล้ว # รายการ}}",
|
||||
"add_to_group": "เพิ่มในกลุ่ม",
|
||||
"choose_group": "เลือกกลุ่ม",
|
||||
"new_group": "กลุ่มใหม่…",
|
||||
"adding_contacts": "กำลังเพิ่มผู้ติดต่อ {count, plural, one {1 รายการ} other {# รายการ}}",
|
||||
"added_to_group": "เพิ่มผู้ติดต่อในกลุ่มแล้ว",
|
||||
"export": "ส่งออก",
|
||||
@@ -2595,6 +2664,20 @@
|
||||
"sort_by": "เรียงลำดับตาม",
|
||||
"sort_first_name": "ชื่อ",
|
||||
"sort_last_name": "นามสกุล"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "รายการ",
|
||||
"actions": "การทำงาน",
|
||||
"first_last": "แรกสุดหรือท้ายสุด",
|
||||
"extend": "ขยายการเลือก",
|
||||
"toggle": "เลือกหรือยกเลิกการเลือก",
|
||||
"select_all": "เลือกทั้งหมด",
|
||||
"clear": "ล้างการเลือกหรือการค้นหา",
|
||||
"delete": "ลบ",
|
||||
"search": "ค้นหา",
|
||||
"move": "ผู้ติดต่อถัดไปหรือก่อนหน้า",
|
||||
"open": "เปิดผู้ติดต่อ",
|
||||
"menu": "เปิดเมนูของผู้ติดต่อ"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Bilgi alanına ekle",
|
||||
"add_bcc": "Gizli alanına ekle",
|
||||
"select_hint": "Ayrıntıları görmek için birini seçin",
|
||||
"write_email": "E-posta yaz",
|
||||
"write_email": "E-posta gönder",
|
||||
"add_to_contacts": "Kişilere ekle",
|
||||
"email": "E-posta",
|
||||
"read_only_note": "Dizin girdileri posta sunucunuzdan gelir ve burada düzenlenemez.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Kişiler",
|
||||
"disabled_title": "Kişiler yöneticiniz tarafından kapatıldı",
|
||||
"disabled_description": "İhtiyacınız varsa yöneticinize başvurun.",
|
||||
"drag_count": "{count, plural, one {1 kişi} other {# kişi}}",
|
||||
"select_contact": "{name} öğesini seç",
|
||||
"new": "Yeni",
|
||||
"search_placeholder": "Kişilerde ara...",
|
||||
"create_new": "Yeni Kişi",
|
||||
"no_category": "Kategori Yok",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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",
|
||||
"empty_state_title": "Henüz kişi yok",
|
||||
"empty_state_subtitle": "İlk kişinizi oluşturun veya vCard dosyasından içe aktarın",
|
||||
"empty_view_title": "Burada kişi yok",
|
||||
"empty_view_hint": "Bir kişi ekleyin veya bir vCard dosyası içe aktarın.",
|
||||
"empty_search": "Aramanızla eşleşen kişi yok",
|
||||
"empty_search_hint": "Farklı bir arama terimi deneyin",
|
||||
"empty_filtered": "Filtrelerinizle eşleşen kişi yok",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Yeni adres defteri",
|
||||
"create_confirm": "Oluştur",
|
||||
"created": "Adres defteri oluşturuldu",
|
||||
"create_failed": "Adres defteri oluşturulamadı",
|
||||
"title": "Adres Defterlerim",
|
||||
@@ -2982,7 +2994,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",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Sertifika S/MIME deposuna aktarıldı",
|
||||
"cert_import_failed": "Sertifika içe aktarılamadı",
|
||||
"no_contact_selected": "Ayrıntıları görüntülemek için bir kişi seçin",
|
||||
"compose_email": "E-posta oluştur",
|
||||
"compose_email": "E-posta gönder",
|
||||
"copy_email": "E-postayı kopyala",
|
||||
"copy_phone": "Telefon numarasını kopyala",
|
||||
"copy_url": "URL'yi kopyala",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Yeni Kişi",
|
||||
"edit_title": "Kişiyi Düzenle",
|
||||
"section_address_book": "Dizin",
|
||||
"select_address_book": "Bir dizin seçin...",
|
||||
"section_address_book": "Adres Defteri",
|
||||
"select_address_book": "Adres Defteri",
|
||||
"section_identity": "Ad ve Kimlik",
|
||||
"contact_type": "Kişi türü",
|
||||
"type_person": "Kişi",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Telefon ekle",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"discard_title": "Değişiklikler atılsın mı?",
|
||||
"discard_message": "Değişiklikleriniz kaydedilmedi.",
|
||||
"discard": "At",
|
||||
"keep_editing": "Düzenlemeye devam et",
|
||||
"remove_entry": "Kaldır",
|
||||
"close": "Kapat",
|
||||
"remove_category": "{name} öğesini kaldır",
|
||||
"creating": "Oluşturuluyor...",
|
||||
"updating": "Güncelleniyor...",
|
||||
"name_required": "Bir ad, soyad veya kuruluş girin",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Yeni Grup",
|
||||
"delete": "Grubu sil",
|
||||
"remove_member": "{name} kişisini gruptan çıkar",
|
||||
"edit": "Grubu Düzenle",
|
||||
"empty": "Henüz grup yok",
|
||||
"delete_confirm_title": "Grubu sil",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Eklemek için kişileri arayın...",
|
||||
"no_members": "Bu grupta üye yok",
|
||||
"member_count": "{count, plural, =0 {Üye yok} one {1 üye} other {# üye}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "E-posta gönder",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 kişi silindi} other {# kişi silindi}}",
|
||||
"add_to_group": "Gruba ekle",
|
||||
"choose_group": "Grup seçin",
|
||||
"new_group": "Yeni grup…",
|
||||
"adding_contacts": "{count, plural, one {1 kişi} other {# kişi}} ekleniyor",
|
||||
"added_to_group": "Kişiler gruba eklendi",
|
||||
"export": "Dışa Aktar",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Ad",
|
||||
"sort_last_name": "Soyad"
|
||||
},
|
||||
"open_categories": "Kategorileri aç"
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "İşlemler",
|
||||
"first_last": "İlk veya son",
|
||||
"extend": "Seçimi genişlet",
|
||||
"toggle": "Seç veya seçimi kaldır",
|
||||
"select_all": "Tümünü seç",
|
||||
"clear": "Seçimi veya aramayı temizle",
|
||||
"delete": "Sil",
|
||||
"search": "Ara",
|
||||
"move": "Sonraki veya önceki kişi",
|
||||
"open": "Kişiyi aç",
|
||||
"menu": "Kişi menüsünü aç"
|
||||
},
|
||||
"open_categories": "Kategorileri aç",
|
||||
"row_actions": "{name} için işlemler"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Takvim",
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "Adlar denetim karakterleri içeremez.",
|
||||
"name_too_long": "Bu ad sunucu için çok uzun.",
|
||||
"delete": "Sil",
|
||||
"delete_confirm_title": "Kaynağı sil",
|
||||
"delete_confirm_message": "\"{name}\" silinsin mi? Bu geri alınamaz.",
|
||||
"download": "İndir",
|
||||
"name": "Ad",
|
||||
"size": "Boyut",
|
||||
@@ -3972,18 +4010,57 @@
|
||||
"error_partial_copy": "Kopyanın bir kısmı yerinde kaldı.",
|
||||
"create_folder_success": "Klasör oluşturuldu",
|
||||
"create_folder_error": "Klasör oluşturulamadı",
|
||||
"delete_success": "Başarıyla silindi",
|
||||
"delete_error": "Silinemedi",
|
||||
"trash": "Çöp kutusu",
|
||||
"restore": "Geri yükle",
|
||||
"restored": "{count, plural, one {Öğe geri yüklendi} other {# öğe geri yüklendi}}",
|
||||
"restore_error": "Geri yüklenemedi",
|
||||
"moved_to_trash": "{count, plural, one {Çöp kutusuna taşındı} other {# öğe çöp kutusuna taşındı}}",
|
||||
"delete_forever": "Kalıcı olarak sil",
|
||||
"delete_forever_title": "{count, plural, one {Kalıcı olarak silinsin mi?} other {# öğe kalıcı olarak silinsin mi?}}",
|
||||
"delete_forever_message": "\"{name}\" silinecek. Bu işlem geri alınamaz.",
|
||||
"delete_forever_message_count": "{count, plural, one {1 öğe silinecek} other {# öğe silinecek}}. Bu işlem geri alınamaz.",
|
||||
"deleted_forever": "{count, plural, one {Kalıcı olarak silindi} other {# öğe kalıcı olarak silindi}}",
|
||||
"empty_trash": "Çöp kutusunu boşalt",
|
||||
"empty_trash_title": "Çöp kutusu boşaltılsın mı?",
|
||||
"empty_trash_message": "{count, plural, one {Çöp kutusundaki öğe} other {Çöp kutusundaki # öğenin tümü}} kalıcı olarak silinecek. Bu işlem geri alınamaz.",
|
||||
"trash_emptied": "Çöp kutusu boşaltıldı",
|
||||
"empty_trash_error": "Çöp kutusu boşaltılamadı",
|
||||
"trash_empty_title": "Çöp kutusu boş",
|
||||
"trash_empty_hint": "Silinen dosyalar siz çöp kutusunu boşaltana kadar burada kalır.",
|
||||
"deleted": "Silinme",
|
||||
"deleted_from": "{folder} konumundan silindi",
|
||||
"shortcuts": {
|
||||
"list": "Liste",
|
||||
"actions": "İşlemler",
|
||||
"first_last": "İlk veya son",
|
||||
"extend": "Seçimi genişlet",
|
||||
"toggle": "Seç veya seçimi kaldır",
|
||||
"select_all": "Tümünü seç",
|
||||
"clear": "Seçimi veya aramayı temizle",
|
||||
"delete": "Sil",
|
||||
"search": "Ara",
|
||||
"move": "Sonraki veya önceki öğe",
|
||||
"move_grid": "Izgarada yanındaki öğe",
|
||||
"open": "Aç",
|
||||
"up": "Bir klasör yukarı",
|
||||
"rename": "Yeniden adlandır",
|
||||
"copy": "Kopyala",
|
||||
"cut": "Kes",
|
||||
"paste": "Yapıştır"
|
||||
},
|
||||
"rename_success": "Başarıyla yeniden adlandırıldı",
|
||||
"rename_error": "Yeniden adlandırılamadı",
|
||||
"download_error": "İndirilemedi",
|
||||
"download_zip": "ZIP olarak indir",
|
||||
"zipping": "{name} hazırlanıyor",
|
||||
"download_canceled": "İndirme iptal edildi",
|
||||
"zip_error": "{name} indirilemedi",
|
||||
"not_available": "Bu sunucuda dosya depolama kullanılamıyor",
|
||||
"cancel": "İptal",
|
||||
"create": "Oluştur",
|
||||
"save": "Kaydet",
|
||||
"no_results": "Aramanızla eşleşen dosya yok",
|
||||
"batch_delete_confirm_message": "{count, plural, one {1 öge} other {# öge}} silinsin mi? Bu geri alınamaz.",
|
||||
"batch_delete_success": "{count, plural, one {1 öge silindi} other {# öge silindi}}",
|
||||
"grid_view": "Izgara görünümü",
|
||||
"list_view": "Liste görünümü",
|
||||
"details": "Ayrıntılar",
|
||||
@@ -3995,6 +4072,9 @@
|
||||
"paste": "Yapıştır",
|
||||
"move_success": "{count, plural, one {1 öge taşındı} other {# öge taşındı}}",
|
||||
"move_error": "Taşınamadı",
|
||||
"move_to": "Taşı…",
|
||||
"move_to_title": "Taşı",
|
||||
"move_here": "Buraya taşı",
|
||||
"paste_success": "Başarıyla yapıştırıldı",
|
||||
"paste_error": "Yapıştırılamadı",
|
||||
"new_text_file": "Yeni Metin Dosyası",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "Uzaklaştır",
|
||||
"zoom_reset": "Ekrana sığdır",
|
||||
"rotate": "Döndür",
|
||||
"close_preview": "Kapat"
|
||||
"close_preview": "Kapat",
|
||||
"previous_file": "Önceki dosya",
|
||||
"next_file": "Sonraki dosya",
|
||||
"loading": "Yükleniyor…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Sertifikalarınız",
|
||||
|
||||
+96
-13
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "Додати в «Копія»",
|
||||
"add_bcc": "Додати в «Прихована копія»",
|
||||
"select_hint": "Виберіть людину, щоб побачити подробиці",
|
||||
"write_email": "Написати листа",
|
||||
"write_email": "Надіслати лист",
|
||||
"add_to_contacts": "Додати до контактів",
|
||||
"email": "Ел. пошта",
|
||||
"read_only_note": "Записи довідника надходять з вашого поштового сервера, тут їх не можна змінити.",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "Контакти",
|
||||
"disabled_title": "Адміністратор вимкнув контакти",
|
||||
"disabled_description": "Зверніться до адміністратора, якщо вони вам потрібні.",
|
||||
"drag_count": "{count, plural, one {# контакт} few {# контакти} other {# контактів}}",
|
||||
"select_contact": "Вибрати {name}",
|
||||
"new": "Створити",
|
||||
"search_placeholder": "Пошук контактів...",
|
||||
"create_new": "Новий контакт",
|
||||
"no_category": "Без категорії",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "Контактів ще немає",
|
||||
"empty_state_title": "Контактів ще немає",
|
||||
"empty_state_subtitle": "Створіть свій перший контакт або імпортуйте його з файлу vCard",
|
||||
"empty_view_title": "Тут немає контактів",
|
||||
"empty_view_hint": "Додайте контакт або імпортуйте файл vCard.",
|
||||
"empty_search": "Жоден контакт не відповідає вашому пошуку",
|
||||
"empty_search_hint": "Спробуйте інший термін пошуку",
|
||||
"empty_filtered": "Жоден контакт не відповідає фільтрам",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "Нова адресна книга",
|
||||
"create_confirm": "Створити",
|
||||
"created": "Адресну книгу створено",
|
||||
"create_failed": "Не вдалося створити адресну книгу",
|
||||
"title": "Мої адресні книги",
|
||||
@@ -2982,7 +2994,10 @@
|
||||
"delete": "Видалити адресну книгу",
|
||||
"confirm_delete": "Видалити «{name}»? Усі контакти в цій адресній книзі будуть видалені.",
|
||||
"deleted": "Адресну книгу видалено",
|
||||
"delete_failed": "Не вдалося видалити адресну книгу"
|
||||
"delete_failed": "Не вдалося видалити адресну книгу",
|
||||
"move_to": "Перемістити до…",
|
||||
"move_title": "Перемістити до адресної книги",
|
||||
"move_here": "Перемістити сюди"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Адреси електронної пошти",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "Сертифікат імпортовано до магазину S/MIME",
|
||||
"cert_import_failed": "Не вдалося імпортувати сертифікат",
|
||||
"no_contact_selected": "Виберіть контакт, щоб переглянути деталі",
|
||||
"compose_email": "Написати електронний лист",
|
||||
"compose_email": "Надіслати лист",
|
||||
"copy_email": "Копіювати електронну пошту",
|
||||
"copy_phone": "Копіювати номер телефону",
|
||||
"copy_url": "Копіювати URL",
|
||||
@@ -3065,8 +3080,8 @@
|
||||
"form": {
|
||||
"create_title": "Новий контакт",
|
||||
"edit_title": "Редагувати контакт",
|
||||
"section_address_book": "Довідник",
|
||||
"select_address_book": "Виберіть каталог...",
|
||||
"section_address_book": "Адресна книга",
|
||||
"select_address_book": "Адресна книга",
|
||||
"section_identity": "Ім'я та ідентифікація",
|
||||
"contact_type": "Тип контакту",
|
||||
"type_person": "Людина",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "Додати телефон",
|
||||
"save": "зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"discard_title": "Відкинути зміни?",
|
||||
"discard_message": "Ваші зміни не збережено.",
|
||||
"discard": "Відкинути",
|
||||
"keep_editing": "Продовжити редагування",
|
||||
"remove_entry": "Прибрати",
|
||||
"close": "Закрити",
|
||||
"remove_category": "Прибрати {name}",
|
||||
"creating": "Створення...",
|
||||
"updating": "Оновлення...",
|
||||
"name_required": "Вкажіть ім'я, прізвище або організацію",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "Нова група",
|
||||
"delete": "Видалити групу",
|
||||
"remove_member": "Прибрати {name} з групи",
|
||||
"edit": "Редагувати групу",
|
||||
"empty": "Груп ще немає",
|
||||
"delete_confirm_title": "Видалити групу",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "Пошук контактів для додавання...",
|
||||
"no_members": "У цій групі немає учасників",
|
||||
"member_count": "{count, plural, =0 {Немає учасників} one {1 учасник} few {# учасники} many {# учасників} other {# учасників}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "Надіслати лист",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {1 контакт видалено} few {# контакти видалено} many {# контактів видалено} other {# контактів видалено}}",
|
||||
"add_to_group": "Додати до групи",
|
||||
"choose_group": "Виберіть групу",
|
||||
"new_group": "Нова група…",
|
||||
"adding_contacts": "Додавання {count, plural, one {1 contact} other {# contacts}}",
|
||||
"added_to_group": "Контакти додано до групи",
|
||||
"export": "Експорт",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "Ім'я",
|
||||
"sort_last_name": "Прізвище"
|
||||
},
|
||||
"open_categories": "Відкрити категорії"
|
||||
"shortcuts": {
|
||||
"list": "Список",
|
||||
"actions": "Дії",
|
||||
"first_last": "Перший або останній",
|
||||
"extend": "Розширити виділення",
|
||||
"toggle": "Вибрати або скасувати вибір",
|
||||
"select_all": "Вибрати все",
|
||||
"clear": "Скинути виділення або пошук",
|
||||
"delete": "Видалити",
|
||||
"search": "Пошук",
|
||||
"move": "Наступний або попередній контакт",
|
||||
"open": "Відкрити контакт",
|
||||
"menu": "Відкрити меню контакту"
|
||||
},
|
||||
"open_categories": "Відкрити категорії",
|
||||
"row_actions": "Дії для {name}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Календар",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "Назви не можуть містити керівні символи.",
|
||||
"name_too_long": "Ця назва задовга для сервера.",
|
||||
"delete": "Видалити",
|
||||
"delete_confirm_title": "Видалити ресурс",
|
||||
"delete_confirm_message": "Ви впевнені, що хочете видалити \"{name}\"? Це неможливо скасувати.",
|
||||
"download": "Завантажити",
|
||||
"name": "Ім'я",
|
||||
"size": "Розмір",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "Частина копії залишилася на місці.",
|
||||
"create_folder_success": "Папка створена",
|
||||
"create_folder_error": "Не вдалося створити папку",
|
||||
"delete_success": "Успішно видалено",
|
||||
"delete_error": "Не вдалося видалити",
|
||||
"trash": "Кошик",
|
||||
"restore": "Відновити",
|
||||
"restored": "{count, plural, one {Елемент відновлено} few {Відновлено # елементи} other {Відновлено # елементів}}",
|
||||
"restore_error": "Не вдалося відновити",
|
||||
"moved_to_trash": "{count, plural, one {Переміщено в кошик} few {# елементи переміщено в кошик} other {# елементів переміщено в кошик}}",
|
||||
"delete_forever": "Видалити назавжди",
|
||||
"delete_forever_title": "{count, plural, one {Видалити назавжди?} few {Видалити # елементи назавжди?} other {Видалити # елементів назавжди?}}",
|
||||
"delete_forever_message": "«{name}» буде видалено. Цю дію не можна скасувати.",
|
||||
"delete_forever_message_count": "{count, plural, one {Буде видалено 1 елемент} few {Буде видалено # елементи} other {Буде видалено # елементів}}. Цю дію не можна скасувати.",
|
||||
"deleted_forever": "{count, plural, one {Видалено назавжди} few {# елементи видалено назавжди} other {# елементів видалено назавжди}}",
|
||||
"empty_trash": "Очистити кошик",
|
||||
"empty_trash_title": "Очистити кошик?",
|
||||
"empty_trash_message": "{count, plural, one {Елемент у кошику буде видалено} few {Усі # елементи в кошику буде видалено} other {Усі # елементів у кошику буде видалено}} назавжди. Цю дію не можна скасувати.",
|
||||
"trash_emptied": "Кошик очищено",
|
||||
"empty_trash_error": "Не вдалося очистити кошик",
|
||||
"trash_empty_title": "Кошик порожній",
|
||||
"trash_empty_hint": "Видалені файли зберігаються тут, доки ви не очистите кошик.",
|
||||
"deleted": "Видалено",
|
||||
"deleted_from": "Видалено з {folder}",
|
||||
"shortcuts": {
|
||||
"list": "Список",
|
||||
"actions": "Дії",
|
||||
"first_last": "Перший або останній",
|
||||
"extend": "Розширити виділення",
|
||||
"toggle": "Вибрати або скасувати вибір",
|
||||
"select_all": "Вибрати все",
|
||||
"clear": "Скинути виділення або пошук",
|
||||
"delete": "Видалити",
|
||||
"search": "Пошук",
|
||||
"move": "Наступний або попередній елемент",
|
||||
"move_grid": "Сусідній елемент у сітці",
|
||||
"open": "Відкрити",
|
||||
"up": "На папку вище",
|
||||
"rename": "Перейменувати",
|
||||
"copy": "Копіювати",
|
||||
"cut": "Вирізати",
|
||||
"paste": "Вставити"
|
||||
},
|
||||
"rename_success": "Успішно перейменовано",
|
||||
"rename_error": "Не вдалося перейменувати",
|
||||
"download_error": "Не вдалося завантажити",
|
||||
"download_zip": "Завантажити як ZIP",
|
||||
"zipping": "Підготовка {name}",
|
||||
"download_canceled": "Завантаження скасовано",
|
||||
"zip_error": "Не вдалося завантажити {name}",
|
||||
"not_available": "Зберігання файлів недоступне на цьому сервері",
|
||||
"cancel": "Скасувати",
|
||||
"create": "Створити",
|
||||
"save": "зберегти",
|
||||
"no_results": "Немає файлів, що відповідають вашому пошуку",
|
||||
"batch_delete_confirm_message": "Ви впевнені, що хочете видалити {count, plural, one {1 item} other {# items}}? Це неможливо скасувати.",
|
||||
"batch_delete_success": "{count, plural, one {1 елемент видалено} few {# елементи видалено} many {# елементів видалено} other {# елементів видалено}}",
|
||||
"grid_view": "Вигляд сітки",
|
||||
"list_view": "Перегляд списку",
|
||||
"details": "Подробиці",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "Вставити",
|
||||
"move_success": "{count, plural, one {1 елемент переміщено} few {# елементи переміщено} many {# елементів переміщено} other {# елементів переміщено}}",
|
||||
"move_error": "Не вдалося переміститися",
|
||||
"move_to": "Перемістити до…",
|
||||
"move_to_title": "Перемістити до",
|
||||
"move_here": "Перемістити сюди",
|
||||
"paste_success": "Вставлено успішно",
|
||||
"paste_error": "Не вдалося вставити",
|
||||
"new_text_file": "Новий текстовий файл",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "Зменшити",
|
||||
"zoom_reset": "За розміром екрана",
|
||||
"rotate": "Повернути",
|
||||
"close_preview": "Закрити"
|
||||
"close_preview": "Закрити",
|
||||
"previous_file": "Попередній файл",
|
||||
"next_file": "Наступний файл",
|
||||
"loading": "Завантаження…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "Ваші сертифікати",
|
||||
|
||||
+94
-11
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "加入副本",
|
||||
"add_bcc": "加入密件副本",
|
||||
"select_hint": "選取一個人以查看詳細資料",
|
||||
"write_email": "撰寫郵件",
|
||||
"write_email": "傳送郵件",
|
||||
"add_to_contacts": "加入聯絡人",
|
||||
"email": "電子郵件",
|
||||
"read_only_note": "目錄項目來自你的郵件伺服器,無法在此編輯。",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "聯絡人",
|
||||
"disabled_title": "管理員已關閉聯絡人",
|
||||
"disabled_description": "如需使用,請聯絡管理員。",
|
||||
"drag_count": "{count, plural, other {# 位聯絡人}}",
|
||||
"select_contact": "選取 {name}",
|
||||
"new": "新增",
|
||||
"search_placeholder": "搜尋聯絡人…",
|
||||
"create_new": "新增聯絡人",
|
||||
"no_category": "無類別",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "尚無聯絡人",
|
||||
"empty_state_title": "尚無聯絡人",
|
||||
"empty_state_subtitle": "建立第一位聯絡人,或從 vCard 檔案匯入",
|
||||
"empty_view_title": "這裡沒有聯絡人",
|
||||
"empty_view_hint": "新增聯絡人或匯入 vCard 檔案。",
|
||||
"empty_search": "沒有符合您搜尋條件的聯絡人",
|
||||
"empty_search_hint": "嘗試其他搜尋字詞",
|
||||
"empty_filtered": "沒有符合您篩選條件的聯絡人",
|
||||
@@ -2952,6 +2963,7 @@
|
||||
"back_to_contacts": "回到聯絡人",
|
||||
"back_to_email": "回到郵件",
|
||||
"open_categories": "開啟類別",
|
||||
"row_actions": "{name} 的動作",
|
||||
"tabs": {
|
||||
"all": "全部",
|
||||
"groups": "群組"
|
||||
@@ -2961,6 +2973,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "新增通訊錄",
|
||||
"create_confirm": "建立",
|
||||
"created": "通訊錄已建立",
|
||||
"create_failed": "建立通訊錄失敗",
|
||||
"title": "我的通訊錄",
|
||||
@@ -2983,7 +2996,10 @@
|
||||
"delete": "刪除通訊錄",
|
||||
"confirm_delete": "要刪除「{name}」嗎?此通訊錄中的所有聯絡人都會移除。",
|
||||
"deleted": "通訊錄已刪除",
|
||||
"delete_failed": "刪除通訊錄失敗"
|
||||
"delete_failed": "刪除通訊錄失敗",
|
||||
"move_to": "移至…",
|
||||
"move_title": "移至通訊錄",
|
||||
"move_here": "移到這裡"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "電子郵件地址",
|
||||
@@ -3009,7 +3025,7 @@
|
||||
"cert_imported": "憑證已匯入 S/MIME 憑證庫",
|
||||
"cert_import_failed": "匯入憑證失敗",
|
||||
"no_contact_selected": "選取一位聯絡人以檢視詳細資料",
|
||||
"compose_email": "撰寫郵件",
|
||||
"compose_email": "傳送郵件",
|
||||
"copy_email": "複製電子郵件地址",
|
||||
"copy_phone": "複製電話號碼",
|
||||
"copy_url": "複製 URL",
|
||||
@@ -3067,7 +3083,7 @@
|
||||
"create_title": "新聯絡人",
|
||||
"edit_title": "編輯聯絡人",
|
||||
"section_address_book": "通訊錄",
|
||||
"select_address_book": "選擇通訊錄…",
|
||||
"select_address_book": "通訊錄",
|
||||
"section_identity": "姓名與身分",
|
||||
"contact_type": "聯絡人類型",
|
||||
"type_person": "個人",
|
||||
@@ -3154,6 +3170,13 @@
|
||||
"add_phone": "新增電話",
|
||||
"save": "儲存",
|
||||
"cancel": "取消",
|
||||
"discard_title": "要捨棄變更嗎?",
|
||||
"discard_message": "您的變更尚未儲存。",
|
||||
"discard": "捨棄",
|
||||
"keep_editing": "繼續編輯",
|
||||
"remove_entry": "移除",
|
||||
"close": "關閉",
|
||||
"remove_category": "移除 {name}",
|
||||
"creating": "正在建立…",
|
||||
"updating": "正在更新…",
|
||||
"name_required": "請輸入名、姓或組織名稱",
|
||||
@@ -3170,6 +3193,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "新增群組",
|
||||
"delete": "刪除群組",
|
||||
"remove_member": "將 {name} 移出群組",
|
||||
"edit": "編輯群組",
|
||||
"empty": "尚無群組",
|
||||
"delete_confirm_title": "刪除群組",
|
||||
@@ -3182,7 +3207,7 @@
|
||||
"search_members": "搜尋要新增的聯絡人…",
|
||||
"no_members": "該群組中沒有成員",
|
||||
"member_count": "{count, plural, =0 {無成員} one {1 位成員} other {# 位成員}}",
|
||||
"send_email": "傳送郵件給群組",
|
||||
"send_email": "傳送郵件",
|
||||
"send_email_to": "收件者",
|
||||
"send_email_cc": "副本",
|
||||
"send_email_bcc": "密件副本",
|
||||
@@ -3220,6 +3245,7 @@
|
||||
"deleted": "{count, plural, one {已刪除 1 位聯絡人} other {已刪除 # 位聯絡人}}",
|
||||
"add_to_group": "新增至群組",
|
||||
"choose_group": "選擇群組",
|
||||
"new_group": "新增群組…",
|
||||
"adding_contacts": "正在新增{count, plural, one {1 位聯絡人} other {# 位聯絡人}}",
|
||||
"added_to_group": "聯絡人已新增至群組",
|
||||
"export": "匯出",
|
||||
@@ -3266,6 +3292,20 @@
|
||||
"sort_by": "排序方式",
|
||||
"sort_first_name": "名",
|
||||
"sort_last_name": "姓"
|
||||
},
|
||||
"shortcuts": {
|
||||
"list": "清單",
|
||||
"actions": "動作",
|
||||
"first_last": "第一個或最後一個",
|
||||
"extend": "擴充選取範圍",
|
||||
"toggle": "選取或取消選取",
|
||||
"select_all": "全選",
|
||||
"clear": "清除選取或搜尋",
|
||||
"delete": "刪除",
|
||||
"search": "搜尋",
|
||||
"move": "下一個或上一個聯絡人",
|
||||
"open": "開啟聯絡人",
|
||||
"menu": "開啟聯絡人選單"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
@@ -3946,8 +3986,6 @@
|
||||
"name_control_chars": "名稱不能包含控制字元。",
|
||||
"name_too_long": "此名稱對伺服器來說過長。",
|
||||
"delete": "刪除",
|
||||
"delete_confirm_title": "刪除項目",
|
||||
"delete_confirm_message": "您確定要刪除「{name}」嗎?此動作無法復原。",
|
||||
"download": "下載",
|
||||
"name": "名稱",
|
||||
"size": "大小",
|
||||
@@ -3973,18 +4011,57 @@
|
||||
"error_partial_copy": "部分副本仍保留在原處。",
|
||||
"create_folder_success": "資料夾已建立",
|
||||
"create_folder_error": "建立資料夾失敗",
|
||||
"delete_success": "刪除成功",
|
||||
"delete_error": "刪除失敗",
|
||||
"trash": "垃圾桶",
|
||||
"restore": "還原",
|
||||
"restored": "{count, plural, other {已還原 # 個項目}}",
|
||||
"restore_error": "無法還原",
|
||||
"moved_to_trash": "{count, plural, other {已將 # 個項目移到垃圾桶}}",
|
||||
"delete_forever": "永久刪除",
|
||||
"delete_forever_title": "{count, plural, other {要永久刪除 # 個項目嗎?}}",
|
||||
"delete_forever_message": "「{name}」將被刪除。此動作無法復原。",
|
||||
"delete_forever_message_count": "{count, plural, other {將刪除 # 個項目}}。此動作無法復原。",
|
||||
"deleted_forever": "{count, plural, other {已永久刪除 # 個項目}}",
|
||||
"empty_trash": "清空垃圾桶",
|
||||
"empty_trash_title": "要清空垃圾桶嗎?",
|
||||
"empty_trash_message": "{count, plural, other {垃圾桶中的 # 個項目將被永久刪除}}。此動作無法復原。",
|
||||
"trash_emptied": "已清空垃圾桶",
|
||||
"empty_trash_error": "無法清空垃圾桶",
|
||||
"trash_empty_title": "垃圾桶是空的",
|
||||
"trash_empty_hint": "刪除的檔案會保留在這裡,直到您清空垃圾桶。",
|
||||
"deleted": "刪除時間",
|
||||
"deleted_from": "從 {folder} 刪除",
|
||||
"shortcuts": {
|
||||
"list": "清單",
|
||||
"actions": "動作",
|
||||
"first_last": "第一個或最後一個",
|
||||
"extend": "擴充選取範圍",
|
||||
"toggle": "選取或取消選取",
|
||||
"select_all": "全選",
|
||||
"clear": "清除選取或搜尋",
|
||||
"delete": "刪除",
|
||||
"search": "搜尋",
|
||||
"move": "下一個或上一個項目",
|
||||
"move_grid": "格狀檢視中的相鄰項目",
|
||||
"open": "開啟",
|
||||
"up": "上一層資料夾",
|
||||
"rename": "重新命名",
|
||||
"copy": "複製",
|
||||
"cut": "剪下",
|
||||
"paste": "貼上"
|
||||
},
|
||||
"rename_success": "重新命名成功",
|
||||
"rename_error": "重新命名失敗",
|
||||
"download_error": "下載失敗",
|
||||
"download_zip": "下載為 ZIP",
|
||||
"zipping": "正在準備 {name}",
|
||||
"download_canceled": "已取消下載",
|
||||
"zip_error": "無法下載 {name}",
|
||||
"not_available": "此伺服器無法使用檔案儲存功能",
|
||||
"cancel": "取消",
|
||||
"create": "建立",
|
||||
"save": "儲存",
|
||||
"no_results": "找不到符合搜尋條件的檔案",
|
||||
"batch_delete_confirm_message": "您確定要刪除{count, plural, one {1 個項目} other {# 個項目}}嗎?此動作無法復原。",
|
||||
"batch_delete_success": "{count, plural, one {已刪除 1 個項目} other {已刪除 # 個項目}}",
|
||||
"grid_view": "網格檢視",
|
||||
"list_view": "清單檢視",
|
||||
"details": "詳細資料",
|
||||
@@ -3996,6 +4073,9 @@
|
||||
"paste": "貼上",
|
||||
"move_success": "{count, plural, one {已移動 1 個項目} other {已移動 # 個項目}}",
|
||||
"move_error": "移動失敗",
|
||||
"move_to": "移至…",
|
||||
"move_to_title": "移至",
|
||||
"move_here": "移到這裡",
|
||||
"paste_success": "貼上成功",
|
||||
"paste_error": "貼上失敗",
|
||||
"new_text_file": "新增文字檔案",
|
||||
@@ -4075,7 +4155,10 @@
|
||||
"zoom_out": "縮小",
|
||||
"zoom_reset": "符合螢幕大小",
|
||||
"rotate": "旋轉",
|
||||
"close_preview": "關閉"
|
||||
"close_preview": "關閉",
|
||||
"previous_file": "上一個檔案",
|
||||
"next_file": "下一個檔案",
|
||||
"loading": "載入中…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的憑證",
|
||||
|
||||
+95
-12
@@ -2915,7 +2915,7 @@
|
||||
"add_cc": "添加到抄送",
|
||||
"add_bcc": "添加到密送",
|
||||
"select_hint": "选择一个人以查看详情",
|
||||
"write_email": "写邮件",
|
||||
"write_email": "发送邮件",
|
||||
"add_to_contacts": "添加到联系人",
|
||||
"email": "邮箱",
|
||||
"read_only_note": "目录条目来自你的邮件服务器,无法在此编辑。",
|
||||
@@ -2928,6 +2928,11 @@
|
||||
}
|
||||
},
|
||||
"title": "联系人",
|
||||
"disabled_title": "管理员已关闭联系人",
|
||||
"disabled_description": "如需使用,请联系管理员。",
|
||||
"drag_count": "{count, plural, other {# 个联系人}}",
|
||||
"select_contact": "选择 {name}",
|
||||
"new": "新建",
|
||||
"search_placeholder": "搜索联系人...",
|
||||
"create_new": "新联系人",
|
||||
"no_category": "没有类别",
|
||||
@@ -2935,11 +2940,17 @@
|
||||
"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": "还没有联系人",
|
||||
"empty_state_title": "还没有联系人",
|
||||
"empty_state_subtitle": "创建您的第一个联系人或从 vCard 文件导入",
|
||||
"empty_view_title": "此处没有联系人",
|
||||
"empty_view_hint": "添加联系人或导入 vCard 文件。",
|
||||
"empty_search": "没有符合您搜索条件的联系人",
|
||||
"empty_search_hint": "尝试不同的搜索词",
|
||||
"empty_filtered": "没有符合您筛选条件的联系人",
|
||||
@@ -2960,6 +2971,7 @@
|
||||
},
|
||||
"address_books": {
|
||||
"create": "新建通讯录",
|
||||
"create_confirm": "创建",
|
||||
"created": "通讯录已创建",
|
||||
"create_failed": "创建通讯录失败",
|
||||
"title": "我的地址簿",
|
||||
@@ -2982,7 +2994,10 @@
|
||||
"delete": "删除通讯录",
|
||||
"confirm_delete": "删除「{name}」?此通讯录中的所有联系人将被移除。",
|
||||
"deleted": "通讯录已删除",
|
||||
"delete_failed": "删除通讯录失败"
|
||||
"delete_failed": "删除通讯录失败",
|
||||
"move_to": "移动到…",
|
||||
"move_title": "移动到通讯录",
|
||||
"move_here": "移到此处"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "邮箱地址",
|
||||
@@ -3008,7 +3023,7 @@
|
||||
"cert_imported": "证书导入到 S/MIME 存储",
|
||||
"cert_import_failed": "导入证书失败",
|
||||
"no_contact_selected": "选择联系人以查看详细信息",
|
||||
"compose_email": "撰写邮件",
|
||||
"compose_email": "发送邮件",
|
||||
"copy_email": "复制邮箱地址",
|
||||
"copy_phone": "复制电话号码",
|
||||
"copy_url": "复制 URL",
|
||||
@@ -3066,7 +3081,7 @@
|
||||
"create_title": "新联系人",
|
||||
"edit_title": "编辑联系人",
|
||||
"section_address_book": "地址簿",
|
||||
"select_address_book": "选择地址簿...",
|
||||
"select_address_book": "地址簿",
|
||||
"section_identity": "姓名和身份",
|
||||
"contact_type": "联系人类型",
|
||||
"type_person": "个人",
|
||||
@@ -3153,6 +3168,13 @@
|
||||
"add_phone": "添加电话",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"discard_title": "放弃更改?",
|
||||
"discard_message": "您的更改尚未保存。",
|
||||
"discard": "放弃",
|
||||
"keep_editing": "继续编辑",
|
||||
"remove_entry": "移除",
|
||||
"close": "关闭",
|
||||
"remove_category": "移除 {name}",
|
||||
"creating": "创建中...",
|
||||
"updating": "更新中...",
|
||||
"name_required": "请输入名字、姓氏或组织",
|
||||
@@ -3169,6 +3191,8 @@
|
||||
},
|
||||
"groups": {
|
||||
"create": "新建群组",
|
||||
"delete": "删除群组",
|
||||
"remove_member": "将 {name} 移出群组",
|
||||
"edit": "编辑群组",
|
||||
"empty": "还没有群组",
|
||||
"delete_confirm_title": "删除群组",
|
||||
@@ -3181,7 +3205,7 @@
|
||||
"search_members": "搜索联系人以添加...",
|
||||
"no_members": "该群组中没有成员",
|
||||
"member_count": "{count, plural, =0 {无成员} one {1 位成员} other {# 位成员}}",
|
||||
"send_email": "Send email to group",
|
||||
"send_email": "发送邮件",
|
||||
"send_email_to": "To",
|
||||
"send_email_cc": "Cc",
|
||||
"send_email_bcc": "Bcc",
|
||||
@@ -3219,6 +3243,7 @@
|
||||
"deleted": "{count, plural, one {已删除 1 位联系人} other {已删除 # 位联系人}}",
|
||||
"add_to_group": "添加到群组",
|
||||
"choose_group": "选择一个组",
|
||||
"new_group": "新建群组…",
|
||||
"adding_contacts": "正在添加{count, plural, one {1 位联系人} other {# 位联系人}}",
|
||||
"added_to_group": "联系人已添加至群组",
|
||||
"export": "导出",
|
||||
@@ -3266,7 +3291,22 @@
|
||||
"sort_first_name": "名",
|
||||
"sort_last_name": "姓"
|
||||
},
|
||||
"open_categories": "打开分类"
|
||||
"shortcuts": {
|
||||
"list": "列表",
|
||||
"actions": "操作",
|
||||
"first_last": "第一个或最后一个",
|
||||
"extend": "扩展选择",
|
||||
"toggle": "选择或取消选择",
|
||||
"select_all": "全选",
|
||||
"clear": "清除选择或搜索",
|
||||
"delete": "删除",
|
||||
"search": "搜索",
|
||||
"move": "下一个或上一个联系人",
|
||||
"open": "打开联系人",
|
||||
"menu": "打开联系人菜单"
|
||||
},
|
||||
"open_categories": "打开分类",
|
||||
"row_actions": "{name} 的操作"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "日历",
|
||||
@@ -3900,8 +3940,6 @@
|
||||
"name_control_chars": "名称不能包含控制字符。",
|
||||
"name_too_long": "此名称对服务器来说过长。",
|
||||
"delete": "删除",
|
||||
"delete_confirm_title": "删除资源",
|
||||
"delete_confirm_message": "您确定要删除\"{name}\"吗?此操作无法撤消。",
|
||||
"download": "下载",
|
||||
"name": "名称",
|
||||
"size": "大小",
|
||||
@@ -3926,18 +3964,57 @@
|
||||
"error_partial_copy": "部分副本被保留在原处。",
|
||||
"create_folder_success": "文件夹已创建",
|
||||
"create_folder_error": "创建文件夹失败",
|
||||
"delete_success": "删除成功",
|
||||
"delete_error": "删除失败",
|
||||
"trash": "回收站",
|
||||
"restore": "恢复",
|
||||
"restored": "{count, plural, other {已恢复 # 个项目}}",
|
||||
"restore_error": "无法恢复",
|
||||
"moved_to_trash": "{count, plural, other {已将 # 个项目移到回收站}}",
|
||||
"delete_forever": "永久删除",
|
||||
"delete_forever_title": "{count, plural, other {永久删除 # 个项目?}}",
|
||||
"delete_forever_message": "“{name}”将被删除。此操作无法撤销。",
|
||||
"delete_forever_message_count": "{count, plural, other {将删除 # 个项目}}。此操作无法撤销。",
|
||||
"deleted_forever": "{count, plural, other {已永久删除 # 个项目}}",
|
||||
"empty_trash": "清空回收站",
|
||||
"empty_trash_title": "清空回收站?",
|
||||
"empty_trash_message": "{count, plural, other {回收站中的 # 个项目将被永久删除}}。此操作无法撤销。",
|
||||
"trash_emptied": "已清空回收站",
|
||||
"empty_trash_error": "无法清空回收站",
|
||||
"trash_empty_title": "回收站为空",
|
||||
"trash_empty_hint": "删除的文件会保留在这里,直到您清空回收站。",
|
||||
"deleted": "删除时间",
|
||||
"deleted_from": "从 {folder} 删除",
|
||||
"shortcuts": {
|
||||
"list": "列表",
|
||||
"actions": "操作",
|
||||
"first_last": "第一个或最后一个",
|
||||
"extend": "扩展选择",
|
||||
"toggle": "选择或取消选择",
|
||||
"select_all": "全选",
|
||||
"clear": "清除选择或搜索",
|
||||
"delete": "删除",
|
||||
"search": "搜索",
|
||||
"move": "下一个或上一个项目",
|
||||
"move_grid": "网格中的相邻项目",
|
||||
"open": "打开",
|
||||
"up": "上一级文件夹",
|
||||
"rename": "重命名",
|
||||
"copy": "复制",
|
||||
"cut": "剪切",
|
||||
"paste": "粘贴"
|
||||
},
|
||||
"rename_success": "重命名成功",
|
||||
"rename_error": "重命名失败",
|
||||
"download_error": "下载失败",
|
||||
"download_zip": "下载为 ZIP",
|
||||
"zipping": "正在准备 {name}",
|
||||
"download_canceled": "已取消下载",
|
||||
"zip_error": "无法下载 {name}",
|
||||
"not_available": "文件存储在此服务器上不可用",
|
||||
"cancel": "取消",
|
||||
"create": "创建",
|
||||
"save": "保存",
|
||||
"no_results": "没有与您的搜索匹配的文件",
|
||||
"batch_delete_confirm_message": "确定要删除{count, plural, one {1 个项目} other {# 个项目}}吗?此操作无法撤销。",
|
||||
"batch_delete_success": "{count, plural, one {已删除 1 个项目} other {已删除 # 个项目}}",
|
||||
"grid_view": "网格视图",
|
||||
"list_view": "列表视图",
|
||||
"details": "详情",
|
||||
@@ -3949,6 +4026,9 @@
|
||||
"paste": "粘贴",
|
||||
"move_success": "{count, plural, one {已移动 1 个项目} other {已移动 # 个项目}}",
|
||||
"move_error": "移动失败",
|
||||
"move_to": "移动到…",
|
||||
"move_to_title": "移动到",
|
||||
"move_here": "移到此处",
|
||||
"paste_success": "粘贴成功",
|
||||
"paste_error": "粘贴失败",
|
||||
"new_text_file": "新建文本文件",
|
||||
@@ -4029,7 +4109,10 @@
|
||||
"zoom_out": "缩小",
|
||||
"zoom_reset": "适应屏幕",
|
||||
"rotate": "旋转",
|
||||
"close_preview": "关闭"
|
||||
"close_preview": "关闭",
|
||||
"previous_file": "上一个文件",
|
||||
"next_file": "下一个文件",
|
||||
"loading": "正在加载…"
|
||||
},
|
||||
"smime": {
|
||||
"your_certificates": "您的证书",
|
||||
|
||||
@@ -1434,3 +1434,13 @@ describe('contact-store', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact display name', () => {
|
||||
it('shows the name an imported card gives itself (FN), not only first and last name', async () => {
|
||||
const { getContactDisplayName } = await import('../contact-store');
|
||||
expect(getContactDisplayName({
|
||||
id: 'x', addressBookIds: {},
|
||||
name: { full: 'UX-cf Jürgen Müller', components: [{ kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: 'Müller' }] },
|
||||
} as never)).toBe('UX-cf Jürgen Müller');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useFileStore, deletesToTrash } from '../file-store';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { FileNode } from '@/lib/jmap/types';
|
||||
import { TRASH_LOCATION, parseTrashBatchName, trashBatchName } from '@/lib/files-trash';
|
||||
|
||||
// An in-memory FileNode server: a folder is a node without a blob, and
|
||||
// destroying a node destroys what is below it, as on Stalwart.
|
||||
function makeServer(initial: FileNode[]) {
|
||||
let nodes = initial.map(n => ({ ...n }));
|
||||
let seq = 0;
|
||||
const now = () => new Date().toISOString();
|
||||
const client = {
|
||||
getCapabilities: () => ({}),
|
||||
async listAllFileNodes() { return nodes.map(n => ({ ...n })); },
|
||||
async listAllFileNodesAcrossAccounts() { return nodes.map(n => ({ ...n })); },
|
||||
async createFileDirectory(name: string, parentId: string | null) {
|
||||
const node: FileNode = { id: `n${++seq}`, parentId, name, type: '', blobId: null, size: 0, created: now(), modified: now() };
|
||||
nodes.push(node);
|
||||
return { ...node };
|
||||
},
|
||||
async updateFileNode(id: string, patch: Partial<FileNode>) {
|
||||
Object.assign(nodes.find(n => n.id === id)!, patch);
|
||||
},
|
||||
async updateFileNodes(updates: Record<string, Partial<FileNode>>) {
|
||||
const updated: string[] = [];
|
||||
for (const [id, patch] of Object.entries(updates)) {
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (!node) continue;
|
||||
const clash = nodes.some(n => n.id !== id && (n.parentId ?? null) === (patch.parentId !== undefined ? patch.parentId : node.parentId) && n.name === (patch.name ?? node.name));
|
||||
if (clash) continue;
|
||||
Object.assign(node, patch);
|
||||
updated.push(id);
|
||||
}
|
||||
return { updated, notUpdated: {} };
|
||||
},
|
||||
async destroyFileNodes(ids: string[]) {
|
||||
const gone = new Set(ids);
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const n of nodes) {
|
||||
if (n.parentId && gone.has(n.parentId) && !gone.has(n.id)) { gone.add(n.id); grew = true; }
|
||||
}
|
||||
}
|
||||
nodes = nodes.filter(n => !gone.has(n.id));
|
||||
return { destroyed: [...gone], notDestroyed: [] };
|
||||
},
|
||||
nodes: () => nodes,
|
||||
};
|
||||
return client as unknown as IJMAPClient & { nodes: () => FileNode[] };
|
||||
}
|
||||
|
||||
const dir = (id: string, name: string, parentId: string | null): FileNode => ({
|
||||
id, parentId, name, type: '', blobId: null, size: 0, created: '', modified: '',
|
||||
});
|
||||
const file = (id: string, name: string, parentId: string | null): FileNode => ({
|
||||
id, parentId, name, type: 'text/plain', blobId: `b-${id}`, size: 3, created: '', modified: '',
|
||||
});
|
||||
|
||||
const names = () => useFileStore.getState().resources.map(r => r.name);
|
||||
|
||||
describe('file-store trash', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useFileStore.setState({
|
||||
client: null, currentParentId: null, currentPath: '/', pathStack: [{ id: null, name: '' }],
|
||||
resources: [], selectedResources: new Set(), clipboard: null, lastAction: null,
|
||||
favorites: [], recentFiles: [], trashCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
async function open(nodes: FileNode[], folder: string | null = null) {
|
||||
const server = makeServer(nodes);
|
||||
useFileStore.getState().initClient(server);
|
||||
await useFileStore.getState().navigate(folder, folder ?? undefined);
|
||||
return server;
|
||||
}
|
||||
|
||||
it('moves deleted items into the trash instead of destroying them, and hides the trash', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('b', 'b.txt', 'docs')], 'docs');
|
||||
expect(await useFileStore.getState().trashResources(['a.txt'])).toBe(1);
|
||||
|
||||
expect(server.nodes().find(n => n.id === 'a')).toBeDefined();
|
||||
expect(names()).toEqual(['b.txt']);
|
||||
expect(useFileStore.getState().trashCount).toBe(1);
|
||||
|
||||
await useFileStore.getState().navigate(null);
|
||||
expect(names()).toEqual(['Documents']);
|
||||
expect(useFileStore.getState().allFolders.map(f => f.name)).toEqual(['Documents']);
|
||||
});
|
||||
|
||||
it('records where an item came from in the name of its delete', () => {
|
||||
expect(parseTrashBatchName(trashBatchName('docs', 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: 'docs' });
|
||||
expect(parseTrashBatchName(trashBatchName(null, 1730112000000))).toEqual({ deletedAt: 1730112000000, parentId: null });
|
||||
expect(parseTrashBatchName('Holiday photos')).toBeNull();
|
||||
});
|
||||
|
||||
it('puts the items back on undo and leaves no empty folder in the trash', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
|
||||
await useFileStore.getState().trashResources(['a.txt']);
|
||||
await useFileStore.getState().undoLastAction();
|
||||
|
||||
expect(names()).toEqual(['a.txt']);
|
||||
expect(useFileStore.getState().trashCount).toBe(0);
|
||||
const trash = server.nodes().find(n => n.name === '.Trash')!;
|
||||
expect(server.nodes().filter(n => n.parentId === trash.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists the trash with where each item came from, and restores it there', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs')], 'docs');
|
||||
await useFileStore.getState().trashResources(['a.txt']);
|
||||
|
||||
await useFileStore.getState().navigate(TRASH_LOCATION);
|
||||
const [item] = useFileStore.getState().resources;
|
||||
expect(item.name).toBe('a.txt');
|
||||
expect(item.trash?.fromParentId).toBe('docs');
|
||||
expect(useFileStore.getState().currentPath).toBe('/.Trash');
|
||||
|
||||
expect(await useFileStore.getState().restoreResources(['a.txt'])).toBe(1);
|
||||
expect(server.nodes().find(n => n.id === 'a')?.parentId).toBe('docs');
|
||||
expect(names()).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores to the root when the folder is gone, under a new name when the old one is taken', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), file('a', 'a.txt', 'docs'), file('top', 'a.txt', null)], 'docs');
|
||||
await useFileStore.getState().trashResources(['a.txt']);
|
||||
await server.destroyFileNodes(['docs']);
|
||||
|
||||
await useFileStore.getState().navigate(TRASH_LOCATION);
|
||||
await useFileStore.getState().restoreResources(['a.txt']);
|
||||
const restored = server.nodes().find(n => n.id === 'a')!;
|
||||
expect(restored.parentId).toBeNull();
|
||||
expect(restored.name).toBe('a (1).txt');
|
||||
});
|
||||
|
||||
it('deletes items for good from the trash, and empties it', async () => {
|
||||
const server = await open([file('a', 'a.txt', null), file('b', 'b.txt', null)]);
|
||||
await useFileStore.getState().trashResources(['a.txt']);
|
||||
await useFileStore.getState().trashResources(['b.txt']);
|
||||
await useFileStore.getState().navigate(TRASH_LOCATION);
|
||||
|
||||
expect(await useFileStore.getState().purgeResources(['a.txt'])).toBe(1);
|
||||
expect(server.nodes().find(n => n.id === 'a')).toBeUndefined();
|
||||
expect(names()).toEqual(['b.txt']);
|
||||
|
||||
await useFileStore.getState().emptyTrash();
|
||||
expect(server.nodes()).toEqual([]);
|
||||
expect(useFileStore.getState().trashCount).toBe(0);
|
||||
});
|
||||
|
||||
it('trashes a folder from the tree and steps out of it', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs')], 'docs');
|
||||
await useFileStore.getState().navigate('sub', 'Sub');
|
||||
expect(await useFileStore.getState().trashNode('docs')).toBe(true);
|
||||
expect(useFileStore.getState().currentParentId).toBeNull();
|
||||
expect(server.nodes().find(n => n.id === 'docs')).toBeDefined();
|
||||
expect(names()).toEqual([]);
|
||||
});
|
||||
|
||||
it('has no trash in a drive someone shared', () => {
|
||||
expect(deletesToTrash(null)).toBe(true);
|
||||
expect(deletesToTrash('docs')).toBe(true);
|
||||
expect(deletesToTrash('e:team')).toBe(false);
|
||||
expect(deletesToTrash(TRASH_LOCATION)).toBe(false);
|
||||
});
|
||||
|
||||
it('moves a folder of the tree by id, and not into itself', async () => {
|
||||
const server = await open([dir('docs', 'Documents', null), dir('sub', 'Sub', 'docs'), dir('arch', 'Archive', null)]);
|
||||
expect(await useFileStore.getState().moveNode('docs', 'sub')).toBe(false);
|
||||
expect(await useFileStore.getState().moveNode('docs', 'arch')).toBe(true);
|
||||
expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBe('arch');
|
||||
await useFileStore.getState().undoLastAction();
|
||||
expect(server.nodes().find(n => n.id === 'docs')?.parentId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1019,3 +1019,20 @@ describe('file-store actions by id', () => {
|
||||
expect(useFileStore.getState().recentFiles.map(r => r.id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('file-store preview content', () => {
|
||||
it('reads a file through the client, so the demo, which has no download URL, can preview it', async () => {
|
||||
const client = makeMockClient([file('a', 'notes.txt', null)]);
|
||||
Object.assign(client, {
|
||||
getBlobDownloadUrl: () => 'data:application/octet-stream;demo-blob=b-a',
|
||||
getAuthHeader: () => '',
|
||||
fetchBlob: async (blobId: string) => new Blob([`content of ${blobId}`], { type: 'text/plain' }),
|
||||
});
|
||||
useFileStore.getState().initClient(client);
|
||||
await useFileStore.getState().navigate(null);
|
||||
|
||||
const { blob, contentType } = await useFileStore.getState().getFileContent('notes.txt');
|
||||
expect(await blob.text()).toBe('content of b-a');
|
||||
expect(contentType).toBe('text/plain');
|
||||
});
|
||||
});
|
||||
|
||||
+50
-3
@@ -178,15 +178,16 @@ function resolveRawAddressBookIds(
|
||||
|
||||
export function getContactDisplayName(contact: ContactCard): string {
|
||||
if (contact.name) {
|
||||
// Try given + surname from components first
|
||||
// The card's own display name (RFC 9553 name.full, vCard FN) first: an
|
||||
// imported "Dr. Jürgen Müller" stays that, and this app's form keeps it
|
||||
// in step with the parts it writes.
|
||||
if (contact.name.full?.trim()) return contact.name.full.trim();
|
||||
if (contact.name.components && contact.name.components.length > 0) {
|
||||
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
|
||||
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
|
||||
const full = [given, surname].filter(Boolean).join(' ');
|
||||
if (full) return full;
|
||||
}
|
||||
// Fall back to name.full (RFC 9553 - used by Stalwart and other JMAP servers)
|
||||
if (contact.name.full) return contact.name.full;
|
||||
}
|
||||
if (contact.nicknames) {
|
||||
const nick = Object.values(contact.nicknames)[0];
|
||||
@@ -463,6 +464,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 +1410,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;
|
||||
|
||||
+310
-18
@@ -9,6 +9,11 @@ import { imageBlobUrl } from '@/lib/file-preview';
|
||||
import { fileHooks } from '@/lib/plugin-hooks';
|
||||
import { fileToView } from '@/lib/plugin-projection';
|
||||
import type { FileResourceView } from '@/lib/plugin-types';
|
||||
import {
|
||||
TRASH_FOLDER_NAME, TRASH_LOCATION, TRASH_PATH, findTrashFolder, isTrashLocation, trashBatchName,
|
||||
trashSubtreeIds, trashedItems,
|
||||
} from '@/lib/files-trash';
|
||||
import { buildZip, planZip, saveBlob, type ZipProgress } from '@/lib/files-zip';
|
||||
|
||||
export interface FileResource {
|
||||
id: string;
|
||||
@@ -31,6 +36,8 @@ export interface FileResource {
|
||||
isShared?: boolean;
|
||||
ownerAccountId?: string;
|
||||
ownerName?: string;
|
||||
/** Set for an item in the trash: when it was deleted and where from (null: the root). */
|
||||
trash?: { deletedAt: string | null; fromParentId: string | null; batchId: string | null };
|
||||
}
|
||||
|
||||
interface UploadProgress {
|
||||
@@ -73,6 +80,8 @@ interface UndoAction {
|
||||
type: 'rename' | 'move';
|
||||
entries: { id: string; from: Partial<Pick<FileNode, 'name' | 'parentId'>>; to: Partial<Pick<FileNode, 'name' | 'parentId'>> }[];
|
||||
sourceParentId: string | null;
|
||||
/** A delete into the trash: the trash folder its items went into, removed on undo. */
|
||||
trashBatchId?: string;
|
||||
}
|
||||
|
||||
interface FileState {
|
||||
@@ -105,6 +114,8 @@ interface FileState {
|
||||
allFolders: FileResource[];
|
||||
/** False until the first listing for the attached account has finished. */
|
||||
hasListed: boolean;
|
||||
/** How many items the trash holds, from the last listing. */
|
||||
trashCount: number;
|
||||
|
||||
// Actions
|
||||
initClient: (client: IJMAPClient, accountId?: string | null) => void;
|
||||
@@ -134,6 +145,11 @@ interface FileState {
|
||||
renameResource: (oldName: string, newName: string) => Promise<boolean>;
|
||||
downloadResource: (name: string) => Promise<void>;
|
||||
downloadResources: (names: string[]) => Promise<void>;
|
||||
/**
|
||||
* Download items by id as one zip built in the browser: files as they are,
|
||||
* folders with everything in them. Rejects with an AbortError when canceled.
|
||||
*/
|
||||
downloadZip: (ids: string[], zipName: string, options?: { signal?: AbortSignal; onProgress?: (p: ZipProgress) => void }) => Promise<void>;
|
||||
getImageUrl: (name: string) => Promise<string>;
|
||||
getFileContent: (name: string) => Promise<{ blob: Blob; contentType: string }>;
|
||||
createTextFile: (name: string) => Promise<void>;
|
||||
@@ -142,6 +158,8 @@ interface FileState {
|
||||
moveToParent: (names: string[]) => Promise<boolean>;
|
||||
/** Move listed items into any folder by id (null = the root), e.g. a tree or breadcrumb drop. */
|
||||
moveToFolderId: (names: string[], targetId: string | null) => Promise<boolean>;
|
||||
/** Move any node by id into a folder (null = the root), e.g. a folder of the tree. */
|
||||
moveNode: (id: string, targetId: string | null) => Promise<boolean>;
|
||||
cutResources: (names: string[]) => void;
|
||||
copyResources: (names: string[]) => void;
|
||||
pasteResources: () => Promise<boolean>;
|
||||
@@ -160,6 +178,19 @@ interface FileState {
|
||||
renameNode: (id: string, newName: string) => Promise<boolean>;
|
||||
/** Delete any node by id; leaves the folder on screen if it was inside it. */
|
||||
deleteNode: (id: string) => Promise<boolean>;
|
||||
/**
|
||||
* Move items of the folder on screen into the trash; how many went (0 when
|
||||
* a plugin stopped it). Undo puts them back.
|
||||
*/
|
||||
trashResources: (names: string[]) => Promise<number>;
|
||||
/** Move any node by id into the trash, like deleteNode. */
|
||||
trashNode: (id: string) => Promise<boolean>;
|
||||
/** Put items listed in the trash back where they came from (the root when that is gone). */
|
||||
restoreResources: (names: string[]) => Promise<number>;
|
||||
/** Delete items listed in the trash for good. */
|
||||
purgeResources: (names: string[]) => Promise<number>;
|
||||
/** Delete everything in the trash for good. */
|
||||
emptyTrash: () => Promise<void>;
|
||||
/** Download a file by id, wherever it lives. */
|
||||
downloadNode: (id: string) => Promise<void>;
|
||||
removeRecentFile: (id: string) => void;
|
||||
@@ -284,11 +315,45 @@ export function linkedPreviewResource(
|
||||
: resources.find(r => r.name === link.name);
|
||||
}
|
||||
|
||||
// A shared-files page lists files; nothing can be put into it.
|
||||
// A shared-files page lists files; nothing can be put into it, nor into the
|
||||
// trash other than by deleting.
|
||||
function assertCanAddTo(parentId: string | null): void {
|
||||
if (sharedFilesOwner(parentId) !== null) {
|
||||
throw jmapError('Nothing can be added to a list of shared files', { type: 'forbidden' });
|
||||
}
|
||||
if (isTrashLocation(parentId)) {
|
||||
throw jmapError('Nothing can be added to the trash', { type: 'forbidden' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether what is deleted in a folder goes to the trash: in the user's own
|
||||
* drive. A drive someone shared has no trash of the user's, and what is
|
||||
* deleted there is gone.
|
||||
*/
|
||||
export function deletesToTrash(parentId: string | null): boolean {
|
||||
return !isCrossAccountId(parentId) && !isTrashLocation(parentId);
|
||||
}
|
||||
|
||||
/** The trash folder, created on the first delete. */
|
||||
async function ensureTrashFolder(client: IJMAPClient, nodes: FileNode[]): Promise<FileNode> {
|
||||
return findTrashFolder(nodes) ?? client.createFileDirectory(TRASH_FOLDER_NAME, null);
|
||||
}
|
||||
|
||||
/** Remove the trash folders of deletes whose items have all left the trash. */
|
||||
async function removeEmptyTrashBatches(client: IJMAPClient, gone: FileResource[]): Promise<void> {
|
||||
const batchIds = [...new Set(gone.map(r => r.trash?.batchId).filter((id): id is string => !!id))];
|
||||
if (batchIds.length === 0) return;
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const empty = batchIds.filter(id => !nodes.some(n => n.parentId === id));
|
||||
if (empty.length > 0) await client.destroyFileNodes(empty);
|
||||
}
|
||||
|
||||
/** Throw the first refusal when nothing of an update went through. */
|
||||
function assertUpdated(result: { updated: string[]; notUpdated: Record<string, string> }): void {
|
||||
if (result.updated.length > 0) return;
|
||||
const [reason] = Object.values(result.notUpdated);
|
||||
throw jmapError(reason || 'The server refused the change', { type: 'forbidden' });
|
||||
}
|
||||
|
||||
// The top of what other accounts share with the user: shared nodes whose
|
||||
@@ -535,6 +600,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
sharedRoots: [],
|
||||
allFolders: [],
|
||||
hasListed: false,
|
||||
trashCount: 0,
|
||||
favorites: (() => {
|
||||
try { return JSON.parse(localStorage.getItem('files-favorites') || '[]'); } catch { return []; }
|
||||
})(),
|
||||
@@ -790,6 +856,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
} else if (sharedFilesOwner(parentId) !== null) {
|
||||
// A shared-files page sits right below Home, whatever was open before.
|
||||
newStack = [{ id: null, name: '' }, { id: parentId, name: name || parentId }];
|
||||
} else if (isTrashLocation(parentId)) {
|
||||
newStack = [{ id: null, name: '' }, { id: TRASH_LOCATION, name: TRASH_FOLDER_NAME }];
|
||||
} else {
|
||||
// Check if navigating to a parent in the stack
|
||||
const existingIdx = pathStack.findIndex(s => s.id === parentId);
|
||||
@@ -816,25 +884,39 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
? await client.listAllFileNodesAcrossAccounts()
|
||||
: await client.listAllFileNodes();
|
||||
const owner = sharedFilesOwner(parentId);
|
||||
const resources = owner !== null
|
||||
? withUniqueNames(sortResources(
|
||||
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
|
||||
))
|
||||
// The owner's folders on the way down to a shared node aren't shown
|
||||
// (see sharedRootsOf); the user can do nothing in them.
|
||||
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource));
|
||||
// The trash and what is in it stay out of every listing but its own.
|
||||
const trash = findTrashFolder(allNodes);
|
||||
const inTrash = trashSubtreeIds(allNodes, trash?.id);
|
||||
const trashed = trashedItems(allNodes, trash);
|
||||
const resources = isTrashLocation(parentId)
|
||||
? withUniqueNames(sortResources(trashed.map(item => ({
|
||||
...nodeToResource(item.node),
|
||||
trash: {
|
||||
deletedAt: item.deletedAt !== null ? new Date(item.deletedAt).toISOString() : null,
|
||||
fromParentId: item.fromParentId,
|
||||
batchId: item.batchId,
|
||||
},
|
||||
}))))
|
||||
: owner !== null
|
||||
? withUniqueNames(sortResources(
|
||||
sharedRootsOf(allNodes).filter(n => n.accountId === owner && !isFolder(n)).map(nodeToResource),
|
||||
))
|
||||
// The owner's folders on the way down to a shared node aren't shown
|
||||
// (see sharedRootsOf); the user can do nothing in them.
|
||||
: sortResources(childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource));
|
||||
// The owner's folders above a shared one stay out of the tree as well.
|
||||
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly).map(nodeToResource);
|
||||
const allFolders = allNodes.filter(n => isFolder(n) && !n.discoverableOnly && !inTrash.has(n.id)).map(nodeToResource);
|
||||
|
||||
// Prune recent files whose backing node no longer exists on the server
|
||||
// or is in the trash.
|
||||
const { recentFiles } = get();
|
||||
const existingIds = new Set(allNodes.map(n => n.id));
|
||||
const existingIds = new Set(allNodes.filter(n => !inTrash.has(n.id)).map(n => n.id));
|
||||
const prunedRecent = recentFiles.filter(r => existingIds.has(r.id));
|
||||
if (prunedRecent.length !== recentFiles.length) {
|
||||
try { localStorage.setItem('files-recent-files', JSON.stringify(prunedRecent)); } catch { /* ignore */ }
|
||||
set({ resources, allFolders, recentFiles: prunedRecent, isLoading: false, hasListed: true });
|
||||
set({ resources, allFolders, trashCount: trashed.length, recentFiles: prunedRecent, isLoading: false, hasListed: true });
|
||||
} else {
|
||||
set({ resources, allFolders, isLoading: false, hasListed: true });
|
||||
set({ resources, allFolders, trashCount: trashed.length, isLoading: false, hasListed: true });
|
||||
}
|
||||
if (opensFolder) void fileHooks.onFileNavigate.emit({ path: newPath });
|
||||
} catch (error) {
|
||||
@@ -853,6 +935,10 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
await navigate(null);
|
||||
return;
|
||||
}
|
||||
if (path === TRASH_PATH) {
|
||||
await navigate(TRASH_LOCATION, TRASH_FOLDER_NAME);
|
||||
return;
|
||||
}
|
||||
// Try to match the path against the current pathStack
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const targetDepth = segments.length;
|
||||
@@ -1210,6 +1296,18 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
downloadZip: async (ids, zipName, options = {}) => {
|
||||
const { client } = get();
|
||||
if (!client) return;
|
||||
const nodes = ids.some(isCrossAccountId)
|
||||
? await client.listAllFileNodesAcrossAccounts()
|
||||
: await client.listAllFileNodes();
|
||||
const plan = planZip(nodes, ids);
|
||||
// A node of a drive shared with the user is fetched from its owner's account.
|
||||
const blob = await buildZip(plan, (node) => client.fetchBlob(node.blobId!, node.name, node.type, node.accountId), options);
|
||||
saveBlob(blob, zipName);
|
||||
},
|
||||
|
||||
getImageUrl: async (name: string) => {
|
||||
const { client, resources } = get();
|
||||
if (!client) throw new Error('No client');
|
||||
@@ -1227,12 +1325,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
const resource = resources.find(r => r.name === name);
|
||||
if (!resource?.blobId) throw new Error('No blob');
|
||||
|
||||
const url = client.getBlobDownloadUrl(resource.blobId, resource.name, resource.contentType);
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': client.getAuthHeader() },
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
// Through the client, like getImageUrl: the demo has no download URL to fetch.
|
||||
const blob = await client.fetchBlob(resource.blobId, resource.name, resource.contentType);
|
||||
return { blob, contentType: resource.contentType || 'application/octet-stream' };
|
||||
},
|
||||
|
||||
@@ -1351,6 +1445,28 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
moveNode: async (id: string, targetId: string | null) => {
|
||||
const { client, allFolders, resources, refresh } = get();
|
||||
if (!client) return false;
|
||||
assertCanAddTo(targetId);
|
||||
// A folder must not move into itself or anything below it.
|
||||
const parentOf = new Map(allFolders.map(f => [f.id, f.parentId ?? null]));
|
||||
for (let cur = targetId; cur != null; cur = parentOf.get(cur) ?? null) {
|
||||
if (cur === id) return false;
|
||||
}
|
||||
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
|
||||
const from = known?.parentId ?? null;
|
||||
if (known && from === targetId) return false;
|
||||
const view = known ? resourceToView(known, parentPath(allFolders, from)) : unknownNodeView(id);
|
||||
const toPath = parentPath(allFolders, targetId);
|
||||
if (!(await fileHooks.onBeforeFileMove.intercept({ files: [view], toPath }))) return false;
|
||||
await client.updateFileNode(id, { parentId: targetId });
|
||||
void fileHooks.onFileMove.emit({ files: [view], toPath });
|
||||
set({ lastAction: { type: 'move', entries: [{ id, from: { parentId: from }, to: { parentId: targetId } }], sourceParentId: null } });
|
||||
await refresh();
|
||||
return true;
|
||||
},
|
||||
|
||||
cutResources: (names: string[]) => {
|
||||
const { currentPath, currentParentId, resources } = get();
|
||||
const picked = names
|
||||
@@ -1615,6 +1731,180 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
|
||||
trashResources: async (names: string[]) => {
|
||||
const { client, resources, currentParentId, currentPath, recentFiles, favorites, refresh } = get();
|
||||
if (!client) return 0;
|
||||
const items = names
|
||||
.map(name => resources.find(r => r.name === name))
|
||||
.filter((r): r is FileResource => !!r);
|
||||
if (items.length === 0) return 0;
|
||||
const files = items.map(r => resourceToView(r, currentPath));
|
||||
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return 0;
|
||||
|
||||
const trash = await ensureTrashFolder(client, await client.listAllFileNodes());
|
||||
const batch = await client.createFileDirectory(trashBatchName(currentParentId), trash.id);
|
||||
let result: { updated: string[]; notUpdated: Record<string, string> };
|
||||
try {
|
||||
result = await client.updateFileNodes(Object.fromEntries(items.map(r => [r.id, { parentId: batch.id }])));
|
||||
assertUpdated(result);
|
||||
} catch (err) {
|
||||
await client.destroyFileNodes([batch.id]).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
const moved = new Set(result.updated);
|
||||
const trashed = items.filter(r => moved.has(r.id));
|
||||
void fileHooks.onAfterFileDelete.emit({ files: files.filter(f => moved.has(f.id)) });
|
||||
|
||||
// Like a delete: recent files and stars of what went drop off.
|
||||
const gonePaths = trashed.filter(r => r.isDirectory).map(r => joinPath(currentPath, r.name));
|
||||
const nextFavorites = favorites.filter(f => !gonePaths.some(path => isUnder(f, path)));
|
||||
const nextRecent = recentFiles.filter(r => !moved.has(r.id));
|
||||
set({
|
||||
favorites: nextFavorites,
|
||||
recentFiles: nextRecent,
|
||||
selectedResources: new Set(),
|
||||
lastAction: {
|
||||
type: 'move',
|
||||
entries: trashed.map(r => ({ id: r.id, from: { parentId: r.parentId ?? null }, to: { parentId: batch.id } })),
|
||||
sourceParentId: currentParentId,
|
||||
trashBatchId: batch.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
|
||||
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
|
||||
} catch { /* ignore */ }
|
||||
await refresh();
|
||||
return trashed.length;
|
||||
},
|
||||
|
||||
trashNode: async (id: string) => {
|
||||
const { client, allFolders, resources, favorites, recentFiles, pathStack, navigate, refresh } = get();
|
||||
if (!client) return false;
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const node = nodes.find(n => n.id === id);
|
||||
if (!node) return false;
|
||||
const path = folderPath(allFolders, id);
|
||||
const known = allFolders.find(f => f.id === id) ?? resources.find(r => r.id === id);
|
||||
const files = [known ? resourceToView(known, parentPath(allFolders, known.parentId)) : unknownNodeView(id)];
|
||||
if (!(await fileHooks.onBeforeFileDelete.intercept({ files }))) return false;
|
||||
|
||||
const trash = await ensureTrashFolder(client, nodes);
|
||||
const batch = await client.createFileDirectory(trashBatchName(node.parentId ?? null), trash.id);
|
||||
try {
|
||||
assertUpdated(await client.updateFileNodes({ [id]: { parentId: batch.id } }));
|
||||
} catch (err) {
|
||||
await client.destroyFileNodes([batch.id]).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
void fileHooks.onAfterFileDelete.emit({ files });
|
||||
|
||||
const nextFavorites = path ? favorites.filter(f => !isUnder(f, path)) : favorites;
|
||||
const nextRecent = recentFiles.filter(r => r.id !== id);
|
||||
set({
|
||||
favorites: nextFavorites,
|
||||
recentFiles: nextRecent,
|
||||
lastAction: {
|
||||
type: 'move',
|
||||
entries: [{ id, from: { parentId: node.parentId ?? null }, to: { parentId: batch.id } }],
|
||||
sourceParentId: node.parentId ?? null,
|
||||
trashBatchId: batch.id,
|
||||
},
|
||||
});
|
||||
try {
|
||||
localStorage.setItem('files-favorites', JSON.stringify(nextFavorites));
|
||||
localStorage.setItem('files-recent-files', JSON.stringify(nextRecent));
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Standing in the deleted folder or below it: step out to its parent.
|
||||
const idx = pathStack.findIndex(s => s.id === id);
|
||||
if (idx > 0) {
|
||||
const parent = pathStack[idx - 1];
|
||||
set({ pathStack: pathStack.slice(0, idx) });
|
||||
await navigate(parent.id, parent.name);
|
||||
return true;
|
||||
}
|
||||
await refresh();
|
||||
return true;
|
||||
},
|
||||
|
||||
restoreResources: async (names: string[]) => {
|
||||
const { client, resources, refresh } = get();
|
||||
if (!client) return 0;
|
||||
const items = names
|
||||
.map(name => resources.find(r => r.name === name && r.trash))
|
||||
.filter((r): r is FileResource => !!r);
|
||||
if (items.length === 0) return 0;
|
||||
|
||||
const nodes = await client.listAllFileNodes();
|
||||
const inTrash = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
|
||||
const folderIds = new Set(nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(n => n.id));
|
||||
// Names taken in each folder, so a restored item never clashes.
|
||||
const taken = new Map<string | null, Set<string>>();
|
||||
const namesIn = (parentId: string | null) => {
|
||||
let set = taken.get(parentId);
|
||||
if (!set) {
|
||||
set = new Set(childrenOf(nodes, parentId).filter(n => !inTrash.has(n.id)).map(n => n.name));
|
||||
taken.set(parentId, set);
|
||||
}
|
||||
return set;
|
||||
};
|
||||
const updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>> = {};
|
||||
const targets = new Map<string, string | null>();
|
||||
for (const item of items) {
|
||||
const from = item.trash?.fromParentId ?? null;
|
||||
// A folder that is gone (or itself in the trash) gives the item back to the root.
|
||||
const target = from !== null && folderIds.has(from) ? from : null;
|
||||
const used = namesIn(target);
|
||||
const name = getUniqueName(item.serverName, used);
|
||||
used.add(name);
|
||||
updates[item.id] = name === item.serverName ? { parentId: target } : { parentId: target, name };
|
||||
targets.set(item.id, target);
|
||||
}
|
||||
const result = await client.updateFileNodes(updates);
|
||||
assertUpdated(result);
|
||||
const restored = new Set(result.updated);
|
||||
const allFolders = nodes.filter(n => isFolder(n) && !inTrash.has(n.id)).map(nodeToResource);
|
||||
const byTarget = new Map<string, FileResourceView[]>();
|
||||
for (const item of items.filter(r => restored.has(r.id))) {
|
||||
const toPath = parentPath(allFolders, targets.get(item.id) ?? null);
|
||||
const list = byTarget.get(toPath) ?? [];
|
||||
list.push(resourceToView(item, toPath));
|
||||
byTarget.set(toPath, list);
|
||||
}
|
||||
for (const [toPath, files] of byTarget) void fileHooks.onFileMove.emit({ files, toPath });
|
||||
await removeEmptyTrashBatches(client, items.filter(r => restored.has(r.id)));
|
||||
set({ selectedResources: new Set() });
|
||||
await refresh();
|
||||
return restored.size;
|
||||
},
|
||||
|
||||
purgeResources: async (names: string[]) => {
|
||||
const { client, resources, refresh } = get();
|
||||
if (!client) return 0;
|
||||
const items = names
|
||||
.map(name => resources.find(r => r.name === name && r.trash))
|
||||
.filter((r): r is FileResource => !!r);
|
||||
if (items.length === 0) return 0;
|
||||
// The server removes what is inside a folder with it.
|
||||
const result = await client.destroyFileNodes(items.map(r => r.id));
|
||||
const destroyed = new Set(result.destroyed);
|
||||
await removeEmptyTrashBatches(client, items.filter(r => destroyed.has(r.id)));
|
||||
set({ selectedResources: new Set() });
|
||||
await refresh();
|
||||
return items.filter(r => destroyed.has(r.id)).length;
|
||||
},
|
||||
|
||||
emptyTrash: async () => {
|
||||
const { client, refresh } = get();
|
||||
if (!client) return;
|
||||
const trash = findTrashFolder(await client.listAllFileNodes());
|
||||
// The folder goes with everything in it; the next delete makes a new one.
|
||||
if (trash) await client.destroyFileNodes([trash.id]);
|
||||
set({ selectedResources: new Set() });
|
||||
await refresh();
|
||||
},
|
||||
|
||||
downloadNode: async (id: string) => {
|
||||
const { client } = get();
|
||||
if (!client) return;
|
||||
@@ -1641,6 +1931,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
for (const entry of lastAction.entries) {
|
||||
await client.updateFileNode(entry.id, entry.from);
|
||||
}
|
||||
// A delete's folder in the trash is empty again.
|
||||
if (lastAction.trashBatchId) await client.destroyFileNodes([lastAction.trashBatchId]);
|
||||
set({ lastAction: null });
|
||||
void fileHooks.onFileUndo.emit({
|
||||
action: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { settingsHooks } from '@/lib/plugin-hooks';
|
||||
import { generateAccountId } from '@/lib/account-utils';
|
||||
import { subscriptionOwnerFor } from '@/lib/calendar-subscription-sync';
|
||||
import type { FolderCountMode } from '@/lib/folder-counts';
|
||||
import { DEFAULT_FILES_SETTINGS, normalizeFilesSettings, type FilesSettings } from '@/lib/files-settings';
|
||||
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
|
||||
import {
|
||||
DEFAULT_SUB_ADDRESS_DELIMITER,
|
||||
@@ -587,6 +588,9 @@ interface SettingsState {
|
||||
// family members sit together (#963).
|
||||
sortContactsByLastName: boolean;
|
||||
|
||||
// Files: view, sort and icons (Settings > Files)
|
||||
filesSettings: FilesSettings;
|
||||
|
||||
// Email Notifications
|
||||
emailNotificationsEnabled: boolean;
|
||||
emailNotificationSound: boolean;
|
||||
@@ -886,6 +890,9 @@ const DEFAULT_SETTINGS = {
|
||||
groupContactsByLetter: true,
|
||||
sortContactsByLastName: false,
|
||||
|
||||
// Files
|
||||
filesSettings: DEFAULT_FILES_SETTINGS,
|
||||
|
||||
// Email Notifications
|
||||
emailNotificationsEnabled: true,
|
||||
emailNotificationSound: true,
|
||||
@@ -1238,6 +1245,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
sharedCalendarColors: state.sharedCalendarColors,
|
||||
groupContactsByLetter: state.groupContactsByLetter,
|
||||
sortContactsByLastName: state.sortContactsByLastName,
|
||||
filesSettings: state.filesSettings,
|
||||
expandedFilterView: state.expandedFilterView,
|
||||
showTimeInMonthView: state.showTimeInMonthView,
|
||||
showWeekNumbers: state.showWeekNumbers,
|
||||
@@ -1418,6 +1426,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
put(key, { notificationMutes: normalizeNotificationMutes(settings[key]) });
|
||||
return;
|
||||
}
|
||||
if (key === 'filesSettings') {
|
||||
put(key, { filesSettings: normalizeFilesSettings(settings[key]) });
|
||||
return;
|
||||
}
|
||||
// The list order is sent to the server as a sort array, so an
|
||||
// unknown criterion from a newer/older client must never get
|
||||
// through (an unsupportedSort refusal empties the folder).
|
||||
|
||||
Reference in New Issue
Block a user