@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,242 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import { Contact, ContactList, createContactList, listContactLists } from "@rapidmx/react-shared/contacts/contactsApi.js";
9
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
10
+
11
+ export type ContactsView =
12
+ | { type: "all" }
13
+ | { type: "favorites" }
14
+ | { type: "list"; uid: string; name: string }
15
+ | { type: "deleted" }
16
+ | { type: "category"; name: string };
17
+
18
+ export function contactsViewKey(view: ContactsView): string {
19
+ switch (view.type) {
20
+ case "list":
21
+ return `list:${view.uid}`;
22
+ case "category":
23
+ return `category:${view.name}`;
24
+ default:
25
+ return view.type;
26
+ }
27
+ }
28
+
29
+ /** A fixed palette of colors for category swatches, cycled by name — Outlook's own categories are
30
+ * user-assigned colors, but nothing server-side models a color for a category (it's a plain string label
31
+ * on `Contact.categories`), so this is a deterministic client-side stand-in, same idea as `ContactAvatar`'s
32
+ * per-name color. */
33
+ const CATEGORY_PALETTE = ["#dc2626", "#d97706", "#059669", "#2563eb", "#7c3aed", "#db2777"];
34
+
35
+ function categoryColor(name: string): string {
36
+ let hash = 0;
37
+ for (let i = 0; i < name.length; i++) {
38
+ hash = (hash * 31 + name.charCodeAt(i)) | 0;
39
+ }
40
+ return CATEGORY_PALETTE[Math.abs(hash) % CATEGORY_PALETTE.length];
41
+ }
42
+
43
+ export interface ContactsSidebarProps {
44
+ mailboxUid?: string;
45
+ /** The caller's own (non-deleted) contacts — used to compute counts and the distinct category list.
46
+ * Deliberately not fetched again here; the parent already has this loaded for the main table. */
47
+ contacts: Contact[];
48
+ active: ContactsView;
49
+ onSelect: (view: ContactsView) => void;
50
+ /** Bumped by the parent whenever a contact-list membership changes, so a newly-created/renamed list
51
+ * shows up without a full page reload. */
52
+ refreshToken?: number;
53
+ }
54
+
55
+ function NavItem({
56
+ label,
57
+ count,
58
+ active,
59
+ onClick,
60
+ swatch,
61
+ }: {
62
+ label: string;
63
+ count?: number;
64
+ active: boolean;
65
+ onClick: () => void;
66
+ swatch?: string;
67
+ }) {
68
+ return (
69
+ <button
70
+ type="button"
71
+ onClick={onClick}
72
+ aria-current={active ? "true" : undefined}
73
+ className={[
74
+ "w-full flex items-center justify-between gap-2 text-sm text-left px-2.5 py-1.5 rounded-sm",
75
+ active ? "bg-primary/10 text-primary-dark font-semibold" : "text-text hover:bg-surface-alt",
76
+ ].join(" ")}
77
+ >
78
+ <span className="flex items-center gap-2 min-w-0">
79
+ {swatch && <span aria-hidden="true" className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: swatch }} />}
80
+ <span className="truncate">{label}</span>
81
+ </span>
82
+ {count !== undefined && <span className="text-xs text-text-muted shrink-0">{count}</span>}
83
+ </button>
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
89
+ * lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
90
+ * distinct `categories` values across the loaded contacts — no separate category registry exists
91
+ * server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
92
+ * `ContactsShellContext` itself minimal (mailbox/folder resolution only).
93
+ */
94
+ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken }: ContactsSidebarProps) {
95
+ const [lists, setLists] = useState<ContactList[]>([]);
96
+ const [listsError, setListsError] = useState<string | null>(null);
97
+ const [addingList, setAddingList] = useState(false);
98
+ const [newListName, setNewListName] = useState("");
99
+ const [drawerOpen, setDrawerOpen] = useState(false);
100
+
101
+ useEffect(() => {
102
+ if (!mailboxUid) {
103
+ setLists([]);
104
+ return;
105
+ }
106
+ setListsError(null);
107
+ listContactLists(mailboxUid, { limit: 200 })
108
+ .then(setLists)
109
+ .catch((err) => setListsError(err instanceof ApiRequestError ? err.message : "Could not load contact lists."));
110
+ }, [mailboxUid, refreshToken]);
111
+
112
+ const favoriteCount = useMemo(() => contacts.filter((c) => c.favorite).length, [contacts]);
113
+ const listCounts = useMemo(() => {
114
+ const counts = new Map<string, number>();
115
+ for (const c of contacts) {
116
+ if (c.contactListUid) {
117
+ counts.set(c.contactListUid, (counts.get(c.contactListUid) ?? 0) + 1);
118
+ }
119
+ }
120
+ return counts;
121
+ }, [contacts]);
122
+ const categories = useMemo(() => {
123
+ const names = new Set<string>();
124
+ for (const c of contacts) {
125
+ for (const category of c.categories ?? []) {
126
+ names.add(category);
127
+ }
128
+ }
129
+ return Array.from(names).sort((a, b) => a.localeCompare(b));
130
+ }, [contacts]);
131
+
132
+ async function handleAddList(e: FormEvent) {
133
+ e.preventDefault();
134
+ if (!mailboxUid || !newListName.trim()) {
135
+ return;
136
+ }
137
+ try {
138
+ const created = await createContactList({ mailboxUid, name: newListName.trim() });
139
+ setLists((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
140
+ setNewListName("");
141
+ setAddingList(false);
142
+ } catch (err) {
143
+ setListsError(err instanceof ApiRequestError ? err.message : "Could not create this list.");
144
+ }
145
+ }
146
+
147
+ function handleSelect(view: ContactsView) {
148
+ onSelect(view);
149
+ setDrawerOpen(false);
150
+ }
151
+
152
+ // A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
153
+ // `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
154
+ // behind the `idPrefix`).
155
+ const navContent = (idPrefix: string) => (
156
+ <>
157
+ <div className="flex flex-col gap-0.5">
158
+ <NavItem label="Your contacts" count={contacts.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
159
+ <NavItem label="Favorites" count={favoriteCount} active={active.type === "favorites"} onClick={() => handleSelect({ type: "favorites" })} />
160
+ <NavItem label="Deleted" active={active.type === "deleted"} onClick={() => handleSelect({ type: "deleted" })} />
161
+ </div>
162
+
163
+ <div>
164
+ <div className="flex items-center justify-between px-2.5 mb-1">
165
+ <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted">Your contact lists</h2>
166
+ <button
167
+ type="button"
168
+ onClick={() => setAddingList(true)}
169
+ aria-label="New contact list"
170
+ className="text-xs font-bold text-primary-dark hover:underline"
171
+ >
172
+ +
173
+ </button>
174
+ </div>
175
+ {listsError && <p className="px-2.5 text-xs text-danger">{listsError}</p>}
176
+ <div className="flex flex-col gap-0.5">
177
+ {lists.map((list) => (
178
+ <NavItem
179
+ key={list.uid}
180
+ label={list.name}
181
+ count={listCounts.get(list.uid) ?? 0}
182
+ active={active.type === "list" && active.uid === list.uid}
183
+ onClick={() => handleSelect({ type: "list", uid: list.uid, name: list.name })}
184
+ />
185
+ ))}
186
+ </div>
187
+ {addingList && (
188
+ <form onSubmit={handleAddList} className="flex items-center gap-1 px-2.5 mt-1">
189
+ <input
190
+ type="text"
191
+ autoFocus
192
+ aria-label="New list name"
193
+ id={`${idPrefix}-new-list-name`}
194
+ value={newListName}
195
+ onChange={(e) => setNewListName(e.target.value)}
196
+ className="flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface"
197
+ />
198
+ <button type="submit" className="text-xs font-semibold text-primary-dark">
199
+ Add
200
+ </button>
201
+ </form>
202
+ )}
203
+ </div>
204
+
205
+ {categories.length > 0 && (
206
+ <div>
207
+ <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1">Categories</h2>
208
+ <div className="flex flex-col gap-0.5">
209
+ {categories.map((name) => (
210
+ <NavItem
211
+ key={name}
212
+ label={name}
213
+ swatch={categoryColor(name)}
214
+ active={active.type === "category" && active.name === name}
215
+ onClick={() => handleSelect({ type: "category", name })}
216
+ />
217
+ ))}
218
+ </div>
219
+ </div>
220
+ )}
221
+ </>
222
+ );
223
+
224
+ return (
225
+ <>
226
+ <button
227
+ type="button"
228
+ className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
229
+ aria-label="Open contacts menu"
230
+ onClick={() => setDrawerOpen(true)}
231
+ >
232
+ <HiOutlineBars3 size={20} aria-hidden="true" />
233
+ </button>
234
+ <nav aria-label="Contacts" className="hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto">
235
+ {navContent("desktop")}
236
+ </nav>
237
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Contacts">
238
+ <div className="flex flex-col gap-4">{navContent("mobile")}</div>
239
+ </Drawer>
240
+ </>
241
+ );
242
+ }
@@ -0,0 +1,115 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { ChangeEvent, useRef } from "react";
6
+ import type { IconType } from "react-icons";
7
+ import {
8
+ HiOutlineArrowDownTray,
9
+ HiOutlineArrowUpTray,
10
+ HiOutlineEnvelope,
11
+ HiOutlinePencil,
12
+ HiOutlineStar,
13
+ HiOutlineTag,
14
+ HiOutlineTrash,
15
+ HiOutlineUserPlus,
16
+ HiStar,
17
+ } from "react-icons/hi2";
18
+
19
+ export interface ContactsToolbarProps {
20
+ selectedCount: number;
21
+ /** `true` only when every currently-selected contact is already favorited — flips the "favorite" button
22
+ * to act (and read) as a removal instead of an addition. */
23
+ allSelectedFavorited: boolean;
24
+ onNewContact: () => void;
25
+ onEdit: () => void;
26
+ onDelete: () => void;
27
+ onEmail: () => void;
28
+ onToggleFavorite: () => void;
29
+ onAddCategory: () => void;
30
+ onExportVCard: () => void;
31
+ /** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
32
+ * page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
33
+ onImportFile: (file: File) => void;
34
+ }
35
+
36
+ function ToolbarButton({
37
+ label,
38
+ icon: Icon,
39
+ disabled,
40
+ onClick,
41
+ }: {
42
+ label: string;
43
+ icon: IconType;
44
+ disabled?: boolean;
45
+ onClick: () => void;
46
+ }) {
47
+ return (
48
+ <button
49
+ type="button"
50
+ onClick={onClick}
51
+ disabled={disabled}
52
+ className="flex flex-col items-center gap-1 text-xs text-text-muted hover:text-text disabled:opacity-40 disabled:cursor-not-allowed px-2.5 py-1.5 rounded-sm hover:not-disabled:bg-surface-alt"
53
+ >
54
+ <Icon size={18} aria-hidden="true" />
55
+ <span>{label}</span>
56
+ </button>
57
+ );
58
+ }
59
+
60
+ function Divider() {
61
+ return <div className="w-px self-stretch my-1 bg-border" aria-hidden="true" />;
62
+ }
63
+
64
+ /**
65
+ * Contacts' Outlook-style ribbon toolbar. Deliberately presentational — every action is a callback the
66
+ * page (`apps/www/contacts/index.tsx`) implements, since it owns the actual contact data, the current
67
+ * selection, and the mailbox/folder context every real action (create/delete/email/favorite/category)
68
+ * needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
69
+ * "+ New list" affordance next to the "Your contact lists" heading.
70
+ */
71
+ export default function ContactsToolbar({
72
+ selectedCount,
73
+ allSelectedFavorited,
74
+ onNewContact,
75
+ onEdit,
76
+ onDelete,
77
+ onEmail,
78
+ onToggleFavorite,
79
+ onAddCategory,
80
+ onExportVCard,
81
+ onImportFile,
82
+ }: ContactsToolbarProps) {
83
+ const importInputRef = useRef<HTMLInputElement | null>(null);
84
+ const hasSelection = selectedCount > 0;
85
+
86
+ function handleImportFileChosen(e: ChangeEvent<HTMLInputElement>) {
87
+ const file = e.target.files?.[0];
88
+ e.target.value = "";
89
+ if (file) {
90
+ onImportFile(file);
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div role="toolbar" aria-label="Contacts actions" className="border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1">
96
+ <ToolbarButton label="New contact" icon={HiOutlineUserPlus} onClick={onNewContact} />
97
+ <Divider />
98
+ <ToolbarButton label="Edit" icon={HiOutlinePencil} disabled={selectedCount !== 1} onClick={onEdit} />
99
+ <ToolbarButton label="Delete" icon={HiOutlineTrash} disabled={!hasSelection} onClick={onDelete} />
100
+ <Divider />
101
+ <ToolbarButton label="Email" icon={HiOutlineEnvelope} disabled={!hasSelection} onClick={onEmail} />
102
+ <ToolbarButton
103
+ label={allSelectedFavorited ? "Unfavorite" : "Favorite"}
104
+ icon={allSelectedFavorited ? HiStar : HiOutlineStar}
105
+ disabled={!hasSelection}
106
+ onClick={onToggleFavorite}
107
+ />
108
+ <ToolbarButton label="Add category" icon={HiOutlineTag} disabled={!hasSelection} onClick={onAddCategory} />
109
+ <Divider />
110
+ <ToolbarButton label="Export" icon={HiOutlineArrowUpTray} disabled={!hasSelection} onClick={onExportVCard} />
111
+ <ToolbarButton label="Import" icon={HiOutlineArrowDownTray} onClick={() => importInputRef.current?.click()} />
112
+ <input ref={importInputRef} type="file" accept=".vcf" className="sr-only" aria-label="Import contacts file" onChange={handleImportFileChosen} />
113
+ </div>
114
+ );
115
+ }
@@ -0,0 +1,197 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
6
+ import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
8
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
9
+ import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
10
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
11
+ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
12
+ import AppShell, { AppShellProps } from "../../layout/AppShell.js";
13
+ import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
14
+
15
+ export type ContactsShellProps = Omit<AppShellProps, "active">;
16
+
17
+ export interface ContactsShellContextValue {
18
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
19
+ mailboxUid?: string;
20
+ /** The selected mailbox's single `contacts`-type folder. */
21
+ folderUid?: string;
22
+ mailboxes: Mailbox[];
23
+ }
24
+
25
+ const ContactsShellContext = createContext<ContactsShellContextValue>({ mailboxes: [] });
26
+
27
+ /** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
28
+ export function useContactsShell(): ContactsShellContextValue {
29
+ return useContext(ContactsShellContext);
30
+ }
31
+
32
+ type Status = "checking" | "error" | "ready";
33
+
34
+ /**
35
+ * The Contacts app's shell: unlike `MailShell` (a folder tree, since a mailbox has many mail folders),
36
+ * a mailbox has exactly one well-known `contacts` folder — see `BaseMailboxRoute.create()`'s eager
37
+ * provisioning in `@rapidmx/restapi`, which guarantees it exists for every mailbox. This shell's own
38
+ * sidebar is therefore just a mailbox switcher (when the caller has more than one); the actual contact
39
+ * list/detail UI is `apps/www/contacts/index.tsx`'s own content, rendered as `children` here.
40
+ */
41
+ export default function ContactsShell({
42
+ userUid,
43
+ authServerUrl,
44
+ impersonating,
45
+ impersonationBaseUrl,
46
+ trusted,
47
+ children,
48
+ }: PropsWithChildren<ContactsShellProps>) {
49
+ const [status, setStatus] = useState<Status>("checking");
50
+ const [error, setError] = useState<string | null>(null);
51
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
52
+ const [folders, setFolders] = useState<Folder[]>([]);
53
+ const [folderError, setFolderError] = useState<string | null>(null);
54
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
55
+ const [drawerOpen, setDrawerOpen] = useState(false);
56
+
57
+ useEffect(() => {
58
+ setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!userUid) {
63
+ return;
64
+ }
65
+ listMailboxes({ limit: 100 })
66
+ .then((result) => {
67
+ setMailboxes(result);
68
+ setStatus("ready");
69
+ })
70
+ .catch((err) => {
71
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
72
+ setStatus("error");
73
+ });
74
+ }, [userUid]);
75
+
76
+ const mailboxUid: string | undefined =
77
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
78
+ mailboxes[0]?.uid;
79
+
80
+ useEffect(() => {
81
+ if (!mailboxUid) {
82
+ setFolders([]);
83
+ return;
84
+ }
85
+ setFolderError(null);
86
+ listFolders(mailboxUid)
87
+ .then(setFolders)
88
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's contacts folder."));
89
+ }, [mailboxUid]);
90
+
91
+ const folderUid: string | undefined = folders.find((f) => f.type === "contacts")?.uid;
92
+
93
+ const contextValue = useMemo<ContactsShellContextValue>(
94
+ () => ({ mailboxUid, folderUid, mailboxes }),
95
+ [mailboxUid, folderUid, mailboxes],
96
+ );
97
+
98
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
99
+ // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
100
+ if (userUid && status === "ready" && !mailboxUid) {
101
+ return <MailboxProvisioning />;
102
+ }
103
+
104
+ let inner: ReactNode = null;
105
+ if (userUid && status === "checking") {
106
+ // Renders immediately (no network round trip needed) so switching into Contacts never shows a
107
+ // blank pane while `listMailboxes()` is in flight — the real sidebar (favorites/lists/categories)
108
+ // lives in `apps/www/contacts/index.tsx`'s own content, so this mimics its rough shape rather
109
+ // than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
110
+ inner = (
111
+ <div className="w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3">
112
+ <Skeleton height="h-9" />
113
+ <SkeletonList count={5} />
114
+ </div>
115
+ );
116
+ } else if (userUid && status === "error") {
117
+ inner = (
118
+ <div className="flex-1 flex items-center justify-center p-8">
119
+ <div className="w-full max-w-md">
120
+ <Alert>{error}</Alert>
121
+ </div>
122
+ </div>
123
+ );
124
+ } else if (userUid && status === "ready") {
125
+ // A function, not a plain JSX constant — see MailShell's identical comment: it's rendered twice
126
+ // (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the `<select>`'s
127
+ // `id`/its `<label>`'s `htmlFor` need a distinct value per instance to avoid a duplicate-id
128
+ // label-association break.
129
+ const sidebarContent = (idPrefix: string) => (
130
+ <>
131
+ {mailboxes.length > 1 && (
132
+ <div>
133
+ <label
134
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
135
+ htmlFor={`${idPrefix}-contacts-mailbox-switcher`}
136
+ >
137
+ Mailbox
138
+ </label>
139
+ <select
140
+ id={`${idPrefix}-contacts-mailbox-switcher`}
141
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
142
+ value={mailboxUid}
143
+ onChange={(e) => {
144
+ window.location.href = `/contacts?mailboxUid=${encodeURIComponent(e.target.value)}`;
145
+ }}
146
+ >
147
+ {mailboxes.map((mb) => (
148
+ <option key={mb.uid} value={mb.uid}>
149
+ {mb.displayName}
150
+ {mb.ownerUserUid ? "" : " (shared)"}
151
+ </option>
152
+ ))}
153
+ </select>
154
+ </div>
155
+ )}
156
+ {folderError && <Alert>{folderError}</Alert>}
157
+ </>
158
+ );
159
+
160
+ inner = (
161
+ <>
162
+ <aside className="hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3">
163
+ {sidebarContent("desktop")}
164
+ </aside>
165
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
166
+ <div className="flex flex-col gap-3">{sidebarContent("mobile")}</div>
167
+ </Drawer>
168
+ <main className="flex-1 min-w-0 flex flex-col">
169
+ {(mailboxes.length > 1 || folderError) && (
170
+ <button
171
+ type="button"
172
+ className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
173
+ aria-label="Open mailbox switcher"
174
+ onClick={() => setDrawerOpen(true)}
175
+ >
176
+ <HiOutlineBars3 size={20} aria-hidden="true" />
177
+ </button>
178
+ )}
179
+ <ContactsShellContext.Provider value={contextValue}>{children}</ContactsShellContext.Provider>
180
+ </main>
181
+ </>
182
+ );
183
+ }
184
+
185
+ return (
186
+ <AppShell
187
+ active="contacts"
188
+ userUid={userUid}
189
+ authServerUrl={authServerUrl}
190
+ impersonating={impersonating}
191
+ impersonationBaseUrl={impersonationBaseUrl}
192
+ trusted={trusted}
193
+ >
194
+ {inner}
195
+ </AppShell>
196
+ );
197
+ }