@rapidmx/web-client 0.14.0 → 0.15.1

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 (171) hide show
  1. package/apps/admin/domains/[uid].tsx +3 -2
  2. package/apps/shared/appearance/AppearanceForm.tsx +9 -1
  3. package/apps/shared/calendar/bookingPlugin.ts +100 -0
  4. package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
  5. package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
  6. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
  7. package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
  8. package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
  9. package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
  10. package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
  11. package/apps/shared/components/calendar/EventDetails.tsx +401 -0
  12. package/apps/shared/components/calendar/EventEditor.tsx +640 -0
  13. package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
  14. package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
  15. package/apps/shared/components/calendar/EventModal.tsx +126 -862
  16. package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
  17. package/apps/shared/components/calendar/EventShell.tsx +202 -0
  18. package/apps/shared/components/calendar/FindATime.tsx +379 -0
  19. package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
  20. package/apps/shared/components/calendar/MonthView.tsx +15 -3
  21. package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
  22. package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
  23. package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
  24. package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
  25. package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
  26. package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
  27. package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
  28. package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
  29. package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
  30. package/apps/shared/components/calendar/eventForm.ts +108 -0
  31. package/apps/shared/components/calendar/eventFormat.ts +183 -0
  32. package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
  33. package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
  34. package/apps/shared/components/layout/UserMenu.tsx +80 -28
  35. package/apps/shared/components/mail/ConversationList.tsx +100 -48
  36. package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
  37. package/apps/shared/components/mail/InviteCard.tsx +387 -0
  38. package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
  39. package/apps/shared/components/mail/SwipeRow.tsx +120 -0
  40. package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
  41. package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
  42. package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
  43. package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
  44. package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
  45. package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
  46. package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
  47. package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
  48. package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
  49. package/apps/shared/gestures/useEnterSlide.ts +54 -0
  50. package/apps/shared/gestures/useSwipe.ts +114 -0
  51. package/apps/shared/gestures/useSwipeSlide.ts +113 -0
  52. package/apps/shared/gestures/useWheelPaging.ts +107 -0
  53. package/apps/shared/mail/newMailNotifications.ts +171 -183
  54. package/apps/shared/mail/useNewMailNotifications.ts +178 -178
  55. package/apps/shared/notifications/preferences.ts +36 -0
  56. package/apps/shared/notifications/store.ts +560 -551
  57. package/apps/www/_routes.ts +1 -0
  58. package/apps/www/calendar/index.tsx +139 -37
  59. package/apps/www/index.tsx +273 -126
  60. package/apps/www/messages/[uid].tsx +31 -4
  61. package/apps/www/settings/profile/index.tsx +248 -0
  62. package/dist/apps/admin/domains/[uid].js +4 -2
  63. package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
  64. package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
  65. package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
  66. package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
  67. package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
  68. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
  69. package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
  70. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
  71. package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
  72. package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
  73. package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
  74. package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
  75. package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
  76. package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
  77. package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
  78. package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
  79. package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
  80. package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
  81. package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
  82. package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
  83. package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
  84. package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
  85. package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
  86. package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
  87. package/dist/apps/shared/components/calendar/EventModal.js +57 -455
  88. package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
  89. package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
  90. package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
  91. package/dist/apps/shared/components/calendar/EventShell.js +134 -0
  92. package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
  93. package/dist/apps/shared/components/calendar/FindATime.js +190 -0
  94. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
  95. package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
  96. package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
  97. package/dist/apps/shared/components/calendar/MonthView.js +11 -5
  98. package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
  99. package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
  100. package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
  101. package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
  102. package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
  103. package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
  104. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
  105. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
  106. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
  107. package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
  108. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
  109. package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
  110. package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
  111. package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
  112. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
  113. package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
  114. package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
  115. package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
  116. package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
  117. package/dist/apps/shared/components/calendar/eventForm.js +1 -0
  118. package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
  119. package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
  120. package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
  121. package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
  122. package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
  123. package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
  124. package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
  125. package/dist/apps/shared/components/layout/UserMenu.js +70 -23
  126. package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
  127. package/dist/apps/shared/components/mail/ConversationList.js +21 -4
  128. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
  129. package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
  130. package/dist/apps/shared/components/mail/InviteCard.js +150 -0
  131. package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
  132. package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
  133. package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
  134. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
  135. package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
  136. package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
  137. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
  138. package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
  139. package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
  140. package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
  141. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
  142. package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
  143. package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
  144. package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
  145. package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
  146. package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
  147. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
  148. package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
  149. package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
  150. package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
  151. package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
  152. package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
  153. package/dist/apps/shared/gestures/useSwipe.js +90 -0
  154. package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
  155. package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
  156. package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
  157. package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
  158. package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
  159. package/dist/apps/shared/mail/newMailNotifications.js +3 -12
  160. package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
  161. package/dist/apps/shared/notifications/preferences.d.ts +13 -0
  162. package/dist/apps/shared/notifications/preferences.js +36 -0
  163. package/dist/apps/shared/notifications/store.d.ts +1 -0
  164. package/dist/apps/shared/notifications/store.js +7 -23
  165. package/dist/apps/www/_routes.js +1 -0
  166. package/dist/apps/www/calendar/index.js +76 -6
  167. package/dist/apps/www/index.js +140 -39
  168. package/dist/apps/www/messages/[uid].js +13 -5
  169. package/dist/apps/www/settings/profile/index.d.ts +4 -0
  170. package/dist/apps/www/settings/profile/index.js +124 -0
  171. package/package.json +3 -3
@@ -113,7 +113,8 @@ function DomainDetailContent({ uid }: { uid: string }) {
113
113
  id="aliasOf"
114
114
  type="text"
115
115
  className={INPUT_CLASS}
116
- value={aliasInput ?? ""}
116
+ // Set in the same update as `domain` (see `handleLoaded()`), which is what this section waits for.
117
+ value={aliasInput!}
117
118
  onChange={(e) => {
118
119
  setAliasInput(e.target.value);
119
120
  setAliasSaved(false);
@@ -126,7 +127,7 @@ function DomainDetailContent({ uid }: { uid: string }) {
126
127
  type="button"
127
128
  className="!w-auto"
128
129
  loading={savingAlias}
129
- disabled={savingAlias || (aliasInput ?? "") === (domain.aliasOf ?? "")}
130
+ disabled={savingAlias || aliasInput! === (domain.aliasOf ?? "")}
130
131
  onClick={handleSaveAlias}
131
132
  >
132
133
  Save
@@ -75,7 +75,15 @@ export default function AppearanceForm() {
75
75
 
76
76
  // The kind the radio shows: chosen before it can take effect ("Image" is chosen before there is one).
77
77
  const [kind, setKind] = useState<BackgroundKind>(background?.kind ?? "none");
78
- useEffect(() => setKind(background?.kind ?? "none"), [background?.kind]);
78
+ // Follows a change of the stored kind (the server's answer, another tab, a reset) - not the first render, which `useState()` above
79
+ // already started from: that effect can run after a choice made right after the page appeared, and would put the old kind back.
80
+ const shownKind = useRef(background?.kind);
81
+ useEffect(() => {
82
+ if (shownKind.current !== background?.kind) {
83
+ shownKind.current = background?.kind;
84
+ setKind(background?.kind ?? "none");
85
+ }
86
+ }, [background?.kind]);
79
87
  const [fileError, setFileError] = useState<string | null>(null);
80
88
  const [dragging, setDragging] = useState(false);
81
89
  const [uploading, setUploading] = useState<string | null>(null);
@@ -0,0 +1,100 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The calendar's side of `@rapidmx/booking-plugin`: whether the plugin is running, and the one call the quick-create popover's
7
+ * "Appointment schedule" tab makes on it - creating a booking type (`POST /api/mail/booking-types`).
8
+ *
9
+ * The plugin ships its own typed client, but inside its own UI (`apps/shared/bookingApi.ts` of the plugin), which this package cannot import,
10
+ * so the few fields this popover writes are mirrored here. The plugin's `BaseBookingTypeRoute` is the authority: it normalizes the slug,
11
+ * assigns the uids, refuses a taken slug with a 409 and validates the availability, durations and calendar folder.
12
+ */
13
+ import { apiFetch } from "@rapidmx/react-shared/util/api.js";
14
+ import { PluginNav, isSafePluginHref } from "../plugins/pluginNav.js";
15
+
16
+ /** The id of the Settings section the booking plugin's manifest contributes (`ui.settingsSections`) - what tells the client it is running. */
17
+ export const BOOKING_SETTINGS_SECTION_ID = "booking-types";
18
+
19
+ /**
20
+ * Where the booking plugin's Settings pages are mounted (`/settings/booking-types`), or `undefined` when the plugin is not running.
21
+ *
22
+ * A server only puts a plugin's navigation into `pluginNav` while the plugin is enabled and its pages built, so its section being there is the
23
+ * client's one way of knowing (the plugin list itself is admin-only).
24
+ */
25
+ export function bookingSettingsHref(pluginNav: PluginNav | undefined): string | undefined {
26
+ const section = pluginNav?.settingsSections?.find((item) => item.id === BOOKING_SETTINGS_SECTION_ID);
27
+ return section && isSafePluginHref(section.href) ? section.href.replace(/\/+$/, "") : undefined;
28
+ }
29
+
30
+ /** The plugin's "new booking link" page for `mailboxUid` - the full form behind the popover's More options. */
31
+ export function newBookingLinkHref(settingsHref: string, mailboxUid: string): string {
32
+ return `${settingsHref}/new?mailboxUid=${encodeURIComponent(mailboxUid)}`;
33
+ }
34
+
35
+ export interface BookingAvailabilityWindow {
36
+ /** 0 (Sunday) to 6 (Saturday). */
37
+ dayOfWeek: number;
38
+ startMinute: number;
39
+ endMinute: number;
40
+ }
41
+
42
+ export type BookingLocationType = "phone" | "video" | "other";
43
+
44
+ export interface BookingMeetingType {
45
+ name: string;
46
+ durationMinutes: number;
47
+ locationOptions: { type: BookingLocationType }[];
48
+ }
49
+
50
+ export interface CreateBookingTypeInput {
51
+ mailboxUid: string;
52
+ calendarFolderUid: string;
53
+ slug: string;
54
+ name: string;
55
+ hostDisplayName: string;
56
+ meetingTypes: BookingMeetingType[];
57
+ timezone: string;
58
+ availability: BookingAvailabilityWindow[];
59
+ bufferAfterMinutes: number;
60
+ }
61
+
62
+ /** What comes back from a create: the fields the popover needs to show the link. */
63
+ export interface CreatedBookingType {
64
+ uid: string;
65
+ mailboxUid: string;
66
+ slug: string;
67
+ name: string;
68
+ }
69
+
70
+ /** Creates a booking type with the plugin's own defaults for everything the popover leaves out (an hour's notice, 30 days ahead, no approval). */
71
+ export function createBookingType(input: CreateBookingTypeInput): Promise<CreatedBookingType> {
72
+ return apiFetch("/mail/booking-types", {
73
+ method: "POST",
74
+ body: JSON.stringify({
75
+ dateOverrides: [],
76
+ bufferBeforeMinutes: 0,
77
+ minimumNoticeMinutes: 60,
78
+ bookingWindowDays: 30,
79
+ requiresApproval: false,
80
+ enabled: true,
81
+ ...input,
82
+ }),
83
+ });
84
+ }
85
+
86
+ /** A URL-safe slug for a schedule's name, the way the plugin's own `normalizeSlug()` reads it; `"appointments"` when nothing usable is left. */
87
+ export function slugFor(name: string): string {
88
+ return (
89
+ name
90
+ .trim()
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-+|-+$/g, "") || "appointments"
94
+ );
95
+ }
96
+
97
+ /** The public page of a booking type on this site's own origin (`/book/<mailbox>/<slug>`, the `@` of a mailbox uid left as it is). Browser only. */
98
+ export function bookingPublicUrl(mailboxUid: string, slug: string): string {
99
+ return `${window.location.origin}/book/${encodeURIComponent(mailboxUid).replace(/%40/g, "@")}/${encodeURIComponent(slug)}`;
100
+ }
@@ -0,0 +1,27 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /** Pure helper behind the calendar page's live updates: which push events say "an event changed, and the payload is only a busy block". */
6
+
7
+ import type { PushEvent } from "@rapidmx/react-shared/mail/pushClient.js";
8
+
9
+ /** The published model name of a calendar event on either database (`CalendarEventMongo`, `CalendarEventSQL`) - and not the reminder's `"CalendarEvent"`,
10
+ * which has its own action. */
11
+ const EVENT_MODEL = /^CalendarEvent(Mongo|SQL)$/;
12
+
13
+ /**
14
+ * The uid of the event a push event announces as a redacted busy block, or `undefined` for anything else the shared push connection delivers.
15
+ *
16
+ * When somebody creates or changes a private or confidential event, restapi publishes it on the calendar's channel once, for every subscriber - and that one
17
+ * payload has to be safe for a read-only reader of a shared calendar, so it is the busy block (`redacted: true`, title "Busy", no details). It must not be
18
+ * stored as the event by the calendar's owner (or anybody else who may see it in full): they fetch the event again by its uid, which answers with as
19
+ * much as they may see.
20
+ */
21
+ export function redactedEventUidOf(event: PushEvent): string | undefined {
22
+ if (!EVENT_MODEL.test(event.type) || (event.action !== "create" && event.action !== "update")) {
23
+ return undefined;
24
+ }
25
+ const data = event.data as { uid?: unknown; redacted?: unknown } | null | undefined;
26
+ return data?.redacted === true && typeof data.uid === "string" ? data.uid : undefined;
27
+ }
@@ -21,6 +21,7 @@ import {
21
21
  HiOutlineShieldExclamation,
22
22
  HiOutlineUserGroup,
23
23
  HiOutlineWrenchScrewdriver,
24
+ HiOutlineBars3,
24
25
  } from "react-icons/hi2";
25
26
  import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
26
27
  import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
@@ -28,7 +29,8 @@ import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session
28
29
  import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
29
30
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
30
31
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
31
- import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
32
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
33
+ import type { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
32
34
  import { FrameBrandingFooter, useBrandingHtml } from "../../layout/BrandingChrome.js";
33
35
  import RailIcon from "../../layout/RailIcon.js";
34
36
  import AppearanceProvider from "../../../appearance/AppearanceProvider.js";
@@ -184,6 +186,7 @@ export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
184
186
  export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>) {
185
187
  const [status, setStatus] = useState<Status>("checking");
186
188
  const [error, setError] = useState<string | null>(null);
189
+ const [menuOpen, setMenuOpen] = useState(false);
187
190
  // `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
188
191
  // console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
189
192
  const { branding, iconSrc } = useBranding();
@@ -321,13 +324,42 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
321
324
  </a>
322
325
  ))}
323
326
  </nav>
324
- <BottomTabBar apps={navItems} active={active} />
327
+ {/* Below `md` the sections are a menu that slides in from the left: ten or so of them don't fit a bar along the bottom. */}
328
+ <Drawer open={menuOpen} onClose={() => setMenuOpen(false)} title="Admin">
329
+ <nav aria-label="Admin menu" className="flex flex-col gap-1 -mx-2">
330
+ {navItems.map(({ id, href, label, icon: Icon }) => (
331
+ <a
332
+ key={id}
333
+ href={href}
334
+ aria-current={id === active ? "page" : undefined}
335
+ onClick={() => setMenuOpen(false)}
336
+ className={[
337
+ "flex items-center gap-3 px-3 py-2.5 rounded-sm text-sm font-medium",
338
+ id === active ? "bg-primary/10 text-primary-dark" : "text-text hover:bg-surface-alt",
339
+ ].join(" ")}
340
+ >
341
+ <Icon size={20} aria-hidden="true" />
342
+ {label}
343
+ </a>
344
+ ))}
345
+ </nav>
346
+ </Drawer>
325
347
  <div className="flex-1 flex flex-col min-w-0">
326
- <header className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
327
- <span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
348
+ <header className="rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-4 md:px-6">
349
+ <div className="flex items-center gap-2 min-w-0">
350
+ <button
351
+ type="button"
352
+ className="md:hidden shrink-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
353
+ aria-label="Open menu"
354
+ onClick={() => setMenuOpen(true)}
355
+ >
356
+ <HiOutlineBars3 size={20} aria-hidden="true" />
357
+ </button>
358
+ <span className="font-display font-bold text-lg uppercase tracking-wide truncate">{activeItem?.label}</span>
359
+ </div>
328
360
  <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
329
361
  </header>
330
- <div id="app-content" className="flex-1 pb-14 md:pb-0">
362
+ <div id="app-content" className="flex-1">
331
363
  <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
332
364
  </div>
333
365
  </div>
@@ -6,6 +6,7 @@ import React, { FormEvent, useEffect, useRef, useState } from "react";
6
6
  import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
7
  import { getMailboxPolicy } from "@rapidmx/react-shared/admin/mailboxPolicyApi.js";
8
8
  import { createMailbox, listMailboxDomains, Mailbox, resolveMailboxOwner, ResolvedPrincipal } from "@rapidmx/react-shared/mail/mailApi.js";
9
+ import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
9
10
  import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
10
11
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
11
12
  import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
@@ -36,7 +37,8 @@ export default function MailboxCreateForm({ onCreated, defaults, submitLabel = "
36
37
  const [displayName, setDisplayName] = useState(defaults?.displayName ?? "");
37
38
  const [ownerMode, setOwnerMode] = useState<"shared" | "owned">(defaults?.ownerUserUid ? "owned" : "shared");
38
39
  const [resolvedOwner, setResolvedOwner] = useState<ResolvedPrincipal | null>(null);
39
- const [timezone, setTimezone] = useState("UTC");
40
+ // The admin's own device zone, not "UTC": the server's "UTC" is only a placeholder for a mailbox nobody chose a zone for.
41
+ const [timezone, setTimezone] = useState(deviceTimeZone);
40
42
  const [quotaGb, setQuotaGb] = useState(5);
41
43
  // Only replaced by the mailbox policy's default if the admin hasn't typed a quota of their own yet.
42
44
  const quotaTouched = useRef(false);