@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,201 @@
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 { Task, TaskList, createTaskList, listTaskLists } from "@rapidmx/react-shared/tasks/tasksApi.js";
9
+ import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
10
+
11
+ export type TasksView =
12
+ | { type: "myDay" }
13
+ | { type: "important" }
14
+ | { type: "planned" }
15
+ | { type: "assignedToMe" }
16
+ | { type: "flagged" }
17
+ | { type: "all" }
18
+ | { type: "list"; uid: string; name: string };
19
+
20
+ export function tasksViewKey(view: TasksView): string {
21
+ return view.type === "list" ? `list:${view.uid}` : view.type;
22
+ }
23
+
24
+ export interface TasksSidebarProps {
25
+ mailboxUid?: string;
26
+ /** The caller's own (incomplete) tasks — used to compute every smart-filter's count. Deliberately not
27
+ * fetched again here; the parent already has this loaded for the main list. */
28
+ tasks: Task[];
29
+ userUid?: string;
30
+ active: TasksView;
31
+ onSelect: (view: TasksView) => void;
32
+ /** Bumped by the parent to force a contact-list-style re-fetch of task lists (mirrors
33
+ * `ContactsSidebar`'s identical prop) — not currently used by any caller, kept for parity/future use. */
34
+ refreshToken?: number;
35
+ }
36
+
37
+ function NavItem({ label, count, active, onClick }: { label: string; count?: number; active: boolean; onClick: () => void }) {
38
+ return (
39
+ <button
40
+ type="button"
41
+ onClick={onClick}
42
+ aria-current={active ? "true" : undefined}
43
+ className={[
44
+ "w-full flex items-center justify-between gap-2 text-sm text-left px-2.5 py-1.5 rounded-sm",
45
+ active ? "bg-primary/10 text-primary-dark font-semibold" : "text-text hover:bg-surface-alt",
46
+ ].join(" ")}
47
+ >
48
+ <span className="truncate">{label}</span>
49
+ {count !== undefined && <span className="text-xs text-text-muted shrink-0">{count}</span>}
50
+ </button>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Tasks' Outlook To-Do-style left sidebar. "My Day"/"Assigned to me" are real persisted fields
56
+ * (`Task.myDay`/`assignedTo`, from Phase 0); "Important"/"Planned" are pure smart-filters derived from
57
+ * already-loaded fields (`priority === "high"`/`dueDate != null`) with no backend concept of their own;
58
+ * "Flagged email" is a `Message` smart-filter (see `flaggedMessages.ts`), not a `Task` one at all — the
59
+ * page renders it as a distinct list, not the task table. "Tasks" is the flat, unfiltered default. Custom
60
+ * lists are real `TaskList` records, fetched here (same pattern as `ContactsSidebar`'s contact lists).
61
+ */
62
+ export default function TasksSidebar({ mailboxUid, tasks, userUid, active, onSelect, refreshToken }: TasksSidebarProps) {
63
+ const [lists, setLists] = useState<TaskList[]>([]);
64
+ const [listsError, setListsError] = useState<string | null>(null);
65
+ const [addingList, setAddingList] = useState(false);
66
+ const [newListName, setNewListName] = useState("");
67
+ const [drawerOpen, setDrawerOpen] = useState(false);
68
+
69
+ useEffect(() => {
70
+ if (!mailboxUid) {
71
+ setLists([]);
72
+ return;
73
+ }
74
+ setListsError(null);
75
+ listTaskLists(mailboxUid, { limit: 200 })
76
+ .then(setLists)
77
+ .catch((err) => setListsError(err instanceof ApiRequestError ? err.message : "Could not load task lists."));
78
+ }, [mailboxUid, refreshToken]);
79
+
80
+ const myDayCount = useMemo(() => tasks.filter((t) => t.myDay).length, [tasks]);
81
+ const importantCount = useMemo(() => tasks.filter((t) => t.priority === "high").length, [tasks]);
82
+ const plannedCount = useMemo(() => tasks.filter((t) => t.dueDate != null).length, [tasks]);
83
+ const assignedToMeCount = useMemo(() => tasks.filter((t) => t.assignedTo === userUid).length, [tasks, userUid]);
84
+ const listCounts = useMemo(() => {
85
+ const counts = new Map<string, number>();
86
+ for (const t of tasks) {
87
+ if (t.taskListUid) {
88
+ counts.set(t.taskListUid, (counts.get(t.taskListUid) ?? 0) + 1);
89
+ }
90
+ }
91
+ return counts;
92
+ }, [tasks]);
93
+
94
+ async function handleAddList(e: FormEvent) {
95
+ e.preventDefault();
96
+ if (!mailboxUid || !newListName.trim()) {
97
+ return;
98
+ }
99
+ try {
100
+ const created = await createTaskList({ mailboxUid, name: newListName.trim() });
101
+ setLists((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
102
+ setNewListName("");
103
+ setAddingList(false);
104
+ } catch (err) {
105
+ setListsError(err instanceof ApiRequestError ? err.message : "Could not create this list.");
106
+ }
107
+ }
108
+
109
+ function handleSelect(view: TasksView) {
110
+ onSelect(view);
111
+ setDrawerOpen(false);
112
+ }
113
+
114
+ // A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
115
+ // `Drawer` (see `ContactsSidebar` for the identical pattern and the duplicate-id reasoning behind
116
+ // the `idPrefix`).
117
+ const navContent = (idPrefix: string) => (
118
+ <>
119
+ <div className="flex flex-col gap-0.5">
120
+ <NavItem label="My Day" count={myDayCount} active={active.type === "myDay"} onClick={() => handleSelect({ type: "myDay" })} />
121
+ <NavItem
122
+ label="Important"
123
+ count={importantCount}
124
+ active={active.type === "important"}
125
+ onClick={() => handleSelect({ type: "important" })}
126
+ />
127
+ <NavItem label="Planned" count={plannedCount} active={active.type === "planned"} onClick={() => handleSelect({ type: "planned" })} />
128
+ <NavItem
129
+ label="Assigned to me"
130
+ count={assignedToMeCount}
131
+ active={active.type === "assignedToMe"}
132
+ onClick={() => handleSelect({ type: "assignedToMe" })}
133
+ />
134
+ <NavItem label="Flagged email" active={active.type === "flagged"} onClick={() => handleSelect({ type: "flagged" })} />
135
+ </div>
136
+
137
+ <NavItem label="Tasks" count={tasks.length} active={active.type === "all"} onClick={() => handleSelect({ type: "all" })} />
138
+
139
+ <div>
140
+ <div className="flex items-center justify-between px-2.5 mb-1">
141
+ <h2 className="text-xs font-bold uppercase tracking-wide text-text-muted">Lists</h2>
142
+ <button
143
+ type="button"
144
+ onClick={() => setAddingList(true)}
145
+ aria-label="New list"
146
+ className="text-xs font-bold text-primary-dark hover:underline"
147
+ >
148
+ +
149
+ </button>
150
+ </div>
151
+ {listsError && <p className="px-2.5 text-xs text-danger">{listsError}</p>}
152
+ <div className="flex flex-col gap-0.5">
153
+ {lists.map((list) => (
154
+ <NavItem
155
+ key={list.uid}
156
+ label={list.name}
157
+ count={listCounts.get(list.uid) ?? 0}
158
+ active={active.type === "list" && active.uid === list.uid}
159
+ onClick={() => handleSelect({ type: "list", uid: list.uid, name: list.name })}
160
+ />
161
+ ))}
162
+ </div>
163
+ {addingList && (
164
+ <form onSubmit={handleAddList} className="flex items-center gap-1 px-2.5 mt-1">
165
+ <input
166
+ type="text"
167
+ autoFocus
168
+ aria-label="New list name"
169
+ id={`${idPrefix}-new-list-name`}
170
+ value={newListName}
171
+ onChange={(e) => setNewListName(e.target.value)}
172
+ className="flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface"
173
+ />
174
+ <button type="submit" className="text-xs font-semibold text-primary-dark">
175
+ Add
176
+ </button>
177
+ </form>
178
+ )}
179
+ </div>
180
+ </>
181
+ );
182
+
183
+ return (
184
+ <>
185
+ <button
186
+ type="button"
187
+ 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"
188
+ aria-label="Open tasks menu"
189
+ onClick={() => setDrawerOpen(true)}
190
+ >
191
+ <HiOutlineBars3 size={20} aria-hidden="true" />
192
+ </button>
193
+ <nav aria-label="Tasks" className="hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto">
194
+ {navContent("desktop")}
195
+ </nav>
196
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Tasks">
197
+ <div className="flex flex-col gap-4">{navContent("mobile")}</div>
198
+ </Drawer>
199
+ </>
200
+ );
201
+ }
@@ -0,0 +1,68 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import type { IconType } from "react-icons";
7
+ import { HiOutlineCheckCircle, HiOutlineListBullet, HiOutlineSun, HiOutlineTableCells, HiOutlineTrash } from "react-icons/hi2";
8
+
9
+ export type TasksViewMode = "list" | "grid";
10
+
11
+ export interface TasksToolbarProps {
12
+ viewMode: TasksViewMode;
13
+ onViewModeChange: (mode: TasksViewMode) => void;
14
+ selectedCount: number;
15
+ onComplete: () => void;
16
+ onDelete: () => void;
17
+ onAddToMyDay: () => void;
18
+ }
19
+
20
+ function ToolbarButton({
21
+ label,
22
+ icon: Icon,
23
+ active,
24
+ disabled,
25
+ onClick,
26
+ }: {
27
+ label: string;
28
+ icon: IconType;
29
+ active?: boolean;
30
+ disabled?: boolean;
31
+ onClick: () => void;
32
+ }) {
33
+ return (
34
+ <button
35
+ type="button"
36
+ aria-label={label}
37
+ aria-pressed={active}
38
+ title={label}
39
+ disabled={disabled}
40
+ onClick={onClick}
41
+ className={[
42
+ "flex flex-col items-center gap-1 text-xs px-2.5 py-1.5 rounded-sm disabled:opacity-40 disabled:cursor-not-allowed",
43
+ active ? "bg-primary/10 text-primary-dark" : "text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text",
44
+ ].join(" ")}
45
+ >
46
+ <Icon size={18} aria-hidden="true" />
47
+ <span>{label}</span>
48
+ </button>
49
+ );
50
+ }
51
+
52
+ /** Tasks' toolbar: a Grid/List view-mode toggle (local, not persisted) plus bulk actions for whatever
53
+ * rows are checked (Complete, Add to My Day, Delete) — deliberately presentational, matching
54
+ * `ContactsToolbar`'s convention, since the page owns the actual task data/selection. */
55
+ export default function TasksToolbar({ viewMode, onViewModeChange, selectedCount, onComplete, onDelete, onAddToMyDay }: TasksToolbarProps) {
56
+ const hasSelection = selectedCount > 0;
57
+
58
+ return (
59
+ <div role="toolbar" aria-label="Tasks actions" className="border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1">
60
+ <ToolbarButton label="List" icon={HiOutlineListBullet} active={viewMode === "list"} onClick={() => onViewModeChange("list")} />
61
+ <ToolbarButton label="Grid" icon={HiOutlineTableCells} active={viewMode === "grid"} onClick={() => onViewModeChange("grid")} />
62
+ <div className="w-px self-stretch my-1 bg-border" aria-hidden="true" />
63
+ <ToolbarButton label="Complete" icon={HiOutlineCheckCircle} disabled={!hasSelection} onClick={onComplete} />
64
+ <ToolbarButton label="Add to My Day" icon={HiOutlineSun} disabled={!hasSelection} onClick={onAddToMyDay} />
65
+ <ToolbarButton label="Delete" icon={HiOutlineTrash} disabled={!hasSelection} onClick={onDelete} />
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,199 @@
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 TasksShellProps = Omit<AppShellProps, "active">;
16
+
17
+ export interface TasksShellContextValue {
18
+ /** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
19
+ mailboxUid?: string;
20
+ /** The selected mailbox's single `tasks`-type folder. */
21
+ folderUid?: string;
22
+ mailboxes: Mailbox[];
23
+ /** The caller's own uid — exposed here (rather than only as `AppShell`'s prop) so the page content
24
+ * can compute "assigned to me" without needing it threaded through separately. */
25
+ userUid?: string;
26
+ }
27
+
28
+ const TasksShellContext = createContext<TasksShellContextValue>({ mailboxes: [] });
29
+
30
+ /** Reads the mailbox/folder Tasks is currently showing, as resolved by the enclosing `TasksShell`. */
31
+ export function useTasksShell(): TasksShellContextValue {
32
+ return useContext(TasksShellContext);
33
+ }
34
+
35
+ type Status = "checking" | "error" | "ready";
36
+
37
+ /**
38
+ * The Tasks app's shell — structurally identical to `ContactsShell` (a mailbox has exactly one
39
+ * well-known `tasks` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s eager provisioning
40
+ * in `@rapidmx/restapi`), just bound to a different folder type. Kept as its own small component
41
+ * (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
42
+ * grow its own app-specific controls later without threading extra props through a shared one.
43
+ */
44
+ export default function TasksShell({
45
+ userUid,
46
+ authServerUrl,
47
+ impersonating,
48
+ impersonationBaseUrl,
49
+ trusted,
50
+ children,
51
+ }: PropsWithChildren<TasksShellProps>) {
52
+ const [status, setStatus] = useState<Status>("checking");
53
+ const [error, setError] = useState<string | null>(null);
54
+ const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
55
+ const [folders, setFolders] = useState<Folder[]>([]);
56
+ const [folderError, setFolderError] = useState<string | null>(null);
57
+ const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
58
+ const [drawerOpen, setDrawerOpen] = useState(false);
59
+
60
+ useEffect(() => {
61
+ setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
62
+ }, []);
63
+
64
+ useEffect(() => {
65
+ if (!userUid) {
66
+ return;
67
+ }
68
+ listMailboxes({ limit: 100 })
69
+ .then((result) => {
70
+ setMailboxes(result);
71
+ setStatus("ready");
72
+ })
73
+ .catch((err) => {
74
+ setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
75
+ setStatus("error");
76
+ });
77
+ }, [userUid]);
78
+
79
+ const mailboxUid: string | undefined =
80
+ (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
81
+ mailboxes[0]?.uid;
82
+
83
+ useEffect(() => {
84
+ if (!mailboxUid) {
85
+ setFolders([]);
86
+ return;
87
+ }
88
+ setFolderError(null);
89
+ listFolders(mailboxUid)
90
+ .then(setFolders)
91
+ .catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's tasks folder."));
92
+ }, [mailboxUid]);
93
+
94
+ const folderUid: string | undefined = folders.find((f) => f.type === "tasks")?.uid;
95
+
96
+ const contextValue = useMemo<TasksShellContextValue>(
97
+ () => ({ mailboxUid, folderUid, mailboxes, userUid }),
98
+ [mailboxUid, folderUid, mailboxes, userUid],
99
+ );
100
+
101
+ // A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
102
+ // for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
103
+ if (userUid && status === "ready" && !mailboxUid) {
104
+ return <MailboxProvisioning />;
105
+ }
106
+
107
+ let inner: ReactNode = null;
108
+ if (userUid && status === "checking") {
109
+ // Renders immediately (no network round trip needed) so switching into Tasks never shows a
110
+ // blank pane while `listMailboxes()` is in flight — the real sidebar (My Day/Important/Planned/
111
+ // lists) lives in `apps/www/tasks/index.tsx`'s own content, so this mimics its rough shape rather
112
+ // than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
113
+ inner = (
114
+ <aside className="w-56 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4">
115
+ <Skeleton height="h-9" />
116
+ <SkeletonList count={5} />
117
+ </aside>
118
+ );
119
+ } else if (userUid && status === "error") {
120
+ inner = (
121
+ <div className="flex-1 flex items-center justify-center p-8">
122
+ <div className="w-full max-w-md">
123
+ <Alert>{error}</Alert>
124
+ </div>
125
+ </div>
126
+ );
127
+ } else if (userUid && status === "ready") {
128
+ // A function, not a plain JSX constant — see MailShell/ContactsShell/CalendarShell's identical
129
+ // comment: it's rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously
130
+ // mounted, so the `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
131
+ const sidebarContent = (idPrefix: string) => (
132
+ <>
133
+ {mailboxes.length > 1 && (
134
+ <div>
135
+ <label
136
+ className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
137
+ htmlFor={`${idPrefix}-tasks-mailbox-switcher`}
138
+ >
139
+ Mailbox
140
+ </label>
141
+ <select
142
+ id={`${idPrefix}-tasks-mailbox-switcher`}
143
+ className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
144
+ value={mailboxUid}
145
+ onChange={(e) => {
146
+ window.location.href = `/tasks?mailboxUid=${encodeURIComponent(e.target.value)}`;
147
+ }}
148
+ >
149
+ {mailboxes.map((mb) => (
150
+ <option key={mb.uid} value={mb.uid}>
151
+ {mb.displayName}
152
+ {mb.ownerUserUid ? "" : " (shared)"}
153
+ </option>
154
+ ))}
155
+ </select>
156
+ </div>
157
+ )}
158
+ {folderError && <Alert>{folderError}</Alert>}
159
+ </>
160
+ );
161
+
162
+ inner = (
163
+ <>
164
+ <aside className="hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3">
165
+ {sidebarContent("desktop")}
166
+ </aside>
167
+ <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
168
+ <div className="flex flex-col gap-3">{sidebarContent("mobile")}</div>
169
+ </Drawer>
170
+ <main className="flex-1 min-w-0 flex flex-col">
171
+ {(mailboxes.length > 1 || folderError) && (
172
+ <button
173
+ type="button"
174
+ 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"
175
+ aria-label="Open mailbox switcher"
176
+ onClick={() => setDrawerOpen(true)}
177
+ >
178
+ <HiOutlineBars3 size={20} aria-hidden="true" />
179
+ </button>
180
+ )}
181
+ <TasksShellContext.Provider value={contextValue}>{children}</TasksShellContext.Provider>
182
+ </main>
183
+ </>
184
+ );
185
+ }
186
+
187
+ return (
188
+ <AppShell
189
+ active="tasks"
190
+ userUid={userUid}
191
+ authServerUrl={authServerUrl}
192
+ impersonating={impersonating}
193
+ impersonationBaseUrl={impersonationBaseUrl}
194
+ trusted={trusted}
195
+ >
196
+ {inner}
197
+ </AppShell>
198
+ );
199
+ }