@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.
Files changed (168) hide show
  1. package/LICENSE +382 -0
  2. package/apps/admin/_404.tsx +9 -0
  3. package/apps/admin/_500.tsx +9 -0
  4. package/apps/admin/_layout.tsx +41 -0
  5. package/apps/admin/audit-log/index.tsx +187 -0
  6. package/apps/admin/branding/index.tsx +404 -0
  7. package/apps/admin/distribution-lists/[uid].tsx +70 -0
  8. package/apps/admin/distribution-lists/index.tsx +115 -0
  9. package/apps/admin/distribution-lists/new/index.tsx +114 -0
  10. package/apps/admin/domains/[uid].tsx +259 -0
  11. package/apps/admin/domains/index.tsx +125 -0
  12. package/apps/admin/domains/new/index.tsx +84 -0
  13. package/apps/admin/index.tsx +82 -0
  14. package/apps/admin/ingest-queue/index.tsx +116 -0
  15. package/apps/admin/mailboxes/[uid].tsx +126 -0
  16. package/apps/admin/mailboxes/new/index.tsx +291 -0
  17. package/apps/admin/quarantine/index.tsx +171 -0
  18. package/apps/admin/transport-rules/[uid].tsx +143 -0
  19. package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
  20. package/apps/admin/transport-rules/index.tsx +117 -0
  21. package/apps/admin/transport-rules/new/index.tsx +105 -0
  22. package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
  23. package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
  24. package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
  25. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
  26. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
  27. package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
  28. package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
  29. package/apps/shared/components/calendar/EventModal.tsx +513 -0
  30. package/apps/shared/components/calendar/MonthView.tsx +139 -0
  31. package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
  32. package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
  33. package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
  34. package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
  35. package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
  36. package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
  37. package/apps/shared/components/contacts/ContactForm.tsx +314 -0
  38. package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
  39. package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
  40. package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
  41. package/apps/shared/components/layout/AppShell.tsx +165 -0
  42. package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
  43. package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
  44. package/apps/shared/components/layout/UserMenu.tsx +150 -0
  45. package/apps/shared/components/mail/ConversationList.tsx +61 -0
  46. package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
  47. package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
  48. package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
  49. package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
  50. package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
  51. package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
  52. package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
  53. package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
  54. package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
  55. package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
  56. package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
  57. package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
  58. package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
  59. package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
  60. package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
  61. package/apps/shared/styles/app.css +265 -0
  62. package/apps/www/_404.tsx +9 -0
  63. package/apps/www/_500.tsx +9 -0
  64. package/apps/www/_layout.tsx +45 -0
  65. package/apps/www/calendar/index.tsx +407 -0
  66. package/apps/www/contacts/[uid].tsx +88 -0
  67. package/apps/www/contacts/index.tsx +436 -0
  68. package/apps/www/index.tsx +375 -0
  69. package/apps/www/messages/[uid].tsx +70 -0
  70. package/apps/www/settings/auto-reply/index.tsx +129 -0
  71. package/apps/www/settings/booking-types/[uid].tsx +300 -0
  72. package/apps/www/settings/booking-types/index.tsx +98 -0
  73. package/apps/www/settings/booking-types/new/index.tsx +212 -0
  74. package/apps/www/settings/filters/[uid].tsx +155 -0
  75. package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
  76. package/apps/www/settings/filters/index.tsx +99 -0
  77. package/apps/www/settings/filters/new/index.tsx +126 -0
  78. package/apps/www/settings/focused-inbox/index.tsx +148 -0
  79. package/apps/www/settings/read-receipts/index.tsx +125 -0
  80. package/apps/www/settings/signatures/[uid].tsx +167 -0
  81. package/apps/www/settings/signatures/index.tsx +85 -0
  82. package/apps/www/settings/signatures/new/index.tsx +129 -0
  83. package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
  84. package/apps/www/tasks/index.tsx +530 -0
  85. package/dist/apps/admin/_404.js +4 -0
  86. package/dist/apps/admin/_500.js +4 -0
  87. package/dist/apps/admin/_layout.js +10 -0
  88. package/dist/apps/admin/audit-log/index.js +66 -0
  89. package/dist/apps/admin/branding/index.js +126 -0
  90. package/dist/apps/admin/distribution-lists/[uid].js +34 -0
  91. package/dist/apps/admin/distribution-lists/index.js +31 -0
  92. package/dist/apps/admin/distribution-lists/new/index.js +51 -0
  93. package/dist/apps/admin/domains/[uid].js +102 -0
  94. package/dist/apps/admin/domains/index.js +31 -0
  95. package/dist/apps/admin/domains/new/index.js +41 -0
  96. package/dist/apps/admin/index.js +32 -0
  97. package/dist/apps/admin/ingest-queue/index.js +48 -0
  98. package/dist/apps/admin/mailboxes/[uid].js +59 -0
  99. package/dist/apps/admin/mailboxes/new/index.js +93 -0
  100. package/dist/apps/admin/quarantine/index.js +66 -0
  101. package/dist/apps/admin/transport-rules/[uid].js +87 -0
  102. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
  103. package/dist/apps/admin/transport-rules/index.js +32 -0
  104. package/dist/apps/admin/transport-rules/new/index.js +50 -0
  105. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
  106. package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
  107. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
  108. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
  109. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
  110. package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
  111. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
  112. package/dist/apps/shared/components/calendar/EventModal.js +167 -0
  113. package/dist/apps/shared/components/calendar/MonthView.js +38 -0
  114. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
  115. package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
  116. package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
  117. package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
  118. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
  119. package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
  120. package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
  121. package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
  122. package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
  123. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
  124. package/dist/apps/shared/components/layout/AppShell.js +68 -0
  125. package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
  126. package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
  127. package/dist/apps/shared/components/layout/UserMenu.js +59 -0
  128. package/dist/apps/shared/components/mail/ConversationList.js +17 -0
  129. package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
  130. package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
  131. package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
  132. package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
  133. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
  134. package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
  135. package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
  136. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
  137. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
  138. package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
  139. package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
  140. package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
  141. package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
  142. package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
  143. package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
  144. package/dist/apps/shared/styles/app.css +265 -0
  145. package/dist/apps/www/_404.js +4 -0
  146. package/dist/apps/www/_500.js +4 -0
  147. package/dist/apps/www/_layout.js +8 -0
  148. package/dist/apps/www/calendar/index.js +228 -0
  149. package/dist/apps/www/contacts/[uid].js +62 -0
  150. package/dist/apps/www/contacts/index.js +262 -0
  151. package/dist/apps/www/index.js +228 -0
  152. package/dist/apps/www/messages/[uid].js +47 -0
  153. package/dist/apps/www/settings/auto-reply/index.js +54 -0
  154. package/dist/apps/www/settings/booking-types/[uid].js +129 -0
  155. package/dist/apps/www/settings/booking-types/index.js +29 -0
  156. package/dist/apps/www/settings/booking-types/new/index.js +86 -0
  157. package/dist/apps/www/settings/filters/[uid].js +94 -0
  158. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
  159. package/dist/apps/www/settings/filters/index.js +30 -0
  160. package/dist/apps/www/settings/filters/new/index.js +61 -0
  161. package/dist/apps/www/settings/focused-inbox/index.js +69 -0
  162. package/dist/apps/www/settings/read-receipts/index.js +51 -0
  163. package/dist/apps/www/settings/signatures/[uid].js +96 -0
  164. package/dist/apps/www/settings/signatures/index.js +34 -0
  165. package/dist/apps/www/settings/signatures/new/index.js +63 -0
  166. package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
  167. package/dist/apps/www/tasks/index.js +280 -0
  168. package/package.json +87 -0
@@ -0,0 +1,180 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { RecurrenceFrequency, RecurrenceRule, WeekdayCode } from "@rapidmx/react-shared/calendar/calendarApi.js";
7
+ import { WEEKDAY_CODES, WEEKDAY_LABELS, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
8
+
9
+ const SELECT_CLASS =
10
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
11
+ const INPUT_CLASS =
12
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
13
+
14
+ const FREQ_LABEL: Record<RecurrenceFrequency, { unit: string; unitPlural: string }> = {
15
+ daily: { unit: "day", unitPlural: "days" },
16
+ weekly: { unit: "week", unitPlural: "weeks" },
17
+ monthly: { unit: "month", unitPlural: "months" },
18
+ yearly: { unit: "year", unitPlural: "years" },
19
+ };
20
+
21
+ type EndCondition = "never" | "count" | "until";
22
+
23
+ function endConditionOf(rule: RecurrenceRule): EndCondition {
24
+ if (rule.count) return "count";
25
+ if (rule.until) return "until";
26
+ return "never";
27
+ }
28
+
29
+ export interface RecurrenceEditorProps {
30
+ /** `null` means "does not repeat". */
31
+ value: RecurrenceRule | null;
32
+ onChange: (value: RecurrenceRule | null) => void;
33
+ }
34
+
35
+ /**
36
+ * A recurring-event editor: frequency + interval, a weekday picker (weekly only), and an end
37
+ * condition (never / after N occurrences / on a date) — everything `RecurrenceRule` can express. The
38
+ * live "every ... until/for ..." summary comes from `describeRecurrence()` (built on `rrule`'s own
39
+ * `.toText()`), so it never drifts out of sync with what will actually be submitted.
40
+ */
41
+ export default function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
42
+ function handleEnable(enabled: boolean) {
43
+ onChange(enabled ? { freq: "weekly", interval: 1, byDay: ["MO"], exceptions: [] } : null);
44
+ }
45
+
46
+ // `update`/`toggleDay` are only ever invoked from handlers rendered inside the `{value && (...)}`
47
+ // block below, so `value` is always non-null here — no defensive null check needed.
48
+ function update(patch: Partial<RecurrenceRule>) {
49
+ onChange({ ...(value as RecurrenceRule), ...patch });
50
+ }
51
+
52
+ function toggleDay(day: WeekdayCode) {
53
+ const current = (value as RecurrenceRule).byDay ?? [];
54
+ const next = current.includes(day) ? current.filter((d) => d !== day) : [...current, day];
55
+ update({ byDay: next });
56
+ }
57
+
58
+ function handleEndCondition(condition: EndCondition) {
59
+ if (condition === "never") {
60
+ update({ count: undefined, until: undefined });
61
+ } else if (condition === "count") {
62
+ update({ count: 10, until: undefined });
63
+ } else {
64
+ update({ until: new Date().toISOString(), count: undefined });
65
+ }
66
+ }
67
+
68
+ return (
69
+ <div className="flex flex-col gap-3">
70
+ <label className="flex items-center gap-2 text-sm font-medium">
71
+ <input type="checkbox" checked={value !== null} onChange={(e) => handleEnable(e.target.checked)} />
72
+ Repeats
73
+ </label>
74
+
75
+ {value && (
76
+ <div className="flex flex-col gap-3 pl-6 border-l-2 border-border">
77
+ <div className="flex items-center gap-2 text-sm">
78
+ <span>Every</span>
79
+ <input
80
+ type="number"
81
+ min={1}
82
+ className={`${INPUT_CLASS} w-16`}
83
+ value={value.interval}
84
+ onChange={(e) => update({ interval: Number(e.target.value) })}
85
+ aria-label="Recurrence interval"
86
+ />
87
+ <select
88
+ className={SELECT_CLASS}
89
+ value={value.freq}
90
+ onChange={(e) => update({ freq: e.target.value as RecurrenceFrequency })}
91
+ aria-label="Recurrence frequency"
92
+ >
93
+ <option value="daily">{value.interval === 1 ? FREQ_LABEL.daily.unit : FREQ_LABEL.daily.unitPlural}</option>
94
+ <option value="weekly">{value.interval === 1 ? FREQ_LABEL.weekly.unit : FREQ_LABEL.weekly.unitPlural}</option>
95
+ <option value="monthly">{value.interval === 1 ? FREQ_LABEL.monthly.unit : FREQ_LABEL.monthly.unitPlural}</option>
96
+ <option value="yearly">{value.interval === 1 ? FREQ_LABEL.yearly.unit : FREQ_LABEL.yearly.unitPlural}</option>
97
+ </select>
98
+ </div>
99
+
100
+ {value.freq === "weekly" && (
101
+ <div className="flex gap-1">
102
+ {WEEKDAY_CODES.map((day) => (
103
+ <button
104
+ key={day}
105
+ type="button"
106
+ onClick={() => toggleDay(day)}
107
+ aria-pressed={(value.byDay ?? []).includes(day)}
108
+ aria-label={WEEKDAY_LABELS[day]}
109
+ className={[
110
+ "w-9 h-9 rounded-full text-xs font-semibold border",
111
+ (value.byDay ?? []).includes(day)
112
+ ? "bg-primary text-white border-primary"
113
+ : "bg-surface text-text-muted border-border hover:border-primary",
114
+ ].join(" ")}
115
+ >
116
+ <span aria-hidden="true">{WEEKDAY_LABELS[day][0]}</span>
117
+ </button>
118
+ ))}
119
+ </div>
120
+ )}
121
+
122
+ <fieldset className="flex flex-col gap-1.5 text-sm">
123
+ <legend className="text-xs font-bold uppercase tracking-wide text-text-muted mb-1">Ends</legend>
124
+ <label className="flex items-center gap-2">
125
+ <input
126
+ type="radio"
127
+ name="recurrence-end"
128
+ checked={endConditionOf(value) === "never"}
129
+ onChange={() => handleEndCondition("never")}
130
+ />
131
+ Never
132
+ </label>
133
+ <label className="flex items-center gap-2">
134
+ <input
135
+ type="radio"
136
+ name="recurrence-end"
137
+ aria-label="After"
138
+ checked={endConditionOf(value) === "count"}
139
+ onChange={() => handleEndCondition("count")}
140
+ />
141
+ After
142
+ <input
143
+ type="number"
144
+ min={1}
145
+ className={`${INPUT_CLASS} w-16`}
146
+ value={value.count ?? 10}
147
+ disabled={endConditionOf(value) !== "count"}
148
+ onChange={(e) => update({ count: Number(e.target.value) })}
149
+ aria-label="Number of occurrences"
150
+ />
151
+ occurrences
152
+ </label>
153
+ <label className="flex items-center gap-2">
154
+ <input
155
+ type="radio"
156
+ name="recurrence-end"
157
+ aria-label="On"
158
+ checked={endConditionOf(value) === "until"}
159
+ onChange={() => handleEndCondition("until")}
160
+ />
161
+ On
162
+ <input
163
+ type="date"
164
+ className={INPUT_CLASS}
165
+ value={value.until ? value.until.slice(0, 10) : ""}
166
+ disabled={endConditionOf(value) !== "until"}
167
+ onChange={(e) => update({ until: new Date(e.target.value).toISOString() })}
168
+ aria-label="End date"
169
+ />
170
+ </label>
171
+ </fieldset>
172
+
173
+ {((value.freq !== "weekly" || (value.byDay ?? []).length > 0)) && (
174
+ <p className="text-xs text-text-muted">Repeats {describeRecurrence(value)}.</p>
175
+ )}
176
+ </div>
177
+ )}
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,80 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { RefObject, useEffect, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { Mailbox, listResourceMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
8
+ import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
9
+
10
+ export interface ResourcePickerProps {
11
+ anchorRef: RefObject<HTMLElement | null>;
12
+ onClose: () => void;
13
+ onSelect: (mailbox: Mailbox) => void;
14
+ /** Addresses already added as attendees (lowercased by the caller) — excluded from the list so the
15
+ * same room/equipment mailbox can't be picked twice. */
16
+ excludeAddresses: string[];
17
+ }
18
+
19
+ /**
20
+ * A searchable dropdown of bookable resource mailboxes (rooms/equipment), for adding one as a `"resource"`
21
+ * attendee in `EventModal` without typing its raw address. Fetches the caller's full visible resource-mailbox
22
+ * list once on open and filters it client-side by name/address as the reader types — the same
23
+ * "fetch-flat-list, filter-client-side" contract every other list endpoint in this codebase already uses
24
+ * (see `calendarApi.ts`'s `listCalendarEvents` doc comment), since there is no dedicated server-side search
25
+ * endpoint for mailboxes and the resource-mailbox count in any one org is expected to be small.
26
+ */
27
+ export default function ResourcePicker({ anchorRef, onClose, onSelect, excludeAddresses }: ResourcePickerProps) {
28
+ const [resources, setResources] = useState<Mailbox[] | null>(null);
29
+ const [error, setError] = useState<string | null>(null);
30
+ const [query, setQuery] = useState("");
31
+
32
+ useEffect(() => {
33
+ listResourceMailboxes({ limit: 100 })
34
+ .then(setResources)
35
+ .catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load resources."));
36
+ }, []);
37
+
38
+ const excluded = new Set(excludeAddresses);
39
+ const filtered = (resources ?? []).filter((mailbox) => {
40
+ if (excluded.has(mailbox.primarySmtpAddress.toLowerCase())) {
41
+ return false;
42
+ }
43
+ const haystack = `${mailbox.displayName} ${mailbox.primarySmtpAddress}`.toLowerCase();
44
+ return haystack.includes(query.trim().toLowerCase());
45
+ });
46
+
47
+ return (
48
+ <PopoverPortal anchorRef={anchorRef} onClose={onClose} width={280} height={320} aria-label="Add a room or equipment resource">
49
+ <input
50
+ type="text"
51
+ autoFocus
52
+ placeholder="Search rooms & equipment…"
53
+ className="text-sm py-2 px-3 border-b border-border bg-surface text-text focus:outline-none"
54
+ value={query}
55
+ onChange={(e) => setQuery(e.target.value)}
56
+ />
57
+ <div className="flex-1 overflow-y-auto">
58
+ {error && <p className="text-xs text-danger p-3">{error}</p>}
59
+ {!error && resources === null && <p className="text-xs text-text-muted p-3">Loading&hellip;</p>}
60
+ {!error && resources !== null && filtered.length === 0 && (
61
+ <p className="text-xs text-text-muted p-3">No matching resources.</p>
62
+ )}
63
+ {filtered.map((mailbox) => (
64
+ <button
65
+ key={mailbox.uid}
66
+ type="button"
67
+ onClick={() => onSelect(mailbox)}
68
+ className="w-full text-left px-3 py-2 text-sm hover:bg-surface-alt flex items-center justify-between gap-2"
69
+ >
70
+ <span className="truncate">{mailbox.displayName}</span>
71
+ <span className="text-xs text-text-muted shrink-0 capitalize">
72
+ {mailbox.resourceType ?? "room"}
73
+ {mailbox.resourceCapacity ? ` · ${mailbox.resourceCapacity}` : ""}
74
+ </span>
75
+ </button>
76
+ ))}
77
+ </div>
78
+ </PopoverPortal>
79
+ );
80
+ }
@@ -0,0 +1,137 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { addMinutes, format, isSameDay, startOfDay } from "date-fns";
7
+ import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
8
+
9
+ const HOUR_HEIGHT_PX = 48;
10
+ const SLOT_MINUTES = 30;
11
+ const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
12
+ const HOURS = Array.from({ length: 24 }, (_, h) => h);
13
+
14
+ export interface SplitDayColumn {
15
+ folderUid: string;
16
+ name: string;
17
+ color: string;
18
+ }
19
+
20
+ export interface SplitDayViewProps {
21
+ day: Date;
22
+ columns: SplitDayColumn[];
23
+ /** Already expanded for `day` — see `recurrence.ts`'s `expandAllOccurrences`. Not pre-filtered by
24
+ * calendar; this component splits them into columns itself by `occurrence.folderUid`. */
25
+ occurrences: CalendarOccurrence[];
26
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
27
+ /** Click on an empty slot in one calendar's column — the calendar it belongs to is `folderUid`. */
28
+ onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
29
+ }
30
+
31
+ /**
32
+ * Outlook's "Split" view: one column per checked calendar, all showing the same single day, so
33
+ * events across calendars can be compared side by side. A deliberately separate component from
34
+ * `TimeGridView` rather than a generalization of it — it shares that component's visual language
35
+ * (same hour rail, same slot height) but is genuinely simpler in one respect: no drag-to-move/resize.
36
+ * Dragging an event between calendar columns would mean reassigning its `folderUid`, a real feature
37
+ * `moveOccurrence`/`resolveDragAction` don't support today; rather than bolt that on, this view is
38
+ * click-only (select an event, or click a slot to create one already targeted at that column's
39
+ * calendar) — a practical scope cut, not an oversight.
40
+ */
41
+ export default function SplitDayView({ day, columns, occurrences, onSelectEvent, onSelectSlot }: SplitDayViewProps) {
42
+ const dayOccurrences = occurrences.filter((occ) => isSameDay(new Date(occ.startDate), day));
43
+ const dayStart = startOfDay(day);
44
+
45
+ return (
46
+ <div role="region" aria-label="Split view" className="flex-1 flex flex-col min-h-0 overflow-y-auto">
47
+ <div className="flex border-b border-border shrink-0">
48
+ <div className="w-14 shrink-0" />
49
+ {columns.map((col) => (
50
+ <div key={col.folderUid} className="flex-1 min-w-0 py-1 flex items-center justify-center gap-1.5 border-l border-border">
51
+ <span aria-hidden="true" className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: col.color }} />
52
+ <span className="text-xs font-semibold truncate">{col.name}</span>
53
+ </div>
54
+ ))}
55
+ </div>
56
+ <div className="flex flex-1">
57
+ <div className="w-14 shrink-0">
58
+ {HOURS.map((hour) => (
59
+ <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
60
+ {hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
61
+ </div>
62
+ ))}
63
+ </div>
64
+ {columns.map((col) => (
65
+ <SplitColumn
66
+ key={col.folderUid}
67
+ column={col}
68
+ dayStart={dayStart}
69
+ occurrences={dayOccurrences.filter((occ) => occ.folderUid === col.folderUid)}
70
+ onSelectEvent={onSelectEvent}
71
+ onSelectSlot={onSelectSlot}
72
+ />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function SplitColumn({
80
+ column,
81
+ dayStart,
82
+ occurrences,
83
+ onSelectEvent,
84
+ onSelectSlot,
85
+ }: {
86
+ column: SplitDayColumn;
87
+ dayStart: Date;
88
+ occurrences: CalendarOccurrence[];
89
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
90
+ onSelectSlot: (start: Date, end: Date, folderUid: string) => void;
91
+ }) {
92
+ return (
93
+ <div className="flex-1 min-w-0 relative border-l border-border">
94
+ {Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
95
+ const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
96
+ return (
97
+ <button
98
+ key={i}
99
+ type="button"
100
+ onClick={() => onSelectSlot(slotStart, addMinutes(slotStart, SLOT_MINUTES), column.folderUid)}
101
+ style={{ height: HOUR_HEIGHT_PX / 2 }}
102
+ className="block w-full border-b border-border/50 text-left"
103
+ aria-label={`New event at ${format(slotStart, "h:mm a")} in ${column.name}`}
104
+ />
105
+ );
106
+ })}
107
+ {occurrences.map((occurrence) => {
108
+ const start = new Date(occurrence.startDate);
109
+ const end = new Date(occurrence.endDate);
110
+ const top = ((start.getTime() - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
111
+ const height = Math.max(((end.getTime() - start.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
112
+ const isFree = occurrence.busyStatus === "free";
113
+ return (
114
+ <div
115
+ key={occurrence.occurrenceKey}
116
+ onClick={() => onSelectEvent(occurrence)}
117
+ style={{
118
+ position: "absolute",
119
+ top,
120
+ height,
121
+ left: 2,
122
+ right: 2,
123
+ ...(isFree ? undefined : { backgroundColor: column.color, color: "#fff" }),
124
+ }}
125
+ className={[
126
+ "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
127
+ isFree ? "bg-surface-alt text-text-muted" : "",
128
+ ].join(" ")}
129
+ >
130
+ <div className="font-medium truncate">{occurrence.title}</div>
131
+ <div className="truncate">{format(start, "h:mma")}</div>
132
+ </div>
133
+ );
134
+ })}
135
+ </div>
136
+ );
137
+ }
@@ -0,0 +1,198 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
7
+ import { addMinutes, format, isSameDay, isToday, startOfDay } from "date-fns";
8
+ import { dayDropId, eventDragId, resizeDragId, slotDropId } from "@rapidmx/react-shared/calendar/calendarDragIds.js";
9
+ import { CalendarOccurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
10
+
11
+ const HOUR_HEIGHT_PX = 48;
12
+ const SLOT_MINUTES = 30;
13
+ const SLOTS_PER_DAY = (24 * 60) / SLOT_MINUTES;
14
+ const HOURS = Array.from({ length: 24 }, (_, h) => h);
15
+
16
+ export interface TimeGridViewProps {
17
+ /** One day for the Day view, seven for the Week view, five for the Work Week view. */
18
+ days: Date[];
19
+ /** Already expanded for the visible range — see `recurrence.ts`'s `expandAllOccurrences`. */
20
+ occurrences: CalendarOccurrence[];
21
+ /** Each occurrence's own calendar color, keyed by `folderUid` — see `calendarColors.ts`. */
22
+ folderColors: Record<string, string>;
23
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
24
+ /** Click on an empty slot — start/end default to a 30-minute block there, for the "New event" flow. */
25
+ onSelectSlot: (start: Date, end: Date) => void;
26
+ }
27
+
28
+ /** An hourly time grid (Week, Work Week, or Day view — they share all their rendering/drag logic and
29
+ * differ only in how many day columns are shown), with a day-header row and all-day events in a
30
+ * header strip above it. */
31
+ export default function TimeGridView({ days, occurrences, folderColors, onSelectEvent, onSelectSlot }: TimeGridViewProps) {
32
+ const allDayEvents = occurrences.filter((occ) => occ.allDay);
33
+ const timedEvents = occurrences.filter((occ) => !occ.allDay);
34
+
35
+ return (
36
+ <div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
37
+ <div className="flex border-b border-border shrink-0">
38
+ <div className="w-14 shrink-0" />
39
+ {days.map((day) => (
40
+ <div
41
+ key={day.toISOString()}
42
+ className={["flex-1 min-w-0 py-1 text-center text-xs font-semibold border-l border-border", isToday(day) ? "text-primary-dark" : ""].join(" ")}
43
+ >
44
+ {format(day, "EEE d")}
45
+ </div>
46
+ ))}
47
+ </div>
48
+ {allDayEvents.length > 0 && (
49
+ <div className="flex border-b border-border shrink-0">
50
+ <div className="w-14 shrink-0" />
51
+ {days.map((day) => (
52
+ <div key={day.toISOString()} className="flex-1 min-w-0 p-1 flex flex-col gap-0.5 border-l border-border">
53
+ {allDayEvents
54
+ .filter((occ) => isSameDay(new Date(occ.startDate), day))
55
+ .map((occ) => (
56
+ <button
57
+ key={occ.occurrenceKey}
58
+ type="button"
59
+ onClick={() => onSelectEvent(occ)}
60
+ style={{ backgroundColor: folderColors[occ.folderUid], color: "#fff" }}
61
+ className="text-xs text-left truncate rounded-sm px-1.5 py-0.5"
62
+ >
63
+ {occ.title}
64
+ </button>
65
+ ))}
66
+ </div>
67
+ ))}
68
+ </div>
69
+ )}
70
+ <div className="flex flex-1">
71
+ <div className="w-14 shrink-0">
72
+ {HOURS.map((hour) => (
73
+ <div key={hour} style={{ height: HOUR_HEIGHT_PX }} className="text-[10px] text-text-muted text-right pr-1.5 -mt-1.5">
74
+ {hour === 0 ? "" : format(new Date(2000, 0, 1, hour), "ha")}
75
+ </div>
76
+ ))}
77
+ </div>
78
+ {days.map((day) => (
79
+ <DayColumn
80
+ key={day.toISOString()}
81
+ day={day}
82
+ occurrences={timedEvents.filter((occ) => isSameDay(new Date(occ.startDate), day))}
83
+ folderColors={folderColors}
84
+ onSelectEvent={onSelectEvent}
85
+ onSelectSlot={onSelectSlot}
86
+ />
87
+ ))}
88
+ </div>
89
+ </div>
90
+ );
91
+ }
92
+
93
+ interface DayColumnProps {
94
+ day: Date;
95
+ occurrences: CalendarOccurrence[];
96
+ folderColors: Record<string, string>;
97
+ onSelectEvent: (occurrence: CalendarOccurrence) => void;
98
+ onSelectSlot: (start: Date, end: Date) => void;
99
+ }
100
+
101
+ function DayColumn({ day, occurrences, folderColors, onSelectEvent, onSelectSlot }: DayColumnProps) {
102
+ const dayStart = startOfDay(day);
103
+
104
+ return (
105
+ <div className={["flex-1 min-w-0 relative border-l border-border", isToday(day) ? "bg-primary/5" : ""].join(" ")}>
106
+ {Array.from({ length: SLOTS_PER_DAY }, (_, i) => {
107
+ const slotStart = addMinutes(dayStart, i * SLOT_MINUTES);
108
+ return <TimeSlot key={i} start={slotStart} onSelectSlot={onSelectSlot} />;
109
+ })}
110
+ {occurrences.map((occurrence) => (
111
+ <EventBlock
112
+ key={occurrence.occurrenceKey}
113
+ occurrence={occurrence}
114
+ color={folderColors[occurrence.folderUid]}
115
+ dayStart={dayStart}
116
+ onSelect={onSelectEvent}
117
+ />
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ function TimeSlot({ start, onSelectSlot }: { start: Date; onSelectSlot: (start: Date, end: Date) => void }) {
124
+ const { setNodeRef, isOver } = useDroppable({ id: slotDropId(start) });
125
+ return (
126
+ <button
127
+ ref={setNodeRef}
128
+ type="button"
129
+ onClick={() => onSelectSlot(start, addMinutes(start, SLOT_MINUTES))}
130
+ style={{ height: HOUR_HEIGHT_PX / 2 }}
131
+ className={["block w-full border-b border-border/50 text-left", isOver ? "bg-primary/10" : ""].join(" ")}
132
+ aria-label={`New event at ${format(start, "h:mm a, MMM d")}`}
133
+ />
134
+ );
135
+ }
136
+
137
+ function EventBlock({
138
+ occurrence,
139
+ color,
140
+ dayStart,
141
+ onSelect,
142
+ }: {
143
+ occurrence: CalendarOccurrence;
144
+ /** This occurrence's calendar color — see `calendarColors.ts`. Only applied for a "busy" block; a
145
+ * "free" one keeps Outlook's own muted/outline treatment regardless of which calendar it's on. */
146
+ color: string;
147
+ dayStart: Date;
148
+ onSelect: (occurrence: CalendarOccurrence) => void;
149
+ }) {
150
+ const { setNodeRef, listeners, attributes, transform, isDragging } = useDraggable({ id: eventDragId(occurrence) });
151
+ const { setNodeRef: setResizeRef, listeners: resizeListeners, attributes: resizeAttributes } = useDraggable({
152
+ id: resizeDragId(occurrence),
153
+ });
154
+ const isFree = occurrence.busyStatus === "free";
155
+
156
+ const start = new Date(occurrence.startDate);
157
+ const end = new Date(occurrence.endDate);
158
+ const top = ((start.getTime() - dayStart.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX;
159
+ // The resize handle's own drag position has no live pixel preview (it only applies its ns-resize
160
+ // affordance) — the actual new end time is computed from whichever slot it's dropped onto (see
161
+ // `resolveDragAction`'s "resize" case), snapped to the grid rather than following the pointer
162
+ // continuously. Simpler, and avoids a visual preview that could disagree with the snapped result.
163
+ const height = Math.max(((end.getTime() - start.getTime()) / 60_000 / 60) * HOUR_HEIGHT_PX, 16);
164
+
165
+ return (
166
+ <div
167
+ ref={setNodeRef}
168
+ onClick={() => onSelect(occurrence)}
169
+ style={{
170
+ position: "absolute",
171
+ top,
172
+ height,
173
+ left: 2,
174
+ right: 2,
175
+ transform: transform ? `translate(${transform.x}px, ${transform.y}px)` : undefined,
176
+ zIndex: isDragging ? 10 : undefined,
177
+ ...(isFree ? undefined : { backgroundColor: color, color: "#fff" }),
178
+ }}
179
+ className={[
180
+ "rounded-sm px-1.5 py-0.5 text-xs text-left overflow-hidden cursor-pointer",
181
+ isFree ? "bg-surface-alt text-text-muted" : "",
182
+ isDragging ? "opacity-50" : "",
183
+ ].join(" ")}
184
+ {...listeners}
185
+ {...attributes}
186
+ >
187
+ <div className="font-medium truncate">{occurrence.title}</div>
188
+ <div className="truncate">{format(start, "h:mma")}</div>
189
+ <div
190
+ ref={setResizeRef}
191
+ className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize"
192
+ aria-label={`Resize "${occurrence.title}"`}
193
+ {...resizeListeners}
194
+ {...resizeAttributes}
195
+ />
196
+ </div>
197
+ );
198
+ }