@rapidmx/web-client 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from "@dnd-kit/core";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import {
|
|
9
|
+
addDays,
|
|
10
|
+
addMonths,
|
|
11
|
+
addWeeks,
|
|
12
|
+
eachDayOfInterval,
|
|
13
|
+
endOfDay,
|
|
14
|
+
endOfMonth,
|
|
15
|
+
endOfWeek,
|
|
16
|
+
format,
|
|
17
|
+
startOfDay,
|
|
18
|
+
startOfMonth,
|
|
19
|
+
startOfWeek,
|
|
20
|
+
} from "date-fns";
|
|
21
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
22
|
+
import { colorForFolder } from "@rapidmx/react-shared/calendar/calendarColors.js";
|
|
23
|
+
import { CalendarEvent, listCalendarEvents } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
24
|
+
import { moveOccurrence, resizeOccurrenceEnd } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
25
|
+
import { resolveDragAction } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
|
|
26
|
+
import { createFolder } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
27
|
+
import { CalendarOccurrence, expandAllOccurrences } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
28
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
29
|
+
import CalendarShell, { CalendarShellProps, useCalendarShell } from "../../shared/components/calendar/layout/CalendarShell.js";
|
|
30
|
+
import CalendarListSidebar from "../../shared/components/calendar/CalendarListSidebar.js";
|
|
31
|
+
import EventModal from "../../shared/components/calendar/EventModal.js";
|
|
32
|
+
import MiniDatePicker from "@rapidmx/react-shared/components/pickers/MiniDatePicker.js";
|
|
33
|
+
import MonthView from "../../shared/components/calendar/MonthView.js";
|
|
34
|
+
import SplitDayView from "../../shared/components/calendar/SplitDayView.js";
|
|
35
|
+
import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
36
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
37
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
38
|
+
|
|
39
|
+
export default function CalendarPage(props: CalendarShellProps) {
|
|
40
|
+
return (
|
|
41
|
+
<CalendarShell {...props}>
|
|
42
|
+
<CalendarContent />
|
|
43
|
+
</CalendarShell>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type ViewType = "month" | "week" | "workWeek" | "day" | "split";
|
|
48
|
+
const VIEW_TYPES: ViewType[] = ["month", "week", "workWeek", "day", "split"];
|
|
49
|
+
const VIEW_LABELS: Record<ViewType, string> = { month: "Month", week: "Week", workWeek: "Work Week", day: "Day", split: "Split" };
|
|
50
|
+
|
|
51
|
+
interface ModalState {
|
|
52
|
+
occurrence: CalendarOccurrence | null;
|
|
53
|
+
initialStart?: Date;
|
|
54
|
+
initialEnd?: Date;
|
|
55
|
+
/** Overrides the default calendar a new event is created into (e.g. the column clicked in Split
|
|
56
|
+
* view) — ignored when editing an existing occurrence, which always keeps its own `folderUid`. */
|
|
57
|
+
targetFolderUid?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function CalendarContent() {
|
|
61
|
+
const { mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders } = useCalendarShell();
|
|
62
|
+
// `CalendarShell` only ever renders this component once `mailboxUid` is set, and always to a value
|
|
63
|
+
// drawn from `mailboxes` itself (see its own resolution logic) — the lookup below always succeeds.
|
|
64
|
+
const organizerAddress = mailboxes.find((mb) => mb.uid === mailboxUid)!.primarySmtpAddress;
|
|
65
|
+
// `PointerSensor` alone activates a drag on the very first touch-move, indistinguishable from a
|
|
66
|
+
// scroll gesture on a touch device. `MouseSensor` (a small `distance` — desktop drags still start
|
|
67
|
+
// immediately on a deliberate movement, no change from before) + `TouchSensor` (a `delay`+`tolerance`
|
|
68
|
+
// — a touch drag only activates after a brief press-and-hold, so a quick swipe scrolls normally) is
|
|
69
|
+
// dnd-kit's own documented pattern for this exact conflict.
|
|
70
|
+
const sensors = useSensors(
|
|
71
|
+
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
|
|
72
|
+
useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
76
|
+
const [view, setView] = useState<ViewType>("month");
|
|
77
|
+
const [viewDate, setViewDate] = useState(() => startOfDay(new Date()));
|
|
78
|
+
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
|
79
|
+
const [loading, setLoading] = useState(true);
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
82
|
+
const [modal, setModal] = useState<ModalState | null>(null);
|
|
83
|
+
// `null` means "not yet customized by the user" — every calendar is treated as checked without
|
|
84
|
+
// needing to be seeded from `calendarFolders` the moment it loads (see `checkedFolderUids` below).
|
|
85
|
+
// Once the user toggles anything, this becomes a real (possibly empty) set of exactly what's checked.
|
|
86
|
+
const [checkedFolderUidsState, setCheckedFolderUidsState] = useState<Set<string> | null>(null);
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
const params = new URLSearchParams(window.location.search);
|
|
90
|
+
const requestedView = params.get("view");
|
|
91
|
+
if ((VIEW_TYPES as string[]).includes(requestedView ?? "")) {
|
|
92
|
+
setView(requestedView as ViewType);
|
|
93
|
+
}
|
|
94
|
+
const requestedDate = params.get("date");
|
|
95
|
+
const parsedDate = requestedDate ? new Date(requestedDate) : null;
|
|
96
|
+
if (parsedDate && !isNaN(parsedDate.getTime())) {
|
|
97
|
+
setViewDate(startOfDay(parsedDate));
|
|
98
|
+
}
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const checkedFolderUids = checkedFolderUidsState ?? new Set(calendarFolders.map((f) => f.uid));
|
|
102
|
+
const folderColors = useMemo(
|
|
103
|
+
() => Object.fromEntries(calendarFolders.map((f) => [f.uid, colorForFolder(f)])),
|
|
104
|
+
[calendarFolders],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
function toggleCalendar(toggledUid: string) {
|
|
108
|
+
setCheckedFolderUidsState((prev) => {
|
|
109
|
+
const next = new Set(prev ?? calendarFolders.map((f) => f.uid));
|
|
110
|
+
if (next.has(toggledUid)) {
|
|
111
|
+
next.delete(toggledUid);
|
|
112
|
+
} else {
|
|
113
|
+
next.add(toggledUid);
|
|
114
|
+
}
|
|
115
|
+
return next;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleAddCalendar(name: string, color: string) {
|
|
120
|
+
// `CalendarContent` only renders once `mailboxUid` is resolved — same invariant as `organizerAddress` above.
|
|
121
|
+
const created = await createFolder({ mailboxUid: mailboxUid!, name, type: "calendar", color });
|
|
122
|
+
setCheckedFolderUidsState((prev) => new Set([...(prev ?? calendarFolders.map((f) => f.uid)), created.uid]));
|
|
123
|
+
reloadFolders();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { rangeStart, rangeEnd, days } = useMemo(() => {
|
|
127
|
+
if (view === "month") {
|
|
128
|
+
const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 1 });
|
|
129
|
+
const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 1 });
|
|
130
|
+
return { rangeStart: start, rangeEnd: end, days: [] as Date[] };
|
|
131
|
+
}
|
|
132
|
+
if (view === "week") {
|
|
133
|
+
const start = startOfWeek(viewDate, { weekStartsOn: 1 });
|
|
134
|
+
const end = endOfWeek(viewDate, { weekStartsOn: 1 });
|
|
135
|
+
return { rangeStart: start, rangeEnd: end, days: eachDayOfInterval({ start, end }) };
|
|
136
|
+
}
|
|
137
|
+
if (view === "workWeek") {
|
|
138
|
+
const start = startOfWeek(viewDate, { weekStartsOn: 1 });
|
|
139
|
+
const weekdays = eachDayOfInterval({ start, end: endOfWeek(viewDate, { weekStartsOn: 1 }) }).slice(0, 5);
|
|
140
|
+
return { rangeStart: start, rangeEnd: endOfDay(weekdays[weekdays.length - 1]), days: weekdays };
|
|
141
|
+
}
|
|
142
|
+
// "day" and "split" both show a single day, just laid out differently below.
|
|
143
|
+
const start = startOfDay(viewDate);
|
|
144
|
+
const end = endOfDay(viewDate);
|
|
145
|
+
return { rangeStart: start, rangeEnd: end, days: [start] };
|
|
146
|
+
}, [view, viewDate]);
|
|
147
|
+
|
|
148
|
+
function reload() {
|
|
149
|
+
const targets = calendarFolders.filter((f) => checkedFolderUids.has(f.uid));
|
|
150
|
+
if (targets.length === 0) {
|
|
151
|
+
setEvents([]);
|
|
152
|
+
setLoading(false);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setLoading(true);
|
|
156
|
+
setError(null);
|
|
157
|
+
void Promise.allSettled(targets.map((f) => listCalendarEvents(f.uid)))
|
|
158
|
+
.then((results) => {
|
|
159
|
+
const loaded: CalendarEvent[] = [];
|
|
160
|
+
const failures: { name: string; err: unknown }[] = [];
|
|
161
|
+
results.forEach((result, i) => {
|
|
162
|
+
if (result.status === "fulfilled") {
|
|
163
|
+
loaded.push(...result.value);
|
|
164
|
+
} else {
|
|
165
|
+
failures.push({ name: targets[i].name, err: result.reason });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
setEvents(loaded);
|
|
169
|
+
if (failures.length === 0) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// A single calendar's failure (by far the common case — most mailboxes have exactly one)
|
|
173
|
+
// surfaces that calendar's own error message unchanged, matching this page's original
|
|
174
|
+
// single-calendar behavior exactly; only a genuine multi-calendar partial failure falls
|
|
175
|
+
// back to a combined message naming which calendars didn't load.
|
|
176
|
+
if (failures.length === 1 && targets.length === 1) {
|
|
177
|
+
const err = failures[0].err;
|
|
178
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your calendar.");
|
|
179
|
+
} else {
|
|
180
|
+
setError(`Could not load events for: ${failures.map((f) => f.name).join(", ")}.`);
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.finally(() => setLoading(false));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Keyed on stable string summaries (not the array/set objects themselves, which are new references
|
|
187
|
+
// every render) so this only re-fires when the actual set of folders to fetch changes.
|
|
188
|
+
const calendarFolderUidsKey = calendarFolders.map((f) => f.uid).sort().join(",");
|
|
189
|
+
const checkedFolderUidsKey = Array.from(checkedFolderUids).sort().join(",");
|
|
190
|
+
useEffect(reload, [calendarFolderUidsKey, checkedFolderUidsKey]);
|
|
191
|
+
|
|
192
|
+
const occurrences = useMemo(
|
|
193
|
+
() => expandAllOccurrences(events, rangeStart, rangeEnd),
|
|
194
|
+
[events, rangeStart, rangeEnd],
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const splitColumns = useMemo(
|
|
198
|
+
() =>
|
|
199
|
+
calendarFolders
|
|
200
|
+
.filter((f) => checkedFolderUids.has(f.uid))
|
|
201
|
+
.map((f) => ({ folderUid: f.uid, name: f.name, color: colorForFolder(f) })),
|
|
202
|
+
[calendarFolders, checkedFolderUidsKey],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
function shiftView(direction: 1 | -1) {
|
|
206
|
+
setViewDate((d) =>
|
|
207
|
+
view === "month"
|
|
208
|
+
? addMonths(d, direction)
|
|
209
|
+
: view === "week" || view === "workWeek"
|
|
210
|
+
? addWeeks(d, direction)
|
|
211
|
+
: addDays(d, direction),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function goToday() {
|
|
216
|
+
setViewDate(startOfDay(new Date()));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function handleSelectDay(date: Date) {
|
|
220
|
+
setView("day");
|
|
221
|
+
setViewDate(startOfDay(date));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function openNewEvent(start?: Date, end?: Date, targetFolderUid?: string) {
|
|
225
|
+
if (!mailboxUid || !folderUid) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
setModal({
|
|
229
|
+
occurrence: null,
|
|
230
|
+
initialStart: start ?? new Date(),
|
|
231
|
+
initialEnd: end ?? new Date(Date.now() + 30 * 60_000),
|
|
232
|
+
targetFolderUid,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function openEvent(occurrence: CalendarOccurrence) {
|
|
237
|
+
setModal({ occurrence });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function closeModal() {
|
|
241
|
+
setModal(null);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function handleSaved() {
|
|
245
|
+
setModal(null);
|
|
246
|
+
reload();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handleDeleted() {
|
|
250
|
+
setModal(null);
|
|
251
|
+
reload();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function handleDragEnd(event: DragEndEvent) {
|
|
255
|
+
const overId = event.over ? String(event.over.id) : undefined;
|
|
256
|
+
const action = resolveDragAction(String(event.active.id), overId, occurrences);
|
|
257
|
+
if (!action) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setActionError(null);
|
|
261
|
+
try {
|
|
262
|
+
if (action.type === "move") {
|
|
263
|
+
await moveOccurrence(action.occurrence, action.deltaMs);
|
|
264
|
+
} else {
|
|
265
|
+
await resizeOccurrenceEnd(action.occurrence, action.newEnd);
|
|
266
|
+
}
|
|
267
|
+
reload();
|
|
268
|
+
} catch (err) {
|
|
269
|
+
setActionError(err instanceof ApiRequestError ? err.message : "Could not update this event.");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const title =
|
|
274
|
+
view === "month"
|
|
275
|
+
? format(viewDate, "MMMM yyyy")
|
|
276
|
+
: view === "week" || view === "workWeek"
|
|
277
|
+
? `${format(rangeStart, "MMM d")} – ${format(rangeEnd, "MMM d, yyyy")}`
|
|
278
|
+
: format(viewDate, "EEEE, MMMM d, yyyy");
|
|
279
|
+
|
|
280
|
+
const sidebarContent = (
|
|
281
|
+
<>
|
|
282
|
+
<MiniDatePicker selected={viewDate} onSelect={(date) => setViewDate(startOfDay(date))} />
|
|
283
|
+
<CalendarListSidebar
|
|
284
|
+
calendars={calendarFolders}
|
|
285
|
+
checkedFolderUids={checkedFolderUids}
|
|
286
|
+
onToggle={toggleCalendar}
|
|
287
|
+
onAddCalendar={handleAddCalendar}
|
|
288
|
+
/>
|
|
289
|
+
</>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div className="flex-1 flex min-h-0">
|
|
294
|
+
<div className="hidden md:flex w-56 shrink-0 border-r border-border flex-col overflow-y-auto">{sidebarContent}</div>
|
|
295
|
+
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Calendars">
|
|
296
|
+
<div className="flex flex-col">{sidebarContent}</div>
|
|
297
|
+
</Drawer>
|
|
298
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
299
|
+
<div className="flex items-center gap-3 p-3 border-b border-border shrink-0">
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
className="md:hidden w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text shrink-0"
|
|
303
|
+
aria-label="Open calendars"
|
|
304
|
+
onClick={() => setDrawerOpen(true)}
|
|
305
|
+
>
|
|
306
|
+
<HiOutlineBars3 size={20} aria-hidden="true" />
|
|
307
|
+
</button>
|
|
308
|
+
<Button type="button" onClick={() => openNewEvent()} className="!w-auto shrink-0">
|
|
309
|
+
+ New event
|
|
310
|
+
</Button>
|
|
311
|
+
<div className="flex items-center gap-1">
|
|
312
|
+
<button type="button" onClick={() => shiftView(-1)} aria-label="Previous" className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
|
|
313
|
+
‹
|
|
314
|
+
</button>
|
|
315
|
+
<button type="button" onClick={goToday} className="px-2 h-7 text-sm rounded-sm hover:bg-surface-alt">
|
|
316
|
+
Today
|
|
317
|
+
</button>
|
|
318
|
+
<button type="button" onClick={() => shiftView(1)} aria-label="Next" className="w-7 h-7 text-sm rounded-sm hover:bg-surface-alt">
|
|
319
|
+
›
|
|
320
|
+
</button>
|
|
321
|
+
</div>
|
|
322
|
+
<h1 className="hidden md:block text-lg font-bold tracking-tight">{title}</h1>
|
|
323
|
+
<div className="ml-auto flex gap-1 overflow-x-auto">
|
|
324
|
+
{VIEW_TYPES.map((v) => (
|
|
325
|
+
<button
|
|
326
|
+
key={v}
|
|
327
|
+
type="button"
|
|
328
|
+
onClick={() => setView(v)}
|
|
329
|
+
className={[
|
|
330
|
+
"px-3 h-7 text-sm rounded-sm whitespace-nowrap",
|
|
331
|
+
view === v ? "bg-primary text-white" : "hover:bg-surface-alt",
|
|
332
|
+
(v === "workWeek" || v === "split") && "hidden md:inline-block",
|
|
333
|
+
]
|
|
334
|
+
.filter(Boolean)
|
|
335
|
+
.join(" ")}
|
|
336
|
+
>
|
|
337
|
+
{VIEW_LABELS[v]}
|
|
338
|
+
</button>
|
|
339
|
+
))}
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
{error && (
|
|
344
|
+
<div className="p-3">
|
|
345
|
+
<Alert>{error}</Alert>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
{actionError && (
|
|
349
|
+
<div className="p-3">
|
|
350
|
+
<Alert>{actionError}</Alert>
|
|
351
|
+
</div>
|
|
352
|
+
)}
|
|
353
|
+
|
|
354
|
+
{loading ? (
|
|
355
|
+
<p className="p-4 text-sm text-text-muted">Loading…</p>
|
|
356
|
+
) : (
|
|
357
|
+
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
|
358
|
+
{view === "month" ? (
|
|
359
|
+
<MonthView
|
|
360
|
+
viewDate={viewDate}
|
|
361
|
+
occurrences={occurrences}
|
|
362
|
+
folderColors={folderColors}
|
|
363
|
+
onSelectDay={handleSelectDay}
|
|
364
|
+
onSelectEvent={openEvent}
|
|
365
|
+
/>
|
|
366
|
+
) : view === "split" ? (
|
|
367
|
+
<SplitDayView
|
|
368
|
+
day={viewDate}
|
|
369
|
+
columns={splitColumns}
|
|
370
|
+
occurrences={occurrences}
|
|
371
|
+
onSelectEvent={openEvent}
|
|
372
|
+
onSelectSlot={(start, end, targetFolderUid) => openNewEvent(start, end, targetFolderUid)}
|
|
373
|
+
/>
|
|
374
|
+
) : (
|
|
375
|
+
<TimeGridView
|
|
376
|
+
days={days}
|
|
377
|
+
occurrences={occurrences}
|
|
378
|
+
folderColors={folderColors}
|
|
379
|
+
onSelectEvent={openEvent}
|
|
380
|
+
onSelectSlot={openNewEvent}
|
|
381
|
+
/>
|
|
382
|
+
)}
|
|
383
|
+
</DndContext>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
{/* `mailboxUid`/`folderUid` are guaranteed defined whenever `modal` is: `openNewEvent` only sets it
|
|
387
|
+
after checking both, and `openEvent` only fires from an occurrence that itself required a
|
|
388
|
+
successful, folder-scoped load to render. */}
|
|
389
|
+
{modal && (
|
|
390
|
+
<EventModal
|
|
391
|
+
open
|
|
392
|
+
onClose={closeModal}
|
|
393
|
+
mailboxUid={mailboxUid!}
|
|
394
|
+
folderUid={modal.targetFolderUid ?? modal.occurrence?.folderUid ?? folderUid!}
|
|
395
|
+
calendars={calendarFolders.map((f) => ({ uid: f.uid, name: f.name }))}
|
|
396
|
+
organizerAddress={organizerAddress}
|
|
397
|
+
occurrence={modal.occurrence}
|
|
398
|
+
initialStart={modal.initialStart}
|
|
399
|
+
initialEnd={modal.initialEnd}
|
|
400
|
+
onSaved={handleSaved}
|
|
401
|
+
onDeleted={handleDeleted}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
);
|
|
407
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { Contact, deleteContact, getContact } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
8
|
+
import ContactsShell, { ContactsShellProps } from "../../shared/components/contacts/layout/ContactsShell.js";
|
|
9
|
+
import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
|
|
10
|
+
import ContactForm from "../../shared/components/contacts/ContactForm.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Only reached on mobile (below the `md` breakpoint) — desktop's `apps/www/contacts/index.tsx` keeps its
|
|
15
|
+
* existing inline detail pane and never navigates here; see that file's `handleSelectRow`. There is no
|
|
16
|
+
* equivalent "new contact" route — an unsaved contact has no uid to route on, so creation stays an
|
|
17
|
+
* in-place mode-switch on every device (see `apps/www/contacts/index.tsx`'s `handleNew`).
|
|
18
|
+
*/
|
|
19
|
+
export default function ContactDetailPage(props: ContactsShellProps & { params: { uid: string } }) {
|
|
20
|
+
return (
|
|
21
|
+
<ContactsShell {...props}>
|
|
22
|
+
<ContactDetailContent uid={props.params.uid} />
|
|
23
|
+
</ContactsShell>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type Mode = "view" | "edit";
|
|
28
|
+
|
|
29
|
+
function ContactDetailContent({ uid }: { uid: string }) {
|
|
30
|
+
const [contact, setContact] = useState<Contact | null>(null);
|
|
31
|
+
const [loading, setLoading] = useState(true);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
const [mode, setMode] = useState<Mode>("view");
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setLoading(true);
|
|
37
|
+
setError(null);
|
|
38
|
+
getContact(uid)
|
|
39
|
+
.then(setContact)
|
|
40
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this contact."))
|
|
41
|
+
.finally(() => setLoading(false));
|
|
42
|
+
}, [uid]);
|
|
43
|
+
|
|
44
|
+
// Takes `contact` as a parameter rather than reading it from the closure/state — mirrors the desktop
|
|
45
|
+
// pane's own `handleDelete` in `apps/www/contacts/index.tsx`. The only caller (below) is only ever
|
|
46
|
+
// rendered once `contact` is already resolved (see the `error || !contact` guard above it), so this
|
|
47
|
+
// never runs with a stale/absent contact — no redundant null check needed here.
|
|
48
|
+
async function handleDelete(contact: Contact) {
|
|
49
|
+
try {
|
|
50
|
+
await deleteContact(contact.uid, contact.version);
|
|
51
|
+
window.location.href = "/contacts";
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (loading) {
|
|
58
|
+
return <p className="p-8 text-sm text-text-muted">Loading…</p>;
|
|
59
|
+
}
|
|
60
|
+
if (error || !contact) {
|
|
61
|
+
return <Alert>{error ?? "Contact not found."}</Alert>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const backHref = "/contacts";
|
|
65
|
+
if (mode === "edit") {
|
|
66
|
+
return (
|
|
67
|
+
<div className="p-6">
|
|
68
|
+
<a href={backHref} className="text-sm text-primary-dark hover:underline block mb-4">
|
|
69
|
+
← Back to contacts
|
|
70
|
+
</a>
|
|
71
|
+
<ContactForm
|
|
72
|
+
contact={contact}
|
|
73
|
+
onSaved={(saved) => {
|
|
74
|
+
setContact(saved);
|
|
75
|
+
setMode("view");
|
|
76
|
+
}}
|
|
77
|
+
onCancel={() => setMode("view")}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="p-6">
|
|
85
|
+
<ContactDetailPane contact={contact} onEdit={() => setMode("edit")} onDelete={() => handleDelete(contact)} backHref={backHref} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|