@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,211 @@
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 CalendarShellProps = Omit<AppShellProps, "active">;
16
+
17
+ export interface CalendarShellContextValue {
18
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
19
+ mailboxUid?: string;
20
+ /** The selected mailbox's first `calendar`-type folder — kept for callers that only care about a
21
+ * single calendar (e.g. as the default target for "+ New event"). */
22
+ folderUid?: string;
23
+ /** Every `calendar`-type folder for the selected mailbox — usually just one, but a mailbox can have
24
+ * several (see `CalendarListSidebar`). */
25
+ calendarFolders: Folder[];
26
+ mailboxes: Mailbox[];
27
+ /** Re-fetches this mailbox's folders (e.g. after creating a new calendar) without a full mailbox reload. */
28
+ reloadFolders: () => void;
29
+ }
30
+
31
+ const CalendarShellContext = createContext<CalendarShellContextValue>({ calendarFolders: [], mailboxes: [], reloadFolders: () => undefined });
32
+
33
+ /** Reads the mailbox/folder the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
34
+ export function useCalendarShell(): CalendarShellContextValue {
35
+ return useContext(CalendarShellContext);
36
+ }
37
+
38
+ type Status = "checking" | "error" | "ready";
39
+
40
+ /**
41
+ * The Calendar app's shell — structurally identical to `ContactsShell`/`TasksShell` (a mailbox has
42
+ * exactly one well-known `calendar` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s
43
+ * eager provisioning in `@rapidmx/restapi`), just bound to a different folder type. Unlike Contacts/
44
+ * Tasks, the actual view being looked at (month/week/day, and which one) is *not* shell state — it
45
+ * lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between views/dates must
46
+ * be instant (no full page reload) and this framework has no client-side router to make a URL-driven
47
+ * approach for that free.
48
+ */
49
+ export default function CalendarShell({
50
+ userUid,
51
+ authServerUrl,
52
+ impersonating,
53
+ impersonationBaseUrl,
54
+ trusted,
55
+ children,
56
+ }: PropsWithChildren<CalendarShellProps>) {
57
+ const [status, setStatus] = useState<Status>("checking");
58
+ const [error, setError] = useState<string | null>(null);
59
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
60
+ const [folders, setFolders] = useState<Folder[]>([]);
61
+ const [folderError, setFolderError] = useState<string | null>(null);
62
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
63
+ const [drawerOpen, setDrawerOpen] = useState(false);
64
+
65
+ useEffect(() => {
66
+ setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ if (!userUid) {
71
+ return;
72
+ }
73
+ listMailboxes({ limit: 100 })
74
+ .then((result) => {
75
+ setMailboxes(result);
76
+ setStatus("ready");
77
+ })
78
+ .catch((err) => {
79
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
80
+ setStatus("error");
81
+ });
82
+ }, [userUid]);
83
+
84
+ const mailboxUid: string | undefined =
85
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
86
+ mailboxes[0]?.uid;
87
+
88
+ const [folderRefreshToken, setFolderRefreshToken] = useState(0);
89
+
90
+ useEffect(() => {
91
+ if (!mailboxUid) {
92
+ setFolders([]);
93
+ return;
94
+ }
95
+ setFolderError(null);
96
+ listFolders(mailboxUid)
97
+ .then(setFolders)
98
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder."));
99
+ }, [mailboxUid, folderRefreshToken]);
100
+
101
+ const calendarFolders: Folder[] = useMemo(() => folders.filter((f) => f.type === "calendar"), [folders]);
102
+ const folderUid: string | undefined = calendarFolders[0]?.uid;
103
+
104
+ const contextValue = useMemo<CalendarShellContextValue>(
105
+ () => ({ mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders: () => setFolderRefreshToken((t) => t + 1) }),
106
+ [mailboxUid, folderUid, calendarFolders, mailboxes],
107
+ );
108
+
109
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
110
+ // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
111
+ if (userUid && status === "ready" && !mailboxUid) {
112
+ return <MailboxProvisioning />;
113
+ }
114
+
115
+ let inner: ReactNode = null;
116
+ if (userUid && status === "checking") {
117
+ // Renders immediately (no network round trip needed) so switching into Calendar never shows a
118
+ // blank pane while `listMailboxes()` is in flight — the actual sidebar (mini date-picker +
119
+ // calendar list) lives in `apps/www/calendar/index.tsx`'s own content, so this mimics its rough
120
+ // shape rather than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
121
+ inner = (
122
+ <div className="w-56 shrink-0 bg-surface border-r border-border p-3 flex flex-col gap-4">
123
+ <Skeleton height="h-40" className="rounded-md" />
124
+ <SkeletonList count={4} />
125
+ </div>
126
+ );
127
+ } else if (userUid && status === "error") {
128
+ inner = (
129
+ <div className="flex-1 flex items-center justify-center p-8">
130
+ <div className="w-full max-w-md">
131
+ <Alert>{error}</Alert>
132
+ </div>
133
+ </div>
134
+ );
135
+ } else if (userUid && status === "ready") {
136
+ // A function, not a plain JSX constant — see MailShell/ContactsShell's identical comment: it's
137
+ // rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
138
+ // `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
139
+ const mailboxSwitcher = (idPrefix: string) =>
140
+ mailboxes.length > 1 && (
141
+ <div>
142
+ <label
143
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
144
+ htmlFor={`${idPrefix}-calendar-mailbox-switcher`}
145
+ >
146
+ Mailbox
147
+ </label>
148
+ <select
149
+ id={`${idPrefix}-calendar-mailbox-switcher`}
150
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
151
+ value={mailboxUid}
152
+ onChange={(e) => {
153
+ window.location.href = `/calendar?mailboxUid=${encodeURIComponent(e.target.value)}`;
154
+ }}
155
+ >
156
+ {mailboxes.map((mb) => (
157
+ <option key={mb.uid} value={mb.uid}>
158
+ {mb.displayName}
159
+ {mb.ownerUserUid ? "" : " (shared)"}
160
+ </option>
161
+ ))}
162
+ </select>
163
+ </div>
164
+ );
165
+
166
+ inner = (
167
+ <>
168
+ {mailboxes.length > 1 && (
169
+ <div className="hidden md:block w-56 shrink-0 bg-surface border-r border-border p-3">{mailboxSwitcher("desktop")}</div>
170
+ )}
171
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
172
+ <div className="flex flex-col gap-3">
173
+ {mailboxSwitcher("mobile")}
174
+ {folderError && <Alert>{folderError}</Alert>}
175
+ </div>
176
+ </Drawer>
177
+ <div className="flex-1 min-w-0 flex flex-col">
178
+ {(mailboxes.length > 1 || folderError) && (
179
+ <button
180
+ type="button"
181
+ 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"
182
+ aria-label="Open mailbox switcher"
183
+ onClick={() => setDrawerOpen(true)}
184
+ >
185
+ <HiOutlineBars3 size={20} aria-hidden="true" />
186
+ </button>
187
+ )}
188
+ {folderError && (
189
+ <div className="hidden md:block p-3">
190
+ <Alert>{folderError}</Alert>
191
+ </div>
192
+ )}
193
+ <CalendarShellContext.Provider value={contextValue}>{children}</CalendarShellContext.Provider>
194
+ </div>
195
+ </>
196
+ );
197
+ }
198
+
199
+ return (
200
+ <AppShell
201
+ active="calendar"
202
+ userUid={userUid}
203
+ authServerUrl={authServerUrl}
204
+ impersonating={impersonating}
205
+ impersonationBaseUrl={impersonationBaseUrl}
206
+ trusted={trusted}
207
+ >
208
+ {inner}
209
+ </AppShell>
210
+ );
211
+ }
@@ -0,0 +1,99 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { Contact } from "@rapidmx/react-shared/contacts/contactsApi.js";
7
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
+ import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
9
+
10
+ export interface ContactDetailPaneProps {
11
+ contact: Contact;
12
+ onEdit: () => void;
13
+ onDelete: () => void;
14
+ /** Present only on the mobile detail route — renders a "back to contacts" link above the header. Absent
15
+ * on the desktop inline pane, which never navigates away (selecting a different contact just swaps
16
+ * `contact` in place). */
17
+ backHref?: string;
18
+ }
19
+
20
+ /**
21
+ * A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
22
+ * always visible alongside the contact list) and the mobile detail route
23
+ * (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
24
+ */
25
+ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }: ContactDetailPaneProps) {
26
+ return (
27
+ <div role="region" aria-label="Contact details" className="max-w-xl flex flex-col gap-5">
28
+ {backHref && (
29
+ <a href={backHref} className="text-sm text-primary-dark hover:underline">
30
+ &larr; Back to contacts
31
+ </a>
32
+ )}
33
+ <div className="flex items-start justify-between gap-4">
34
+ <div className="flex items-center gap-3">
35
+ <ContactAvatar displayName={contact.displayName} size={48} />
36
+ <div>
37
+ <h1 className="text-xl font-bold tracking-tight">{contact.displayName}</h1>
38
+ {contact.jobTitle && contact.company && (
39
+ <p className="text-sm text-text-muted mt-0.5">
40
+ {contact.jobTitle} at {contact.company}
41
+ </p>
42
+ )}
43
+ </div>
44
+ </div>
45
+ <div className="flex gap-2 shrink-0">
46
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onEdit}>
47
+ Edit
48
+ </Button>
49
+ <Button type="button" variant="secondary" className="!w-auto text-danger" onClick={onDelete}>
50
+ Delete
51
+ </Button>
52
+ </div>
53
+ </div>
54
+
55
+ <div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm">
56
+ {contact.emails.length > 0 && (
57
+ <div>
58
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Email</div>
59
+ {contact.emails.map((e, i) => (
60
+ <div key={i}>
61
+ {e.address} <span className="text-text-muted">({e.type})</span>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ )}
66
+ {contact.phones.length > 0 && (
67
+ <div>
68
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Phone</div>
69
+ {contact.phones.map((p, i) => (
70
+ <div key={i}>
71
+ {p.phoneNumber} <span className="text-text-muted">({p.type})</span>
72
+ </div>
73
+ ))}
74
+ </div>
75
+ )}
76
+ {contact.addresses.length > 0 && (
77
+ <div>
78
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Address</div>
79
+ {contact.addresses.map((a, i) => (
80
+ <div key={i}>{[a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ")}</div>
81
+ ))}
82
+ </div>
83
+ )}
84
+ {contact.categories && contact.categories.length > 0 && (
85
+ <div>
86
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Categories</div>
87
+ <div>{contact.categories.join(", ")}</div>
88
+ </div>
89
+ )}
90
+ {contact.notes && (
91
+ <div>
92
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Notes</div>
93
+ <div>{contact.notes}</div>
94
+ </div>
95
+ )}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,314 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ Contact,
9
+ ContactAddressKind,
10
+ ContactEmail,
11
+ ContactPhone,
12
+ ContactPostalAddress,
13
+ createContact,
14
+ updateContact,
15
+ } from "@rapidmx/react-shared/contacts/contactsApi.js";
16
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
17
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
18
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
19
+
20
+ const INPUT_CLASS =
21
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
22
+ const SELECT_CLASS =
23
+ "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
24
+
25
+ export interface ContactFormProps {
26
+ contact?: Contact;
27
+ mailboxUid?: string;
28
+ folderUid?: string;
29
+ onSaved: (contact: Contact) => void;
30
+ onCancel: () => void;
31
+ }
32
+
33
+ const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
34
+
35
+ /**
36
+ * A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
37
+ * mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
38
+ * desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
39
+ */
40
+ export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }: ContactFormProps) {
41
+ const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
42
+ const [givenName, setGivenName] = useState(contact?.givenName ?? "");
43
+ const [surname, setSurname] = useState(contact?.surname ?? "");
44
+ const [company, setCompany] = useState(contact?.company ?? "");
45
+ const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
46
+ const [notes, setNotes] = useState(contact?.notes ?? "");
47
+ const [favorite, setFavorite] = useState(contact?.favorite ?? false);
48
+ const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
49
+ const [emails, setEmails] = useState<ContactEmail[]>(contact?.emails ?? []);
50
+ const [phones, setPhones] = useState<ContactPhone[]>(contact?.phones ?? []);
51
+ const [address, setAddress] = useState<ContactPostalAddress | null>(contact?.addresses[0] ?? null);
52
+ const [error, setError] = useState<string | null>(null);
53
+ const [saving, setSaving] = useState(false);
54
+
55
+ function updateEmail(index: number, patch: Partial<ContactEmail>) {
56
+ setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
57
+ }
58
+ function removeEmail(index: number) {
59
+ setEmails((prev) => prev.filter((_, i) => i !== index));
60
+ }
61
+ function updatePhone(index: number, patch: Partial<ContactPhone>) {
62
+ setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
63
+ }
64
+ function removePhone(index: number) {
65
+ setPhones((prev) => prev.filter((_, i) => i !== index));
66
+ }
67
+
68
+ async function handleSubmit(e: FormEvent) {
69
+ e.preventDefault();
70
+ setError(null);
71
+
72
+ if (!displayName.trim()) {
73
+ setError("A display name is required.");
74
+ return;
75
+ }
76
+
77
+ setSaving(true);
78
+ try {
79
+ const categories = categoriesText
80
+ .split(",")
81
+ .map((c) => c.trim())
82
+ .filter(Boolean);
83
+ const input = {
84
+ displayName: displayName.trim(),
85
+ givenName: givenName.trim() || undefined,
86
+ surname: surname.trim() || undefined,
87
+ company: company.trim() || undefined,
88
+ jobTitle: jobTitle.trim() || undefined,
89
+ notes: notes.trim() || undefined,
90
+ favorite,
91
+ categories,
92
+ emails,
93
+ phones,
94
+ addresses: address ? [address] : [],
95
+ };
96
+ const saved = contact
97
+ ? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
98
+ : await createContact({ mailboxUid: mailboxUid as string, folderUid: folderUid as string, ...input });
99
+ onSaved(saved);
100
+ } catch (err) {
101
+ setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
102
+ } finally {
103
+ setSaving(false);
104
+ }
105
+ }
106
+
107
+ return (
108
+ <form onSubmit={handleSubmit} className="max-w-xl flex flex-col gap-1">
109
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
110
+
111
+ {error && <Alert>{error}</Alert>}
112
+
113
+ <FormField label="Display name" htmlFor="contact-displayName">
114
+ <input
115
+ id="contact-displayName"
116
+ type="text"
117
+ className={INPUT_CLASS}
118
+ value={displayName}
119
+ onChange={(e) => setDisplayName(e.target.value)}
120
+ />
121
+ </FormField>
122
+
123
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
124
+ <FormField label="First name" htmlFor="contact-givenName">
125
+ <input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
126
+ </FormField>
127
+ <FormField label="Last name" htmlFor="contact-surname">
128
+ <input id="contact-surname" type="text" className={INPUT_CLASS} value={surname} onChange={(e) => setSurname(e.target.value)} />
129
+ </FormField>
130
+ </div>
131
+
132
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
133
+ <FormField label="Company" htmlFor="contact-company">
134
+ <input id="contact-company" type="text" className={INPUT_CLASS} value={company} onChange={(e) => setCompany(e.target.value)} />
135
+ </FormField>
136
+ <FormField label="Job title" htmlFor="contact-jobTitle">
137
+ <input id="contact-jobTitle" type="text" className={INPUT_CLASS} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
138
+ </FormField>
139
+ </div>
140
+
141
+ <label className="flex items-center gap-2 text-sm mb-4">
142
+ <input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
143
+ Favorite
144
+ </label>
145
+
146
+ <FormField label="Categories (comma-separated)" htmlFor="contact-categories">
147
+ <input
148
+ id="contact-categories"
149
+ type="text"
150
+ className={INPUT_CLASS}
151
+ value={categoriesText}
152
+ onChange={(e) => setCategoriesText(e.target.value)}
153
+ placeholder="VIP, Work"
154
+ />
155
+ </FormField>
156
+
157
+ <FormField label="Email" htmlFor="contact-emails">
158
+ <div id="contact-emails" className="flex flex-col gap-2">
159
+ {emails.map((email, i) => (
160
+ <div key={i} className="flex gap-2">
161
+ <input
162
+ type="email"
163
+ className={`${INPUT_CLASS} flex-1`}
164
+ value={email.address}
165
+ onChange={(e) => updateEmail(i, { address: e.target.value })}
166
+ aria-label={`Email address ${i + 1}`}
167
+ />
168
+ <select
169
+ className={SELECT_CLASS}
170
+ value={email.type}
171
+ onChange={(e) => updateEmail(i, { type: e.target.value as ContactAddressKind })}
172
+ aria-label={`Email type ${i + 1}`}
173
+ >
174
+ {ADDRESS_KINDS.map((kind) => (
175
+ <option key={kind} value={kind}>
176
+ {kind}
177
+ </option>
178
+ ))}
179
+ </select>
180
+ <button type="button" onClick={() => removeEmail(i)} className="text-sm text-danger px-2" aria-label={`Remove email ${i + 1}`}>
181
+ &times;
182
+ </button>
183
+ </div>
184
+ ))}
185
+ <button
186
+ type="button"
187
+ onClick={() => setEmails((prev) => [...prev, { address: "", type: "work" }])}
188
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
189
+ >
190
+ + Add email
191
+ </button>
192
+ </div>
193
+ </FormField>
194
+
195
+ <FormField label="Phone" htmlFor="contact-phones">
196
+ <div id="contact-phones" className="flex flex-col gap-2">
197
+ {phones.map((phone, i) => (
198
+ <div key={i} className="flex gap-2">
199
+ <input
200
+ type="tel"
201
+ className={`${INPUT_CLASS} flex-1`}
202
+ value={phone.phoneNumber}
203
+ onChange={(e) => updatePhone(i, { phoneNumber: e.target.value })}
204
+ aria-label={`Phone number ${i + 1}`}
205
+ />
206
+ <select
207
+ className={SELECT_CLASS}
208
+ value={phone.type}
209
+ onChange={(e) => updatePhone(i, { type: e.target.value as ContactAddressKind })}
210
+ aria-label={`Phone type ${i + 1}`}
211
+ >
212
+ {ADDRESS_KINDS.map((kind) => (
213
+ <option key={kind} value={kind}>
214
+ {kind}
215
+ </option>
216
+ ))}
217
+ </select>
218
+ <button type="button" onClick={() => removePhone(i)} className="text-sm text-danger px-2" aria-label={`Remove phone ${i + 1}`}>
219
+ &times;
220
+ </button>
221
+ </div>
222
+ ))}
223
+ <button
224
+ type="button"
225
+ onClick={() => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }])}
226
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
227
+ >
228
+ + Add phone
229
+ </button>
230
+ </div>
231
+ </FormField>
232
+
233
+ <FormField label="Address" htmlFor="contact-address">
234
+ {address ? (
235
+ <div className="flex flex-col gap-2">
236
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
237
+ <input
238
+ type="text"
239
+ placeholder="Street"
240
+ className={INPUT_CLASS}
241
+ value={address.street ?? ""}
242
+ onChange={(e) => setAddress({ ...address, street: e.target.value })}
243
+ />
244
+ <input
245
+ type="text"
246
+ placeholder="City"
247
+ className={INPUT_CLASS}
248
+ value={address.city ?? ""}
249
+ onChange={(e) => setAddress({ ...address, city: e.target.value })}
250
+ />
251
+ <input
252
+ type="text"
253
+ placeholder="State/Province"
254
+ className={INPUT_CLASS}
255
+ value={address.state ?? ""}
256
+ onChange={(e) => setAddress({ ...address, state: e.target.value })}
257
+ />
258
+ <input
259
+ type="text"
260
+ placeholder="Postal code"
261
+ className={INPUT_CLASS}
262
+ value={address.postalCode ?? ""}
263
+ onChange={(e) => setAddress({ ...address, postalCode: e.target.value })}
264
+ />
265
+ <input
266
+ type="text"
267
+ placeholder="Country"
268
+ className={INPUT_CLASS}
269
+ value={address.country ?? ""}
270
+ onChange={(e) => setAddress({ ...address, country: e.target.value })}
271
+ />
272
+ <select
273
+ className={SELECT_CLASS}
274
+ value={address.type}
275
+ onChange={(e) => setAddress({ ...address, type: e.target.value as ContactAddressKind })}
276
+ aria-label="Address type"
277
+ >
278
+ {ADDRESS_KINDS.map((kind) => (
279
+ <option key={kind} value={kind}>
280
+ {kind}
281
+ </option>
282
+ ))}
283
+ </select>
284
+ </div>
285
+ <button type="button" onClick={() => setAddress(null)} className="self-start text-xs font-medium text-danger hover:underline">
286
+ Remove address
287
+ </button>
288
+ </div>
289
+ ) : (
290
+ <button
291
+ type="button"
292
+ onClick={() => setAddress({ type: "home" })}
293
+ className="self-start text-xs font-medium text-primary-dark hover:underline"
294
+ >
295
+ + Add address
296
+ </button>
297
+ )}
298
+ </FormField>
299
+
300
+ <FormField label="Notes" htmlFor="contact-notes">
301
+ <textarea id="contact-notes" rows={3} className={INPUT_CLASS} value={notes} onChange={(e) => setNotes(e.target.value)} />
302
+ </FormField>
303
+
304
+ <div className="flex gap-3 mt-2">
305
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
306
+ Save
307
+ </Button>
308
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onCancel}>
309
+ Cancel
310
+ </Button>
311
+ </div>
312
+ </form>
313
+ );
314
+ }