@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,436 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
Contact,
|
|
9
|
+
createContact,
|
|
10
|
+
deleteContact,
|
|
11
|
+
listContacts,
|
|
12
|
+
listDeletedContacts,
|
|
13
|
+
setContactFavorite,
|
|
14
|
+
updateContact,
|
|
15
|
+
} from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
16
|
+
import { contactsToVCardFile, contactToVCard, parseVCards } from "@rapidmx/react-shared/contacts/vcard.js";
|
|
17
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
18
|
+
import { useCompose } from "../../shared/components/mail/compose/ComposeContext.js";
|
|
19
|
+
import ContactsShell, { ContactsShellProps, useContactsShell } from "../../shared/components/contacts/layout/ContactsShell.js";
|
|
20
|
+
import ContactsSidebar, { ContactsView } from "../../shared/components/contacts/ContactsSidebar.js";
|
|
21
|
+
import ContactsToolbar from "../../shared/components/contacts/ContactsToolbar.js";
|
|
22
|
+
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
23
|
+
import ContactDetailPane from "../../shared/components/contacts/ContactDetailPane.js";
|
|
24
|
+
import ContactForm from "../../shared/components/contacts/ContactForm.js";
|
|
25
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
|
+
|
|
27
|
+
const INPUT_CLASS =
|
|
28
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
29
|
+
|
|
30
|
+
export default function ContactsPage(props: ContactsShellProps) {
|
|
31
|
+
return (
|
|
32
|
+
<ContactsShell {...props}>
|
|
33
|
+
<ContactsContent />
|
|
34
|
+
</ContactsShell>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Mode = "view" | "edit" | "new";
|
|
39
|
+
type SortColumn = "name" | "info";
|
|
40
|
+
|
|
41
|
+
function primaryInfo(contact: Contact): string {
|
|
42
|
+
return contact.emails[0]?.address ?? contact.phones[0]?.phoneNumber ?? "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function downloadTextFile(filename: string, content: string): void {
|
|
46
|
+
const blob = new Blob([content], { type: "text/vcard" });
|
|
47
|
+
const url = URL.createObjectURL(blob);
|
|
48
|
+
const a = document.createElement("a");
|
|
49
|
+
a.href = url;
|
|
50
|
+
a.download = filename;
|
|
51
|
+
a.click();
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ContactsContent() {
|
|
56
|
+
const { folderUid, mailboxUid } = useContactsShell();
|
|
57
|
+
const { openCompose } = useCompose();
|
|
58
|
+
const isMobile = useIsMobile();
|
|
59
|
+
const [contacts, setContacts] = useState<Contact[]>([]);
|
|
60
|
+
const [deletedContacts, setDeletedContacts] = useState<Contact[]>([]);
|
|
61
|
+
const [deletedLoading, setDeletedLoading] = useState(false);
|
|
62
|
+
const [deletedError, setDeletedError] = useState<string | null>(null);
|
|
63
|
+
const [loading, setLoading] = useState(true);
|
|
64
|
+
const [error, setError] = useState<string | null>(null);
|
|
65
|
+
const [query, setQuery] = useState("");
|
|
66
|
+
const [view, setView] = useState<ContactsView>({ type: "all" });
|
|
67
|
+
const [sortColumn, setSortColumn] = useState<SortColumn>("name");
|
|
68
|
+
const [sortDesc, setSortDesc] = useState(false);
|
|
69
|
+
const [checkedUids, setCheckedUids] = useState<Set<string>>(new Set());
|
|
70
|
+
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
|
71
|
+
const [mode, setMode] = useState<Mode>("view");
|
|
72
|
+
|
|
73
|
+
/** Returns a promise so bulk actions (below) can wait for the refreshed list before re-asserting their
|
|
74
|
+
* own error message — this always clears `error` first (a legitimate reset for a fresh fetch attempt),
|
|
75
|
+
* which would otherwise silently wipe out a bulk action's own just-set failure message before the user
|
|
76
|
+
* ever saw it, since every bulk handler calls this right after its own error-setting loop. */
|
|
77
|
+
function reload(): Promise<void> {
|
|
78
|
+
if (!folderUid) {
|
|
79
|
+
setContacts([]);
|
|
80
|
+
setLoading(false);
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
setLoading(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
return listContacts(folderUid, { limit: 500 })
|
|
86
|
+
.then(setContacts)
|
|
87
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load contacts."))
|
|
88
|
+
.finally(() => setLoading(false));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
void reload();
|
|
93
|
+
}, [folderUid]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (view.type !== "deleted" || !folderUid) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
setDeletedLoading(true);
|
|
100
|
+
setDeletedError(null);
|
|
101
|
+
listDeletedContacts(folderUid, { limit: 500 })
|
|
102
|
+
.then(setDeletedContacts)
|
|
103
|
+
.catch((err) => setDeletedError(err instanceof ApiRequestError ? err.message : "Could not load deleted contacts."))
|
|
104
|
+
.finally(() => setDeletedLoading(false));
|
|
105
|
+
// Re-fetches every time the Deleted view is (re-)selected, matching this page's own `reload()`
|
|
106
|
+
// convention elsewhere rather than caching a possibly-stale list across visits.
|
|
107
|
+
}, [view, folderUid]);
|
|
108
|
+
|
|
109
|
+
const viewFiltered = useMemo(() => {
|
|
110
|
+
switch (view.type) {
|
|
111
|
+
case "all":
|
|
112
|
+
return contacts;
|
|
113
|
+
case "favorites":
|
|
114
|
+
return contacts.filter((c) => c.favorite);
|
|
115
|
+
case "list":
|
|
116
|
+
return contacts.filter((c) => c.contactListUid === view.uid);
|
|
117
|
+
case "category":
|
|
118
|
+
return contacts.filter((c) => (c.categories ?? []).includes(view.name));
|
|
119
|
+
case "deleted":
|
|
120
|
+
return deletedContacts;
|
|
121
|
+
}
|
|
122
|
+
}, [contacts, deletedContacts, view]);
|
|
123
|
+
|
|
124
|
+
const filtered = useMemo(() => {
|
|
125
|
+
const q = query.trim().toLowerCase();
|
|
126
|
+
if (!q) {
|
|
127
|
+
return viewFiltered;
|
|
128
|
+
}
|
|
129
|
+
return viewFiltered.filter(
|
|
130
|
+
(c) => c.displayName.toLowerCase().includes(q) || c.emails.some((e) => e.address.toLowerCase().includes(q)),
|
|
131
|
+
);
|
|
132
|
+
}, [viewFiltered, query]);
|
|
133
|
+
|
|
134
|
+
const sorted = useMemo(() => {
|
|
135
|
+
const arr = [...filtered];
|
|
136
|
+
arr.sort((a, b) => {
|
|
137
|
+
const cmp = (sortColumn === "name" ? a.displayName : primaryInfo(a)).localeCompare(
|
|
138
|
+
sortColumn === "name" ? b.displayName : primaryInfo(b),
|
|
139
|
+
);
|
|
140
|
+
return sortDesc ? -cmp : cmp;
|
|
141
|
+
});
|
|
142
|
+
return arr;
|
|
143
|
+
}, [filtered, sortColumn, sortDesc]);
|
|
144
|
+
|
|
145
|
+
const selected = contacts.find((c) => c.uid === selectedUid) ?? null;
|
|
146
|
+
const isDeletedView = view.type === "deleted";
|
|
147
|
+
const checkedContacts = sorted.filter((c) => checkedUids.has(c.uid));
|
|
148
|
+
const allChecked = sorted.length > 0 && sorted.every((c) => checkedUids.has(c.uid));
|
|
149
|
+
|
|
150
|
+
function handleSort(column: SortColumn) {
|
|
151
|
+
if (column === sortColumn) {
|
|
152
|
+
setSortDesc((prev) => !prev);
|
|
153
|
+
} else {
|
|
154
|
+
setSortColumn(column);
|
|
155
|
+
setSortDesc(false);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toggleChecked(uid: string) {
|
|
160
|
+
setCheckedUids((prev) => {
|
|
161
|
+
const next = new Set(prev);
|
|
162
|
+
if (next.has(uid)) {
|
|
163
|
+
next.delete(uid);
|
|
164
|
+
} else {
|
|
165
|
+
next.add(uid);
|
|
166
|
+
}
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toggleCheckAll() {
|
|
172
|
+
setCheckedUids(allChecked ? new Set() : new Set(sorted.map((c) => c.uid)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function handleSelectView(next: ContactsView) {
|
|
176
|
+
setView(next);
|
|
177
|
+
setCheckedUids(new Set());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function handleSelectRow(contact: Contact) {
|
|
181
|
+
if (isMobile) {
|
|
182
|
+
window.location.href = `/contacts/${encodeURIComponent(contact.uid)}`;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
setSelectedUid(contact.uid);
|
|
186
|
+
setMode("view");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleNew() {
|
|
190
|
+
setSelectedUid(null);
|
|
191
|
+
setMode("new");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleSaved(contact: Contact) {
|
|
195
|
+
setSelectedUid(contact.uid);
|
|
196
|
+
setMode("view");
|
|
197
|
+
void reload();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function handleCancel() {
|
|
201
|
+
setMode("view");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function handleDelete(contact: Contact) {
|
|
205
|
+
try {
|
|
206
|
+
await deleteContact(contact.uid, contact.version);
|
|
207
|
+
setSelectedUid(null);
|
|
208
|
+
setMode("view");
|
|
209
|
+
void reload();
|
|
210
|
+
} catch (err) {
|
|
211
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not delete this contact.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleBulkDelete() {
|
|
216
|
+
let bulkError: string | null = null;
|
|
217
|
+
for (const contact of checkedContacts) {
|
|
218
|
+
try {
|
|
219
|
+
await deleteContact(contact.uid, contact.version);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not delete one or more contacts.";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
setCheckedUids(new Set());
|
|
225
|
+
await reload();
|
|
226
|
+
setError(bulkError);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `ContactsToolbar`'s Edit button — the only caller — is itself `disabled` unless exactly one contact
|
|
230
|
+
// is checked, so `checkedContacts[0]` is guaranteed to exist whenever this actually runs; no length
|
|
231
|
+
// guard needed, matching this codebase's established pattern for the same class of "always non-empty
|
|
232
|
+
// by the time it's called" value guaranteed by a disabled control (see `ComposeToolbar.run()`).
|
|
233
|
+
function handleToolbarEdit() {
|
|
234
|
+
setSelectedUid(checkedContacts[0].uid);
|
|
235
|
+
setMode("edit");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleEmail() {
|
|
239
|
+
const addresses = checkedContacts.map((c) => c.emails[0]?.address).filter((a): a is string => Boolean(a));
|
|
240
|
+
// `ContactsShell` only ever renders this component once `mailboxUid` is resolved (see its own
|
|
241
|
+
// invariant comment) — same non-null pattern as `organizerAddress` in the calendar page.
|
|
242
|
+
openCompose({ mailboxUid: mailboxUid!, to: addresses.join(", ") });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function handleToggleFavorite() {
|
|
246
|
+
const allFavorited = checkedContacts.every((c) => c.favorite);
|
|
247
|
+
let bulkError: string | null = null;
|
|
248
|
+
for (const contact of checkedContacts) {
|
|
249
|
+
try {
|
|
250
|
+
await setContactFavorite(contact, !allFavorited);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
await reload();
|
|
256
|
+
setError(bulkError);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function handleAddCategory() {
|
|
260
|
+
const category = window.prompt("Category name");
|
|
261
|
+
if (!category?.trim()) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
let bulkError: string | null = null;
|
|
265
|
+
for (const contact of checkedContacts) {
|
|
266
|
+
const categories = Array.from(new Set([...(contact.categories ?? []), category.trim()]));
|
|
267
|
+
try {
|
|
268
|
+
await updateContact({ ...contact, categories });
|
|
269
|
+
} catch (err) {
|
|
270
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not update one or more contacts.";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await reload();
|
|
274
|
+
setError(bulkError);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function handleExportVCard() {
|
|
278
|
+
downloadTextFile(
|
|
279
|
+
checkedContacts.length === 1 ? `${checkedContacts[0].displayName}.vcf` : "contacts.vcf",
|
|
280
|
+
checkedContacts.length === 1 ? contactToVCard(checkedContacts[0]) : contactsToVCardFile(checkedContacts),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function handleImportFile(file: File) {
|
|
285
|
+
if (!mailboxUid || !folderUid) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const text = await file.text();
|
|
289
|
+
const parsed = parseVCards(text);
|
|
290
|
+
let bulkError: string | null = null;
|
|
291
|
+
for (const input of parsed) {
|
|
292
|
+
try {
|
|
293
|
+
await createContact({ mailboxUid, folderUid, ...input });
|
|
294
|
+
} catch (err) {
|
|
295
|
+
bulkError = err instanceof ApiRequestError ? err.message : "Could not import one or more contacts.";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
await reload();
|
|
299
|
+
setError(bulkError);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div className="flex-1 flex min-h-0">
|
|
304
|
+
<ContactsSidebar mailboxUid={mailboxUid} contacts={contacts} active={view} onSelect={handleSelectView} />
|
|
305
|
+
{/* Hidden on mobile while the "new contact" form (the one form-pane state mobile keeps
|
|
306
|
+
in-place — see the pane's own comment below) is showing, so the two never compete for the
|
|
307
|
+
same row's width. Desktop always shows both side by side, unchanged. */}
|
|
308
|
+
<div
|
|
309
|
+
className={[
|
|
310
|
+
"w-full md:w-96 shrink-0 md:border-r border-border md:flex flex-col min-h-0",
|
|
311
|
+
mode === "new" ? "hidden md:flex" : "flex",
|
|
312
|
+
].join(" ")}
|
|
313
|
+
>
|
|
314
|
+
<ContactsToolbar
|
|
315
|
+
selectedCount={checkedContacts.length}
|
|
316
|
+
allSelectedFavorited={checkedContacts.length > 0 && checkedContacts.every((c) => c.favorite)}
|
|
317
|
+
onNewContact={handleNew}
|
|
318
|
+
onEdit={handleToolbarEdit}
|
|
319
|
+
onDelete={handleBulkDelete}
|
|
320
|
+
onEmail={handleEmail}
|
|
321
|
+
onToggleFavorite={handleToggleFavorite}
|
|
322
|
+
onAddCategory={handleAddCategory}
|
|
323
|
+
onExportVCard={handleExportVCard}
|
|
324
|
+
onImportFile={handleImportFile}
|
|
325
|
+
/>
|
|
326
|
+
<div className="p-3 border-b border-border">
|
|
327
|
+
<input
|
|
328
|
+
type="search"
|
|
329
|
+
value={query}
|
|
330
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
331
|
+
placeholder="Search contacts"
|
|
332
|
+
aria-label="Search contacts"
|
|
333
|
+
className={INPUT_CLASS}
|
|
334
|
+
/>
|
|
335
|
+
</div>
|
|
336
|
+
{error && (
|
|
337
|
+
<div className="p-3">
|
|
338
|
+
<Alert>{error}</Alert>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
{deletedError && isDeletedView && (
|
|
342
|
+
<div className="p-3">
|
|
343
|
+
<Alert>{deletedError}</Alert>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
{(isDeletedView ? deletedLoading : loading) ? (
|
|
347
|
+
<p className="p-4 text-sm text-text-muted">Loading…</p>
|
|
348
|
+
) : sorted.length === 0 ? (
|
|
349
|
+
<p className="p-4 text-sm text-text-muted">No contacts found.</p>
|
|
350
|
+
) : (
|
|
351
|
+
<div className="flex-1 overflow-auto">
|
|
352
|
+
<table className="w-full text-sm">
|
|
353
|
+
<thead>
|
|
354
|
+
<tr className="border-b border-border text-left">
|
|
355
|
+
{!isDeletedView && (
|
|
356
|
+
<th className="w-8 px-3 py-2">
|
|
357
|
+
<input
|
|
358
|
+
type="checkbox"
|
|
359
|
+
aria-label="Select all contacts"
|
|
360
|
+
checked={allChecked}
|
|
361
|
+
onChange={toggleCheckAll}
|
|
362
|
+
/>
|
|
363
|
+
</th>
|
|
364
|
+
)}
|
|
365
|
+
<th className="px-3 py-2">
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={() => handleSort("name")}
|
|
369
|
+
className="text-xs font-bold uppercase tracking-wide text-text-muted"
|
|
370
|
+
>
|
|
371
|
+
Name{sortColumn === "name" ? (sortDesc ? " ▾" : " ▴") : ""}
|
|
372
|
+
</button>
|
|
373
|
+
</th>
|
|
374
|
+
<th className="px-3 py-2">
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={() => handleSort("info")}
|
|
378
|
+
className="text-xs font-bold uppercase tracking-wide text-text-muted"
|
|
379
|
+
>
|
|
380
|
+
Contact info{sortColumn === "info" ? (sortDesc ? " ▾" : " ▴") : ""}
|
|
381
|
+
</button>
|
|
382
|
+
</th>
|
|
383
|
+
</tr>
|
|
384
|
+
</thead>
|
|
385
|
+
<tbody>
|
|
386
|
+
{sorted.map((contact) => (
|
|
387
|
+
<tr
|
|
388
|
+
key={contact.uid}
|
|
389
|
+
className={["border-b border-border", contact.uid === selectedUid ? "bg-primary/10" : "hover:bg-surface-alt"].join(
|
|
390
|
+
" ",
|
|
391
|
+
)}
|
|
392
|
+
>
|
|
393
|
+
{!isDeletedView && (
|
|
394
|
+
<td className="px-3 py-2">
|
|
395
|
+
<input
|
|
396
|
+
type="checkbox"
|
|
397
|
+
aria-label={`Select ${contact.displayName}`}
|
|
398
|
+
checked={checkedUids.has(contact.uid)}
|
|
399
|
+
onChange={() => toggleChecked(contact.uid)}
|
|
400
|
+
/>
|
|
401
|
+
</td>
|
|
402
|
+
)}
|
|
403
|
+
<td className="px-3 py-2">
|
|
404
|
+
<button type="button" onClick={() => handleSelectRow(contact)} className="flex items-center gap-2 text-left">
|
|
405
|
+
<ContactAvatar displayName={contact.displayName} size={28} />
|
|
406
|
+
<span className="truncate font-medium">
|
|
407
|
+
{contact.displayName}
|
|
408
|
+
{contact.favorite && <span aria-label="Favorite"> ★</span>}
|
|
409
|
+
</span>
|
|
410
|
+
</button>
|
|
411
|
+
</td>
|
|
412
|
+
<td className="px-3 py-2 text-text-muted truncate">{primaryInfo(contact)}</td>
|
|
413
|
+
</tr>
|
|
414
|
+
))}
|
|
415
|
+
</tbody>
|
|
416
|
+
</table>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
{/* On mobile, "selected"/"edit" are unreachable (row taps navigate to /contacts/:uid instead —
|
|
421
|
+
see handleSelectRow), so this pane only needs to show there for "new", which stays in-place
|
|
422
|
+
on every device (an unsaved contact has no uid for a route). Always visible on desktop. */}
|
|
423
|
+
<div className={["flex-1 min-w-0 overflow-y-auto p-6 md:block", mode === "new" ? "block" : "hidden"].join(" ")}>
|
|
424
|
+
{mode === "new" && mailboxUid && folderUid ? (
|
|
425
|
+
<ContactForm mailboxUid={mailboxUid} folderUid={folderUid} onSaved={handleSaved} onCancel={handleCancel} />
|
|
426
|
+
) : mode === "edit" && selected ? (
|
|
427
|
+
<ContactForm contact={selected} onSaved={handleSaved} onCancel={handleCancel} />
|
|
428
|
+
) : selected ? (
|
|
429
|
+
<ContactDetailPane contact={selected} onEdit={() => setMode("edit")} onDelete={() => handleDelete(selected)} />
|
|
430
|
+
) : (
|
|
431
|
+
<p className="text-sm text-text-muted">Select a contact, or create a new one.</p>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|