@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,404 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
Branding,
|
|
9
|
+
deleteBrandingIcon,
|
|
10
|
+
deleteBrandingLogo,
|
|
11
|
+
deleteBrandingStylesheet,
|
|
12
|
+
getBranding,
|
|
13
|
+
updateBranding,
|
|
14
|
+
uploadBrandingIcon,
|
|
15
|
+
uploadBrandingLogo,
|
|
16
|
+
uploadBrandingStylesheet,
|
|
17
|
+
} from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
18
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
19
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
20
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
21
|
+
|
|
22
|
+
const INPUT_CLASS =
|
|
23
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
24
|
+
|
|
25
|
+
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
export default function BrandingPage(props: Omit<AdminShellProps, "active">) {
|
|
28
|
+
return (
|
|
29
|
+
<AdminShell {...props} active="branding">
|
|
30
|
+
<BrandingContent />
|
|
31
|
+
</AdminShell>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function BrandingContent() {
|
|
36
|
+
const [branding, setBranding] = useState<Branding | null>(null);
|
|
37
|
+
const [loading, setLoading] = useState(true);
|
|
38
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
getBranding()
|
|
42
|
+
.then(setBranding)
|
|
43
|
+
.catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load branding."))
|
|
44
|
+
.finally(() => setLoading(false));
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
if (loading) {
|
|
48
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
49
|
+
}
|
|
50
|
+
if (loadError || !branding) {
|
|
51
|
+
return <Alert>{loadError}</Alert>;
|
|
52
|
+
}
|
|
53
|
+
return <BrandingForm branding={branding} onChange={setBranding} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function BrandingForm({ branding, onChange }: { branding: Branding; onChange: (b: Branding) => void }) {
|
|
57
|
+
const [companyName, setCompanyName] = useState(branding.companyName);
|
|
58
|
+
const [title, setTitle] = useState(branding.title);
|
|
59
|
+
const [headerHtml, setHeaderHtml] = useState(branding.headerHtml ?? "");
|
|
60
|
+
const [footerHtml, setFooterHtml] = useState(branding.footerHtml ?? "");
|
|
61
|
+
const [logoUrlInput, setLogoUrlInput] = useState(branding.logoUrl ?? "");
|
|
62
|
+
const [iconUrlInput, setIconUrlInput] = useState(branding.iconUrl ?? "");
|
|
63
|
+
const [stylesheetUrlInput, setStylesheetUrlInput] = useState(branding.stylesheetUrl ?? "");
|
|
64
|
+
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
const [saving, setSaving] = useState(false);
|
|
67
|
+
const [saved, setSaved] = useState(false);
|
|
68
|
+
const [assetBusy, setAssetBusy] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const logoFileRef = useRef<HTMLInputElement>(null);
|
|
71
|
+
const iconFileRef = useRef<HTMLInputElement>(null);
|
|
72
|
+
const stylesheetFileRef = useRef<HTMLInputElement>(null);
|
|
73
|
+
|
|
74
|
+
function runAsset(name: string, action: () => Promise<Branding>) {
|
|
75
|
+
setError(null);
|
|
76
|
+
setAssetBusy(name);
|
|
77
|
+
return action()
|
|
78
|
+
.then((updated) => {
|
|
79
|
+
onChange(updated);
|
|
80
|
+
setLogoUrlInput(updated.logoUrl ?? "");
|
|
81
|
+
setIconUrlInput(updated.iconUrl ?? "");
|
|
82
|
+
setStylesheetUrlInput(updated.stylesheetUrl ?? "");
|
|
83
|
+
})
|
|
84
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not update branding."))
|
|
85
|
+
.finally(() => setAssetBusy(null));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleLogoFileChange(e: ChangeEvent<HTMLInputElement>) {
|
|
89
|
+
const file = e.target.files?.[0];
|
|
90
|
+
if (!file) return;
|
|
91
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
92
|
+
setError(`"${file.name}" is too large — logos must be 5MB or smaller.`);
|
|
93
|
+
} else {
|
|
94
|
+
void runAsset("logo-upload", () => uploadBrandingLogo(file));
|
|
95
|
+
}
|
|
96
|
+
e.target.value = "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleIconFileChange(e: ChangeEvent<HTMLInputElement>) {
|
|
100
|
+
const file = e.target.files?.[0];
|
|
101
|
+
if (!file) return;
|
|
102
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
103
|
+
setError(`"${file.name}" is too large — icons must be 5MB or smaller.`);
|
|
104
|
+
} else {
|
|
105
|
+
void runAsset("icon-upload", () => uploadBrandingIcon(file));
|
|
106
|
+
}
|
|
107
|
+
e.target.value = "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function handleStylesheetFileChange(e: ChangeEvent<HTMLInputElement>) {
|
|
111
|
+
const file = e.target.files?.[0];
|
|
112
|
+
if (!file) return;
|
|
113
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
114
|
+
setError(`"${file.name}" is too large — stylesheets must be 5MB or smaller.`);
|
|
115
|
+
} else {
|
|
116
|
+
void runAsset("stylesheet-upload", () => uploadBrandingStylesheet(file));
|
|
117
|
+
}
|
|
118
|
+
e.target.value = "";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleSubmit(e: FormEvent) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
setError(null);
|
|
124
|
+
setSaved(false);
|
|
125
|
+
setSaving(true);
|
|
126
|
+
try {
|
|
127
|
+
const updated = await updateBranding({ companyName, title, headerHtml, footerHtml });
|
|
128
|
+
onChange(updated);
|
|
129
|
+
setSaved(true);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save branding.");
|
|
132
|
+
} finally {
|
|
133
|
+
setSaving(false);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="max-w-2xl">
|
|
139
|
+
<h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
|
|
140
|
+
<p className="text-sm text-text-muted mb-5">
|
|
141
|
+
Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
|
|
142
|
+
webmail and admin console — including anonymous booking-page visitors.
|
|
143
|
+
</p>
|
|
144
|
+
|
|
145
|
+
{error && <Alert>{error}</Alert>}
|
|
146
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
147
|
+
|
|
148
|
+
<div className="flex flex-col gap-6">
|
|
149
|
+
<section className="flex flex-col gap-3">
|
|
150
|
+
<h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Logo</h2>
|
|
151
|
+
<div className="flex items-center gap-4">
|
|
152
|
+
<img
|
|
153
|
+
src={branding.logoUrl || "/images/logo.svg"}
|
|
154
|
+
alt="Current logo"
|
|
155
|
+
width="64"
|
|
156
|
+
height="64"
|
|
157
|
+
className="border border-border rounded-sm bg-surface-alt p-1"
|
|
158
|
+
/>
|
|
159
|
+
<div className="flex flex-col gap-2">
|
|
160
|
+
<Button
|
|
161
|
+
type="button"
|
|
162
|
+
variant="secondary"
|
|
163
|
+
className="!w-auto"
|
|
164
|
+
loading={assetBusy === "logo-upload"}
|
|
165
|
+
disabled={assetBusy !== null}
|
|
166
|
+
onClick={() => logoFileRef.current?.click()}
|
|
167
|
+
>
|
|
168
|
+
Upload logo
|
|
169
|
+
</Button>
|
|
170
|
+
<input
|
|
171
|
+
ref={logoFileRef}
|
|
172
|
+
type="file"
|
|
173
|
+
accept="image/*"
|
|
174
|
+
aria-label="Upload logo"
|
|
175
|
+
className="hidden"
|
|
176
|
+
onChange={handleLogoFileChange}
|
|
177
|
+
/>
|
|
178
|
+
{branding.logoUrl && (
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="text"
|
|
182
|
+
disabled={assetBusy !== null}
|
|
183
|
+
loading={assetBusy === "logo-delete"}
|
|
184
|
+
onClick={() => void runAsset("logo-delete", async () => {
|
|
185
|
+
await deleteBrandingLogo();
|
|
186
|
+
return { ...branding, logoUrl: undefined };
|
|
187
|
+
})}
|
|
188
|
+
>
|
|
189
|
+
Remove logo
|
|
190
|
+
</Button>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
195
|
+
<span className="font-semibold">Or use an external image URL</span>
|
|
196
|
+
<div className="flex gap-2">
|
|
197
|
+
<input
|
|
198
|
+
aria-label="Logo URL"
|
|
199
|
+
className={INPUT_CLASS}
|
|
200
|
+
value={logoUrlInput}
|
|
201
|
+
onChange={(e) => setLogoUrlInput(e.target.value)}
|
|
202
|
+
/>
|
|
203
|
+
<Button
|
|
204
|
+
type="button"
|
|
205
|
+
variant="secondary"
|
|
206
|
+
className="!w-auto"
|
|
207
|
+
disabled={assetBusy !== null || logoUrlInput === (branding.logoUrl ?? "")}
|
|
208
|
+
loading={assetBusy === "logo-url"}
|
|
209
|
+
onClick={() => void runAsset("logo-url", () => updateBranding({ logoUrl: logoUrlInput }))}
|
|
210
|
+
>
|
|
211
|
+
Set
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
214
|
+
</label>
|
|
215
|
+
</section>
|
|
216
|
+
|
|
217
|
+
<section className="flex flex-col gap-3">
|
|
218
|
+
<h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Icon</h2>
|
|
219
|
+
<p className="text-xs text-text-muted -mt-1">
|
|
220
|
+
A compact mark for navigation headers, independent of the full logo above. Falls back to
|
|
221
|
+
the logo, then a default asset, when not set.
|
|
222
|
+
</p>
|
|
223
|
+
<div className="flex items-center gap-4">
|
|
224
|
+
<img
|
|
225
|
+
src={branding.iconUrl || branding.logoUrl || "/images/logo.svg"}
|
|
226
|
+
alt="Current icon"
|
|
227
|
+
width="64"
|
|
228
|
+
height="64"
|
|
229
|
+
className="border border-border rounded-sm bg-surface-alt p-1"
|
|
230
|
+
/>
|
|
231
|
+
<div className="flex flex-col gap-2">
|
|
232
|
+
<Button
|
|
233
|
+
type="button"
|
|
234
|
+
variant="secondary"
|
|
235
|
+
className="!w-auto"
|
|
236
|
+
loading={assetBusy === "icon-upload"}
|
|
237
|
+
disabled={assetBusy !== null}
|
|
238
|
+
onClick={() => iconFileRef.current?.click()}
|
|
239
|
+
>
|
|
240
|
+
Upload icon
|
|
241
|
+
</Button>
|
|
242
|
+
<input
|
|
243
|
+
ref={iconFileRef}
|
|
244
|
+
type="file"
|
|
245
|
+
accept="image/*"
|
|
246
|
+
aria-label="Upload icon"
|
|
247
|
+
className="hidden"
|
|
248
|
+
onChange={handleIconFileChange}
|
|
249
|
+
/>
|
|
250
|
+
{branding.iconUrl && (
|
|
251
|
+
<Button
|
|
252
|
+
type="button"
|
|
253
|
+
variant="text"
|
|
254
|
+
disabled={assetBusy !== null}
|
|
255
|
+
loading={assetBusy === "icon-delete"}
|
|
256
|
+
onClick={() => void runAsset("icon-delete", async () => {
|
|
257
|
+
await deleteBrandingIcon();
|
|
258
|
+
return { ...branding, iconUrl: undefined };
|
|
259
|
+
})}
|
|
260
|
+
>
|
|
261
|
+
Remove icon
|
|
262
|
+
</Button>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
267
|
+
<span className="font-semibold">Or use an external image URL</span>
|
|
268
|
+
<div className="flex gap-2">
|
|
269
|
+
<input
|
|
270
|
+
aria-label="Icon URL"
|
|
271
|
+
className={INPUT_CLASS}
|
|
272
|
+
value={iconUrlInput}
|
|
273
|
+
onChange={(e) => setIconUrlInput(e.target.value)}
|
|
274
|
+
/>
|
|
275
|
+
<Button
|
|
276
|
+
type="button"
|
|
277
|
+
variant="secondary"
|
|
278
|
+
className="!w-auto"
|
|
279
|
+
disabled={assetBusy !== null || iconUrlInput === (branding.iconUrl ?? "")}
|
|
280
|
+
loading={assetBusy === "icon-url"}
|
|
281
|
+
onClick={() => void runAsset("icon-url", () => updateBranding({ iconUrl: iconUrlInput }))}
|
|
282
|
+
>
|
|
283
|
+
Set
|
|
284
|
+
</Button>
|
|
285
|
+
</div>
|
|
286
|
+
</label>
|
|
287
|
+
</section>
|
|
288
|
+
|
|
289
|
+
<section className="flex flex-col gap-3">
|
|
290
|
+
<h2 className="text-sm font-bold uppercase tracking-wide text-text-muted">Custom stylesheet</h2>
|
|
291
|
+
<div className="flex items-center gap-3">
|
|
292
|
+
<span className="text-sm text-text-muted truncate">
|
|
293
|
+
{branding.stylesheetUrl || "None configured"}
|
|
294
|
+
</span>
|
|
295
|
+
</div>
|
|
296
|
+
<div className="flex gap-2">
|
|
297
|
+
<Button
|
|
298
|
+
type="button"
|
|
299
|
+
variant="secondary"
|
|
300
|
+
className="!w-auto"
|
|
301
|
+
loading={assetBusy === "stylesheet-upload"}
|
|
302
|
+
disabled={assetBusy !== null}
|
|
303
|
+
onClick={() => stylesheetFileRef.current?.click()}
|
|
304
|
+
>
|
|
305
|
+
Upload CSS file
|
|
306
|
+
</Button>
|
|
307
|
+
<input
|
|
308
|
+
ref={stylesheetFileRef}
|
|
309
|
+
type="file"
|
|
310
|
+
accept="text/css,.css"
|
|
311
|
+
aria-label="Upload stylesheet"
|
|
312
|
+
className="hidden"
|
|
313
|
+
onChange={handleStylesheetFileChange}
|
|
314
|
+
/>
|
|
315
|
+
{branding.stylesheetUrl && (
|
|
316
|
+
<Button
|
|
317
|
+
type="button"
|
|
318
|
+
variant="text"
|
|
319
|
+
disabled={assetBusy !== null}
|
|
320
|
+
loading={assetBusy === "stylesheet-delete"}
|
|
321
|
+
onClick={() => void runAsset("stylesheet-delete", async () => {
|
|
322
|
+
await deleteBrandingStylesheet();
|
|
323
|
+
return { ...branding, stylesheetUrl: undefined };
|
|
324
|
+
})}
|
|
325
|
+
>
|
|
326
|
+
Remove stylesheet
|
|
327
|
+
</Button>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
331
|
+
<span className="font-semibold">Or use an external stylesheet URL</span>
|
|
332
|
+
<div className="flex gap-2">
|
|
333
|
+
<input
|
|
334
|
+
aria-label="Stylesheet URL"
|
|
335
|
+
className={INPUT_CLASS}
|
|
336
|
+
value={stylesheetUrlInput}
|
|
337
|
+
onChange={(e) => setStylesheetUrlInput(e.target.value)}
|
|
338
|
+
/>
|
|
339
|
+
<Button
|
|
340
|
+
type="button"
|
|
341
|
+
variant="secondary"
|
|
342
|
+
className="!w-auto"
|
|
343
|
+
disabled={assetBusy !== null || stylesheetUrlInput === (branding.stylesheetUrl ?? "")}
|
|
344
|
+
loading={assetBusy === "stylesheet-url"}
|
|
345
|
+
onClick={() =>
|
|
346
|
+
void runAsset("stylesheet-url", () => updateBranding({ stylesheetUrl: stylesheetUrlInput }))
|
|
347
|
+
}
|
|
348
|
+
>
|
|
349
|
+
Set
|
|
350
|
+
</Button>
|
|
351
|
+
</div>
|
|
352
|
+
</label>
|
|
353
|
+
</section>
|
|
354
|
+
|
|
355
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4 pt-2 border-t border-border">
|
|
356
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
357
|
+
<span className="font-semibold">Company name</span>
|
|
358
|
+
<input
|
|
359
|
+
aria-label="Company name"
|
|
360
|
+
className={INPUT_CLASS}
|
|
361
|
+
value={companyName}
|
|
362
|
+
onChange={(e) => setCompanyName(e.target.value)}
|
|
363
|
+
/>
|
|
364
|
+
</label>
|
|
365
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
366
|
+
<span className="font-semibold">Product title</span>
|
|
367
|
+
<input
|
|
368
|
+
aria-label="Product title"
|
|
369
|
+
className={INPUT_CLASS}
|
|
370
|
+
value={title}
|
|
371
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
372
|
+
/>
|
|
373
|
+
<span className="text-xs text-text-muted">Shown as the browser tab title.</span>
|
|
374
|
+
</label>
|
|
375
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
376
|
+
<span className="font-semibold">Header HTML (optional)</span>
|
|
377
|
+
<textarea
|
|
378
|
+
aria-label="Header HTML"
|
|
379
|
+
className={`${INPUT_CLASS} font-mono`}
|
|
380
|
+
rows={3}
|
|
381
|
+
value={headerHtml}
|
|
382
|
+
onChange={(e) => setHeaderHtml(e.target.value)}
|
|
383
|
+
/>
|
|
384
|
+
</label>
|
|
385
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
386
|
+
<span className="font-semibold">Footer HTML (optional)</span>
|
|
387
|
+
<textarea
|
|
388
|
+
aria-label="Footer HTML"
|
|
389
|
+
className={`${INPUT_CLASS} font-mono`}
|
|
390
|
+
rows={3}
|
|
391
|
+
value={footerHtml}
|
|
392
|
+
onChange={(e) => setFooterHtml(e.target.value)}
|
|
393
|
+
/>
|
|
394
|
+
</label>
|
|
395
|
+
<div>
|
|
396
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
397
|
+
Save
|
|
398
|
+
</Button>
|
|
399
|
+
</div>
|
|
400
|
+
</form>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { DistributionList, getDistributionList } from "@rapidmx/react-shared/admin/distributionListsApi.js";
|
|
8
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
9
|
+
import MemberListCard from "../../shared/components/admin/distributionLists/MemberListCard.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
|
|
12
|
+
export default function DistributionListDetailPage(props: Omit<AdminShellProps, "active"> & { params: { uid: string } }) {
|
|
13
|
+
return (
|
|
14
|
+
<AdminShell {...props} active="distributionLists">
|
|
15
|
+
<DistributionListDetailContent uid={props.params.uid} />
|
|
16
|
+
</AdminShell>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function DistributionListDetailContent({ uid }: { uid: string }) {
|
|
21
|
+
const [list, setList] = useState<DistributionList | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
setLoading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
getDistributionList(uid)
|
|
29
|
+
.then(setList)
|
|
30
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load this distribution list."))
|
|
31
|
+
.finally(() => setLoading(false));
|
|
32
|
+
}, [uid]);
|
|
33
|
+
|
|
34
|
+
if (loading) {
|
|
35
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
36
|
+
}
|
|
37
|
+
if (error || !list) {
|
|
38
|
+
return <Alert>{error ?? "Distribution list not found."}</Alert>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="max-w-3xl flex flex-col gap-5">
|
|
43
|
+
<div>
|
|
44
|
+
<a href="/admin/distribution-lists" className="text-sm text-primary-dark hover:underline">
|
|
45
|
+
← All distribution lists
|
|
46
|
+
</a>
|
|
47
|
+
<h1 className="text-xl font-bold tracking-tight mt-1">{list.primarySmtpAddress}</h1>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
51
|
+
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
|
52
|
+
<dt className="text-text-muted">Name</dt>
|
|
53
|
+
<dd>{list.name}</dd>
|
|
54
|
+
<dt className="text-text-muted">Description</dt>
|
|
55
|
+
<dd>{list.description ?? "None"}</dd>
|
|
56
|
+
<dt className="text-text-muted">Owner</dt>
|
|
57
|
+
<dd>{list.ownerUserUid ?? "None"}</dd>
|
|
58
|
+
<dt className="text-text-muted">Alias addresses</dt>
|
|
59
|
+
<dd>{list.aliasAddresses && list.aliasAddresses.length > 0 ? list.aliasAddresses.join(", ") : "None"}</dd>
|
|
60
|
+
<dt className="text-text-muted">Restrict senders</dt>
|
|
61
|
+
<dd>{list.restrictSenders ? "Only members may send" : "No"}</dd>
|
|
62
|
+
<dt className="text-text-muted">Created</dt>
|
|
63
|
+
<dd>{new Date(list.dateCreated).toLocaleString()}</dd>
|
|
64
|
+
</dl>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<MemberListCard list={list} onUpdate={setList} />
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { DistributionList, listDistributionLists } from "@rapidmx/react-shared/admin/distributionListsApi.js";
|
|
8
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
|
|
14
|
+
export default function DistributionListsPage(props: Omit<AdminShellProps, "active">) {
|
|
15
|
+
return (
|
|
16
|
+
<AdminShell {...props} active="distributionLists">
|
|
17
|
+
<DistributionListsContent />
|
|
18
|
+
</AdminShell>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DistributionListsContent() {
|
|
23
|
+
const [page, setPage] = useState(0);
|
|
24
|
+
const [lists, setLists] = useState<DistributionList[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setLoading(true);
|
|
30
|
+
setError(null);
|
|
31
|
+
listDistributionLists({ page, limit: PAGE_SIZE })
|
|
32
|
+
.then(setLists)
|
|
33
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load distribution lists."))
|
|
34
|
+
.finally(() => setLoading(false));
|
|
35
|
+
}, [page]);
|
|
36
|
+
|
|
37
|
+
const hasNextPage = lists.length === PAGE_SIZE;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<div className="flex items-center justify-between mb-5">
|
|
42
|
+
<h1 className="text-xl font-bold uppercase tracking-wide">Distribution lists</h1>
|
|
43
|
+
<a href="/admin/distribution-lists/new">
|
|
44
|
+
<Button type="button" className="!w-auto">
|
|
45
|
+
+ New distribution list
|
|
46
|
+
</Button>
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{error && <Alert>{error}</Alert>}
|
|
51
|
+
|
|
52
|
+
{loading ? (
|
|
53
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
54
|
+
) : lists.length === 0 ? (
|
|
55
|
+
<p className="text-sm text-text-muted">No distribution lists yet.</p>
|
|
56
|
+
) : (
|
|
57
|
+
<div className="overflow-x-auto">
|
|
58
|
+
<table className="w-full text-sm border-collapse">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr>
|
|
61
|
+
{["Address", "Name", "Members", ""].map((h) => (
|
|
62
|
+
<th
|
|
63
|
+
key={h}
|
|
64
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
65
|
+
>
|
|
66
|
+
{h}
|
|
67
|
+
</th>
|
|
68
|
+
))}
|
|
69
|
+
</tr>
|
|
70
|
+
</thead>
|
|
71
|
+
<tbody>
|
|
72
|
+
{lists.map((list) => (
|
|
73
|
+
<tr key={list.uid}>
|
|
74
|
+
<td className="py-2.5 px-2.5 border-b border-border">{list.primarySmtpAddress}</td>
|
|
75
|
+
<td className="py-2.5 px-2.5 border-b border-border">{list.name}</td>
|
|
76
|
+
<td className="py-2.5 px-2.5 border-b border-border">{list.memberAddresses.length}</td>
|
|
77
|
+
<td className="py-2.5 px-2.5 border-b border-border text-right">
|
|
78
|
+
<a
|
|
79
|
+
href={`/admin/distribution-lists/${encodeURIComponent(list.uid)}`}
|
|
80
|
+
className="text-primary-dark hover:underline font-medium"
|
|
81
|
+
>
|
|
82
|
+
View
|
|
83
|
+
</a>
|
|
84
|
+
</td>
|
|
85
|
+
</tr>
|
|
86
|
+
))}
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
<div className="flex gap-3 items-center mt-4">
|
|
93
|
+
<Button
|
|
94
|
+
variant="secondary"
|
|
95
|
+
type="button"
|
|
96
|
+
className="!w-auto"
|
|
97
|
+
disabled={page === 0 || loading}
|
|
98
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
99
|
+
>
|
|
100
|
+
Previous
|
|
101
|
+
</Button>
|
|
102
|
+
<span className="text-sm text-text-muted">Page {page + 1}</span>
|
|
103
|
+
<Button
|
|
104
|
+
variant="secondary"
|
|
105
|
+
type="button"
|
|
106
|
+
className="!w-auto"
|
|
107
|
+
disabled={!hasNextPage || loading}
|
|
108
|
+
onClick={() => setPage((p) => p + 1)}
|
|
109
|
+
>
|
|
110
|
+
Next
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
</>
|
|
114
|
+
);
|
|
115
|
+
}
|