@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.
- package/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- 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
|
+
}
|