Merge branch 'main' into ux/settings-admin

# Conflicts:
#	components/settings/address-book-management-settings.tsx
This commit is contained in:
Linus Rath
2026-10-28 06:02:59 +01:00
141 changed files with 11304 additions and 3418 deletions
@@ -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', () => {
@@ -57,6 +57,14 @@ function renderView(overrides: Partial<Props> = {}) {
}
describe('CalendarAgendaView infinite scroll', () => {
it('shows one empty state with a create button when the window holds no events', () => {
const onCreateEvent = vi.fn();
renderView({ events: [], onCreateEvent });
expect(screen.queryByText('events.today_header')).toBeNull();
screen.getByRole('button', { name: 'events.new_event' }).click();
expect(onCreateEvent).toHaveBeenCalled();
});
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date(2026, 8, 9, 10, 0, 0));
@@ -148,7 +156,7 @@ describe('CalendarAgendaView infinite scroll', () => {
});
it('only anchors a "Today" row when today lies inside the loaded window', () => {
renderView();
renderView({ events: [makeEvent('b', '2026-09-20T09:00:00')] });
expect(screen.getByText('events.today_header')).toBeInTheDocument();
cleanup();
@@ -0,0 +1,665 @@
import { render, screen, fireEvent, within, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { useTaskStore } from '@/stores/task-store';
// The whole calendar page, with the shell around it stubbed out and the
// stores holding a fixed set of calendars and events.
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
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-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const config = vi.hoisted(() => ({ demoMode: false }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
import { CalendarApp } from '../calendar-app';
import { useRegisteredCommandGroups } from '@/lib/command-registry';
// The first render loads the whole page and is slow under load.
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true },
participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed',
...overrides,
} as unknown as CalendarEvent;
}
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const store = {
fetchCalendars: vi.fn(),
fetchEvents: vi.fn(),
refreshAllSubscriptions: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
rsvpEvent: vi.fn(),
};
function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') {
for (const fn of Object.values(store)) fn.mockReset();
store.createEvent.mockImplementation(async (_c: unknown, data: Partial<CalendarEvent>) => ({ ...makeEvent(), ...data, id: 'new' }));
store.updateEvent.mockResolvedValue(true);
store.deleteEvent.mockResolvedValue(true);
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
useCalendarStore.setState({
calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode,
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
...store,
} as never);
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never);
useToastStore.setState({ toasts: [] } as never);
useTaskStore.setState({ tasks: [] } as never);
client.getCalendarTasks.mockResolvedValue([]);
}
function renderApp() {
return render(<CalendarApp />);
}
beforeEach(() => {
media.mobile = false;
media.desktop = true;
setUp();
});
describe('CalendarApp keyboard shortcuts', () => {
it('leaves Ctrl, Cmd and Alt combinations to the browser', () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'a', code: 'KeyA', ctrlKey: true });
fireEvent.keyDown(document.body, { key: 'd', code: 'KeyD', metaKey: true });
fireEvent.keyDown(document.body, { key: 'ArrowLeft', code: 'ArrowLeft', altKey: true });
expect(useCalendarStore.getState().viewMode).toBe('week');
expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 12));
});
it('switches views by the physical key on any layout', () => {
renderApp();
// A Cyrillic layout types "ф" on the A key.
fireEvent.keyDown(document.body, { key: 'ф', code: 'KeyA' });
expect(useCalendarStore.getState().viewMode).toBe('agenda');
});
it('keeps working while the hover preview of an event shows', async () => {
renderApp();
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
fireEvent.mouseEnter(card);
await screen.findByRole('dialog', { name: 'Standup' });
fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' });
expect(useCalendarStore.getState().viewMode).toBe('month');
});
});
function PaletteGroups() {
const groups = useRegisteredCommandGroups();
return <ul data-testid="palette">{groups.flatMap((g) => g.commands.map((c) => <li key={`${g.label}:${c.id}`}><button type="button" onClick={c.run}>{`${g.label}: ${c.label}`}</button></li>))}</ul>;
}
describe('CalendarApp in the command palette', () => {
it('offers Calendar commands while the calendar is open', () => {
render(<><CalendarApp /><PaletteGroups /></>);
const items = screen.getByTestId('palette').textContent ?? '';
expect(items).toContain('calendar: events.new_event');
expect(items).toContain('calendar: events.go_to_today');
expect(items).toContain('calendar: commands.month');
expect(items).toContain('calendar: toolbar.import');
expect(items).toContain('calendar: refresh');
// The view on screen isn't offered again.
expect(items).not.toContain('calendar: commands.week');
});
it('runs a view command', () => {
render(<><CalendarApp /><PaletteGroups /></>);
fireEvent.click(within(screen.getByTestId('palette')).getByText('calendar: commands.day'));
expect(useCalendarStore.getState().viewMode).toBe('day');
});
});
describe('CalendarApp in the Tasks view', () => {
beforeEach(() => setUp([makeEvent()], 'tasks'));
it('has no date navigation', () => {
renderApp();
expect(screen.queryByRole('button', { name: 'nav_prev' })).toBeNull();
expect(screen.queryByRole('button', { name: 'nav_next' })).toBeNull();
expect(screen.queryByRole('button', { name: 'views.today' })).toBeNull();
});
it('creates a task with n, not an event', () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
expect(screen.getByRole('heading', { name: 'tasks.create' })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'events.create' })).toBeNull();
});
it('creates a task with the phone create button', () => {
media.mobile = true;
media.desktop = false;
renderApp();
// The floating button, after the drawer's.
fireEvent.click(screen.getAllByRole('button', { name: 'events.new_task' }).at(-1)!);
expect(screen.getByRole('dialog', { name: 'tasks.create' })).toBeInTheDocument();
});
});
const weekly = makeEvent({ recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 1 }] as never });
function openEditorOn(title = 'Standup') {
fireEvent.click(screen.getAllByRole('button', { name: new RegExp(title) })[0]);
const card = screen.getByRole('dialog', { name: title });
fireEvent.click(within(card).getByRole('button', { name: 'events.edit' }));
}
describe('CalendarApp recurring event editor', () => {
beforeEach(() => setUp([weekly]));
it('goes back to the editor with the changes when the scope question is cancelled', async () => {
renderApp();
openEditorOn();
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Standup moved' } });
fireEvent.click(screen.getByRole('button', { name: 'form.save' }));
const scope = await screen.findByRole('dialog', { name: 'edit_title' });
fireEvent.click(within(scope).getByRole('button', { name: 'cancel' }));
expect(screen.queryByRole('dialog', { name: 'edit_title' })).toBeNull();
expect(screen.getByPlaceholderText('form.title')).toHaveValue('Standup moved');
expect(store.updateEvent).not.toHaveBeenCalled();
});
});
describe('CalendarApp unsaved editor changes', () => {
it('asks before Escape drops a typed title, and keeps it on Keep editing', async () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } });
fireEvent.keyDown(screen.getByPlaceholderText('form.title'), { key: 'Escape' });
const ask = await screen.findByRole('alertdialog', { name: 'discard.title' });
fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch');
fireEvent.click(screen.getByText('form.cancel'));
const again = await screen.findByRole('alertdialog', { name: 'discard.title' });
fireEvent.click(within(again).getByRole('button', { name: 'discard.confirm' }));
await waitFor(() => expect(screen.queryByPlaceholderText('form.title')).toBeNull());
});
it('closes an untouched editor without asking', () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.click(screen.getByText('form.cancel'));
expect(screen.queryByPlaceholderText('form.title')).toBeNull();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('asks before a click on an empty slot replaces an editor with changes', async () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Lunch' } });
// A single click on a slot opens the quick editor after the double-click window.
fireEvent.click(screen.getAllByRole('gridcell')[3]);
const ask = await screen.findByRole('alertdialog', { name: 'discard.title' }, { timeout: 2000 });
fireEvent.click(within(ask).getByRole('button', { name: 'discard.keep' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(screen.getByPlaceholderText('form.title')).toHaveValue('Lunch');
});
it('asks before Cancel drops a changed task', async () => {
setUp([], 'tasks');
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.change(screen.getByPlaceholderText('tasks.title_placeholder'), { target: { value: 'Buy milk' } });
fireEvent.click(screen.getByRole('button', { name: 'tasks.cancel' }));
const ask = await screen.findByRole('alertdialog', { name: 'discard.title' });
fireEvent.click(within(ask).getByRole('button', { name: 'discard.confirm' }));
await waitFor(() => expect(screen.queryByPlaceholderText('tasks.title_placeholder')).toBeNull());
});
});
function toastTitles(): string[] {
return useToastStore.getState().toasts.map((t) => t.title);
}
describe('CalendarApp deleting', () => {
it('asks before the right-click menu deletes an event, then says it did', async () => {
renderApp();
fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 });
fireEvent.click(await screen.findByRole('menuitem', { name: 'events.delete' }));
const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.event_title' });
expect(store.deleteEvent).not.toHaveBeenCalled();
fireEvent.click(within(ask).getByRole('button', { name: 'delete_dialog.confirm' }));
await waitFor(() => expect(store.deleteEvent).toHaveBeenCalledWith(client, 'e1', undefined));
await waitFor(() => expect(toastTitles()).toContain('notifications.event_deleted'));
});
it('asks before the card deletes an event, and Cancel keeps it', async () => {
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.event_title' });
fireEvent.click(within(ask).getByRole('button', { name: 'cancel' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).toBeNull());
expect(store.deleteEvent).not.toHaveBeenCalled();
});
it('asks a series only once: the scope question replaces the confirm', async () => {
setUp([weekly]);
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
fireEvent.click(within(screen.getByRole('dialog', { name: 'Standup' })).getByRole('button', { name: 'events.delete' }));
expect(await screen.findByRole('dialog', { name: 'delete_title' })).toBeInTheDocument();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('asks before deleting a task and says it did', async () => {
setUp([], 'tasks');
const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never;
useTaskStore.setState({ tasks: [task] } as never);
client.getCalendarTasks.mockResolvedValue([task]);
const deleteCalendarTask = vi.fn().mockResolvedValue(undefined);
Object.assign(client, { deleteCalendarTask });
renderApp();
fireEvent.click(await screen.findByText('Buy milk'));
fireEvent.click(screen.getByRole('button', { name: 'tasks.delete' }));
const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.task_title' });
expect(deleteCalendarTask).not.toHaveBeenCalled();
fireEvent.click(within(ask).getByRole('button', { name: 'delete_dialog.confirm' }));
await waitFor(() => expect(deleteCalendarTask).toHaveBeenCalledWith('t1'));
await waitFor(() => expect(toastTitles()).toContain('notifications.task_deleted'));
});
});
describe('CalendarApp read-only events', () => {
const readOnlyCalendar = {
...calendars[0], id: 'ro', name: 'Holidays', isDefault: false,
myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false, mayDelete: false, mayRSVP: false },
} as unknown as Calendar;
const holiday = makeEvent({ id: 'h1', title: 'Holiday', calendarIds: { ro: true } });
beforeEach(() => {
setUp([holiday]);
useCalendarStore.setState({ calendars: [...calendars, readOnlyCalendar], selectedCalendarIds: ['cal', 'ro'] } as never);
});
it('offers no Edit or Delete in the right-click menu', async () => {
renderApp();
fireEvent.contextMenu(screen.getAllByRole('button', { name: /Holiday/ })[0], { clientX: 50, clientY: 50 });
expect(await screen.findByRole('menuitem', { name: 'events.duplicate' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'events.edit' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'events.delete' })).toBeNull();
});
it('can be neither dragged nor resized', () => {
const { container } = renderApp();
const card = screen.getAllByRole('button', { name: /Holiday/ })[0];
expect(card).not.toHaveAttribute('draggable', 'true');
expect(container.querySelectorAll('[data-resize-handle]')).toHaveLength(0);
});
it('keeps an editable event draggable', () => {
setUp([makeEvent()]);
renderApp();
expect(screen.getAllByRole('button', { name: /Standup/ })[0]).toHaveAttribute('draggable', 'true');
});
});
function dropOnMonthDay(day: RegExp, event: CalendarEvent) {
const cell = screen.getAllByRole('gridcell').find((c) => day.test(c.getAttribute('aria-label') ?? ''));
if (!cell) throw new Error(`no cell ${day}`);
const json = JSON.stringify({ type: 'calendar-event', eventId: event.id, originalStart: event.start, duration: event.duration });
fireEvent.drop(cell, { dataTransfer: { types: ['application/x-calendar-event'], getData: () => json } });
}
describe('CalendarApp dragging', () => {
it('asks which occurrences a drag of a series moves', async () => {
setUp([weekly], 'month');
renderApp();
dropOnMonthDay(/October 14, 2026/, weekly);
const scope = await screen.findByRole('dialog', { name: 'edit_title' });
expect(store.updateEvent).not.toHaveBeenCalled();
fireEvent.click(within(scope).getByRole('button', { name: 'cancel' }));
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'edit_title' })).toBeNull());
expect(store.updateEvent).not.toHaveBeenCalled();
});
it('moves one event at once and can move it back', async () => {
setUp([makeEvent()], 'month');
renderApp();
dropOnMonthDay(/October 14, 2026/, makeEvent());
await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(1));
expect(store.updateEvent.mock.calls[0][2]).toMatchObject({ start: '2026-10-14T10:00:00' });
const moved = await waitFor(() => {
const found = useToastStore.getState().toasts.find((t) => t.title === 'notifications.event_moved');
expect(found).toBeTruthy();
return found!;
});
moved.action!.onClick();
await waitFor(() => expect(store.updateEvent).toHaveBeenCalledTimes(2));
expect(store.updateEvent.mock.calls[1][2]).toMatchObject({ start: '2026-10-12T10:00:00', duration: 'PT1H' });
});
});
describe('CalendarApp task feedback', () => {
const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never;
it('says when a task couldn\'t be saved and keeps the form open', async () => {
setUp([], 'tasks');
Object.assign(client, { createCalendarTask: vi.fn().mockRejectedValue(new Error('boom')) });
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.change(screen.getByPlaceholderText('tasks.title_placeholder'), { target: { value: 'Buy milk' } });
fireEvent.click(screen.getByRole('button', { name: 'tasks.save' }));
await waitFor(() => expect(toastTitles()).toContain('notifications.task_save_error'));
expect(screen.getByPlaceholderText('tasks.title_placeholder')).toHaveValue('Buy milk');
});
it('says when ticking a task off failed', async () => {
setUp([], 'tasks');
useTaskStore.setState({ tasks: [task] } as never);
client.getCalendarTasks.mockResolvedValue([task]);
Object.assign(client, { updateCalendarTask: vi.fn().mockRejectedValue(new Error('boom')) });
renderApp();
fireEvent.click(await screen.findByRole('button', { name: 'tasks.mark_complete' }));
await waitFor(() => expect(toastTitles()).toContain('notifications.task_update_error'));
});
it('adds a quick task to a calendar it may write to', async () => {
setUp([], 'tasks');
const readOnly = { ...calendars[0], id: 'ro', isDefault: false, myRights: { ...RIGHTS, mayWriteAll: false, mayWriteOwn: false } };
useCalendarStore.setState({ calendars: [readOnly, { ...calendars[0], isDefault: false }], selectedCalendarIds: ['ro', 'cal'] } as never);
const createCalendarTask = vi.fn().mockImplementation(async (data: object) => ({ ...data, id: 't9' }));
Object.assign(client, { createCalendarTask });
renderApp();
const input = await screen.findByPlaceholderText('tasks.quick_add_placeholder');
fireEvent.change(input, { target: { value: 'Call Bob' } });
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() => expect(createCalendarTask).toHaveBeenCalled());
expect(createCalendarTask.mock.calls[0][0]).toMatchObject({ title: 'Call Bob', calendarIds: { cal: true } });
await waitFor(() => expect(toastTitles()).toContain('notifications.task_created'));
});
it('shows completed tasks under All', async () => {
setUp([], 'tasks');
const done = { ...(task as object), id: 't2', title: 'Pay rent', progress: 'completed' } as never;
useTaskStore.setState({ tasks: [task, done], filter: 'all', showCompleted: false } as never);
client.getCalendarTasks.mockResolvedValue([task, done]);
renderApp();
expect(await screen.findByText('Pay rent')).toBeInTheDocument();
expect(screen.queryByText('tasks.show_completed')).toBeNull();
});
});
describe('CalendarApp creating in the month view', () => {
it('opens the quick editor for an all-day event on a single click', async () => {
setUp([], 'month');
renderApp();
const day = screen.getAllByRole('gridcell').find((c) => /October 15, 2026/.test(c.getAttribute('aria-label') ?? ''))!;
fireEvent.click(day);
const quick = await screen.findByRole('dialog', { name: 'quick_create.aria_label' });
expect(within(quick).getByText('events.all_day')).toBeInTheDocument();
fireEvent.change(within(quick).getByLabelText('form.title'), { target: { value: 'Holiday' } });
fireEvent.click(within(quick).getByRole('button', { name: 'form.save' }));
await waitFor(() => expect(store.createEvent).toHaveBeenCalledTimes(1));
expect(store.createEvent.mock.calls[0][1]).toMatchObject({
title: 'Holiday', start: '2026-10-15T00:00:00', duration: 'P1D', showWithoutTime: true,
});
});
it('opens the day on a phone tap instead', () => {
media.mobile = true;
media.desktop = false;
setUp([], 'month');
renderApp();
const day = screen.getAllByRole('gridcell').find((c) => /October 15, 2026/.test(c.getAttribute('aria-label') ?? ''))!;
fireEvent.click(day);
expect(screen.queryByRole('dialog', { name: 'quick_create.aria_label' })).toBeNull();
expect(useCalendarStore.getState().viewMode).toBe('day');
});
});
describe('CalendarApp on touch screens', () => {
it("has Import, Subscribe and Refresh in the phone header's ⋮", async () => {
media.mobile = true;
media.desktop = false;
renderApp();
// The shell's phone ⋮, labelled by its own namespace (top_bar.more).
// Error toasts of earlier tests add an unread count to its name.
fireEvent.click(screen.getByRole('button', { name: /^more/ }));
expect(await screen.findByRole('menuitem', { name: 'toolbar.import' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'toolbar.subscribe' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'refresh' })).toBeInTheDocument();
});
it('offers the event menu from a ⋮ in the phone event view', async () => {
media.mobile = true;
media.desktop = false;
setUp([makeEvent()], 'day');
renderApp();
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
const view = await screen.findByRole('dialog', { name: 'Standup' });
fireEvent.click(within(view).getByRole('button', { name: 'toolbar.more' }));
expect(await screen.findByRole('menuitem', { name: 'events.export_ics' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'events.copy_title' })).toBeInTheDocument();
});
it('opens the event menu on a long press', async () => {
renderApp();
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
fireEvent.touchStart(card, { touches: [{ clientX: 40, clientY: 40 }] });
expect(await screen.findByRole('menuitem', { name: 'events.duplicate' }, { timeout: 2000 })).toBeInTheDocument();
fireEvent.touchEnd(card);
});
});
describe('CalendarApp event menu', () => {
it('opens the form from Edit event, not the summary', async () => {
renderApp();
fireEvent.contextMenu(screen.getAllByRole('button', { name: /Standup/ })[0], { clientX: 50, clientY: 50 });
fireEvent.click(await screen.findByRole('menuitem', { name: 'events.edit' }));
expect(await screen.findByPlaceholderText('form.title')).toHaveValue('Standup');
});
});
describe('CalendarApp from the keyboard', () => {
it('gives the month grid one tab stop and moves between days with the arrows', () => {
setUp([], 'month');
const { container } = renderApp();
const tabbable = container.querySelectorAll('[data-day-button][tabindex="0"]');
expect(tabbable).toHaveLength(1);
const today = tabbable[0] as HTMLButtonElement;
today.focus();
fireEvent.keyDown(today, { key: 'ArrowRight' });
expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 13));
expect(document.activeElement).toBe(container.querySelector('[data-day-button="2026-10-13"]'));
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 20));
// The arrows moved the selection, not the calendar a month on.
expect(useCalendarStore.getState().viewMode).toBe('month');
});
it('moves the focus into an opened event card and back to the event on Escape', async () => {
renderApp();
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
card.focus();
fireEvent.click(card, { detail: 0 });
const popover = await screen.findByRole('dialog', { name: 'Standup' });
await waitFor(() => expect(popover.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Standup' })).toBeNull());
expect(document.activeElement).toBe(card);
});
it('reaches a task with Tab and opens it', async () => {
setUp([], 'tasks');
const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never;
useTaskStore.setState({ tasks: [task] } as never);
client.getCalendarTasks.mockResolvedValue([task]);
renderApp();
fireEvent.click(await screen.findByRole('button', { name: /Buy milk/ }));
expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument();
});
});
describe('CalendarApp control names', () => {
it('names close buttons Close and tells the arrows apart', async () => {
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
// Cancel is the text button in the footer, not the X.
expect(screen.getAllByRole('button', { name: 'form.cancel' })).toHaveLength(1);
// The mini calendar's arrows step months, the toolbar's the view.
expect(screen.getByRole('button', { name: 'mini_prev_month' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'nav_prev' })).toHaveLength(1);
expect(screen.getByRole('button', { name: 'nav_hide_sidebar' })).toBeInTheDocument();
});
it('names the task editor fields and its close button', async () => {
setUp([], 'tasks');
renderApp();
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
expect(screen.getByRole('button', { name: 'close' })).toBeInTheDocument();
expect(screen.getByLabelText('tasks.due_date')).toHaveAttribute('type', 'date');
expect(screen.getByLabelText('tasks.priority').tagName).toBe('SELECT');
expect(screen.getByLabelText('tasks.quick_add_placeholder')).toBeInTheDocument();
});
it('reads an all-day event as all day, not as midnight to 23:00', () => {
setUp([makeEvent({ title: 'Trip', showWithoutTime: true, start: '2026-10-12T00:00:00', duration: 'P1D' })], 'month');
renderApp();
expect(screen.getAllByRole('button', { name: /Trip/ })[0].getAttribute('aria-label')).toContain('events.all_day');
});
});
describe('CalendarApp in the demo', () => {
it('leaves Import and Subscribe out, which need a mail server', async () => {
config.demoMode = true;
try {
renderApp();
fireEvent.click(screen.getByRole('button', { name: 'toolbar.more' }));
expect(await screen.findByRole('menuitem', { name: 'refresh' })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'toolbar.import' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'toolbar.subscribe' })).toBeNull();
} finally {
config.demoMode = false;
}
});
});
describe('CalendarApp view menu', () => {
it('opens as a menu the keyboard can use and switches the view', async () => {
renderApp();
fireEvent.click(screen.getByTestId('calendar-view-menu'), { detail: 0 });
const day = await screen.findByRole('menuitemradio', { name: /views\.day/ });
await waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('menuitemradio'));
fireEvent.click(day);
expect(useCalendarStore.getState().viewMode).toBe('day');
});
});
describe('CalendarApp long names', () => {
it('shows the full name of a cut-off calendar on hover', () => {
const long = { ...calendars[0], name: 'Stalwart Calendar (usera@internal.bulwarkmail.org)' };
useCalendarStore.setState({ calendars: [long] } as never);
renderApp();
expect(screen.getByTestId('calendar-item').querySelector('[title]')).toHaveAttribute('title', long.name);
});
});
describe('CalendarApp after saving', () => {
it('brings a new event into view when it starts outside the hours shown', async () => {
useSettingsStore.setState({ calendarLimitHours: true, calendarDayStartHour: 8, calendarDayEndHour: 18 } as never);
try {
renderApp();
expect(screen.queryAllByRole('gridcell', { name: /01:00$/ })).toHaveLength(0);
fireEvent.keyDown(document.body, { key: 'n', code: 'KeyN' });
fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Night train' } });
fireEvent.change(document.querySelector('input[type="time"]')!, { target: { value: '01:00' } });
fireEvent.click(screen.getByRole('button', { name: 'form.save' }));
await waitFor(() => expect(store.createEvent).toHaveBeenCalled());
await waitFor(() => expect(screen.getAllByRole('gridcell', { name: /01:00$/ }).length).toBeGreaterThan(0));
} finally {
useSettingsStore.setState({ calendarLimitHours: false } as never);
}
});
});
describe('CalendarApp empty agenda', () => {
it('offers to create the first event', () => {
setUp([], 'agenda');
renderApp();
fireEvent.click(screen.getByRole('button', { name: 'events.new_event' }));
expect(screen.getByRole('heading', { name: 'events.create' })).toBeInTheDocument();
});
});
describe('CalendarApp selected event', () => {
it('rings the event whose card is open', async () => {
renderApp();
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
expect(card.style.boxShadow).not.toContain('--color-primary');
fireEvent.click(card);
await screen.findByRole('dialog', { name: 'Standup' });
expect(screen.getAllByRole('button', { name: /Standup/ })[0].style.boxShadow).toContain('var(--color-primary)');
});
});
describe('CalendarApp day view tasks', () => {
it('draws a due task with the same chip as the week and month grids', async () => {
setUp([], 'day');
const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2026-10-12', showWithoutTime: true } as never;
useSettingsStore.setState({ showTasksOnCalendar: true } as never);
useTaskStore.setState({ tasks: [task] } as never);
client.getCalendarTasks.mockResolvedValue([task]);
try {
renderApp();
const chip = await waitFor(() => {
const found = document.querySelector('[data-calendar-task="t1"]');
expect(found).not.toBeNull();
return found as HTMLElement;
});
// The shared chip: the title is its own button that opens the task.
fireEvent.click(within(chip).getByRole('button', { name: 'Buy milk' }));
expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument();
} finally {
useSettingsStore.setState({ showTasksOnCalendar: false } as never);
}
});
});
@@ -0,0 +1,112 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Calendar } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The event editor opened with the focus on its close button, so typing
// went nowhere; the import, subscription and new-calendar dialogs were
// hand-rolled: Escape only worked from some places and no focus handling.
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
import { EventModal } from '../event-modal';
import { ICalImportModal } from '../ical-import-modal';
import { ICalSubscriptionModal } from '../ical-subscription-modal';
import { CreateCalendarModal } from '../create-calendar-modal';
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true },
}] as unknown as Calendar[];
const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient;
describe('calendar dialogs', () => {
it('opens a new event with the cursor in Title', () => {
render(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByPlaceholderText('form.title'));
});
it('closes the import dialog with Escape, wherever the focus is', () => {
const onClose = vi.fn();
render(<ICalImportModal calendars={calendars} client={client} onClose={onClose} />);
const dialog = screen.getByRole('dialog', { name: 'title' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
fireEvent.keyDown(document.body, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('opens the subscription dialog in the address field, with a named close button', () => {
const onClose = vi.fn();
render(<ICalSubscriptionModal client={client} onClose={onClose} />);
expect(document.activeElement).toBe(screen.getByLabelText('url_label'));
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(onClose).toHaveBeenCalled();
});
it('opens the new-calendar dialog in the name field and keeps typed input on an outside click', () => {
const onClose = vi.fn();
render(<CreateCalendarModal client={client} onClose={onClose} />);
const name = screen.getByLabelText('name');
expect(document.activeElement).toBe(name);
fireEvent.change(name, { target: { value: 'Gym' } });
const backdrop = screen.getByRole('dialog').parentElement!;
fireEvent.mouseDown(backdrop);
fireEvent.click(backdrop);
expect(onClose).not.toHaveBeenCalled();
});
});
describe('import dialog from the keyboard', () => {
it('lets the keyboard reach the file drop zone', () => {
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument();
});
});
describe('import preview', () => {
it('names a repeating event\'s rule in the app language', async () => {
const weekly = { title: 'Gym', start: '2026-10-12T18:00:00', duration: 'PT1H', recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly', interval: 1 }] };
const parsing = {
getAccountId: () => 'acct',
getCalendarsAccountId: () => 'acct',
uploadBlob: vi.fn().mockResolvedValue({ blobId: 'b1' }),
parseCalendarEvents: vi.fn().mockResolvedValue([weekly]),
} as unknown as IJMAPClient;
render(<ICalImportModal calendars={calendars} client={parsing} onClose={vi.fn()} />);
const file = new File(['BEGIN:VCALENDAR'], 'gym.ics', { type: 'text/calendar' });
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files: [file] } });
expect(await screen.findByText('Gym')).toBeInTheDocument();
expect(screen.queryByText('weekly')).toBeNull();
expect(screen.getByText('recurrence.weekly')).toBeInTheDocument();
});
});
describe('invitation view', () => {
it('shows an invitation with the same details as any event: maps link, meeting link, menu and answers', () => {
const shared = [{
id: 'team', name: 'Team', color: '#123456',
myRights: { mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayRSVP: true, mayDelete: false },
}] as unknown as Calendar[];
const invite = {
id: 'i1', uid: 'u1', '@type': 'Event', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H',
showWithoutTime: false, calendarIds: { team: true },
description: 'Join: https://teams.microsoft.com/l/meetup-join/abc',
locations: { l: { '@type': 'Location', name: 'Main St 1, Berlin' } },
organizerCalendarAddress: 'mailto:boss@example.org',
participants: {
boss: { '@type': 'Participant', calendarAddress: 'mailto:boss@example.org', roles: { owner: true }, participationStatus: 'accepted' },
me: { '@type': 'Participant', calendarAddress: 'mailto:me@example.com', roles: { attendee: true }, participationStatus: 'needs-action' },
},
} as unknown as import('@/lib/jmap/types').CalendarEvent;
render(
<EventModal event={invite} calendars={shared} onSave={vi.fn()} onClose={vi.fn()} onRsvp={vi.fn()} onMore={vi.fn()}
currentUserEmails={['me@example.com']} />,
);
expect(screen.getByText('participants.invited_by')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Main St 1, Berlin' }).getAttribute('href')).toMatch(/maps/);
expect(screen.getByRole('link', { name: 'teams.microsoft.com' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'toolbar.more' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'participants.accept' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /events\.edit/ })).toBeNull();
});
});
@@ -127,7 +127,7 @@ describe('calendar display hours and working days (#1164)', () => {
expect(eventBlock('weekend')).toBeNull();
const trip = screen.getByText('Event trip').closest<HTMLElement>('.absolute')!;
// Column 5 of 6, two columns wide (jsdom rounds the percentages)
expect(trip.style.left).toBe('calc(66.6667% + 1px)');
expect(trip.style.insetInlineStart).toBe('calc(66.6667% + 1px)');
expect(trip.style.width).toBe('calc(33.3333% - 2px)');
});
@@ -0,0 +1,56 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// In Arabic the day grid mirrors, but the event chips laid over it were
// placed with `left`, so Sunday's event was drawn under Monday. And the
// phone's weekday headers were the first two letters of each name ("اث").
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => ({ 'days.mon': 'الاثنين' } as Record<string, string>)[key] ?? key,
useLocale: () => 'ar',
useFormatter: () => ({
dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('ar', options).format(d),
}),
}));
import { CalendarMonthView } from '../calendar-month-view';
const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[];
const birthday = {
id: 'e1', uid: 'u1', title: 'Birthday', start: '2026-10-07T00:00:00', duration: 'P1D', showWithoutTime: true,
calendarIds: { 'cal-1': true }, status: 'confirmed',
} as unknown as CalendarEvent;
function renderMonth(isMobile = false) {
return render(
<CalendarMonthView
focus={{ date: new Date(2026, 9, 7), nonce: 0 }}
windowKey="month:2026-10-07"
rangeStart={new Date(2026, 8, 28)}
rangeEnd={new Date(2026, 10, 1)}
selectedDate={new Date(2026, 9, 7)}
events={[birthday]}
calendars={calendars}
onSelectDate={vi.fn()}
onSelectEvent={vi.fn()}
isMobile={isMobile}
/>,
);
}
describe('CalendarMonthView in a right-to-left language', () => {
it('places event chips from the start edge, so they follow the mirrored grid', () => {
renderMonth();
const chip = screen.getByRole('button', { name: /Birthday/ }).parentElement as HTMLElement;
// Wednesday is the third column of a week starting on Monday.
expect(chip.style.insetInlineStart).toMatch(/^calc\(28\.57\d*% \+ 1px\)$/);
expect(chip.style.left).toBe('');
});
it('heads the phone grid with the language\'s short weekday names', () => {
renderMonth(true);
const monday = screen.getAllByRole('columnheader')[0];
expect(monday.textContent).toBe(new Intl.DateTimeFormat('ar', { weekday: 'narrow' }).format(new Date(2026, 8, 28)));
expect(monday.textContent).not.toBe('ال');
});
});
@@ -97,10 +97,10 @@ describe('CalendarMonthView tasks', () => {
// already has an event in row 0; Friday has no events.
// (jsdom rounds the calc() percentage.)
const thu = chipSlot('thu');
expect(thu.style.left).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
expect(thu.style.insetInlineStart).toMatch(/^calc\(42\.857\d*% \+ 1px\)$/);
expect(thu.style.top).toBe('22px');
const fri = chipSlot('fri');
expect(fri.style.left).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
expect(fri.style.insetInlineStart).toMatch(/^calc\(57\.142\d*% \+ 1px\)$/);
expect(fri.style.top).toBe('0px');
});
@@ -108,17 +108,19 @@ describe('CalendarMonthView tasks', () => {
const onOpenDay = vi.fn();
renderView({
events: [makeAllDayEvent('e1', '2026-09-10')],
tasks: ['a', 'b', 'c'].map((id) => makeTask(id, '2026-09-10')),
tasks: ['a', 'b', 'c', 'd'].map((id) => makeTask(id, '2026-09-10')),
onOpenDay,
});
const row = document.querySelector<HTMLElement>('[data-week="2026-09-07"]');
// jsdom measures no viewport, so rows keep the 100px minimum: room for
// two 22px rows under the 30 + 4 offset, the second taken by the link.
expect(row?.style.minHeight).toBe('100px');
// jsdom measures no viewport, so rows keep the 130px minimum: room for
// four 22px rows under the 30 + 4 offset, the last taken by the link -
// three entries, not one, on a short desktop window.
expect(row?.style.minHeight).toBe('130px');
expect(screen.getByText('Event e1')).toBeInTheDocument();
expect(document.querySelector('[data-calendar-task="a"]')).toBeNull();
expect(document.querySelector('[data-calendar-task="b"]')).not.toBeNull();
expect(document.querySelector('[data-calendar-task="c"]')).toBeNull();
const more = screen.getByRole('button', { name: 'events.more' });
expect(more.parentElement?.style.top).toBe('22px');
expect(more.parentElement?.style.top).toBe('66px');
fireEvent.click(more);
expect(onOpenDay).toHaveBeenCalledWith(new Date(2026, 8, 10));
});
@@ -0,0 +1,31 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
// On phones and tablets a calendar's actions (share, colour, default,
// delete) were behind a ⋮ that only showed on hover, and nothing opened on a
// long press.
const work = {
id: 'work', name: 'Work', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'all', timeZone: null, shareWith: null,
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true, mayShare: true },
} as unknown as Calendar;
const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient;
describe('CalendarSidebarPanel on touch screens', () => {
it('keeps the row ⋮ visible on touch and keyboard focus', () => {
render(<CalendarSidebarPanel calendars={[work]} selectedCalendarIds={['work']} onToggleVisibility={() => {}} client={client} />);
const more = screen.getByRole('button', { name: 'calendar_options' });
expect(more.className).toContain('pointer-coarse:opacity-100');
expect(more.className).toContain('group-focus-within/cal:opacity-100');
});
it('opens a calendar\'s menu on a long press', async () => {
render(<CalendarSidebarPanel calendars={[work]} selectedCalendarIds={['work']} onToggleVisibility={() => {}} onDeleteCalendar={() => {}} client={client} />);
fireEvent.touchStart(screen.getByTestId('calendar-item'), { touches: [{ clientX: 20, clientY: 20 }] });
expect(await screen.findByRole('menuitem', { name: 'delete' }, { timeout: 2000 })).toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CalendarSidebarPanel } from '../calendar-sidebar-panel';
import { useCalendarStore } from '@/stores/calendar-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar } from '@/lib/jmap/types';
// Unsubscribing removes the feed's calendar and every event in it. Settings
// asked first, the sidebar's menu removed it at once.
const feed = {
id: 'feed', name: 'Holidays', color: '#3b82f6', sortOrder: 0, isSubscribed: true, isVisible: true,
isDefault: false, includeInAvailability: 'none', timeZone: null, shareWith: null,
myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true },
} as unknown as Calendar;
describe('CalendarSidebarPanel unsubscribe', () => {
it('asks before it removes a subscribed calendar', async () => {
const removeICalSubscription = vi.fn().mockResolvedValue(undefined);
useCalendarStore.setState({
icalSubscriptions: [{ id: 'sub', url: 'https://example.com/h.ics', calendarId: 'feed', name: 'Holidays', color: '#3b82f6', refreshInterval: 60, lastRefreshed: null }],
isSubscriptionCalendar: (id: string) => id === 'feed',
removeICalSubscription,
} as never);
const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient;
render(<CalendarSidebarPanel calendars={[feed]} selectedCalendarIds={['feed']} onToggleVisibility={() => {}} client={client} />);
fireEvent.contextMenu(screen.getByTestId('calendar-item'), { clientX: 20, clientY: 20 });
fireEvent.click(await screen.findByRole('menuitem', { name: 'unsubscribe' }));
const ask = await screen.findByRole('alertdialog', { name: 'delete_dialog.subscription_title' });
expect(removeICalSubscription).not.toHaveBeenCalled();
fireEvent.click(within(ask).getByRole('button', { name: 'unsubscribe' }));
await waitFor(() => expect(removeICalSubscription).toHaveBeenCalledWith(client, 'sub'));
});
});
@@ -107,7 +107,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => {
],
});
const toggle = screen.getByRole('button', { name: /all-day-expand/i });
const toggle = screen.getByRole('button', { name: /events.show_more|events.show_less/ });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-expanded', 'false');
@@ -137,7 +137,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => {
],
});
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
expect(screen.queryByRole('button', { name: /events.show_more|events.show_less/ })).toBeNull();
expect(document.querySelector('[data-testid="all-day-grid"]')).toBeNull();
});
@@ -156,7 +156,7 @@ describe('CalendarWeekView all-day and tasks height cap', () => {
const allDayGrid = document.querySelector<HTMLElement>('[data-testid="all-day-grid"]');
expect(allDayGrid?.style.height).toBe('28px');
expect(screen.queryByRole('button', { name: /all-day-expand/i })).toBeNull();
expect(screen.queryByRole('button', { name: /events.show_more|events.show_less/ })).toBeNull();
});
it('resizes the all-day area when scrolling to a crowded week (#1293)', async () => {
@@ -183,6 +183,6 @@ describe('CalendarWeekView all-day and tasks height cap', () => {
const allDayGrid = await screen.findByTestId('all-day-grid');
expect(allDayGrid.style.height).toBe('76px');
expect(screen.getByRole('button', { name: /all-day-expand/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /events.show_more|events.show_less/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ParticipantStatusBadge, RsvpButtons } from '../rsvp-buttons';
// "Tentative" (#eab308) and "Accepted" (#22c55e) text on white measured
// about 1.9:1 and 2.3:1, and the chosen Tentative was white on yellow. Text
// uses the darker status shades; a chosen answer gets dark text.
describe('participation status colours', () => {
it('writes answers in readable status shades, not the light fills', () => {
render(<RsvpButtons status={null} onRespond={vi.fn()} />);
const maybe = screen.getByRole('button', { name: 'participants.maybe' });
expect(maybe.className).toContain('text-amber-600');
expect(maybe.className).not.toMatch(/\btext-warning\b/);
expect(screen.getByRole('button', { name: 'participants.accept' }).className).toContain('text-green-700');
});
it('puts dark text on the chosen yellow answer', () => {
render(<RsvpButtons status="tentative" onRespond={vi.fn()} />);
const maybe = screen.getByRole('button', { name: 'participants.maybe' });
expect(maybe).toHaveAttribute('aria-pressed', 'true');
expect(maybe.className).toContain('text-neutral-950');
expect(maybe.className).not.toContain('text-warning-foreground');
});
it('labels the buttons with what they do, not the state', () => {
render(<RsvpButtons status={null} onRespond={vi.fn()} />);
expect(screen.getAllByRole('button').map((b) => b.textContent)).toEqual(['participants.accept', 'participants.maybe', 'participants.decline']);
});
it('colours a participant\'s answer the same way', () => {
render(<ParticipantStatusBadge status="accepted" />);
expect(screen.getByText('participants.accepted').className).toContain('text-green-700');
});
});
@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import type { Calendar, CalendarTask } from '@/lib/jmap/types';
// Due dates were formatted with date-fns' English month names, so a German
// list read "Oct 30" next to "Überfällig". They follow the app's language.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'de',
useFormatter: () => ({
dateTime: (d: Date, options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('de-DE', options).format(d),
}),
}));
import { TaskListView } from '../task-list-view';
const calendars = [{ id: 'cal', name: 'Privat', color: '#123456' }] as unknown as Calendar[];
describe('TaskListView due dates', () => {
it('writes the due date in the app language', () => {
const task = { id: 't', title: 'Steuer', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: '2031-10-30', showWithoutTime: true } as unknown as CalendarTask;
render(<TaskListView tasks={[task]} calendars={calendars} selectedCalendarIds={['cal']} filter="all" onSelectTask={() => {}} onToggleComplete={() => {}} />);
expect(screen.getByText('30. Okt.')).toBeInTheDocument();
expect(screen.queryByText('Oct 30')).toBeNull();
});
});
@@ -69,9 +69,7 @@ describe('task-detail-section', () => {
describe('task-list-actions', () => {
const toolbar = {
filter: 'all' as const,
showCompleted: false,
onFilterChange: vi.fn(),
onShowCompletedChange: vi.fn(),
onCreateTask: vi.fn(),
};
@@ -104,7 +102,8 @@ describe('task-list-actions', () => {
makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
makeTask('soon', { due: '2030-01-01' }),
];
expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
// All is all of them: completed ones too, at the end.
expect(filterTasksForList(tasks, ['cal-1'], 'all').map((t) => t.id)).toEqual(['soon', 'later', 'done']);
expect(filterTasksForList(tasks, ['cal-1'], 'pending').map((t) => t.id)).toEqual(['soon', 'later']);
});
});
+19 -8
View File
@@ -4,7 +4,9 @@ import { useMemo, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { format, isTomorrow, startOfDay } from "date-fns";
import { MapPin, Users } from "@/components/icons";
import { CalendarDays, MapPin, Users } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { cn } from "@/lib/utils";
import { getEventColor } from "./event-card";
import { getEventDayBounds, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -24,6 +26,8 @@ interface CalendarAgendaViewProps extends ScrollWindowViewProps {
timeFormat?: "12h" | "24h";
/** The user's calendar addresses, to mark events they declined (#1110). */
currentUserEmails?: string[];
/** Offered on an empty agenda: make the first event. */
onCreateEvent?: () => void;
}
interface DayGroup {
@@ -48,6 +52,7 @@ export function CalendarAgendaView({
onContextMenuEvent,
timeFormat = "24h",
currentUserEmails,
onCreateEvent,
}: CalendarAgendaViewProps) {
const t = useTranslations("calendar");
// Grid days / event dates are display dates (local fields = wall-clock in
@@ -63,6 +68,7 @@ export function CalendarAgendaView({
const scrollContainerRef = useRef<HTMLDivElement>(null);
const bottomSentinelRef = useRef<HTMLDivElement>(null);
const hasEvents = events.length > 0;
const grouped = useMemo(() => {
const sorted = [...events].sort((a, b) =>
getEventStartDate(a).getTime() - getEventStartDate(b).getTime()
@@ -177,13 +183,18 @@ export function CalendarAgendaView({
)}
</div>
{grouped.length === 0 && !isLoading && (
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
{t("events.no_events")}
</div>
{/* Nothing in the loaded window: one empty state with the way out,
not a lone "Today" row. */}
{!hasEvents && !isLoading && (
<EmptyState
compact
icon={CalendarDays}
title={t("events.no_events")}
actions={onCreateEvent ? <Button size="sm" onClick={onCreateEvent}>{t("events.new_event")}</Button> : undefined}
/>
)}
{grouped.map((group, index) => {
{hasEvents && grouped.map((group, index) => {
const today = isDisplayToday(group.date);
const relativeLabel = today
? t("events.today_header")
@@ -264,13 +275,13 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")}>
<div className={cn("text-sm font-medium truncate", isInactive && "line-through text-muted-foreground")} title={ev.title || t("events.no_title")}>
{ev.title || t("events.no_title")}
</div>
{(meta || getParticipantCount(ev) > 0) && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5 min-w-0">
{locationName && <MapPin className="w-3 h-3 flex-shrink-0" />}
{meta && <span className="truncate">{meta}</span>}
{meta && <span className="truncate" title={meta}>{meta}</span>}
{getParticipantCount(ev) > 0 && (
<span className="flex items-center gap-1 flex-shrink-0">
<Users className="w-3 h-3" />
+368 -112
View File
@@ -3,13 +3,13 @@
import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react";
import { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { Plus } from "@/components/icons";
import { Plus, ListTodo, CalendarDays, Upload, Globe, RefreshCw } from "@/components/icons";
import {
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
format, parseISO,
} from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store";
import { isCalendarViewMode } from "@/stores/calendar-store";
import { isCalendarViewMode, type CalendarViewMode } from "@/stores/calendar-store";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -20,6 +20,7 @@ import { toast } from "@/stores/toast-store";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
import { Button } from "@/components/ui/button";
import { PaneSpinner, Spinner } from "@/components/ui/spinner";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
@@ -59,9 +60,9 @@ import { deleteOccurrence, saveOccurrenceEdit, type EventEditorState } from "@/l
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { getClientByLocalAccountId } from "@/stores/client-registry";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { getEventStartDate } from "@/lib/calendar-utils";
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { displayNow, getEffectiveTimeZone } from "@/lib/timezone";
import { canCreateEventsIn } from "@/lib/calendar-editability";
import { canCreateEventsIn, getEventEditability, type EditabilityContext } from "@/lib/calendar-editability";
import { QuickCreatePopover, type QuickCreateDraft } from "@/components/calendar/quick-create-popover";
import { TASKS_LOAD_ERROR, useTaskStore } from "@/stores/task-store";
import { useContactStore } from "@/stores/contact-store";
@@ -69,11 +70,14 @@ import { cn, generateUUID } from "@/lib/utils";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Dialog } from "@/components/ui/dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
import { CalendarSidebarHeader } from "@/components/calendar/calendar-sidebar-header";
import { getUserParticipantId, collectUserCalendarAddresses } from "@/lib/calendar-participants";
import { getUserParticipantId, collectUserCalendarAddresses, userOrganizes, getParticipantList } from "@/lib/calendar-participants";
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import type { RevealTime } from "@/hooks/use-display-hours";
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar";
import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-calendar-colors";
import { debug } from "@/lib/debug";
@@ -96,10 +100,17 @@ import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { calendarHooks } from "@/lib/plugin-hooks";
import { eventToView } from "@/lib/plugin-projection";
import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isContextMenuOpen } from "@/components/ui/context-menu";
import { useRegisterCommands } from "@/lib/command-registry";
import { useConfig } from "@/hooks/use-config";
// `fromEditor`: asked on the way out of the event editor, which stays open
// until a scope is picked, so Cancel goes back to it with the changes.
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; editor?: EventEditorState; sendScheduling?: boolean }
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean }
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; editor?: EventEditorState; sendScheduling?: boolean; fromEditor?: boolean }
| { type: "delete"; event: CalendarEvent; sendScheduling?: boolean; fromEditor?: boolean }
| { type: "rsvp"; event: CalendarEvent; participantId: string; status: CalendarParticipant["participationStatus"] };
function isRecurringEvent(event: CalendarEvent): boolean {
@@ -121,6 +132,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const tSidebar = useTranslations("sidebar");
useDocumentTitle(tSidebar("calendar"));
const tWebcalAction = useTranslations("calendar.webcal_action");
const { demoMode } = useConfig();
const tDeepLink = useTranslations("deep_link");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
@@ -218,6 +230,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
// Whether the open event or task editor holds unsaved changes (R5).
const eventEditorDirtyRef = useRef(false);
const taskEditorDirtyRef = useRef(false);
const setEventEditorDirty = useCallback((dirty: boolean) => { eventEditorDirtyRef.current = dirty; }, []);
const setTaskEditorDirty = useCallback((dirty: boolean) => { taskEditorDirtyRef.current = dirty; }, []);
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [swipeKey, setSwipeKey] = useState(0);
const hasFetched = useRef(false);
@@ -639,7 +656,31 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
jumpTo(date);
}, [jumpTo]);
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => {
// Runs `then` at once, or, while an editor holds unsaved changes, after
// the user agreed to drop them: a click elsewhere in the calendar must
// not throw away what was typed (R5).
const discardingRef = useRef(false);
const whenEditorsDiscarded = useCallback((then: () => void) => {
const dirty = eventEditorDirtyRef.current || taskEditorDirtyRef.current;
if (!dirty) { then(); return; }
if (discardingRef.current) return;
discardingRef.current = true;
void confirmAction({
title: t("discard.title"),
message: t("discard.message"),
confirmText: t("discard.confirm"),
cancelText: t("discard.keep"),
variant: "destructive",
}).then((ok) => {
discardingRef.current = false;
if (!ok) return;
eventEditorDirtyRef.current = false;
taskEditorDirtyRef.current = false;
then();
});
}, [confirmAction, t]);
const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => whenEditorsDiscarded(() => {
setQuickDraft(null);
setDefaultModalTitle(undefined);
setEditEvent(null);
@@ -651,16 +692,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
}), [selectedDate, setSelectedDate, whenEditorsDiscarded]);
const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => {
const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => whenEditorsDiscarded(() => {
setModalStartsInEdit(startInForm);
setEditEvent(event);
setDefaultModalDate(undefined);
setShowTaskModal(false);
setEditTask(null);
setShowEventModal(true);
}, []);
}), [whenEditorsDiscarded]);
// The event and task panels share the space beside the grid; now that
// tasks open from the month, week and day views (#1107), opening one
@@ -673,40 +714,68 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setDefaultModalAllDay(false);
}, []);
const openCreateTaskModal = useCallback(() => {
const openCreateTaskModal = useCallback(() => whenEditorsDiscarded(() => {
closeEventPanelForTask();
setEditTask(null);
setShowTaskModal(true);
}, [closeEventPanelForTask]);
}), [closeEventPanelForTask, whenEditorsDiscarded]);
const openEditTaskModal = useCallback((task: CalendarTask) => {
const openEditTaskModal = useCallback((task: CalendarTask) => whenEditorsDiscarded(() => {
closeEventPanelForTask();
setEditTask(task);
setShowTaskModal(true);
}, [closeEventPanelForTask]);
}), [closeEventPanelForTask, whenEditorsDiscarded]);
const handleToggleTaskComplete = useCallback((task: CalendarTask) => {
if (client) toggleTaskCompleteFn(client, task);
}, [client, toggleTaskCompleteFn]);
if (!client) return;
// The checkbox shows the result; only a failure needs saying.
toggleTaskCompleteFn(client, task).catch((error: unknown) => {
toast.error(describeError(error, t("notifications.task_update_error")));
});
}, [client, toggleTaskCompleteFn, describeError, t]);
// false: a plugin stopped it and said why, so the task form stays open.
// false: a plugin stopped it and said why, or the save failed: the task
// form stays open with what was typed.
const handleSaveTask = useCallback(async (data: Partial<import("@/lib/jmap/types").CalendarTask>) => {
if (!client) return false;
const saved = editTask
? await taskStore.updateTask(client, editTask.id, data)
: (await taskStore.createTask(client, data)) !== null;
let saved: boolean;
try {
saved = editTask
? await taskStore.updateTask(client, editTask.id, data)
: (await taskStore.createTask(client, data)) !== null;
} catch (error) {
toast.error(describeError(error, t("notifications.task_save_error")));
return false;
}
if (!saved) return false;
toast.success(t(editTask ? "notifications.task_updated" : "notifications.task_created"));
setShowTaskModal(false);
setEditTask(null);
return true;
}, [client, editTask, taskStore]);
}, [client, editTask, taskStore, describeError, t]);
// A task delete can't be taken back: ask first (R3), then say how it went.
const handleDeleteTask = useCallback(async (id: string) => {
if (!client) return;
if (!(await taskStore.deleteTask(client, id))) return;
const task = taskStore.tasks.find((x) => x.id === id);
const ok = await confirmAction({
title: t("delete_dialog.task_title"),
message: t("delete_dialog.task_message", { title: task?.title || t("tasks.no_title") }),
confirmText: t("delete_dialog.confirm"),
variant: "destructive",
});
if (!ok) return;
try {
// false: a plugin stopped it and said why.
if (!(await taskStore.deleteTask(client, id))) return;
} catch (error) {
toast.error(describeError(error, t("notifications.task_delete_error")));
return;
}
toast.success(t("notifications.task_deleted"));
setShowTaskModal(false);
setEditTask(null);
}, [client, taskStore]);
}, [client, taskStore, confirmAction, describeError, t]);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -740,6 +809,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const {
contextMenu: eventContextMenu,
openContextMenu: openEventContextMenu,
openContextMenuFrom: openEventContextMenuFrom,
closeContextMenu: closeEventContextMenu,
menuRef: eventContextMenuRef,
} = useContextMenu<CalendarEvent>();
@@ -933,19 +1003,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh calendar data via JMAP instead of reloading the page.
const refreshCalendar = useCallback(async () => {
if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([
calendarRefresh,
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
}, [client, multiAccountEnabled, accountClients, activeAccountId, fetchAllAccountsCalendarsFn, fetchCalendars, refetchCurrentRange, refreshAllSubscriptions]);
const { indicator: refreshIndicator } = useRefreshGesture({
enabled: isAuthenticated && !!client,
onRefresh: async () => {
if (!client) return;
const calendarRefresh = multiAccountEnabled && accountClients.length > 0 && activeAccountId
? fetchAllAccountsCalendarsFn(accountClients, activeAccountId)
: fetchCalendars(client);
await Promise.all([
calendarRefresh,
refetchCurrentRange(),
refreshAllSubscriptions(client),
]);
},
onRefresh: refreshCalendar,
});
// Changes made elsewhere (another device, another session, an invitation)
@@ -961,6 +1032,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (enableCalendarTasks && (normalizedViewMode === "tasks" || showTasksOnCalendar)) void fetchTasksFn(client);
}, { enabled: isAuthenticated && !!client, includeBackground: multiAccountEnabled });
// The time a saved event starts at, for the week and day grids to show it
// even when it is outside the hours on screen.
const [revealAt, setRevealAt] = useState<RevealTime | null>(null);
const focusCalendarOnEvent = useCallback((event: Pick<Partial<CalendarEvent>, "start" | "utcStart" | "showWithoutTime">) => {
if (!event.start) {
return;
@@ -976,6 +1050,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
jumpTo(eventDate);
if (!event.showWithoutTime) {
setRevealAt((prev) => ({ minutes: eventDate.getHours() * 60 + eventDate.getMinutes(), nonce: (prev?.nonce ?? 0) + 1 }));
}
}, [jumpTo]);
const handleSaveEvent = useCallback(async (data: Partial<CalendarEvent>, requestedScheduling?: boolean, editor?: EventEditorState) => {
@@ -990,9 +1067,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
updates: data,
editor,
sendScheduling: sendSchedulingMessages,
fromEditor: true,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
// false: a plugin stopped it and said why; the form stays open.
@@ -1034,6 +1110,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
await save(requestedScheduling);
}, [client, editEvent, createEvent, updateEvent, focusCalendarOnEvent, confirmAction, describeError, t]);
// Calendars a new task can go into: writable, not a subscription.
const writableTaskCalendars = useMemo(
() => calendars.filter((c) => canCreateEventsIn(c, isSubscriptionCalendar)),
[calendars, isSubscriptionCalendar],
);
// Calendars a new event can go into: never a subscription or read-only one (#762).
const writableCalendars = useMemo(
() => calendars.filter((c) => !c.isTasksOnly && canCreateEventsIn(c, isSubscriptionCalendar)),
@@ -1042,12 +1124,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// A click or drag on empty grid space. On wide screens it opens the quick
// editor at that spot; on phones the full editor, as before.
const handleGridCreate = useCallback((start: Date, end?: Date) => {
const handleGridCreate = useCallback((start: Date, end?: Date, allDay = false) => whenEditorsDiscarded(() => {
if (isMobile) {
openCreateModal(start, end);
openCreateModal(start, end, allDay);
return;
}
const finalEnd = end ?? new Date(start.getTime() + 60 * 60 * 1000);
const finalEnd = end ?? (allDay ? start : new Date(start.getTime() + 60 * 60 * 1000));
const preferred = writableCalendars.find((c) => c.isDefault) ?? writableCalendars[0];
setShowEventModal(false);
setEditEvent(null);
@@ -1058,25 +1140,58 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
start,
end: finalEnd,
title: "",
allDay,
calendarId: prev && writableCalendars.some((c) => c.id === prev.calendarId)
? prev.calendarId
: preferred?.id ?? "",
}));
}, [isMobile, openCreateModal, writableCalendars, closeDetail]);
}), [isMobile, openCreateModal, writableCalendars, closeDetail, whenEditorsDiscarded]);
// A day in the month view or the all-day row: an all-day event, the same
// quick editor as a slot in week and day view.
const handleGridCreateAllDay = useCallback((day: Date) => {
const start = new Date(day);
start.setHours(0, 0, 0, 0);
handleGridCreate(start, start, true);
}, [handleGridCreate]);
const closeQuickCreate = useCallback(() => setQuickDraft(null), []);
// The create button and `n` make what the view shows: a task in Tasks.
const inTasksView = normalizedViewMode === "tasks";
const openCreateForView = useCallback(() => {
if (inTasksView) openCreateTaskModal();
else openCreateModal();
}, [inTasksView, openCreateTaskModal, openCreateModal]);
const createLabel = inTasksView ? t("events.new_task") : t("events.create");
const handleQuickCreateMoreOptions = useCallback(() => {
if (!quickDraft) return;
const draft = quickDraft;
openCreateModal(draft.start, draft.end);
openCreateModal(draft.start, draft.end, draft.allDay);
setDefaultCalendarIdForCreate(draft.calendarId || undefined);
setDefaultModalTitle(draft.title || undefined);
}, [quickDraft, openCreateModal]);
const handleQuickCreateSave = useCallback(async () => {
if (!quickDraft) return;
const { start, end, title, calendarId } = quickDraft;
const { start, end, title, calendarId, allDay } = quickDraft;
if (allDay) {
setQuickDraft(null);
await handleSaveEvent({
uid: generateUUID(),
title: title.trim(),
start: format(start, "yyyy-MM-dd'T'00:00:00"),
duration: "P1D",
timeZone: null,
showWithoutTime: true,
calendarIds: { [calendarId]: true },
status: "confirmed",
freeBusyStatus: "busy",
privacy: "public",
});
return;
}
const minutes = Math.max(15, Math.round((end.getTime() - start.getTime()) / 60000));
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
@@ -1105,7 +1220,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
start: quickDraft.start,
end: quickDraft.end,
title: quickDraft.title.trim() || t("events.no_title"),
allDay: false,
allDay: !!quickDraft.allDay,
calendarId: quickDraft.calendarId,
};
}, [quickDraft, pendingPreview, t]);
@@ -1137,17 +1252,34 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
type: "delete",
event: eventToDelete,
sendScheduling: sendSchedulingMessages || undefined,
fromEditor: showEventModal && editEvent?.id === eventToDelete.id,
});
setShowEventModal(false);
setEditEvent(null);
return;
}
// One event: asked here, from every place Delete is offered (R3). A
// series gets the scope question above instead.
const title = eventToDelete?.title || t("events.no_title");
const cancels = !!sendSchedulingMessages && !!eventToDelete && userOrganizes(eventToDelete, currentUserEmails);
const ok = await confirmAction({
title: t("delete_dialog.event_title"),
message: cancels
? t("delete_dialog.event_message_attendees", { title })
: t("delete_dialog.event_message", { title }),
confirmText: t("delete_dialog.confirm"),
variant: "destructive",
});
if (!ok) return;
try {
if (await deleteEvent(client, id, sendSchedulingMessages)) toast.success(t("notifications.event_deleted"));
} catch {
toast.error(t("notifications.event_error"));
if (!(await deleteEvent(client, id, sendSchedulingMessages))) return;
toast.success(t("notifications.event_deleted"));
if (editEvent?.id === id) {
setShowEventModal(false);
setEditEvent(null);
}
} catch (error) {
toast.error(describeError(error, t("notifications.event_delete_error")));
}
}, [client, deleteEvent, events, editEvent, t]);
}, [client, deleteEvent, events, editEvent, showEventModal, confirmAction, currentUserEmails, describeError, t]);
const submitRsvp = useCallback(async (
eventId: string,
@@ -1171,10 +1303,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
await submitRsvp(event.id, participantId, status, scope === "this" ? "occurrence" : "series");
return;
}
const { type, event, sendScheduling } = pendingScopeAction;
const { type, event, sendScheduling, fromEditor } = pendingScopeAction;
const updates = type === "edit" ? pendingScopeAction.updates : undefined;
const editor = type === "edit" ? pendingScopeAction.editor : undefined;
setPendingScopeAction(null);
// The editor the question came from closes once the change went through.
const closeEditor = () => {
if (!fromEditor) return;
setShowEventModal(false);
setEditEvent(null);
};
// false when a plugin stopped the change (it says why): no "updated".
let done = true;
@@ -1194,6 +1332,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return;
}
if (!done) return;
closeEditor();
toast.success(t("notifications.event_updated"));
} else {
const deleted = await deleteOccurrence(scope, {
@@ -1207,6 +1346,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return;
}
if (!done) return;
closeEditor();
toast.success(t("notifications.event_deleted"));
}
try {
@@ -1215,10 +1355,57 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
debug.error("Failed to refresh calendar after scope operation");
}
} catch (error) {
toast.error(describeError(error, t("notifications.event_error")));
toast.error(describeError(error, t(type === "delete" ? "notifications.event_delete_error" : "notifications.event_error")));
}
}, [client, pendingScopeAction, updateEvent, deleteEvent, createEvent, findMasterEvent, currentUserEmails, resolveContactName, refetchCurrentRange, submitRsvp, describeError, t]);
// A drag or resize. A series asks which occurrences it covers, as the
// editor does; one event moves at once and can be moved back.
const handleMoveEvent = useCallback<MoveEventHandler>(async (event, updates, sendScheduling, kind) => {
if (!client) { toast.error(t("notifications.event_move_error")); return; }
if (isRecurringEvent(event)) {
const moved = { ...event, ...updates };
const allDay = !!event.showWithoutTime;
// The attendees stay as they are; the save works out the rest from
// where the occurrence went, as for a time changed in the editor.
const attendees = getParticipantList(event, { resolveName: resolveContactName })
.filter((p) => !p.isOrganizer && p.email)
.map((p) => ({ name: p.name, email: p.email.trim() }));
setPendingScopeAction({
type: "edit",
event,
updates,
sendScheduling,
editor: {
initialAttendees: attendees,
attendees,
when: { allDay, start: getEventStartDate(moved), end: allDay ? getEventDisplayEndDate(moved) : getEventEndDate(moved) },
ruleChanged: false,
},
});
return;
}
try {
// false: a plugin stopped it and said why.
if (!(await updateEvent(client, event.id, updates, sendScheduling))) return;
} catch (error) {
toast.error(describeError(error, t(kind === "resize" ? "notifications.event_resize_error" : "notifications.event_move_error")));
return;
}
const before: Partial<CalendarEvent> = { start: event.start, duration: event.duration };
if (updates.timeZone !== undefined) before.timeZone = event.timeZone;
toast.success(t(kind === "resize" ? "notifications.event_updated" : "notifications.event_moved"), {
action: {
label: t("notifications.undo"),
onClick: () => {
void updateEvent(client, event.id, before, sendScheduling).catch((error: unknown) => {
toast.error(describeError(error, t("notifications.event_move_error")));
});
},
},
});
}, [client, updateEvent, resolveContactName, describeError, t]);
const handleRsvp = useCallback(async (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => {
if (!client) return;
// An answer on one occurrence of a series asks whether it covers just
@@ -1327,26 +1514,58 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return;
if (target.getAttribute("contenteditable") === "true") return;
if (showEventModal || detailEvent) return;
if (e.defaultPrevented) return;
if (isEditableEventTarget(e)) return;
// An open menu or dialog owns the keyboard; a hover preview doesn't,
// only a card the user opened.
if (isContextMenuOpen()) return;
if (document.querySelector('[aria-modal="true"]')) return;
if (showEventModal || showTaskModal || detailPinned) return;
switch (e.key) {
case "ArrowLeft": e.preventDefault(); navigatePrev(); break;
case "ArrowRight": e.preventDefault(); navigateNext(); break;
case "t": goToToday(); break;
case "m": setViewMode("month"); break;
case "w": setViewMode("week"); break;
case "d": setViewMode("day"); break;
case "a": setViewMode("agenda"); break;
case "k": if (enableCalendarTasks) setViewMode("tasks"); break;
case "n": openCreateModal(); break;
const action = calendarShortcutAction(e);
if (!action) return;
switch (action) {
case "prev": e.preventDefault(); navigatePrev(); break;
case "next": e.preventDefault(); navigateNext(); break;
case "today": goToToday(); break;
case "month": setViewMode("month"); break;
case "week": setViewMode("week"); break;
case "day": setViewMode("day"); break;
case "agenda": setViewMode("agenda"); break;
case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break;
case "new": openCreateForView(); break;
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]);
}, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateForView, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]);
// The command palette's Calendar group, while this calendar is the one in
// front (in the Pro shell: its focused tab).
const tCommon = useTranslations("common");
const paletteViews: { mode: CalendarViewMode; key: string }[] = [
{ mode: "day", key: "d" },
{ mode: "week", key: "w" },
{ mode: "month", key: "m" },
{ mode: "agenda", key: "a" },
{ mode: "tasks", key: "k" },
];
useRegisterCommands(tSidebar("calendar"), [
{ id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() },
{ id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks },
{ id: "today", label: t("events.go_to_today"), icon: CalendarDays, shortcut: "t", run: goToToday, when: () => normalizedViewMode !== "tasks" },
...paletteViews.map(({ mode, key }) => ({
id: `view-${mode}`,
label: t(`commands.${mode}`),
icon: CalendarDays,
shortcut: key,
run: () => { setMobileReturnToMonth(false); setViewMode(mode); },
when: () => mode !== normalizedViewMode && (mode !== "tasks" || enableCalendarTasks),
})),
{ id: "import", label: t("toolbar.import"), icon: Upload, run: () => setShowImportModal(true), when: () => !demoMode },
{ id: "subscribe", label: t("toolbar.subscribe"), icon: Globe, run: () => setShowSubscriptionModal(true), when: () => !demoMode },
{ id: "refresh", label: tCommon("refresh"), icon: RefreshCw, shortcut: "F5", run: () => { void refreshCalendar(); } },
], isEmbedded ? isFocusedProTab : !proInterfaceActive);
const birthdayEvents = useMemo(() => {
if (!showBirthdayCalendar || !dateRange) return [];
@@ -1399,6 +1618,22 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return [...displayCalendars, createBirthdayCalendar(birthdayCalendarName, birthdayCalendarColor)];
}, [displayCalendars, showBirthdayCalendar, birthdayCalendarName, birthdayCalendarColor]);
// What the user may change, by the rights of each event's calendar
// (birthdays, subscriptions and read-only shares can't be changed).
const editabilityContext = useMemo<EditabilityContext>(() => ({
calendarsById: new Map(allCalendars.map((c) => [c.id, c])),
userCalendarAddresses: currentUserEmails,
isSubscriptionCalendar,
}), [allCalendars, currentUserEmails, isSubscriptionCalendar]);
const canEditEvent = useCallback(
(event: CalendarEvent) => getEventEditability(event, editabilityContext) === "editable",
[editabilityContext],
);
// The event the user opened, marked in the grid (APP.md: the selected
// event gets the primary ring).
const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null;
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
@@ -1413,8 +1648,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// The tasks the task list shows; plugin buttons in its toolbar act on them.
const listedTaskIds = useMemo(
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
[taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
() => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter).map((task) => task.id),
[taskStore.tasks, selectedCalendarIds, taskStore.filter],
);
// Tasks on the month, week and day grids follow the calendar filter like
@@ -1466,19 +1701,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Dialog
isOpen
onClose={closeWebcalActionChoice}
title={tWebcalAction("title")}
description={tWebcalAction("description", { name: pendingSubscription.name })}
footer={<Button variant="outline" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>}
>
<div className="pt-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
@@ -1492,11 +1722,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
</Dialog>
) : null;
if (!isAuthenticated) return null;
@@ -1505,11 +1731,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const renderView = () => {
if (isLoading && calendars.length === 0) {
return (
<div className="flex items-center justify-center flex-1 text-muted-foreground">
<p className="text-sm">{t("status.loading_calendars")}</p>
</div>
);
return <PaneSpinner label={t("status.loading_calendars")} className="flex-1" />;
}
const viewContent = (() => {
@@ -1528,14 +1750,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onHoverLeave={handleHoverLeave}
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={openCreateModal}
onCreateAllDay={handleGridCreateAllDay}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
pendingPreview={pendingPreview}
pendingPreview={gridPreview}
tasks={calendarTasks}
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
selectedEventId={selectedEventId}
onMoveEvent={handleMoveEvent}
/>
);
case "week":
@@ -1553,6 +1778,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onContextMenuEvent={handleContextMenuEvent}
onContextMenuEmpty={handleContextMenuEmpty}
onCreateAtTime={handleGridCreate}
onCreateAllDay={handleGridCreateAllDay}
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
@@ -1561,6 +1787,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
selectedEventId={selectedEventId}
onMoveEvent={handleMoveEvent}
revealAt={revealAt}
/>
);
case "day":
@@ -1583,6 +1813,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onToggleTaskComplete={handleToggleTaskComplete}
onSelectTask={openEditTaskModal}
currentUserEmails={currentUserEmails}
canEditEvent={canEditEvent}
selectedEventId={selectedEventId}
onMoveEvent={handleMoveEvent}
revealAt={revealAt}
/>
);
case "agenda":
@@ -1597,6 +1831,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onContextMenuEvent={handleContextMenuEvent}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onCreateEvent={() => openCreateModal()}
/>
);
case "tasks":
@@ -1607,9 +1842,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
)}
<TaskToolbar
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
onFilterChange={taskStore.setFilter}
onShowCompletedChange={taskStore.setShowCompleted}
onCreateTask={openCreateTaskModal}
taskIds={listedTaskIds}
compact={isMobile}
@@ -1619,14 +1852,20 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
calendars={displayCalendars}
selectedCalendarIds={selectedCalendarIds}
filter={taskStore.filter}
showCompleted={taskStore.showCompleted}
onSelectTask={openEditTaskModal}
onToggleComplete={handleToggleTaskComplete}
selectedTaskId={taskStore.selectedTaskId}
onQuickCreate={(title) => {
if (client) {
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [calendars[0]?.id ?? ""]: true } });
// Into a calendar that takes it: the default one, else the
// first the user may write to.
const target = writableTaskCalendars.find((c) => c.isDefault) ?? writableTaskCalendars[0];
if (!client || !target) {
toast.error(t("notifications.task_save_error"));
return;
}
taskStore.createTask(client, { "@type": "Task", title, progress: "needs-action", calendarIds: { [target.id]: true } })
.then((created) => { if (created) toast.success(t("notifications.task_created")); })
.catch((error: unknown) => toast.error(describeError(error, t("notifications.task_save_error"))));
}}
/>
</div>
@@ -1642,7 +1881,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{viewContent}
{isLoadingEvents && calendars.length > 0 && events.length === 0 && (
<div className="absolute inset-0 bg-background/50 flex items-center justify-center pointer-events-none">
<div className="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<Spinner label={t("status.loading_events")} />
</div>
)}
</div>
@@ -1658,9 +1897,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onNext={navigateNext}
onToday={goToToday}
onViewModeChange={(mode) => { setMobileReturnToMonth(false); setViewMode(mode); }}
onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)}
onSubscribe={() => setShowSubscriptionModal(true)}
onCreateEvent={openCreateForView}
createLabel={createLabel}
onImport={demoMode ? undefined : () => setShowImportModal(true)}
onSubscribe={demoMode ? undefined : () => setShowSubscriptionModal(true)}
onRefresh={() => { void refreshCalendar(); }}
isMobile={isMobile}
onNavigateBack={isMobile && mobileReturnToMonth && normalizedViewMode === "day" ? navigateBackToMonth : undefined}
calendars={displayCalendars}
@@ -1669,6 +1910,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
enableCalendarTasks={enableCalendarTasks}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : isMobile ? undefined : toggleSidebarCollapsed}
sidebarOpen={isNarrow ? narrowSidebarOpen : !sidebarCollapsed}
menuTogglesSidebar={!isNarrow}
showCreateButton={isNarrow || sidebarCollapsed}
/>
);
@@ -1729,15 +1971,16 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
"border-e border-border bg-secondary flex flex-col overflow-hidden flex-shrink-0",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"absolute inset-y-0 start-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!narrowSidebarOpen && "-translate-x-full"
!narrowSidebarOpen && "-translate-x-full rtl:translate-x-full"
)
)}
style={isNarrow ? undefined : { width: `${calSidebarWidth}px` }}
>
<CalendarSidebarHeader
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateModal(); }}
onCreateEvent={() => { setNarrowSidebarOpen(false); openCreateForView(); }}
createLabel={createLabel}
tourTarget={!isNarrow}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3">
@@ -1813,7 +2056,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
}
} : undefined}
onCreateCalendar={client ? () => setShowCreateCalendar(true) : undefined}
onSubscribe={() => setShowSubscriptionModal(true)}
onSubscribe={demoMode ? undefined : () => setShowSubscriptionModal(true)}
onEditSubscription={(subId) => setEditingSubscription(subId)}
client={client}
multiAccountMode={multiAccountEnabled && accountClients.length > 1}
@@ -1873,6 +2116,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
@@ -1892,6 +2137,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
calendars={displayCalendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onDirtyChange={setTaskEditorDirty}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
isMobile={false}
/>
@@ -1901,9 +2147,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{/* Floating Create Event Button (mobile) */}
{isMobile && (
<Button
onClick={() => openCreateModal()}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t("events.create")}
onClick={openCreateForView}
className="absolute bottom-4 end-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={createLabel}
>
<Plus className="h-6 w-6" />
</Button>
@@ -1934,12 +2180,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
isOpen={eventContextMenu.isOpen}
onClose={closeEventContextMenu}
menuRef={eventContextMenuRef}
onEdit={() => openEditModal(eventContextMenu.data!)}
// Edit opens the form, as the card's pencil and `e` do.
onEdit={() => openEditModal(eventContextMenu.data!, true)}
onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)}
onExportICS={() => handleExportICS(eventContextMenu.data!)}
onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)}
onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)}
onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)}
editable={canEditEvent(eventContextMenu.data)}
/>
)}
@@ -1992,7 +2240,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{detailEvent && detailAnchorRect && (
<EventDetailPopover
event={detailEvent}
calendar={displayCalendars.find(c => detailEvent.calendarIds[c.id])}
calendar={allCalendars.find(c => detailEvent.calendarIds[c.id])}
anchorRect={detailAnchorRect}
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
@@ -2007,6 +2255,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
isSubscriptionCalendar={isSubscriptionCalendar}
timeFormat={timeFormat}
isMobile={isMobile}
pinned={detailPinned}
/>
)}
@@ -2025,6 +2274,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onMore={editEvent ? (anchor) => openEventContextMenuFrom(anchor, editEvent, "end") : undefined}
onDirtyChange={setEventEditorDirty}
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
currentUserEmails={currentUserEmails}
isSubscriptionCalendar={isSubscriptionCalendar}
@@ -2047,6 +2298,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
calendars={displayCalendars}
onSave={handleSaveTask}
onDelete={handleDeleteTask}
onDirtyChange={setTaskEditorDirty}
onClose={() => { setShowTaskModal(false); setEditTask(null); }}
isMobile={true}
/>
@@ -2095,6 +2347,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<RecurrenceScopeDialog
isOpen={!!pendingScopeAction}
actionType={pendingScopeAction?.type || "edit"}
note={pendingScopeAction?.type === "delete" && pendingScopeAction.sendScheduling
&& userOrganizes(pendingScopeAction.event, currentUserEmails)
? t("participants.cancel_notification")
: undefined}
onSelect={handleScopeSelect}
onClose={() => setPendingScopeAction(null)}
/>
+44 -45
View File
@@ -5,8 +5,8 @@ import { useTranslations } from "next-intl";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import { format, isSameDay, eachDayOfInterval, differenceInCalendarDays } from "date-fns";
import { cn } from "@/lib/utils";
import { Check } from "@/components/icons";
import { EventCard } from "./event-card";
import { CalendarTaskChip } from "./task-chip";
import { QuickEventInput } from "./quick-event-input";
import { AllHoursToggle, HiddenEventsIndicator } from "./display-hours-controls";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, isTimedEventFullDayOnDate, layoutOverlappingEvents } from "@/lib/calendar-utils";
@@ -14,8 +14,8 @@ import { clipToDisplayHours, partitionByDisplayHours } from "@/lib/calendar-disp
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { groupTasksByDueDay } from "@/lib/calendar-tasks";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
@@ -44,6 +44,17 @@ interface CalendarDayViewProps extends ScrollWindowViewProps {
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/** The event whose card or editor is open: drawn with the selection ring. */
selectedEventId?: string | null;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
/** A time to bring into view, such as a just-saved event's start. */
revealAt?: RevealTime | null;
}
const HOUR_HEIGHT = 64;
@@ -75,6 +86,10 @@ export function CalendarDayView({
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
selectedEventId,
onMoveEvent,
revealAt,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -162,7 +177,7 @@ export function CalendarDayView({
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
@@ -277,6 +292,7 @@ export function CalendarDayView({
describeError,
currentUserEmails,
isMobile,
onMoveEvent,
});
const formatHour = (h: number): string => {
@@ -363,6 +379,7 @@ export function CalendarDayView({
<EventCard
key={ev.id}
event={ev}
isSelected={ev.id === selectedEventId}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
@@ -380,37 +397,17 @@ export function CalendarDayView({
<>
<div className={cn("text-[10px] text-muted-foreground mb-1", allDayEvents.length > 0 && "mt-2")}>{t("tasks.label")}</div>
<div className="space-y-0.5">
{dayTasks.map((task) => {
const isCompleted = task.progress === "completed";
const cal = calendars.find(c => task.calendarIds[c.id]);
const color = cal?.color || "#3b82f6";
return (
<div
key={task.id}
data-calendar-task={task.id}
onClick={() => onSelectTask?.(task)}
className="flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs cursor-pointer hover:bg-muted/50 transition-colors"
style={{ borderInlineStart: `3px solid ${color}` }}
>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleTaskComplete?.(task); }}
className={cn(
"flex-shrink-0 w-3.5 h-3.5 rounded-full border flex items-center justify-center",
isCompleted
? "bg-success border-success text-success-foreground"
: "border-muted-foreground/40 hover:border-primary"
)}
aria-label={isCompleted ? t("tasks.mark_incomplete") : t("tasks.mark_complete")}
>
{isCompleted && <Check className="h-2.5 w-2.5" />}
</button>
<span className={cn("truncate", isCompleted && "line-through text-muted-foreground")}>
{task.title || t("tasks.no_title")}
</span>
</div>
);
})}
{/* The same chip as the month and week grids. */}
{dayTasks.map((task) => (
<div key={task.id} className="h-5">
<CalendarTaskChip
task={task}
calendar={calendars.find((c) => task.calendarIds[c.id])}
onToggleComplete={onToggleTaskComplete}
onSelect={onSelectTask}
/>
</div>
))}
</div>
</>
)}
@@ -435,7 +432,7 @@ export function CalendarDayView({
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
"absolute end-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[10px]" : "text-xs",
)}>
@@ -504,6 +501,7 @@ export function CalendarDayView({
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const editable = canEditEvent(ev);
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
@@ -515,10 +513,11 @@ export function CalendarDayView({
key={ev.id}
className="absolute z-10 group/event"
data-calendar-event
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
style={{ top, height, insetInlineStart: `${leftPct}%`, width: `${widthPct}%`, paddingInline: 2 }}
>
<EventCard
event={ev}
isSelected={ev.id === selectedEventId}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="block"
onClick={(rect) => onSelectEvent(ev, rect)}
@@ -526,31 +525,31 @@ export function CalendarDayView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
draggable={editable}
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
{editable && !clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
className="absolute top-0 inset-x-1 h-1 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
<div className="w-8 h-0.5 rounded-full bg-foreground/30" />
</div>
)}
{!clip.clippedEnd && (
{editable && !clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
className="absolute bottom-0 inset-x-1 h-1 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
<div className="w-8 h-0.5 rounded-full bg-foreground/30" />
</div>
)}
</div>
@@ -600,7 +599,7 @@ export function CalendarDayView({
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
<div className="absolute -top-4 start-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
{formatSnapTime(dropTarget.minutes, timeFormat)}
</div>
</div>
@@ -18,7 +18,7 @@ export function CalendarLoadErrorNotice({ what = "events", onRetry }: CalendarLo
const t = useTranslations("calendar.status");
return (
<div role="alert" className="flex items-center gap-2 border-b border-border bg-warning/10 px-4 py-2 text-sm">
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-warning" />
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<span className="min-w-0 flex-1">{what === "tasks" ? t("tasks_load_failed") : t("events_load_failed")}</span>
<Button variant="outline" size="sm" onClick={onRetry}>
{t("retry")}
+81 -17
View File
@@ -21,6 +21,8 @@ import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import { useCalendarErrorMessage } from "@/hooks/use-calendar-error-message";
import { PluginDayLabel } from "./plugin-day-label";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
import type { MoveEventHandler } from "@/hooks/use-time-grid-interactions";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -32,7 +34,8 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
/** A click on a day's empty space: a new all-day event there (wide screens). */
onCreateAllDay?: (date: Date) => void;
/** Open a day in the day view (its date number, or its "N more" link). */
onOpenDay?: (date: Date) => void;
firstDayOfWeek?: number;
@@ -46,6 +49,15 @@ interface CalendarMonthViewProps extends ScrollWindowViewProps {
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/** The event whose card or editor is open: drawn with the selection ring. */
selectedEventId?: string | null;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
}
/** Fraction of the viewport height at which the "current month" is sampled. */
@@ -69,7 +81,7 @@ export function CalendarMonthView({
onHoverLeave,
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onCreateAllDay,
onOpenDay,
isMobile,
pendingPreview,
@@ -77,6 +89,9 @@ export function CalendarMonthView({
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
selectedEventId,
onMoveEvent,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -87,7 +102,9 @@ export function CalendarMonthView({
const overlayTop = isMobile ? 34 : 30;
const rowHeight = isMobile ? 18 : 22;
const chipHeight = rowHeight - 2;
const baseRowMinHeight = isMobile ? 52 : 100;
// A wide-screen week row fits at least three entries and the "N more"
// link, so a short window (1280x720) does not cut every day to one.
const baseRowMinHeight = isMobile ? 52 : overlayTop + 4 + 8 + 4 * rowHeight;
const {
dayHeaderKeys,
getMonthGridDays,
@@ -139,6 +156,14 @@ export function CalendarMonthView({
const tasksByDate = useMemo(() => groupTasksByDueDay(tasks), [tasks]);
// Phones have room for one letter per weekday: the language's own short
// form, not the first letters of the name ("اث" for Monday in Arabic).
const intlFormatter = useDisplayDateFormatter();
const narrowDayNames = useMemo(
() => (weeks[0] ?? []).map((day) => intlFormatter.dateTime(day, { weekday: "narrow" })),
[weeks, intlFormatter],
);
const weekSegments = useMemo(() => {
return weeks.map((week) => {
const segments = buildWeekSegments(events, week);
@@ -254,6 +279,38 @@ export function CalendarMonthView({
anchorSelector: "[data-week]",
});
// One tab stop for the whole grid (the selected day's date), arrows move
// from day to day and week to week, Enter opens the day (R8): Tab used to
// walk every date of the loaded months.
const selectedKey = dayKey(selectedDate);
const rovingKey = useMemo(() => {
const keys = new Set(weeks.flat().map(dayKey));
if (keys.has(selectedKey)) return selectedKey;
const focusKey = dayKey(focus.date);
return keys.has(focusKey) ? focusKey : (weeks[0]?.[0] ? dayKey(weeks[0][0]) : selectedKey);
}, [weeks, selectedKey, focus.date]);
const handleDayKeyDown = useCallback((e: React.KeyboardEvent<HTMLButtonElement>, day: Date, dayIndex: number) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
let delta: number;
switch (e.key) {
case "ArrowLeft": delta = rtl ? 1 : -1; break;
case "ArrowRight": delta = rtl ? -1 : 1; break;
case "ArrowUp": delta = -7; break;
case "ArrowDown": delta = 7; break;
case "Home": delta = -dayIndex; break;
case "End": delta = 6 - dayIndex; break;
default: return;
}
// The grid's arrows, not the calendar's previous/next month.
e.preventDefault();
const next = addDays(day, delta);
const target = scrollRef.current?.querySelector<HTMLButtonElement>(`[data-day-button="${dayKey(next)}"]`);
if (!target) return;
onSelectDate(next);
target.focus();
}, [onSelectDate]);
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
@@ -287,11 +344,12 @@ export function CalendarMonthView({
const client = useAuthStore.getState().client;
if (!client) return;
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
toast.error(describeError(error, t("notifications.event_move_error")));
}
}, [t, describeError, currentUserEmails]);
}, [t, describeError, currentUserEmails, onMoveEvent]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
@@ -305,12 +363,12 @@ export function CalendarMonthView({
className="sticky top-0 z-50 bg-background grid grid-cols-7 border-b border-border"
role="row"
>
{dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn(
{dayHeaderKeys.map((d, index) => (
<div key={d} role="columnheader" aria-label={isMobile ? t(`days.${d}`) : undefined} className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
{isMobile ? narrowDayNames[index] ?? t(`days.${d}`) : t(`days.${d}`)}
</div>
))}
</div>
@@ -354,8 +412,11 @@ export function CalendarMonthView({
role="gridcell"
aria-selected={selected}
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)}
onClick={() => {
onSelectDate(day);
// On phones a tap opens the day instead.
if (!isMobile) onCreateAllDay?.(day);
}}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
@@ -378,13 +439,14 @@ export function CalendarMonthView({
whole cell already does. */}
<button
type="button"
tabIndex={isMobile ? -1 : undefined}
data-day-button={key}
tabIndex={!isMobile && key === rovingKey ? 0 : -1}
onKeyDown={(e) => handleDayKeyDown(e, day, dayIndex)}
onClick={(e) => {
if (isMobile || !onOpenDay) return;
e.stopPropagation();
onOpenDay(day);
}}
onDoubleClick={(e) => e.stopPropagation()}
aria-label={fullDateLabel}
aria-current={today ? "date" : undefined}
className={cn(
@@ -456,13 +518,14 @@ export function CalendarMonthView({
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
insetInlineStart: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * rowHeight,
height: chipHeight,
}}
>
<div
data-pending-preview
className={cn(
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
@@ -485,7 +548,7 @@ export function CalendarMonthView({
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
insetInlineStart: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
top: segment.row * rowHeight,
height: chipHeight,
@@ -493,6 +556,7 @@ export function CalendarMonthView({
>
<EventCard
event={segment.event}
isSelected={segment.event.id === selectedEventId}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="span"
continuesBefore={segment.continuesBefore}
@@ -502,7 +566,7 @@ export function CalendarMonthView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
draggable={canEditEvent(segment.event)}
className={isMobile ? "text-[10px] px-1" : undefined}
/>
</div>
@@ -521,7 +585,7 @@ export function CalendarMonthView({
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
insetInlineStart: `calc(${(dayIndex / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: (dayEventRows[dayIndex] + taskIndex) * rowHeight,
height: chipHeight,
@@ -546,7 +610,7 @@ export function CalendarMonthView({
key={`more-${dayIndex}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / 7) * 100}% + 1px)`,
insetInlineStart: `calc(${(dayIndex / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: rowLimit * rowHeight,
height: chipHeight,
@@ -6,6 +6,8 @@ import { Plus } from "@/components/icons";
interface CalendarSidebarHeaderProps {
onCreateEvent: () => void;
/** The create button's name: what it makes in the current view. */
createLabel?: string;
/** Mark the button for the guided tour; off while the toolbar's own "+" is the visible one. */
tourTarget?: boolean;
}
@@ -14,8 +16,9 @@ interface CalendarSidebarHeaderProps {
* Title row at the top of the calendar sidebar, the same shape as the
* contacts sidebar header: the app name on the left and "+" on the right.
*/
export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: CalendarSidebarHeaderProps) {
export function CalendarSidebarHeader({ onCreateEvent, createLabel, tourTarget = true }: CalendarSidebarHeaderProps) {
const t = useTranslations("calendar");
const label = createLabel ?? t("events.create");
return (
// Same h-14 box as the mail sidebar's account switcher, so the apps line up;
@@ -23,12 +26,12 @@ export function CalendarSidebarHeader({ onCreateEvent, tourTarget = true }: Cale
<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>
<Button
size="icon"
size="icon-sm"
variant="ghost"
onClick={onCreateEvent}
className="h-8 w-8 flex-shrink-0"
aria-label={t("events.create")}
title={t("events.create")}
className="flex-shrink-0"
aria-label={label}
title={label}
data-tour={tourTarget ? "create-event-button" : undefined}
>
<Plus className="w-4 h-4" />
+56 -29
View File
@@ -1,6 +1,8 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useLongPress } from "@/hooks/use-long-press";
import { IconButton } from "@/components/ui/icon-button";
import { useTranslations } from "next-intl";
import { Check, ChevronDown, ChevronRight, Globe, ListTodo, MoreVertical, Pencil, RefreshCw, Share2, Star, Trash2, Cake, User, Users, Plus, Eraser, Palette, Shuffle } from "@/components/icons";
import { cn, formatDateTime } from "@/lib/utils";
@@ -15,6 +17,8 @@ import { sharedCalendarColorKey } from "@/lib/shared-calendar-colors";
import { toast } from "@/stores/toast-store";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { displayNow } from "@/lib/timezone";
import { readableTextOn } from "@/lib/color-transform";
@@ -112,7 +116,14 @@ export function CalendarSidebarPanel({
return tasks.filter(t => t.progress !== 'completed' && t.progress !== 'cancelled' && t.due && new Date(t.due) < now).length;
}, [tasks]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Calendar>();
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<Calendar>();
// A long press on a calendar opens its menu, as a right-click does (R7).
const pressedRef = useRef<{ calendar: Calendar; element: HTMLElement } | null>(null);
const longPress = useLongPress(() => {
const pressed = pressedRef.current;
if (pressed) openContextMenuFrom(pressed.element, pressed.calendar);
});
const [refreshingSubId, setRefreshingSubId] = useState<string | null>(null);
// Persisted across mounts so toggle state survives tab switches in the
@@ -231,8 +242,19 @@ export function CalendarSidebarPanel({
}
};
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useConfirmDialog();
// Removes the feed's calendar and its events: asked first (R3).
const handleUnsubscribe = async (subId: string) => {
if (!client) return;
const sub = icalSubscriptions.find((s) => s.id === subId);
const ok = await confirmAction({
title: t('delete_dialog.subscription_title'),
message: tSub('confirm_delete', { name: sub?.name ?? '' }),
confirmText: tSub('unsubscribe'),
variant: 'destructive',
});
if (!ok) return;
try {
await removeICalSubscription(client, subId);
toast.success(tSub('deleted'));
@@ -263,6 +285,12 @@ export function CalendarSidebarPanel({
<button
onClick={() => onToggleVisibility(cal.id)}
onContextMenu={hasMenu ? (e) => openContextMenu(e, cal) : undefined}
{...(hasMenu ? {
onTouchStart: (e: React.TouchEvent<HTMLButtonElement>) => { pressedRef.current = { calendar: cal, element: e.currentTarget }; longPress.onTouchStart(e); },
onTouchMove: longPress.onTouchMove,
onTouchEnd: longPress.onTouchEnd,
onTouchCancel: longPress.onTouchCancel,
} : {})}
role="checkbox"
aria-checked={isVisible}
data-testid="calendar-item"
@@ -270,11 +298,11 @@ export function CalendarSidebarPanel({
data-account={cal.accountName ?? ''}
data-visible={isVisible}
className={cn(
"flex items-center gap-2 w-full ps-5 pe-3 text-sm text-foreground/80 transition-colors duration-150",
"flex items-center gap-2 w-full ps-5 pe-3 min-h-8 pointer-coarse:min-h-11 text-sm text-foreground/80 transition-colors duration-150",
"hover:bg-muted",
hasMenu && "pe-9"
hasMenu && "pe-11"
)}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
>
{/* A checkbox filled in the calendar colour, empty when hidden. */}
<span
@@ -287,7 +315,7 @@ export function CalendarSidebarPanel({
>
{isVisible && <Check className="w-2.5 h-2.5" stroke={3.5} />}
</span>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
</span>
{isSubscriptionCalendar(cal.id) && (
@@ -312,19 +340,19 @@ export function CalendarSidebarPanel({
})()}
</button>
{hasMenu && (
<button
type="button"
onClick={(e) => openContextMenu(e, cal)}
<IconButton
onClick={(e) => openContextMenuFrom(e.currentTarget, cal, "end")}
aria-haspopup="menu"
className={cn(
"absolute end-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-opacity",
"opacity-0 group-hover/cal:opacity-100 focus-visible:opacity-100",
"absolute end-1 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-opacity",
// Hover shows it; keyboard focus and touch screens always do (R7).
"opacity-0 group-hover/cal:opacity-100 group-focus-within/cal:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100",
contextMenu.isOpen && contextMenu.data?.id === cal.id && "opacity-100"
)}
aria-label={t('calendar_options', { name: cal.name })}
title={t('calendar_options', { name: cal.name })}
label={t('calendar_options', { name: cal.name })}
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
<MoreVertical />
</IconButton>
)}
</div>
);
@@ -388,7 +416,7 @@ export function CalendarSidebarPanel({
)}
{canShare && (
<ContextMenuItem
icon={Users}
icon={Share2}
label={tMgmt('share')}
onClick={() => { closeContextMenu(); onShareCalendar(cal); }}
/>
@@ -453,20 +481,18 @@ export function CalendarSidebarPanel({
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0 rtl:rotate-180" />
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">{label}</span>
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate" title={label}>{label}</span>
</button>
{onAdd && (
<button
type="button"
<IconButton
onClick={onAdd}
className="p-0.5 rounded text-muted-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-foreground hover:bg-muted transition-opacity"
title={tMgmt('add_calendar')}
aria-label={tMgmt('add_calendar')}
className="text-muted-foreground opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground transition-opacity"
label={tMgmt('add_calendar')}
>
<Plus className="w-3.5 h-3.5" />
</button>
<Plus />
</IconButton>
)}
</div>
);
@@ -488,8 +514,8 @@ export function CalendarSidebarPanel({
{enableCalendarTasks && (
<button
onClick={() => setViewMode('tasks')}
className="flex items-center gap-2 w-full px-3 mb-2 text-sm text-foreground/80 hover:bg-muted transition-colors"
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
className="flex items-center gap-2 w-full px-3 mb-2 min-h-8 pointer-coarse:min-h-11 text-sm text-foreground/80 hover:bg-muted transition-colors"
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)' }}
>
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
@@ -519,7 +545,7 @@ export function CalendarSidebarPanel({
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
<User className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-xs font-semibold text-foreground/90 truncate">
<span className="text-xs font-semibold text-foreground/90 truncate" title={group.label}>
{group.label}
</span>
{isActive && onCreateCalendar && (
@@ -534,7 +560,7 @@ export function CalendarSidebarPanel({
onCreateCalendar();
}
}}
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
@@ -599,6 +625,7 @@ export function CalendarSidebarPanel({
)}
{renderCalendarMenu()}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}
+160 -182
View File
@@ -3,7 +3,10 @@
import { useState, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw } from "@/components/icons";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { startOfWeek } from "date-fns";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
@@ -22,8 +25,12 @@ interface CalendarToolbarProps {
onToday: () => void;
onViewModeChange: (mode: CalendarViewMode) => void;
onCreateEvent: () => void;
/** The create button's name: what it makes in the current view. */
createLabel?: string;
onImport?: () => void;
onSubscribe?: () => void;
/** Reload calendars, events and subscriptions. */
onRefresh?: () => void;
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
@@ -35,6 +42,8 @@ interface CalendarToolbarProps {
onMenuClick?: () => void;
/** Whether the sidebar is currently shown (for the burger's aria-expanded). */
sidebarOpen?: boolean;
/** The burger shows and hides the sidebar beside the grid, rather than opening it over it. */
menuTogglesSidebar?: boolean;
/**
* Show a compact create button in the bar. The full "Create" button lives
* at the top of the sidebar, so this is only needed while it is hidden.
@@ -60,8 +69,10 @@ export function CalendarToolbar({
onToday,
onViewModeChange,
onCreateEvent,
createLabel,
onImport,
onSubscribe,
onRefresh,
isMobile,
firstDayOfWeek = 1,
onNavigateBack,
@@ -71,9 +82,11 @@ export function CalendarToolbar({
enableCalendarTasks,
onMenuClick,
sidebarOpen,
menuTogglesSidebar,
showCreateButton,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
const {
weekStartsOn,
formatMonthYear,
@@ -89,8 +102,12 @@ export function CalendarToolbar({
const menuViews: CalendarViewMode[] = enableCalendarTasks
? ["day", "week", "month", "agenda", "tasks"]
: ["day", "week", "month", "agenda"];
const [showViewMenu, setShowViewMenu] = useState(false);
const viewMenuRef = useRef<HTMLDivElement>(null);
const {
contextMenu: viewMenu,
openContextMenuFrom: openViewMenuFrom,
closeContextMenu: closeViewMenu,
menuRef: viewMenuRef,
} = useContextMenu<true>();
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@@ -135,41 +152,49 @@ export function CalendarToolbar({
}
};
const [showImportDropdown, setShowImportDropdown] = useState(false);
const importDropdownRef = useRef<HTMLDivElement>(null);
// Tasks have no dates to step through.
const hasDateNav = viewMode !== "tasks";
// The ⋮ menu: what the calendar as a whole offers.
const {
contextMenu: moreMenu,
openContextMenuFrom: openMoreMenuFrom,
closeContextMenu: closeMoreMenu,
menuRef: moreMenuRef,
} = useContextMenu<true>();
const hasMoreMenu = !!(onImport || onSubscribe || onRefresh);
const pick = (fn: () => void) => () => { closeMoreMenu(); fn(); };
const moreButton = hasMoreMenu ? (
<>
<Button
variant="ghost"
size="icon-sm"
className={cn(moreMenu.isOpen && "bg-accent text-accent-foreground")}
onClick={(e) => openMoreMenuFrom(e.currentTarget, true, "end")}
aria-label={t("toolbar.more")}
title={t("toolbar.more")}
aria-haspopup="menu"
aria-expanded={moreMenu.isOpen}
>
<MoreVertical />
</Button>
<ContextMenu ref={moreMenuRef} isOpen={moreMenu.isOpen} position={moreMenu.position} onClose={closeMoreMenu}>
{onImport && <ContextMenuItem icon={Upload} label={t("toolbar.import")} onClick={pick(onImport)} />}
{onSubscribe && <ContextMenuItem icon={Globe} label={t("toolbar.subscribe")} onClick={pick(onSubscribe)} />}
{onRefresh && (onImport || onSubscribe) && <ContextMenuSeparator />}
{onRefresh && <ContextMenuItem icon={RefreshCw} label={tCommon("refresh")} onClick={pick(onRefresh)} />}
</ContextMenu>
</>
) : null;
// The same entries in the phone header's ⋮.
const phoneMenuItems = hasMoreMenu ? (close: () => void) => (
<>
{onImport && <ContextMenuItem icon={Upload} label={t("toolbar.import")} onClick={() => { close(); onImport(); }} />}
{onSubscribe && <ContextMenuItem icon={Globe} label={t("toolbar.subscribe")} onClick={() => { close(); onSubscribe(); }} />}
{onRefresh && <ContextMenuItem icon={RefreshCw} label={tCommon("refresh")} onClick={() => { close(); onRefresh(); }} />}
</>
) : undefined;
useEffect(() => {
if (!showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showImportDropdown]);
useEffect(() => {
if (!showViewMenu && !showImportDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewMenuRef.current && !viewMenuRef.current.contains(e.target as Node)) {
setShowViewMenu(false);
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setShowViewMenu(false);
setShowImportDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKey);
};
}, [showViewMenu, showImportDropdown]);
@@ -182,36 +207,35 @@ export function CalendarToolbar({
{/* Row 1: Back / Date nav / Today */}
<div className="flex items-center gap-1">
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
</button>
<IconButton size="icon-lg" label={t("nav_open_menu")} onClick={onMenuClick} className="-ms-1 touch-manipulation">
<Menu />
</IconButton>
)}
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
</button>
<IconButton size="icon-lg" label={t("back_to_month")} onClick={onNavigateBack} className="-ms-1 touch-manipulation">
<ArrowLeft />
</IconButton>
)}
<button onClick={onPrev} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-center flex-1 select-none truncate">
{hasDateNav && (
<IconButton size="icon-lg" label={t("nav_prev")} onClick={onPrev} className="touch-manipulation">
<ChevronLeft />
</IconButton>
)}
<span className="text-sm font-semibold text-center flex-1 select-none truncate" title={getDateLabel()}>
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
<PhoneHeaderActions below="md" />
{hasDateNav && (
<>
<IconButton size="icon-lg" label={t("nav_next")} onClick={onNext} className="touch-manipulation">
<ChevronRight />
</IconButton>
<Button variant="ghost" size="xs" onClick={onToday} className="touch-manipulation ms-0.5">
{t("views.today")}
</Button>
</>
)}
{/* The shell's phone ⋮ (notifications, palette, account) with the calendar's own entries on top. */}
<PhoneHeaderActions below="md" menuItems={phoneMenuItems} />
</div>
{/* Row 2: View switcher pills + calendar toggle */}
@@ -222,7 +246,7 @@ export function CalendarToolbar({
key={v}
onClick={() => onViewModeChange(v)}
className={cn(
"flex-1 py-1.5 text-[11px] font-medium transition-colors touch-manipulation",
"flex-1 min-h-11 text-[11px] font-medium transition-colors touch-manipulation",
v === viewMode
? "bg-primary text-primary-foreground"
: "text-muted-foreground active:bg-muted"
@@ -235,16 +259,16 @@ export function CalendarToolbar({
{calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<button
<IconButton
size="icon-lg"
variant="outline"
label={t("my_calendars")}
aria-expanded={showCalendarDropdown}
onClick={() => setShowCalendarDropdown((v) => !v)}
className={cn(
"p-1.5 rounded-md border border-border transition-colors touch-manipulation",
showCalendarDropdown ? "bg-muted" : "hover:bg-muted"
)}
aria-label={t("my_calendars")}
className={cn("touch-manipulation", showCalendarDropdown && "bg-muted")}
>
<CalendarDays className="w-4 h-4" />
</button>
<CalendarDays />
</IconButton>
{showCalendarDropdown && (
<div className="absolute top-full end-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
@@ -259,7 +283,7 @@ export function CalendarToolbar({
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"flex items-center gap-2 w-full px-2 min-h-11 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
@@ -270,7 +294,7 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
</span>
</button>
@@ -299,7 +323,7 @@ export function CalendarToolbar({
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"flex items-center gap-2 w-full px-2 min-h-11 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
@@ -310,7 +334,7 @@ export function CalendarToolbar({
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
<span className={cn("truncate", !isVisible && "text-muted-foreground")} title={cal.name}>
{cal.name}
</span>
</button>
@@ -338,10 +362,11 @@ export function CalendarToolbar({
{onMenuClick && (
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={onMenuClick}
className="h-8 w-8 -ms-1 me-1"
aria-label={t("nav_open_menu")}
className="-ms-1 me-1"
aria-label={menuTogglesSidebar ? t(sidebarOpen ? "nav_hide_sidebar" : "nav_show_sidebar") : t("nav_open_menu")}
title={menuTogglesSidebar ? t(sidebarOpen ? "nav_hide_sidebar" : "nav_show_sidebar") : t("nav_open_menu")}
aria-expanded={sidebarOpen}
>
<Menu className="w-4 h-4" />
@@ -350,132 +375,85 @@ export function CalendarToolbar({
{showCreateButton && (
<Button
variant="ghost"
size="icon"
size="icon-sm"
onClick={onCreateEvent}
className="h-8 w-8"
aria-label={t("events.create")}
title={t("events.create")}
aria-label={createLabel ?? t("events.create")}
title={createLabel ?? t("events.create")}
data-tour="create-event-button"
>
<Plus className="w-4 h-4" />
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onToday}
className="h-8 me-1"
title={formatFullDate(displayNow())}
>
{t("views.today")}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onPrev}
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-4 h-4 rtl:rotate-180" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onNext}
aria-label={t("nav_next")}
>
<ChevronRight className="w-4 h-4 rtl:rotate-180" />
</Button>
<h1 className="ms-2 text-base font-semibold truncate select-none">
{hasDateNav && (
<>
<Button
variant="outline"
size="xs"
onClick={onToday}
className="me-1"
title={formatFullDate(displayNow())}
>
{t("views.today")}
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onPrev}
aria-label={t("nav_prev")}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onNext}
aria-label={t("nav_next")}
>
<ChevronRight className="w-4 h-4" />
</Button>
</>
)}
<h1 className="ms-2 text-base font-semibold truncate select-none" title={getDateLabel()}>
{getDateLabel()}
</h1>
<div className="flex-1" />
{(onImport || onSubscribe) && (
<div className="relative" ref={importDropdownRef}>
<Button
variant="ghost"
size="icon"
className={cn(
"h-8 w-8",
showImportDropdown && "bg-accent text-accent-foreground",
)}
onClick={() => setShowImportDropdown((v) => !v)}
aria-label={t("toolbar.more")}
title={t("toolbar.more")}
aria-haspopup="menu"
aria-expanded={showImportDropdown}
>
<MoreVertical className="w-4 h-4" />
</Button>
{showImportDropdown && (
<div
role="menu"
className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]"
>
{onImport && (
<button
role="menuitem"
onClick={() => { onImport(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Upload className="w-4 h-4" />
{t("import.title")}
</button>
)}
{onSubscribe && (
<button
role="menuitem"
onClick={() => { onSubscribe(); setShowImportDropdown(false); }}
className="flex items-center gap-2 w-full px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors text-foreground"
>
<Globe className="w-4 h-4" />
{t("subscription.title")}
</button>
)}
</div>
)}
</div>
)}
{moreButton}
<div className="relative ms-1" ref={viewMenuRef}>
<div className="relative ms-1">
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={() => setShowViewMenu((v) => !v)}
size="xs"
className="gap-1"
onClick={(e) => openViewMenuFrom(e.currentTarget, true, "end")}
aria-haspopup="menu"
aria-expanded={showViewMenu}
aria-expanded={viewMenu.isOpen}
aria-label={t("toolbar.select_view", { view: currentViewLabel })}
data-testid="calendar-view-menu"
>
{currentViewLabel}
<ChevronDown className="w-3 h-3" />
</Button>
{showViewMenu && (
<div
role="menu"
className="absolute top-full end-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]"
>
{menuViews.map((v) => (
<button
key={v}
role="menuitemradio"
aria-checked={v === viewMode}
onClick={() => { onViewModeChange(v); setShowViewMenu(false); }}
className={cn(
"flex items-center justify-between gap-6 w-full px-3 py-2 rounded-md text-sm transition-colors",
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
)}
>
<span>{t(`views.${v}`)}</span>
<kbd className="font-sans text-xs text-muted-foreground">{VIEW_SHORTCUTS[v]}</kbd>
</button>
))}
</div>
)}
<ContextMenu ref={viewMenuRef} isOpen={viewMenu.isOpen} position={viewMenu.position} onClose={closeViewMenu}>
{menuViews.map((v) => (
<button
key={v}
type="button"
role="menuitemradio"
aria-checked={v === viewMode}
onClick={() => { closeViewMenu(); onViewModeChange(v); }}
className={cn(
"flex items-center justify-between gap-6 w-full px-3 py-2 rounded-md text-sm transition-colors outline-none focus-visible:bg-accent",
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
)}
>
<span>{t(`views.${v}`)}</span>
{/* The key, on devices with a keyboard (R8). */}
<kbd className="font-sans text-xs text-muted-foreground pointer-coarse:hidden">{VIEW_SHORTCUTS[v]}</kbd>
</button>
))}
</ContextMenu>
</div>
</div>
);
+65 -17
View File
@@ -17,8 +17,8 @@ import { clipToDisplayHours, indexOfDayOnOrAfter, partitionByDisplayHours, remap
import { displayNow, isDisplayToday } from "@/lib/timezone";
import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayHours } from "@/hooks/use-display-hours";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours";
import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions";
import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window";
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
@@ -39,6 +39,8 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
/** A click on a day in the all-day row: a new all-day event there. */
onCreateAllDay?: (date: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
@@ -51,6 +53,17 @@ interface CalendarWeekViewProps extends ScrollWindowViewProps {
* Moving an event they organize also asks its attendees again.
*/
currentUserEmails?: string[];
/** Whether the user may change an event: only those can be dragged or resized. */
canEditEvent?: (event: CalendarEvent) => boolean;
/** The event whose card or editor is open: drawn with the selection ring. */
selectedEventId?: string | null;
/**
* Save a drag or resize. The page asks which occurrences a change to a
* series covers; without it the change goes straight to the store.
*/
onMoveEvent?: MoveEventHandler;
/** A time to bring into view, such as a just-saved event's start. */
revealAt?: RevealTime | null;
}
const HOUR_HEIGHT = 60;
@@ -78,6 +91,7 @@ export function CalendarWeekView({
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onCreateAllDay,
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
@@ -86,6 +100,10 @@ export function CalendarWeekView({
onToggleTaskComplete,
onSelectTask,
currentUserEmails,
canEditEvent = () => true,
selectedEventId,
onMoveEvent,
revealAt,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const describeError = useCalendarErrorMessage();
@@ -194,7 +212,7 @@ export function CalendarWeekView({
const {
hours, canToggle: canToggleHours, configured: configuredHours, showAllHours, toggleAllHours, revealMinutes,
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT });
} = useDisplayHours({ scrollRef: rootRef, hourHeight: HOUR_HEIGHT, revealAt });
const firstHour = hours.startMinutes / 60;
const visibleHours = HOURS.slice(firstHour, hours.endMinutes / 60);
const gridHeight = visibleHours.length * HOUR_HEIGHT;
@@ -370,6 +388,7 @@ export function CalendarWeekView({
describeError,
currentUserEmails,
isMobile,
onMoveEvent,
});
const formatHour = (h: number): string => {
@@ -460,7 +479,7 @@ export function CalendarWeekView({
{isExpandable && (
<button
type="button"
aria-label="all-day-expand"
aria-label={isAllDayExpanded ? t("events.show_less") : t("events.show_more")}
aria-expanded={isAllDayExpanded}
onClick={() => setIsAllDayExpanded((prev) => !prev)}
className="self-end mt-auto text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-0.5 rounded px-1 py-0.5 hover:bg-muted transition-colors cursor-pointer"
@@ -485,7 +504,8 @@ export function CalendarWeekView({
{days.map((day) => (
<div
key={format(day, "yyyy-MM-dd")}
className="bg-background min-h-[28px] border-e border-border last:border-e-0"
className={cn("bg-background min-h-[28px] border-e border-border last:border-e-0", onCreateAllDay && "cursor-pointer hover:bg-muted/30")}
onClick={onCreateAllDay ? () => onCreateAllDay(day) : undefined}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
/>
))}
@@ -498,7 +518,7 @@ export function CalendarWeekView({
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
insetInlineStart: `calc(${(segment.startIndex / colCount) * 100}% + 1px)`,
width: `calc(${(segment.span / colCount) * 100}% - 2px)`,
top: segment.row * 24 + 2,
height: 20,
@@ -506,6 +526,7 @@ export function CalendarWeekView({
>
<EventCard
event={segment.event}
isSelected={segment.event.id === selectedEventId}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="span"
continuesBefore={segment.continuesBefore}
@@ -521,6 +542,31 @@ export function CalendarWeekView({
})}
</div>
{pendingPreview?.allDay && (() => {
const index = days.findIndex((d) => isSameDay(d, pendingPreview.start));
if (index === -1) return null;
const color = calendars.find((c) => c.id === pendingPreview.calendarId)?.color || "#3b82f6";
return (
<div
data-pending-preview
className="absolute px-0.5 pointer-events-none"
style={{
insetInlineStart: `calc(${(index / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: dayEventRows[index] * 24 + 2,
height: 20,
}}
>
<div
className="h-full rounded-xs px-1.5 text-xs leading-[16px] font-medium truncate border-2 border-dashed"
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
);
})()}
{/* Task chips in all-day area, stacked under events of their day (#1107, #1270) */}
<div className="absolute inset-0 pointer-events-none">
{days.map((day, dayIndex) => {
@@ -532,7 +578,7 @@ export function CalendarWeekView({
key={`task-${task.id}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
insetInlineStart: `calc(${(dayIndex / colCount) * 100}% + 1px)`,
width: `calc(${(1 / colCount) * 100}% - 2px)`,
top: (baseRow + taskIndex) * 24 + 2,
height: 20,
@@ -568,7 +614,7 @@ export function CalendarWeekView({
inside its row, where the header cannot cover it. */}
{(h > firstHour || firstHour > 0) && (
<span className={cn(
"absolute right-2 leading-none",
"absolute end-2 leading-none",
h > firstHour ? "top-0 -translate-y-1/2" : "top-1",
isMobile ? "text-[9px]" : "text-[10px]",
)}>
@@ -637,6 +683,7 @@ export function CalendarWeekView({
gridHeight - baseHeight,
));
const isResizing = resizeVisual?.eventId === ev.id;
const editable = canEditEvent(ev);
const top = isResizing ? resizeVisual!.topPx : baseTop;
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
const calId = getPrimaryCalendarId(ev);
@@ -648,10 +695,11 @@ export function CalendarWeekView({
key={ev.id}
className="absolute z-10 group/event"
data-calendar-event
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 1, paddingRight: 1 }}
style={{ top, height, insetInlineStart: `${leftPct}%`, width: `${widthPct}%`, paddingInline: 1 }}
>
<EventCard
event={ev}
isSelected={ev.id === selectedEventId}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="block"
onClick={(rect) => onSelectEvent(ev, rect)}
@@ -659,31 +707,31 @@ export function CalendarWeekView({
onMouseLeave={onHoverLeave}
onContextMenu={onContextMenuEvent}
currentUserEmails={currentUserEmails}
draggable
draggable={editable}
/>
{/* An edge beyond the visible hours has nothing to grab. */}
{!clip.clippedStart && (
{editable && !clip.clippedStart && (
<div
data-resize-handle
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
className="absolute top-0 inset-x-1 h-1 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
<div className="w-8 h-0.5 rounded-full bg-foreground/30" />
</div>
)}
{!clip.clippedEnd && (
{editable && !clip.clippedEnd && (
<div
data-resize-handle
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
className="absolute bottom-0 inset-x-1 h-1 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
aria-label={t("events.resize")}
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
>
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
<div className="w-8 h-0.5 rounded-full bg-foreground/30" />
</div>
)}
</div>
@@ -733,7 +781,7 @@ export function CalendarWeekView({
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
<div className="absolute -top-4 start-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
{formatSnapTime(dropTarget.minutes, timeFormat)}
</div>
</div>
+49 -92
View File
@@ -1,9 +1,10 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Calendar as CalendarIcon } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -24,7 +25,9 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
const [color, setColor] = useState("#3b82f6");
const [kind, setKind] = useState<CalendarKind>("events");
const [isSubmitting, setIsSubmitting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const nameId = useId();
const colorId = useId();
const isValid = name.trim().length > 0;
@@ -47,109 +50,63 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
}
}, [name, color, kind, client, createCalendar, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !isSubmitting) onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, isSubmitting]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
// Nothing closes it while the calendar is being created.
const close = useCallback(() => {
if (!isSubmitting) onClose();
}, [isSubmitting, onClose]);
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={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<CalendarKindPicker value={kind} onChange={setKind} disabled={isSubmitting} />
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
<Dialog
isOpen
onClose={close}
title={t("add_calendar")}
dirty={isValid}
initialFocus={nameRef}
footer={
<>
<Button variant="outline" onClick={close} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin me-2" />
<Spinner size="sm" className="me-2 text-current" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</>
}
>
<div className="space-y-4 pt-4">
<div>
<label htmlFor={nameId} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
id={nameId}
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div role="group" aria-labelledby={colorId}>
<span id={colorId} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</span>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<CalendarKindPicker value={kind} onChange={setKind} disabled={isSubmitting} />
</div>
</div>
</Dialog>
);
}
+27 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState, type CSSProperties, type DragEvent } from "react";
import { useCallback, useRef, useState, type CSSProperties, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -10,6 +10,7 @@ import { getParticipantCount, isDeclinedByUser } from "@/lib/calendar-participan
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import { useSettingsStore } from "@/stores/settings-store";
import { readableTextOn } from "@/lib/color-transform";
import { useLongPress } from "@/hooks/use-long-press";
interface EventCardProps {
event: CalendarEvent;
@@ -109,6 +110,8 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
try { return format(d, fmt); } catch { return "--:--"; }
};
const timeString = `${safeFormat(startDate, timeFmt)} – ${safeFormat(endTime, timeFmt)}`;
// An all-day event is read out as such, not as "00:00 – 23:00".
const spokenTime = event.showWithoutTime ? t("events.all_day") : timeString;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572), and a declined invitation stays on the calendar too
// (#1110) - render both struck through and dimmed.
@@ -116,7 +119,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
const isDeclined = !isCancelled && isDeclinedByUser(event, currentUserEmails);
const isInactive = isCancelled || isDeclined;
const statusLabel = isCancelled ? t("detail.cancelled") : isDeclined ? t("participants.declined") : null;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${spokenTime}${calendarName ? `, ${calendarName}` : ""}${statusLabel ? `, ${statusLabel}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
@@ -144,11 +147,29 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
draggable: true as const,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
"aria-roledescription": "draggable event",
"aria-roledescription": t("events.draggable_role"),
} : {};
const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined;
// Touch screens have no right-click: a long press opens the same menu,
// at the finger (R7).
const pressedRef = useRef<HTMLElement | null>(null);
const longPress = useLongPress(({ clientX, clientY }) => {
const target = pressedRef.current;
if (!onContextMenu || !target) return;
onContextMenu({
type: "contextmenu", clientX, clientY, detail: 1, currentTarget: target, target,
preventDefault: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, event);
}, !!onContextMenu);
const touchProps = onContextMenu ? {
onTouchStart: (e: React.TouchEvent<HTMLElement>) => { pressedRef.current = e.currentTarget; longPress.onTouchStart(e); },
onTouchMove: longPress.onTouchMove,
onTouchEnd: longPress.onTouchEnd,
onTouchCancel: longPress.onTouchCancel,
} : {};
if (variant === "chip") {
return (
<button
@@ -156,6 +177,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -179,6 +201,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -207,6 +230,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
{...touchProps}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
+21 -14
View File
@@ -8,11 +8,11 @@ import {
} from "@/components/ui/context-menu";
import {
Pencil,
CopyPlus,
Copy,
Download,
ClipboardCopy,
Video,
Link as LinkIcon,
CalendarArrowUp as CalendarLinkIcon,
Trash2,
} from "@/components/icons";
import type { CalendarEvent } from "@/lib/jmap/types";
@@ -37,6 +37,8 @@ interface EventContextMenuProps {
onCopyTitle: () => void;
onCopyMeetingLink?: () => void;
onDelete: () => void;
/** False for an event the user can't change (a birthday, a subscription, a read-only share): no Edit or Delete. */
editable?: boolean;
}
export function EventContextMenu({
@@ -51,6 +53,7 @@ export function EventContextMenu({
onCopyTitle,
onCopyMeetingLink,
onDelete,
editable = true,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const tDeepLink = useTranslations("deep_link");
@@ -65,8 +68,8 @@ export function EventContextMenu({
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
<ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />
<ContextMenuItem icon={Copy} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
{editable && <ContextMenuItem icon={Pencil} label={t("events.edit")} onClick={handle(onEdit)} />}
<ContextMenuItem icon={CopyPlus} label={t("events.duplicate")} onClick={handle(onDuplicate)} />
<ContextMenuSeparator />
<ContextMenuItem
icon={Download}
@@ -74,13 +77,13 @@ export function EventContextMenu({
onClick={handle(onExportICS)}
/>
<ContextMenuItem
icon={ClipboardCopy}
icon={Copy}
label={t("events.copy_title")}
onClick={handle(onCopyTitle)}
/>
{hasMeetingLink && onCopyMeetingLink && (
<ContextMenuItem
icon={LinkIcon}
icon={Video}
label={t("events.copy_link")}
onClick={handle(onCopyMeetingLink)}
/>
@@ -88,7 +91,7 @@ export function EventContextMenu({
{/* Permalink into Bulwark (#733) - distinct from the meeting URL above,
which points at whatever conferencing tool the organiser used. */}
<ContextMenuItem
icon={CalendarLinkIcon}
icon={LinkIcon}
label={tDeepLink("copy_event")}
onClick={handle(() => {
void copyLink(buildCalendarPath({
@@ -99,13 +102,17 @@ export function EventContextMenu({
}));
})}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
{editable && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("events.delete")}
onClick={handle(onDelete)}
destructive
/>
</>
)}
</ContextMenu>
);
}
+49 -130
View File
@@ -4,10 +4,11 @@ import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } fr
import { useTranslations, useLocale } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { LinkifiedText } from "@/components/ui/linkified-text";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check, MoreVertical, Calendar as CalendarIcon,
Pencil, Trash2, Copy, CopyPlus, Send, Check, MoreVertical, Calendar as CalendarIcon,
} from "@/components/icons";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
@@ -27,6 +28,8 @@ import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
import { isEditableEventTarget } from "@/lib/keyboard";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -46,10 +49,15 @@ interface EventDetailPopoverProps {
isSubscriptionCalendar?: (calendarId: string) => boolean;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
/**
* Opened by a click or Enter rather than shown on hover: it takes the
* focus, and gives it back to the event when it closes.
*/
pinned?: boolean;
}
const POPOVER_WIDTH = 360;
const HEADER_BUTTON = "p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground";
const HEADER_BUTTON = "text-muted-foreground hover:text-foreground";
const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
@@ -124,8 +132,10 @@ export function EventDetailPopover({
isSubscriptionCalendar,
timeFormat = "24h",
isMobile,
pinned = false,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
const locale = useLocale();
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
@@ -133,7 +143,6 @@ export function EventDetailPopover({
const [ready, setReady] = useState(false);
const [noteText, setNoteText] = useState("");
const [noteExpanded, setNoteExpanded] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSavingNote, setIsSavingNote] = useState(false);
// Bare addresses (the organizer above all — Stalwart drops its display name)
@@ -229,7 +238,7 @@ export function EventDetailPopover({
const height = popoverRef.current.offsetHeight;
setPosition(computePosition(anchorRect, height));
if (!ready) requestAnimationFrame(() => setReady(true));
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
}, [anchorRect, noteExpanded, ready]);
// Content that grows after the popover was placed (a plugin section reports
// its height a moment later) moves it again, so it stays in the window.
@@ -244,14 +253,23 @@ export function EventDetailPopover({
return () => observer.disconnect();
}, [anchorRect, isMobile]);
useEffect(() => {
if (!pinned) return;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const node = popoverRef.current;
node?.querySelector<HTMLElement>("button")?.focus({ preventScroll: true });
return () => {
const active = document.activeElement;
if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true });
};
}, [pinned]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
const target = e.target as HTMLElement;
const tag = target?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return;
if (target?.getAttribute("contenteditable") === "true") return;
if (e.key === "e" && !noteExpanded && canEditBody) {
if (isEditableEventTarget(e)) return;
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
if (e.code === "KeyE" && !noteExpanded && canEditBody) {
e.preventDefault();
onEdit();
}
@@ -338,70 +356,39 @@ export function EventDetailPopover({
{/* Actions sit at the top, the event's colour square beside its title. */}
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
{canEditBody && (
<button
type="button"
<IconButton
onClick={onEdit}
className={HEADER_BUTTON}
aria-label={t("events.edit")}
title={t("events.edit")}
label={t("events.edit")}
>
<Pencil className="w-4 h-4" />
</button>
</IconButton>
)}
{canEditBody && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
<IconButton
onClick={onDelete}
className={HEADER_BUTTON}
aria-label={t("events.delete")}
title={t("events.delete")}
label={t("events.delete")}
>
<Trash2 className="w-4 h-4" />
</button>
</IconButton>
)}
<button
type="button"
<IconButton
onClick={onMore ?? onDuplicate}
className={HEADER_BUTTON}
aria-label={onMore ? t("toolbar.more") : t("events.duplicate")}
title={onMore ? t("toolbar.more") : t("events.duplicate")}
label={onMore ? t("toolbar.more") : t("events.duplicate")}
>
{onMore ? <MoreVertical className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<button
type="button"
{onMore ? <MoreVertical className="w-4 h-4" /> : <CopyPlus className="w-4 h-4" />}
</IconButton>
<IconButton
onClick={onClose}
className={HEADER_BUTTON}
aria-label={t("form.cancel")}
label={tCommon("close")}
>
<X className="w-4 h-4" />
</button>
</IconButton>
</div>
{showDeleteConfirm && (
<div className="mx-4 mt-1 mb-2 flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2">
<span className="text-sm text-destructive flex-1">
{t("detail.delete_confirm")}
</span>
<Button
variant="destructive"
size="sm"
onClick={onDelete}
className="h-7 text-xs"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
</div>
)}
<div className="flex items-start gap-2.5 px-4 pb-1">
<span
className="w-4 h-4 rounded-sm flex-shrink-0 mt-1"
@@ -416,12 +403,12 @@ export function EventDetailPopover({
{event.title || t("events.no_title")}
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-amber-600 dark:text-amber-400">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-destructive/10 text-red-600 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
@@ -547,7 +534,7 @@ export function EventDetailPopover({
<div className="mt-1 space-y-0.5">
{participants.slice(0, 5).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground">
<span className="truncate text-foreground" title={p.email}>
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">
@@ -555,7 +542,7 @@ export function EventDetailPopover({
</span>
)}
</span>
<ParticipantStatusBadge status={p.status} t={t} />
<ParticipantStatusBadge status={p.status} className="text-[10px]" />
</div>
))}
{participants.length > 5 && (
@@ -588,7 +575,7 @@ export function EventDetailPopover({
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate">{calendar.name}</span>
<span className="text-sm text-foreground truncate" title={calendar.name}>{calendar.name}</span>
</div>
)}
@@ -623,20 +610,18 @@ export function EventDetailPopover({
<div className="flex justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
size="xs"
onClick={() => {
setNoteText("");
setNoteExpanded(false);
}}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
<Button
size="sm"
size="xs"
onClick={handleSaveNote}
disabled={!noteText.trim() || isSavingNote}
className="h-7 text-xs"
>
<Send className="w-3 h-3 me-1" />
{t("detail.save_note")}
@@ -661,47 +646,7 @@ export function EventDetailPopover({
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => onRsvp("accepted")}
className={
userCurrentStatus === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => onRsvp("tentative")}
className={
userCurrentStatus === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => onRsvp("declined")}
className={
userCurrentStatus === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.declined")}
</Button>
</div>
<RsvpButtons status={userCurrentStatus} onRespond={onRsvp} size="xs" />
</div>
)}
@@ -710,29 +655,3 @@ export function EventDetailPopover({
return createPortal(popover, document.body);
}
function ParticipantStatusBadge({
status,
t,
}: {
status: CalendarParticipant["participationStatus"];
t: ReturnType<typeof useTranslations>;
}) {
const colors: Record<string, string> = {
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return (
<span className={`text-[10px] flex-shrink-0 ${colors[status] || ""}`}>
{t(labels[status] || labels["needs-action"])}
</span>
);
}
+163 -308
View File
@@ -6,7 +6,10 @@ import { toast } from "@/stores/toast-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LinkifiedText } from "@/components/ui/linkified-text";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle } from "@/components/icons";
import { X, Trash2, Users, CalendarDays, CopyPlus, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, AlertTriangle, MoreVertical } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
import { ParticipantStatusBadge, RsvpButtons } from "./rsvp-buttons";
import { format, parseISO, addHours, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
import { AlertListEditor, AlertSummary, formatEventAlertsLabel } from "./alert-list-editor";
@@ -43,6 +46,9 @@ import {
} from "@/lib/plugin-hooks";
import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
import { eventToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useFocusTrap } from "@/hooks/use-focus-trap";
export interface PendingEventPreview {
start: Date;
@@ -73,6 +79,10 @@ interface EventModalProps {
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
/** Opens the event's action menu (export, copy, ...) under the ⋮ of the event view. */
onMore?: (anchor: HTMLElement) => void;
/** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */
onDirtyChange?: (dirty: boolean) => void;
currentUserEmails?: string[];
isSubscriptionCalendar?: (calendarId: string) => boolean;
/** The server's maxParticipantsPerEvent, for a warning; null when not advertised. */
@@ -248,12 +258,15 @@ export function EventModal({
onRsvp,
onClose,
onPreviewChange,
onMore,
onDirtyChange,
currentUserEmails = [],
isSubscriptionCalendar,
maxParticipants = null,
isMobile = false,
}: EventModalProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
const locale = useLocale();
const timeFormat = useSettingsStore((s) => s.timeFormat);
const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
@@ -467,7 +480,6 @@ export function EventModal({
setPreservedAlerts(preserved);
setUseDefaultAlerts(false);
}, [defaultAlerts]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Set synchronously, so a second save in the same tick is refused too.
const savingRef = useRef(false);
@@ -501,6 +513,49 @@ export function EventModal({
const [sendInvitations, setSendInvitations] = useState(true);
const participantInputRef = useRef<ParticipantInputHandle>(null);
// What the form holds against what it opened with: a form with changes
// asks before X, Cancel or Escape drop them (R5).
const formState = useMemo(() => ({
title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations,
}), [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay,
calendarId, recurrence, customRule, useDefaultAlerts, alertRows, preservedAlerts, attendees, sendInvitations]);
const [initialForm] = useState(formState);
const isDirty = mode === "edit" && JSON.stringify(formState) !== JSON.stringify(initialForm);
useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]);
useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]);
const resetForm = useCallback(() => {
const f = initialForm;
setTitle(f.title); setDescription(f.description); setLocation(f.location); setVirtualLocation(f.virtualLocation);
setStartDate(f.startDate); setStartTime(f.startTime); setEndDate(f.endDate); setEndTime(f.endTime); setAllDay(f.allDay);
setCalendarId(f.calendarId); setRecurrence(f.recurrence); setCustomRule(f.customRule); setShowRecurrenceEditor(false);
setUseDefaultAlerts(f.useDefaultAlerts); setAlertRows(f.alertRows); setPreservedAlerts(f.preservedAlerts); setRefreshDefaults(false);
setAttendees(f.attendees); setSendInvitations(f.sendInvitations);
}, [initialForm]);
const { dialogProps: discardDialogProps, confirm: confirmDiscard } = useConfirmDialog();
const askingRef = useRef(false);
// Runs `then` at once when nothing would be lost, else after "Discard".
const whenDiscarded = useCallback((then: () => void) => {
if (!isDirty) { then(); return; }
if (askingRef.current) return;
askingRef.current = true;
void confirmDiscard({
title: t("discard.title"),
message: t("discard.message"),
confirmText: t("discard.confirm"),
cancelText: t("discard.keep"),
variant: "destructive",
}).then((ok) => {
askingRef.current = false;
if (ok) then();
});
}, [isDirty, confirmDiscard, t]);
const requestClose = useCallback(() => whenDiscarded(onClose), [whenDiscarded, onClose]);
// Leaving the form of an existing event goes back to its summary.
const backToView = useCallback(() => whenDiscarded(() => { resetForm(); setMode("view"); }), [whenDiscarded, resetForm]);
// The event window the attendees' free/busy is checked against. Parsed in
// local time like the save path; an all-day event spans its whole days.
const availabilityWindow = useMemo(() => {
@@ -859,15 +914,38 @@ export function EventModal({
onDuplicate(buildDuplicateEventData(event));
}, [event, onDuplicate]);
const modalRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
const primaryRef = useRef<HTMLButtonElement>(null);
const initialFocusTarget = useCallback(
(): HTMLElement | null => (mode === "edit" ? titleRef.current : primaryRef.current),
[mode],
);
// Full screen on the phone: a modal that holds the focus.
const modalRef = useFocusTrap({ isActive: !!isMobile, initialFocus: initialFocusTarget });
// Beside the grid on wide screens: the focus moves in on open and with
// each switch between summary and form, and goes back on close.
useEffect(() => {
if (isMobile) return;
initialFocusTarget()?.focus();
}, [isMobile, initialFocusTarget]);
useEffect(() => {
if (isMobile) return;
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
return () => {
const active = document.activeElement;
if (opener?.isConnected && (!active || active === document.body)) opener.focus();
};
}, [isMobile]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (askingRef.current) return;
if (mode === "edit" && isEdit) {
setMode("view");
backToView();
} else {
onClose();
requestClose();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
@@ -877,150 +955,26 @@ export function EventModal({
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, handleSave, canEditBody, mode, isEdit]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
}, [requestClose, backToView, handleSave, canEditBody, mode, isEdit]);
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (rsvpMode && event) {
const startD = getEventStartDate(event);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name : null;
const participants = getParticipantList(event, { resolveName: resolveContactName });
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-900 dark:text-blue-200">
{t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })}
</p>
<p className="text-blue-700 dark:text-blue-400 mt-0.5">
{t("participants.respond_below")}
</p>
</div>
</div>
{(() => {
const displayEnd = getEventDisplayEndDate(event);
const multiDay = !isSameDay(startD, displayEnd);
if (multiDay && event.showWithoutTime) {
return (
<div className="text-sm">
<div className="font-medium">{formatEventDate(startD)} –</div>
<div className="font-medium">{formatEventDate(displayEnd)}</div>
</div>
);
}
if (multiDay) {
return (
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
}
return (
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ms-2">
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
</span>
)}
</div>
);
})()}
{event.description && (
<p className="text-sm text-muted-foreground whitespace-pre-line break-words">
<LinkifiedText text={event.description} />
</p>
)}
{locationName && (
<p className="text-sm text-muted-foreground">{locationName}</p>
)}
{participants.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-sm font-medium">
<Users className="w-4 h-4" />
{t("participants.title")}
</div>
<div className="space-y-1 ps-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} t={t} />
</div>
))}
</div>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
<div className="px-6 py-4 border-t border-border flex-shrink-0">
<RsvpBar status={userCurrentStatus} onRespond={handleRsvp} t={t} />
</div>
</div>
);
}
// View mode: read-only display of event details with Edit button
if (mode === "view" && event) {
// View mode: read-only display of event details with Edit button. An
// invitation the user can only answer is shown by the same view, with a
// banner on top and the answer buttons below - one layout for every event,
// like the card's.
if ((mode === "view" || rsvpMode) && event) {
const startD = getEventStartDate(event);
const durMin = parseDuration(event.duration);
const endD = getEventEndDate(event);
const locationName = event.locations ? Object.values(event.locations)[0]?.name || null : null;
const virtualLoc = event.virtualLocations ? Object.values(event.virtualLocations)[0]?.uri || null : null;
const locationName = primaryLocationName(event) ?? null;
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; it counts as the meeting link anyway.
const meeting = findMeetingLink(event);
const virtualLoc = meeting?.uri ?? null;
const locationHref = locationName
? (() => { const action = locationAction(locationName, meeting); return action.kind === "url" ? action.uri : mapsUrl(action.query); })()
: null;
const viewParticipants = getParticipantList(event, { resolveName: resolveContactName });
const recurrenceLabel = getRecurrenceLabel(event, t, locale);
const alertLabel = formatEventAlertsLabel(event, calendars, t);
@@ -1037,20 +991,39 @@ export function EventModal({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
<h2 className="text-lg font-semibold truncate" title={event.title || t("events.no_title")}>{event.title || t("events.no_title")}</h2>
</div>
{eventCalendar && (
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
{onMore && (
<IconButton label={t("toolbar.more")} aria-haspopup="menu" className="flex-shrink-0 mt-0.5" onClick={(e) => onMore(e.currentTarget)}>
<MoreVertical />
</IconButton>
)}
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
<X />
</IconButton>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-3">
{rsvpMode && (
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 px-4 py-3">
<CalendarDays className="w-5 h-5 text-primary mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-foreground">
{t("participants.invited_by", { name: organizerInfo?.name || organizerInfo?.email || t("participants.organizer") })}
</p>
<p className="text-muted-foreground mt-0.5">
{t("participants.respond_below")}
</p>
</div>
</div>
)}
{/* Date & Time */}
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
@@ -1111,13 +1084,12 @@ export function EventModal({
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
{/* A URL opens as a link, a postal address in the maps, as on the card. */}
<a href={locationHref ?? undefined} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline min-w-0 break-words" title={locationName}>
{/^https?:\/\//i.test(locationName)
? (() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()
: locationName}
</a>
</div>
)}
@@ -1142,13 +1114,13 @@ export function EventModal({
<div className="mt-1 space-y-0.5">
{viewParticipants.map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground">
<span className="truncate text-foreground" title={p.email}>
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} t={t} />
<ParticipantStatusBadge status={p.status} />
</div>
))}
</div>
@@ -1197,36 +1169,22 @@ export function EventModal({
)}
{/* Action Bar */}
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between">
<div className="px-6 py-3 border-t border-border flex-shrink-0 flex items-center justify-between gap-2">
<div className="flex items-center gap-1">
{onDelete && canEditBody && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<span className="text-sm text-destructive">{t("form.delete_confirm")}</span>
<Button variant="outline" size="sm" onClick={() => { onDelete(event.id, hasParticipants || undefined); onClose(); }} className="text-destructive border-destructive/30">
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
<IconButton label={t("events.delete")} onClick={() => onDelete(event.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive">
<Trash2 />
</IconButton>
)}
{onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
{onDuplicate && (
<IconButton label={t("events.duplicate")} onClick={handleDuplicate}>
<CopyPlus />
</IconButton>
)}
</div>
{!showDeleteConfirm && canEditBody && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 me-1" />
{canEditBody && (
<Button ref={primaryRef} size="xs" onClick={() => setMode("edit")} className="whitespace-nowrap">
<Pencil className="me-1" />
{t("events.edit")}
</Button>
)}
@@ -1241,9 +1199,9 @@ export function EventModal({
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
<X className="w-5 h-5" />
</button>
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
</div>
<div className="flex-1 overflow-y-auto">
@@ -1251,11 +1209,11 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("form.title")}
maxLength={500}
autoFocus
/>
</div>
@@ -1336,7 +1294,7 @@ export function EventModal({
it, Stalwart 1.0 refuses to save more participants - the
organizer entry a save adds counts too. */}
{maxParticipants != null && attendees.length > 0 && attendees.length + 1 > maxParticipants && (
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-warning">
<p role="status" className="mt-1.5 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="mt-px h-3.5 w-3.5 flex-shrink-0" />
{t("participants.limit_warning", { max: maxParticipants })}
</p>
@@ -1420,7 +1378,7 @@ export function EventModal({
? 'text-sm rounded-md border border-destructive/50 bg-destructive/10 text-destructive px-3 py-2'
: w.severity === 'info'
? 'text-sm rounded-md border border-border bg-muted/40 text-muted-foreground px-3 py-2'
: 'text-sm rounded-md border border-yellow-500/50 bg-yellow-500/10 text-yellow-700 dark:text-yellow-300 px-3 py-2'
: 'text-sm rounded-md border border-warning/50 bg-warning/10 text-amber-600 dark:text-amber-400 px-3 py-2'
}
title={w.message}
>
@@ -1549,85 +1507,38 @@ export function EventModal({
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-border flex-shrink-0 flex-wrap gap-y-2">
<div className="flex items-center gap-1 w-full">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2 flex-wrap">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 w-full"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="w-full"
>
{t("form.cancel")}
</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400 w-full"
>
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button
variant="ghost"
size="sm"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
className="w-full"
>
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
</div>
<div className="flex items-center gap-2 px-6 py-4 border-t border-border flex-shrink-0">
{isEdit && onDelete && (
<IconButton size="icon" label={t("events.delete")} onClick={() => onDelete(event!.id, hasParticipants || undefined)} className="text-destructive hover:text-destructive">
<Trash2 className="w-4 h-4" />
</IconButton>
)}
{isEdit && onDuplicate && (
<IconButton size="icon" label={t("events.duplicate")} onClick={handleDuplicate}>
<CopyPlus className="w-4 h-4" />
</IconButton>
)}
{!showDeleteConfirm && (
<div className="flex gap-2 w-full">
<div className="flex gap-2 ms-auto">
<Button
variant="outline"
onClick={isEdit ? () => setMode("view") : onClose}
className="w-full"
onClick={isEdit ? backToView : requestClose}
className="whitespace-nowrap"
>
{t("form.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={!title.trim() || isSaving}
className="w-full"
className="whitespace-nowrap"
>
{/* While a save is in flight the button was disabled with no visible change, so a
slow request (mobile, VPN) looked like a dead button for the whole timeout. */}
{isSaving ? t("subscription.saving") : t("form.save")}
</Button>
</div>
)}
</div>
<ConfirmDialog {...discardDialogProps} />
</div>
);
}
@@ -1643,65 +1554,9 @@ function RsvpBar({ status, onRespond, t }: {
t: ReturnType<typeof useTranslations>;
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
<div className="flex gap-2">
<Button
size="sm"
variant={status === "accepted" ? "default" : "outline"}
onClick={() => onRespond("accepted")}
className={status === "accepted"
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{status === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={status === "tentative" ? "default" : "outline"}
onClick={() => onRespond("tentative")}
className={status === "tentative"
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{status === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={status === "declined" ? "default" : "ghost"}
onClick={() => onRespond("declined")}
className={status === "declined"
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{status === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
<RsvpButtons status={status} onRespond={onRespond} />
</div>
);
}
function StatusBadge({ status, t }: {
status: CalendarParticipant['participationStatus'];
t: ReturnType<typeof useTranslations>;
}) {
// The organizer is marked by the "(组织者)" suffix on the name; the badge
// shows their participation status (organizers default to accepted) rather
// than repeating the organizer label.
const colors: Record<string, string> = {
accepted: "text-success",
declined: "text-destructive",
tentative: "text-warning",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return <span className={`text-xs ${colors[status] || ""}`}>{t(labels[status] || labels["needs-action"])}</span>;
}
+56 -89
View File
@@ -1,9 +1,12 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useState, useCallback, useRef, useId } from "react";
import { useLocale, useTranslations } from "next-intl";
import { buildRecurrenceSummary } from "./recurrence-editor";
import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "@/components/icons";
import { Upload, Check, RefreshCw, Globe } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -31,7 +34,7 @@ type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar");
const tCommon = useTranslations("common");
const locale = useLocale();
const tForm = useTranslations("calendar.form");
const importEvents = useCalendarStore((s) => s.importEvents);
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -50,7 +53,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const targetId = useId();
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) return t("file_too_large");
@@ -215,60 +218,36 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
}
};
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, [step]);
// Nothing closes it while the events are being imported.
const close = useCallback(() => {
if (step !== "importing") onClose();
}, [step, onClose]);
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")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<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">
<Dialog
isOpen
onClose={close}
title={t("title")}
size="full-on-phone"
dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")}
footer={step !== "importing" ? (
<>
<Button variant="outline" onClick={close}>
{tForm("cancel")}
</Button>
{step === "preview" && (
<Button
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
</>
) : undefined}
>
<div className="pt-4 space-y-4">
{step === "select" && !isParsing && (
<>
<div className="flex border-b border-border mb-4">
@@ -300,29 +279,33 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
</div>
{importMode === "file" && (
<div
<>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
className={`w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
isDragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<span className="block text-sm font-medium">{t("select_file")}</span>
<span className="block text-xs text-muted-foreground mt-1">{t("drop_file")}</span>
<span className="block text-xs text-muted-foreground mt-2">{t("supported_formats")}</span>
</button>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
tabIndex={-1}
/>
</div>
</>
)}
{importMode === "url" && (
@@ -334,6 +317,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder={t("url_placeholder")}
aria-label={t("tab_url")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
/>
@@ -342,7 +326,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
disabled={!urlInput.trim() || isFetchingUrl}
>
{isFetchingUrl ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" className="text-current" />
) : (
t("fetch")
)}
@@ -356,13 +340,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{isParsing && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<Spinner className="mb-3" />
<p className="text-sm text-muted-foreground">{t("parsing")}</p>
</div>
)}
{error && (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
@@ -396,7 +380,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
className="mt-0.5 rounded border-input"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
<p className="text-sm font-medium truncate" title={event.title || tCal("events.no_title")}>
{event.title || tCal("events.no_title")}
</p>
<div className="flex items-center gap-2 mt-0.5">
@@ -406,7 +390,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{event.recurrenceRules && event.recurrenceRules.length > 0 && (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
<RefreshCw className="w-3 h-3" />
{event.recurrenceRules[0].frequency}
{buildRecurrenceSummary(event.recurrenceRules[0], tCal, locale) ?? tCal("recurrence.custom")}
</span>
)}
</div>
@@ -417,10 +401,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">
<label htmlFor={targetId} className="text-sm font-medium mb-1 block">
{t("target_calendar")}
</label>
<select
id={targetId}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
@@ -438,29 +423,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{step === "importing" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<Spinner className="mb-3" />
<p className="text-sm text-muted-foreground">{t("importing")}</p>
</div>
)}
</div>
{step !== "importing" && (
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{tForm("cancel")}
</Button>
{step === "preview" && (
<Button
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
</div>
)}
</div>
</div>
</Dialog>
);
}
+95 -125
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useCallback, useId } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "@/components/icons";
import { Dialog } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -31,7 +32,9 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const id = useId();
const isValid = url.trim().length > 0 && name.trim().length > 0;
@@ -83,143 +86,110 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
}
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
// Nothing closes it while the feed is being fetched.
const close = useCallback(() => {
if (!isSubmitting) onClose();
}, [isSubmitting, onClose]);
const changed = isEdit
? url !== editSubscription!.url || name !== editSubscription!.name || color !== editSubscription!.color || refreshInterval !== editSubscription!.refreshInterval
: url.trim().length > 0 || name.trim().length > 0;
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
);
const firstEl = focusableEls[0];
const lastEl = focusableEls[focusableEls.length - 1];
const handler = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl?.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl?.focus();
}
};
modal.addEventListener("keydown", handler);
firstEl?.focus();
return () => modal.removeEventListener("keydown", handler);
}, []);
const fieldClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
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")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</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">
<p className="text-sm text-muted-foreground">{t("description")}</p>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
<Dialog
isOpen
onClose={close}
title={isEdit ? t("edit_title") : t("title")}
description={t("description")}
dirty={changed}
// A link handed over from webcal:// already fills the address.
initialFocus={url ? nameRef : urlRef}
footer={
<>
<Button variant="outline" onClick={close} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin me-2" />
<Spinner size="sm" className="me-2 text-current" />
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
isEdit ? t("save") : t("subscribe")
)}
</Button>
</>
}
>
<div className="space-y-4 pt-4">
<div>
<label htmlFor={`${id}-url`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
id={`${id}-url`}
ref={urlRef}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className={fieldClass}
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label htmlFor={`${id}-name`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
id={`${id}-name`}
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className={fieldClass}
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div role="group" aria-labelledby={`${id}-color`}>
<span id={`${id}-color`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</span>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label htmlFor={`${id}-interval`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
id={`${id}-interval`}
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className={fieldClass}
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
</div>
</Dialog>
);
}
+28 -23
View File
@@ -13,6 +13,7 @@ import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { PluginDayLabel } from "./plugin-day-label";
import { IconButton } from "@/components/ui/icon-button";
type PickerView = "days" | "months" | "years";
@@ -124,13 +125,13 @@ export function MiniCalendar({
return (
<div className="select-none">
<div className="flex items-center justify-between mb-2">
<button
<IconButton
onClick={handlePrev}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("nav_prev")}
className="text-muted-foreground"
label={t(pickerView === "days" ? "mini_prev_month" : pickerView === "months" ? "mini_prev_year" : "mini_prev_years")}
>
<ChevronLeft className="w-4 h-4 text-muted-foreground" />
</button>
<ChevronLeft />
</IconButton>
<button
onClick={handleHeaderClick}
disabled={pickerView === "years"}
@@ -146,13 +147,13 @@ export function MiniCalendar({
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
)}
</button>
<button
<IconButton
onClick={handleNext}
className="p-1 rounded hover:bg-muted transition-colors"
aria-label={t("nav_next")}
className="text-muted-foreground"
label={t(pickerView === "days" ? "mini_next_month" : pickerView === "months" ? "mini_next_year" : "mini_next_years")}
>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
<ChevronRight />
</IconButton>
</div>
{pickerView === "days" && (
@@ -185,22 +186,26 @@ export function MiniCalendar({
<button
key={`day-${day.toISOString()}`}
onClick={() => onSelectDate(day)}
className={cn(
"relative flex items-center justify-center w-7 h-7 text-xs rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "hover:bg-muted"
)}
className="group/day flex items-center justify-center w-7 h-7 pointer-coarse:w-full pointer-coarse:h-11 text-xs"
aria-current={today ? "date" : undefined}
aria-pressed={selected}
>
{formatDayNumber(day)}
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
<span
className={cn(
"relative flex items-center justify-center w-7 h-7 rounded-full transition-colors",
!inMonth && "text-muted-foreground/40",
// Today is the filled circle; the selected day is tinted.
today && "bg-primary text-primary-foreground font-semibold",
selected && !today && "bg-primary/15 text-primary font-semibold",
!today && !selected && "group-hover/day:bg-muted"
)}
>
{formatDayNumber(day)}
{hasEvent && !selected && !today && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
</span>
</button>
</Fragment>
);
@@ -181,14 +181,14 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
status === "free" && "bg-emerald-500",
status === "busy" && "bg-red-500",
status === "tentative" && "bg-amber-500",
status === "free" && "bg-success",
status === "busy" && "bg-destructive",
status === "tentative" && "bg-warning",
(status === "no_busy_times" || status === "unknown" || status === "error" || status === "checking") && "bg-muted-foreground/40",
)}
aria-hidden="true"
/>
<span className="min-w-0 truncate text-foreground/90">{a.name || a.email}</span>
<span className="min-w-0 truncate text-foreground/90" title={a.email}>{a.name || a.email}</span>
<span className="ms-auto flex shrink-0 items-center gap-1 text-muted-foreground">
{status === "checking" && <Loader2 className="h-3 w-3 animate-spin" />}
{t(status)}
+4 -3
View File
@@ -182,9 +182,9 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
>
<Avatar name={s.name} email={s.email} size="sm" className="shrink-0 w-7 h-7 text-[10px]" />
<div className="min-w-0">
<div className="font-medium truncate">{s.name || s.email}</div>
<div className="font-medium truncate" title={s.name || s.email}>{s.name || s.email}</div>
{s.name && (
<div className="text-xs text-muted-foreground truncate">{s.email}</div>
<div className="text-xs text-muted-foreground truncate" title={s.email}>{s.email}</div>
)}
</div>
</li>
@@ -211,11 +211,12 @@ export const ParticipantInput = forwardRef<ParticipantInputHandle, ParticipantIn
}}
className="truncate hover:underline focus:outline-none focus:underline cursor-text"
aria-label={`${t("edit")} ${p.name || p.email}`}
title={p.email}
>
{p.name || p.email}
</button>
) : (
<span className="truncate">{p.name || p.email}</span>
<span className="truncate" title={p.email}>{p.name || p.email}</span>
)}
{!disabled && (
<button
+10 -11
View File
@@ -7,6 +7,7 @@ import { format } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Clock, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import type { Calendar } from "@/lib/jmap/types";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
@@ -15,6 +16,8 @@ export interface QuickCreateDraft {
end: Date;
title: string;
calendarId: string;
/** A day picked in the month view or the all-day row: an all-day event. */
allDay?: boolean;
}
interface QuickCreatePopoverProps {
@@ -63,6 +66,7 @@ export function QuickCreatePopover({
onClose,
}: QuickCreatePopoverProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
const intlFormatter = useDisplayDateFormatter();
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -131,7 +135,7 @@ export function QuickCreatePopover({
const timeFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm";
const dateLabel = intlFormatter.dateTime(draft.start, { weekday: "long", month: "long", day: "numeric" });
const timeLabel = `${format(draft.start, timeFmt)} – ${format(draft.end, timeFmt)}`;
const timeLabel = draft.allDay ? t("events.all_day") : `${format(draft.start, timeFmt)} – ${format(draft.end, timeFmt)}`;
return createPortal(
<div
@@ -148,14 +152,9 @@ export function QuickCreatePopover({
>
<form onSubmit={handleSubmit}>
<div className="flex justify-end px-2 pt-2">
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
<X />
</IconButton>
</div>
<div className="px-4 pb-3 space-y-3">
<Input
@@ -195,10 +194,10 @@ export function QuickCreatePopover({
)}
</div>
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={onMoreOptions}>
<Button type="button" variant="ghost" size="xs" onClick={onMoreOptions}>
{t("quick_create.more_options")}
</Button>
<Button type="submit" size="sm" className="h-8" disabled={!canSave}>
<Button type="submit" size="xs" disabled={!canSave}>
{t("form.save")}
</Button>
</div>
@@ -13,6 +13,8 @@ interface RecurrenceScopeDialogProps {
actionType: "edit" | "delete" | "rsvp";
onSelect: (scope: RecurrenceEditScope) => void;
onClose: () => void;
/** A line under the question, e.g. that the participants get a cancellation. */
note?: string;
}
export function RecurrenceScopeDialog({
@@ -20,6 +22,7 @@ export function RecurrenceScopeDialog({
actionType,
onSelect,
onClose,
note,
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
@@ -74,6 +77,7 @@ export function RecurrenceScopeDialog({
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
{isRsvp ? t("rsvp_description") : t("description")}
</p>
{note && <p className="text-sm text-muted-foreground mt-1">{note}</p>}
</div>
</div>
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useTranslations } from "next-intl";
import { Button, type ButtonSize } from "@/components/ui/button";
import { Check } from "@/components/icons";
import { cn } from "@/lib/utils";
import type { CalendarParticipant } from "@/lib/jmap/types";
type Status = CalendarParticipant["participationStatus"];
/**
* Text in a participation status colour. The status fills (#22c55e,
* #eab308) are too light for text on a light ground, so text uses the
* darker shades (APP.md status colours).
*/
export const STATUS_TEXT: Record<string, string> = {
accepted: "text-green-700 dark:text-green-400",
declined: "text-red-600 dark:text-red-400",
tentative: "text-amber-600 dark:text-amber-400",
"needs-action": "text-muted-foreground",
};
const STATUS_LABEL: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
/** A participant's answer, as a short coloured word. */
export function ParticipantStatusBadge({ status, className }: { status: Status; className?: string }) {
const t = useTranslations("calendar");
return (
<span className={cn("text-xs flex-shrink-0", STATUS_TEXT[status] ?? "", className)}>
{t(STATUS_LABEL[status] ?? STATUS_LABEL["needs-action"])}
</span>
);
}
// The chosen answer is filled; dark text keeps it readable on the light
// green and yellow fills.
const CHOSEN: Record<string, string> = {
accepted: "bg-success hover:bg-success/90 text-neutral-950 border-transparent",
tentative: "bg-warning hover:bg-warning/90 text-neutral-950 border-transparent",
declined: "bg-destructive hover:bg-destructive/90 text-destructive-foreground border-transparent",
};
const ANSWERS: { status: Status; label: string }[] = [
{ status: "accepted", label: "participants.accept" },
{ status: "tentative", label: "participants.maybe" },
{ status: "declined", label: "participants.decline" },
];
/** The three answers to an invitation, the current one filled. */
export function RsvpButtons({
status,
onRespond,
size = "sm",
}: {
status: Status | null;
onRespond: (status: Status) => void;
size?: ButtonSize;
}) {
const t = useTranslations("calendar");
return (
<div className="flex flex-wrap gap-2">
{ANSWERS.map((answer) => {
const chosen = status === answer.status;
return (
<Button
key={answer.status}
size={size}
variant="outline"
aria-pressed={chosen}
onClick={() => onRespond(answer.status)}
className={chosen ? CHOSEN[answer.status] : STATUS_TEXT[answer.status]}
>
{chosen && <Check className="w-4 h-4 me-1" />}
{t(answer.label)}
</Button>
);
})}
</div>
);
}
+31 -27
View File
@@ -2,20 +2,20 @@
import { useMemo, useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { format, parseISO, isBefore, isTomorrow } from "date-fns";
import { parseISO, isBefore, isTomorrow } from "date-fns";
import { displayNow, isDisplayToday } from "@/lib/timezone";
import { Check, Flag, CalendarDays, ListTodo, Plus } from "@/components/icons";
import { cn } from "@/lib/utils";
import type { CalendarTask, Calendar } from "@/lib/jmap/types";
import type { TaskViewFilter } from "@/stores/task-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useDisplayDateFormatter } from "@/hooks/use-display-date-formatter";
interface TaskListViewProps {
tasks: CalendarTask[];
calendars: Calendar[];
selectedCalendarIds: string[];
filter: TaskViewFilter;
showCompleted: boolean;
onSelectTask: (task: CalendarTask) => void;
onToggleComplete: (task: CalendarTask) => void;
selectedTaskId?: string | null;
@@ -23,20 +23,22 @@ interface TaskListViewProps {
}
function getTaskPriorityIcon(priority: number) {
if (priority >= 1 && priority <= 4) return <Flag className="h-3.5 w-3.5 text-red-500" />;
if (priority === 5) return <Flag className="h-3.5 w-3.5 text-orange-500" />;
if (priority >= 6 && priority <= 9) return <Flag className="h-3.5 w-3.5 text-gray-400" />;
if (priority >= 1 && priority <= 4) return <Flag className="h-3.5 w-3.5 text-red-600 dark:text-red-400" />;
if (priority === 5) return <Flag className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />;
if (priority >= 6 && priority <= 9) return <Flag className="h-3.5 w-3.5 text-muted-foreground" />;
return null;
}
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string): { label: string; className: string } {
type FormatDate = ReturnType<typeof useDisplayDateFormatter>["dateTime"];
function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<typeof useTranslations>, timeFormat: string, formatDate: FormatDate): { label: string; className: string } {
const dueDate = parseISO(due);
const overdue = isBefore(dueDate, displayNow()) && !isDisplayToday(dueDate);
if (isDisplayToday(dueDate)) {
return {
label: t("tasks.due_today"),
className: "text-blue-600 dark:text-blue-400",
className: "text-primary",
};
}
if (isTomorrow(dueDate)) {
@@ -52,9 +54,10 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
};
}
// In the app's language, like every other date (R10).
const formatted = showWithoutTime
? format(dueDate, "MMM d")
: format(dueDate, timeFormat === "12h" ? "MMM d, h:mm a" : "MMM d, HH:mm");
? formatDate(dueDate, { month: "short", day: "numeric" })
: formatDate(dueDate, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", hour12: timeFormat === "12h" });
return {
label: formatted,
@@ -62,22 +65,17 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
};
}
/** The tasks the list shows, in its order: filtered by calendar and status. */
/** The tasks the list shows, in its order: filtered by calendar and status. "All" is all of them. */
export function filterTasksForList(
tasks: CalendarTask[],
selectedCalendarIds: string[],
filter: TaskViewFilter,
showCompleted: boolean,
): CalendarTask[] {
let result = tasks.filter(task => {
const calIds = Object.keys(task.calendarIds);
return calIds.some(id => selectedCalendarIds.includes(id));
});
if (!showCompleted) {
result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
}
switch (filter) {
case "pending":
result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
@@ -121,7 +119,6 @@ export function TaskListView({
calendars,
selectedCalendarIds,
filter,
showCompleted,
onSelectTask,
onToggleComplete,
selectedTaskId,
@@ -129,11 +126,12 @@ export function TaskListView({
}: TaskListViewProps) {
const t = useTranslations("calendar");
const timeFormat = useSettingsStore((s) => s.timeFormat);
const { dateTime: formatDate } = useDisplayDateFormatter();
const [quickAddTitle, setQuickAddTitle] = useState("");
const filteredTasks = useMemo(
() => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
[tasks, selectedCalendarIds, filter, showCompleted],
() => filterTasksForList(tasks, selectedCalendarIds, filter),
[tasks, selectedCalendarIds, filter],
);
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
@@ -159,6 +157,7 @@ export function TaskListView({
}
}}
placeholder={t("tasks.quick_add_placeholder")}
aria-label={t("tasks.quick_add_placeholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
@@ -189,6 +188,7 @@ export function TaskListView({
}
}}
placeholder={t("tasks.quick_add_placeholder")}
aria-label={t("tasks.quick_add_placeholder")}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
@@ -199,7 +199,7 @@ export function TaskListView({
const cal = calendars.find(c => task.calendarIds[c.id]);
const isCompleted = task.progress === "completed";
const priorityIcon = getTaskPriorityIcon(task.priority);
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat) : null;
const dueDateInfo = task.due ? getDueDateLabel(task.due, task.showWithoutTime, t, timeFormat, formatDate) : null;
return (
<div
@@ -224,19 +224,23 @@ export function TaskListView({
{isCompleted && <Check className="h-3 w-3" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{/* Content: a button, so Tab reaches the task and Enter opens it. */}
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectTask(task); }}
className="flex-1 min-w-0 text-start rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex items-center gap-1.5">
<span className={cn(
"text-sm font-medium truncate",
isCompleted && "line-through text-muted-foreground"
)}>
)} title={task.title || t("tasks.no_title")}>
{task.title || t("tasks.no_title")}
</span>
{priorityIcon}
</div>
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-2 mt-0.5">
{dueDateInfo && (
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
<CalendarDays className="h-3 w-3" />
@@ -249,8 +253,8 @@ export function TaskListView({
{cal.name}
</span>
)}
</div>
</div>
</span>
</button>
</div>
);
})}
+53 -12
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { IconButton } from "@/components/ui/icon-button";
import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
@@ -11,6 +12,8 @@ import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/type
import { withOwnAlertIds } from "@/lib/calendar-alerts";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { taskToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -19,6 +22,8 @@ interface TaskModalProps {
onSave: (data: Partial<CalendarTask>) => void | boolean | Promise<void | boolean>;
onDelete?: (id: string) => void;
onClose: () => void;
/** Told whether the form holds changes that aren't saved, so the page can ask before it drops them. */
onDirtyChange?: (dirty: boolean) => void;
isMobile?: boolean;
}
@@ -47,9 +52,12 @@ export function TaskModal({
onSave,
onDelete,
onClose,
onDirtyChange,
isMobile: _isMobile,
}: TaskModalProps) {
const t = useTranslations("calendar");
const tCommon = useTranslations("common");
const id = useId();
const isEdit = !!task;
const titleRef = useRef<HTMLInputElement>(null);
// task-detail-section: the saved task, for an existing task only.
@@ -92,6 +100,30 @@ export function TaskModal({
const [alertTouched, setAlertTouched] = useState(false);
const [saving, setSaving] = useState(false);
// A form with changes asks before X, Cancel or Escape drop them (R5).
const formState = JSON.stringify({ title, description, dueDate, dueTime, showTime, priority, progress, calendarId, alertOption });
const [initialForm] = useState(formState);
const isDirty = formState !== initialForm;
useEffect(() => { onDirtyChange?.(isDirty); }, [isDirty, onDirtyChange]);
useEffect(() => () => onDirtyChange?.(false), [onDirtyChange]);
const { dialogProps: discardDialogProps, confirm: confirmDiscard } = useConfirmDialog();
const askingRef = useRef(false);
const requestClose = useCallback(() => {
if (!isDirty) { onClose(); return; }
if (askingRef.current) return;
askingRef.current = true;
void confirmDiscard({
title: t("discard.title"),
message: t("discard.message"),
confirmText: t("discard.confirm"),
cancelText: t("discard.keep"),
variant: "destructive",
}).then((ok) => {
askingRef.current = false;
if (ok) onClose();
});
}, [isDirty, onClose, confirmDiscard, t]);
useEffect(() => {
titleRef.current?.focus();
}, []);
@@ -164,13 +196,13 @@ export function TaskModal({
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
requestClose();
}
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleSave();
}
}, [onClose, handleSave]);
}, [requestClose, handleSave]);
return (
<div className="flex flex-col h-full bg-background" onKeyDown={handleKeyDown}>
@@ -179,9 +211,9 @@ export function TaskModal({
<h2 className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
<IconButton label={tCommon("close")} onClick={requestClose}>
<X />
</IconButton>
</div>
{/* Body */}
@@ -192,6 +224,7 @@ export function TaskModal({
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("tasks.title_placeholder")}
aria-label={t("tasks.title_placeholder")}
className="text-base font-medium"
/>
@@ -200,18 +233,20 @@ export function TaskModal({
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("tasks.description_placeholder")}
aria-label={t("form.description")}
rows={3}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
{/* Due Date */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<label htmlFor={`${id}-due`} id={`${id}-due-label`} className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
{t("tasks.due_date")}
</label>
<div className="flex items-center gap-2">
<input
id={`${id}-due`}
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
@@ -231,6 +266,7 @@ export function TaskModal({
{showTime && (
<input
type="time"
aria-labelledby={`${id}-due-label`}
value={dueTime}
onChange={(e) => setDueTime(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
@@ -241,11 +277,12 @@ export function TaskModal({
{/* Priority */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<label htmlFor={`${id}-priority`} className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Flag className="h-3.5 w-3.5" />
{t("tasks.priority")}
</label>
<select
id={`${id}-priority`}
value={priority}
onChange={(e) => setPriority(e.target.value as PriorityLevel)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
@@ -259,10 +296,11 @@ export function TaskModal({
{/* Progress */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor={`${id}-progress`} className="text-xs font-medium text-muted-foreground">
{t("tasks.progress")}
</label>
<select
id={`${id}-progress`}
value={progress}
onChange={(e) => setProgress(e.target.value as CalendarTask["progress"])}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
@@ -277,10 +315,11 @@ export function TaskModal({
{/* Calendar */}
{writableCalendars.length > 1 && (
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
<label htmlFor={`${id}-calendar`} className="text-xs font-medium text-muted-foreground">
{t("tasks.calendar")}
</label>
<select
id={`${id}-calendar`}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm w-full"
@@ -294,11 +333,12 @@ export function TaskModal({
{/* Alert */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<label htmlFor={`${id}-alert`} className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Bell className="h-3.5 w-3.5" />
{t("tasks.alert")}
</label>
<select
id={`${id}-alert`}
value={alertOption}
onChange={(e) => {
setAlertOption(e.target.value as AlertOption);
@@ -335,7 +375,7 @@ export function TaskModal({
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
<Button variant="outline" size="sm" onClick={requestClose}>
{t("tasks.cancel")}
</Button>
<Button size="sm" onClick={handleSave} disabled={!title.trim() || saving}>
@@ -343,6 +383,7 @@ export function TaskModal({
</Button>
</div>
</div>
<ConfirmDialog {...discardDialogProps} />
</div>
);
}
-14
View File
@@ -10,9 +10,7 @@ import type { TaskViewFilter } from "@/stores/task-store";
interface TaskToolbarProps {
filter: TaskViewFilter;
showCompleted: boolean;
onFilterChange: (filter: TaskViewFilter) => void;
onShowCompletedChange: (show: boolean) => void;
onCreateTask: () => void;
/** The tasks the list shows, in order: what plugin buttons here act on. */
taskIds?: string[];
@@ -26,9 +24,7 @@ const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
export function TaskToolbar({
filter,
showCompleted,
onFilterChange,
onShowCompletedChange,
onCreateTask,
taskIds = NO_IDS,
compact,
@@ -55,16 +51,6 @@ export function TaskToolbar({
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
<input
type="checkbox"
checked={showCompleted}
onChange={(e) => onShowCompletedChange(e.target.checked)}
className="rounded border-border"
/>
{t("tasks.show_completed")}
</label>
<div className="flex-1" />
<Button size="sm" onClick={onCreateTask}>
@@ -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>
);
}
+54 -30
View File
@@ -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")}
+45 -169
View File
@@ -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
+8 -2
View File
@@ -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";
}
+55 -37
View File
@@ -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" />
+26 -14
View File
@@ -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>
);
})}
+13 -4
View File
@@ -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>
+34 -39
View File
@@ -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>
);
}
+28 -5
View File
@@ -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",
+216 -93
View File
@@ -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}
+64 -34
View File
@@ -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>
+312 -102
View File
@@ -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);
+216 -180
View File
@@ -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 ?? "" }}
/>
);
}
+4 -4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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">
+25 -28
View File
@@ -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>}
/>
);
}
+1 -1
View File
@@ -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"}`} />;
}
+7 -7
View File
@@ -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))}
+26 -96
View File
@@ -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>
);
}
+40 -35
View File
@@ -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
View File
@@ -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.
+81 -44
View File
@@ -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>
);
}
+39 -29
View File
@@ -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];
}
+73 -20
View File
@@ -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))} />
)}
+178
View File
@@ -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>
);
}
+12 -55
View File
@@ -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>
);
}
-63
View File
@@ -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>
);
}
+106
View File
@@ -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>
);
}
-66
View File
@@ -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 -1
View File
@@ -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);
+5
View File
@@ -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";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -97,7 +97,6 @@ export function AddressBookManagementSettings() {
try {
await createAddressBook(client, name);
await fetchAddressBooks(client);
setCreating(false);
toast.success(t("created"));
} catch {
toast.error(t("create_failed"));
@@ -423,15 +422,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)}
/>
<ConfirmDialog {...confirmProps} />
+3 -22
View File
@@ -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>
+15 -41
View File
@@ -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>
);
}
+81
View File
@@ -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;
}
+28 -1
View File
@@ -3,9 +3,16 @@ import { useSettingsStore } from "@/stores/settings-store";
import { displayNow } from "@/lib/timezone";
import { FULL_DAY_HOURS, resolveDisplayHours, type DisplayHours } from "@/lib/calendar-display-range";
/** A time of day to bring into view, e.g. a new event's start; a new nonce asks again. */
export interface RevealTime {
minutes: number;
nonce: number;
}
interface UseDisplayHoursOptions {
scrollRef: RefObject<HTMLElement | null>;
hourHeight: number;
revealAt?: RevealTime | null;
}
/**
@@ -14,7 +21,7 @@ interface UseDisplayHoursOptions {
* position too - it opens an hour before now, and keeps the same hours in
* place when the range changes underneath it.
*/
export function useDisplayHours({ scrollRef, hourHeight }: UseDisplayHoursOptions) {
export function useDisplayHours({ scrollRef, hourHeight, revealAt }: UseDisplayHoursOptions) {
const limitHours = useSettingsStore((s) => s.calendarLimitHours);
const startHour = useSettingsStore((s) => s.calendarDayStartHour);
const endHour = useSettingsStore((s) => s.calendarDayEndHour);
@@ -64,6 +71,26 @@ export function useDisplayHours({ scrollRef, hourHeight }: UseDisplayHoursOption
setShowAllHours(true);
}, []);
// A saved event lands where the user can see it: outside the shown hours
// the whole day opens, else the grid scrolls to it when it is out of view.
const revealNonce = revealAt?.nonce;
useEffect(() => {
if (!revealAt) return;
const { minutes } = revealAt;
if (minutes < hours.startMinutes || minutes >= hours.endMinutes) {
revealMinutes(minutes);
return;
}
const root = scrollRef.current;
if (!root) return;
const top = ((minutes - hours.startMinutes) / 60) * hourHeight;
if (top < root.scrollTop || top > root.scrollTop + root.clientHeight - 2 * hourHeight) {
root.scrollTop = Math.max(0, top - hourHeight / 2);
}
// Only a new request moves the grid, not a change of range under it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revealNonce]);
return {
hours,
/** Whether a range is configured at all, i.e. the toggle has anything to do. */
+18 -5
View File
@@ -7,7 +7,15 @@ import { debug } from "@/lib/debug";
import { formatIsoInTimeZone, getEventStartDate } from "@/lib/calendar-utils";
import { fromDisplayDate, getEffectiveTimeZone } from "@/lib/timezone";
import { movedEventUpdates } from "@/lib/calendar-participants";
import type { Calendar } from "@/lib/jmap/types";
import type { Calendar, CalendarEvent } from "@/lib/jmap/types";
/** Saves a dragged or resized event; `kind` says which, for the message. */
export type MoveEventHandler = (
event: CalendarEvent,
updates: Partial<CalendarEvent>,
sendSchedulingMessages: boolean | undefined,
kind: "move" | "resize",
) => Promise<unknown> | void;
interface DragCreateState {
dayKey: string;
@@ -59,6 +67,8 @@ interface UseTimeGridInteractionsOptions {
*/
gridStartMinutes?: number;
gridEndMinutes?: number;
/** Save a drop or resize (the page asks the scope of a series); else the store directly. */
onMoveEvent?: MoveEventHandler;
}
/** `date` moved by `minutes` on the local clock, not by elapsed time. */
@@ -78,6 +88,7 @@ export function useTimeGridInteractions({
isMobile,
gridStartMinutes = 0,
gridEndMinutes = 1440,
onMoveEvent,
}: UseTimeGridInteractionsOptions) {
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
const raw = gridStartMinutes + ((clientY - containerTop) / hourHeight) * 60;
@@ -275,14 +286,15 @@ export function useTimeGridInteractions({
const updates: { start?: string; duration: string } = { duration: dur };
if (start) updates.start = start;
const moved = movedEventUpdates(event, updates, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "resize");
else await useCalendarStore.getState().updateEvent(client, resize.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
debug.error("Failed to resize event:", resize.eventId, error);
toast.error(describeError ? describeError(error, errorMessages.resize) : errorMessages.resize);
} finally {
setResizeVisual(null);
}
}, [computeResize, errorMessages.resize, describeError, currentUserEmails]);
}, [computeResize, errorMessages.resize, describeError, currentUserEmails, onMoveEvent]);
// --- Click / Double-click / Quick-create ---
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -410,11 +422,12 @@ export function useTimeGridInteractions({
return;
}
const moved = movedEventUpdates(event, { start: newStartISO }, currentUserEmails ?? []);
await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
if (event && onMoveEvent) await onMoveEvent(event, moved.updates, moved.sendSchedulingMessages, "move");
else await useCalendarStore.getState().updateEvent(client, data.eventId, moved.updates, moved.sendSchedulingMessages);
} catch (error) {
toast.error(describeError ? describeError(error, errorMessages.move) : errorMessages.move);
}
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails]);
}, [snapDragMinutes, errorMessages.move, describeError, currentUserEmails, onMoveEvent]);
return {
dragCreate,
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import en from '@/locales/en/common.json';
import { CALENDAR_SECTIONS } from '@/lib/keyboard-shortcuts/calendar';
import { calendarShortcutAction } from '@/lib/calendar-shortcuts';
// The help modal lists Calendar's keys from CALENDAR_SECTIONS; the calendar
// page handles them through calendarShortcutAction. The two must agree.
function lookup(key: string): unknown {
return key.split('.').reduce<unknown>((node, part) => (node as Record<string, unknown> | undefined)?.[part], en);
}
const pressed = (key: string, code = '') => ({ key, code, ctrlKey: false, metaKey: false, altKey: false, shiftKey: false });
describe('Calendar keyboard shortcuts', () => {
it('lists Calendar keys in the help modal', () => {
const rows = CALENDAR_SECTIONS.flatMap((s) => s.entries);
expect(rows.length).toBeGreaterThan(5);
});
it('names every section and row with an existing string', () => {
for (const section of CALENDAR_SECTIONS) {
expect(typeof lookup(section.title), section.title).toBe('string');
for (const entry of section.entries) expect(typeof lookup(entry.description), entry.description).toBe('string');
}
});
it('handles every single letter it lists', () => {
const views = CALENDAR_SECTIONS.find((s) => s.id === 'views')!;
for (const { key } of views.entries) {
expect(calendarShortcutAction(pressed(key, `Key${key.toUpperCase()}`)), key).not.toBeNull();
}
expect(calendarShortcutAction(pressed('t', 'KeyT'))).toBe('today');
expect(calendarShortcutAction(pressed('n', 'KeyN'))).toBe('new');
});
it('ignores letters held with a modifier', () => {
expect(calendarShortcutAction({ ...pressed('a', 'KeyA'), ctrlKey: true })).toBeNull();
expect(calendarShortcutAction({ ...pressed('ArrowLeft'), altKey: true })).toBeNull();
});
});
+15
View File
@@ -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');
});
});
+56
View File
@@ -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' });
});
});
+1 -1
View File
@@ -61,7 +61,7 @@ describe('settings search index (English messages)', () => {
// SET-6: these found nothing, or not the right row.
it('finds the settings people searched for', () => {
expect(resultsFor('message_list', 'inbox type').map((r) => r.label)).toEqual(['Inbox type']);
expect(resultsFor('calendar', 'import calendar').map((r) => r.label)).toContain('Import Calendar');
expect(resultsFor('calendar', 'import calendar').map((r) => r.label)).toContain('Import calendar');
expect(resultsFor('composing', 'mention').map((r) => r.label).slice(0, 2)).toEqual(['Mention recipients with @', 'Contacts in @ mentions']);
expect(resultsFor('security', 'two-factor').map((r) => r.label)).toContain('Two-factor authentication');
expect(resultsFor('filters', 'new rule').map((r) => r.label)).toEqual(['New rule']);
+39
View File
@@ -0,0 +1,39 @@
// The calendar's single-key shortcuts. A key held with Ctrl, ⌘, Alt or
// Shift is left to the browser (Ctrl+A selects, Alt+← goes back), and
// letters come from the physical key, so they work on any layout (R8).
export type CalendarShortcutAction =
| "prev"
| "next"
| "today"
| "month"
| "week"
| "day"
| "agenda"
| "tasks"
| "new";
const LETTERS: Record<string, CalendarShortcutAction> = {
t: "today",
m: "month",
w: "week",
d: "day",
a: "agenda",
k: "tasks",
n: "new",
c: "new",
};
/** The action a key press asks for, or null when it isn't a calendar shortcut. */
export function calendarShortcutAction(
event: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey">,
): CalendarShortcutAction | null {
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
if (event.key === "ArrowLeft") return "prev";
if (event.key === "ArrowRight") return "next";
const code = event.code;
const letter = code && code.length === 4 && code.startsWith("Key")
? code.charAt(3).toLowerCase()
: event.key.length === 1 ? event.key.toLowerCase() : "";
return LETTERS[letter] ?? null;
}
+45
View File
@@ -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() };
}
+11
View File
@@ -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: [] };
}
+6 -1
View File
@@ -14,6 +14,11 @@ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
const rights = (mayDelete: boolean, mayWriteAll = true) => ({
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: mayWriteAll, mayDelete,
});
// Birthdays come from the contacts, like the real birthday calendar: they
// can't be edited, moved or deleted here.
const readOnly = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false, mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
};
export function createDemoCalendars(): Calendar[] {
const base = { description: null, isSubscribed: true, isVisible: true, includeInAvailability: 'all' as const, defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null };
@@ -21,7 +26,7 @@ export function createDemoCalendars(): Calendar[] {
{ ...base, id: CAL.work, name: 'Work', color: '#db2d54', sortOrder: 1, isDefault: true, myRights: rights(false) },
{ ...base, id: CAL.personal, name: 'Personal', color: '#3b82f6', sortOrder: 2, isDefault: false, myRights: rights(true) },
{ ...base, id: CAL.team, name: 'Beacon team', description: 'Shared by Alice Johnson', color: '#8b5cf6', sortOrder: 3, isDefault: false, myRights: rights(false, false) },
{ ...base, id: CAL.birthdays, name: 'Birthdays', color: '#eab308', sortOrder: 4, isDefault: false, includeInAvailability: 'none', myRights: rights(true) },
{ ...base, id: CAL.birthdays, name: 'Birthdays', color: '#eab308', sortOrder: 4, isDefault: false, includeInAvailability: 'none', myRights: readOnly },
];
}
+45
View File
@@ -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;
}

Some files were not shown because too many files have changed in this diff Show More