@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,139 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
|
+
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
import { useCompose } from "../compose/ComposeContext.js";
|
|
16
|
+
const MailShellContext = createContext({ mailboxes: [], folders: [] });
|
|
17
|
+
/** Reads the mailbox/folder a page is currently showing, as resolved by the enclosing `MailShell`. */
|
|
18
|
+
export function useMailShell() {
|
|
19
|
+
return useContext(MailShellContext);
|
|
20
|
+
}
|
|
21
|
+
const FOLDER_LABELS = {
|
|
22
|
+
inbox: "Inbox",
|
|
23
|
+
sent_items: "Sent Items",
|
|
24
|
+
drafts: "Drafts",
|
|
25
|
+
outbox: "Outbox",
|
|
26
|
+
junk: "Junk Email",
|
|
27
|
+
deleted_items: "Deleted Items",
|
|
28
|
+
};
|
|
29
|
+
/** Well-known folders sort first, in Gmail/Outlook's conventional order; anything else (incl. `user`) sorts after, alphabetically. */
|
|
30
|
+
const FOLDER_ORDER = ["inbox", "drafts", "outbox", "sent_items", "junk", "deleted_items"];
|
|
31
|
+
/**
|
|
32
|
+
* A mailbox's `calendar`/`contacts`/`tasks`/`notes` folders back their own dedicated apps (see
|
|
33
|
+
* `CalendarShell`/`ContactsShell`/`TasksShell`), not Mail — `listFolders()` returns every well-known
|
|
34
|
+
* folder for the mailbox regardless of which app owns it, so Mail's own folder tree must filter down
|
|
35
|
+
* to just the mail ones itself, or those other apps' folders leak into this sidebar.
|
|
36
|
+
*/
|
|
37
|
+
const MAIL_FOLDER_TYPES = new Set([...FOLDER_ORDER, "user"]);
|
|
38
|
+
function folderSortKey(folder) {
|
|
39
|
+
const idx = FOLDER_ORDER.indexOf(folder.type);
|
|
40
|
+
return idx === -1 ? FOLDER_ORDER.length : idx;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A separate component (not inlined into `MailShell`'s own render) so `useCompose()` resolves against
|
|
44
|
+
* `ComposeProvider` correctly: that provider is rendered *inside* the `AppShell` that `MailShell` itself
|
|
45
|
+
* returns, i.e. a descendant of `MailShell`, not an ancestor — a hook call made directly in `MailShell`'s
|
|
46
|
+
* own function body would see only whatever context exists *above* `MailShell`, never a provider one of
|
|
47
|
+
* its own descendants creates. This button, rendered as part of `AppShell`'s `children`, sits correctly
|
|
48
|
+
* inside that subtree.
|
|
49
|
+
*/
|
|
50
|
+
function ComposeButton({ mailboxUid }) {
|
|
51
|
+
const { openCompose } = useCompose();
|
|
52
|
+
return (_jsx("button", { type: "button", onClick: () => openCompose({ mailboxUid }), className: "block text-center w-full py-2.5 px-4 rounded-sm font-semibold text-sm bg-primary text-white hover:bg-primary-dark", children: "Compose" }));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Mail's own contextual sidebar (mailbox switcher + folder tree) + content area, rendered inside the shared
|
|
56
|
+
* `AppShell` chrome (icon rail, header, impersonation banner — see that component). There is no client-side
|
|
57
|
+
* router in this framework (see `ReactRoute`'s file-convention resolver) — the selected mailbox/folder live
|
|
58
|
+
* in the URL's `?mailboxUid=`/`?folderUid=` query params, read once on mount (never during the initial
|
|
59
|
+
* render itself, matching every other query-param reader in this codebase — e.g. `apps/admin/quarantine`'s
|
|
60
|
+
* `readMailboxUid()` — so the server-rendered and just-hydrated client markup match).
|
|
61
|
+
*/
|
|
62
|
+
export default function MailShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
63
|
+
const [status, setStatus] = useState("checking");
|
|
64
|
+
const [error, setError] = useState(null);
|
|
65
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
66
|
+
const [folders, setFolders] = useState([]);
|
|
67
|
+
const [foldersLoading, setFoldersLoading] = useState(true);
|
|
68
|
+
const [folderError, setFolderError] = useState(null);
|
|
69
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
70
|
+
const [requestedFolderUid, setRequestedFolderUid] = useState(null);
|
|
71
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const params = new URLSearchParams(window.location.search);
|
|
74
|
+
setRequestedMailboxUid(params.get("mailboxUid"));
|
|
75
|
+
setRequestedFolderUid(params.get("folderUid"));
|
|
76
|
+
}, []);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!userUid) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
listMailboxes({ limit: 100 })
|
|
82
|
+
.then((result) => {
|
|
83
|
+
setMailboxes(result);
|
|
84
|
+
setStatus("ready");
|
|
85
|
+
})
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
88
|
+
setStatus("error");
|
|
89
|
+
});
|
|
90
|
+
}, [userUid]);
|
|
91
|
+
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
92
|
+
mailboxes[0]?.uid;
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!mailboxUid) {
|
|
95
|
+
setFolders([]);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setFolderError(null);
|
|
99
|
+
setFoldersLoading(true);
|
|
100
|
+
listFolders(mailboxUid)
|
|
101
|
+
.then((result) => setFolders(result.filter((f) => MAIL_FOLDER_TYPES.has(f.type))))
|
|
102
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load folders."))
|
|
103
|
+
.finally(() => setFoldersLoading(false));
|
|
104
|
+
}, [mailboxUid]);
|
|
105
|
+
const folderUid = (requestedFolderUid && folders.some((f) => f.uid === requestedFolderUid) ? requestedFolderUid : undefined) ??
|
|
106
|
+
folders.find((f) => f.type === "inbox")?.uid;
|
|
107
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, mailboxes, folders }), [mailboxUid, folderUid, mailboxes, folders]);
|
|
108
|
+
const sortedFolders = useMemo(() => [...folders].sort((a, b) => folderSortKey(a) - folderSortKey(b) || a.name.localeCompare(b.name)), [folders]);
|
|
109
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
110
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header/folder tree don't render at all.
|
|
111
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
112
|
+
return _jsx(MailboxProvisioning, {});
|
|
113
|
+
}
|
|
114
|
+
let inner = null;
|
|
115
|
+
if (userUid && status === "checking") {
|
|
116
|
+
// Renders immediately (no network round trip needed) so switching into Mail never shows a blank
|
|
117
|
+
// pane while `listMailboxes()` is in flight — the outline below mirrors the real sidebar's shape.
|
|
118
|
+
inner = (_jsxs("aside", { className: "w-64 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4", children: [_jsx(Skeleton, { height: "h-9", className: "rounded-sm" }), _jsx(SkeletonList, { count: 6 })] }));
|
|
119
|
+
}
|
|
120
|
+
else if (userUid && status === "error") {
|
|
121
|
+
inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
122
|
+
}
|
|
123
|
+
else if (userUid && status === "ready") {
|
|
124
|
+
// A function, not a plain JSX constant — it's rendered twice (desktop `<aside>` + mobile
|
|
125
|
+
// `Drawer`), possibly *simultaneously* mounted (the aside is only CSS-hidden below `md`, not
|
|
126
|
+
// unmounted), so the mailbox-switcher `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct
|
|
127
|
+
// value per instance. Two elements sharing one id breaks label association (and is invalid HTML).
|
|
128
|
+
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsx("div", { className: "p-3", children: _jsx(ComposeButton, { mailboxUid: mailboxUid }) }), mailboxes.length > 1 && (_jsxs("div", { className: "px-3 pb-2", children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
129
|
+
window.location.href = `/?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
130
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && (_jsx("div", { className: "px-3 pb-2", children: _jsx(Alert, { children: folderError }) })), _jsx("nav", { className: "flex-1 overflow-y-auto px-3 pb-3 flex flex-col gap-0.5", children: foldersLoading ? (_jsx(SkeletonList, { count: 5, className: "pt-1" })) : (sortedFolders.map((folder) => (_jsxs("a", { href: `/?mailboxUid=${encodeURIComponent(mailboxUid)}&folderUid=${encodeURIComponent(folder.uid)}`, className: [
|
|
131
|
+
"flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5",
|
|
132
|
+
folder.uid === folderUid
|
|
133
|
+
? "bg-primary/10 text-primary-dark font-semibold"
|
|
134
|
+
: "text-text hover:bg-surface-alt",
|
|
135
|
+
].join(" "), children: [_jsx("span", { children: FOLDER_LABELS[folder.type] ?? folder.name }), folder.unreadCount > 0 && (_jsx("span", { className: "text-xs font-bold rounded-pill py-0.5 px-1.5 bg-surface-alt text-text-muted", children: folder.unreadCount }))] }, folder.uid)))) })] }));
|
|
136
|
+
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-64 shrink-0 bg-surface border-r border-border flex-col", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Folders", children: _jsx("div", { className: "flex flex-col", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 overflow-y-auto", children: [_jsx("button", { type: "button", 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", "aria-label": "Open folders", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(MailShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
137
|
+
}
|
|
138
|
+
return (_jsx(AppShell, { active: "mail", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
|
|
139
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
const INPUT_CLASS = "flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
9
|
+
/**
|
|
10
|
+
* Shared condition-row editor and enabled/sequence/stop-processing chrome for a rule that matches
|
|
11
|
+
* conditions and runs actions in order — `TransportRule` (admin, org-wide) and `MailFilterRule`
|
|
12
|
+
* (mailbox-scoped) both have this same `{enabled, sequence, stopProcessingRules, conditions, actions}`
|
|
13
|
+
* shape. Conditions are driven by a declarative `conditionFields` list so both scopes share this one
|
|
14
|
+
* editor despite their `Conditions` types not being identical — `MailFilterConditions.importance`
|
|
15
|
+
* (`MailFilterRule`'s one condition field `TransportRuleConditions` has no equivalent of) is what forced
|
|
16
|
+
* this list beyond `"list"`/`"boolean"` to add a third `"select"` kind for a small fixed-choice field,
|
|
17
|
+
* confirming the shared editor still generalizes rather than needing a fork; actions are driven by a
|
|
18
|
+
* small per-scope `actionTypes` registry instead, since the two action vocabularies (reject/quarantine/
|
|
19
|
+
* add-header/add-recipient vs. move/copy/delete/mark-read/forward) are different enough that one
|
|
20
|
+
* component special-casing both would be worse than this split.
|
|
21
|
+
*/
|
|
22
|
+
export default function RuleBuilder({ value, onChange, conditionFields, actionTypes, }) {
|
|
23
|
+
const [draftText, setDraftText] = useState({});
|
|
24
|
+
const [newActionType, setNewActionType] = useState(actionTypes[0]?.value ?? "");
|
|
25
|
+
const conditions = value.conditions;
|
|
26
|
+
function patch(next) {
|
|
27
|
+
onChange({ ...value, ...next });
|
|
28
|
+
}
|
|
29
|
+
function addListEntry(key) {
|
|
30
|
+
const text = (draftText[key] ?? "").trim();
|
|
31
|
+
if (!text)
|
|
32
|
+
return;
|
|
33
|
+
const existing = conditions[key] ?? [];
|
|
34
|
+
if (existing.includes(text))
|
|
35
|
+
return;
|
|
36
|
+
patch({ conditions: { ...conditions, [key]: [...existing, text] } });
|
|
37
|
+
setDraftText({ ...draftText, [key]: "" });
|
|
38
|
+
}
|
|
39
|
+
function removeListEntry(key, entry) {
|
|
40
|
+
const existing = conditions[key] ?? [];
|
|
41
|
+
patch({ conditions: { ...conditions, [key]: existing.filter((e) => e !== entry) } });
|
|
42
|
+
}
|
|
43
|
+
function toggleBoolean(key) {
|
|
44
|
+
patch({ conditions: { ...conditions, [key]: !conditions[key] } });
|
|
45
|
+
}
|
|
46
|
+
function setSelect(key, value) {
|
|
47
|
+
patch({ conditions: { ...conditions, [key]: value || undefined } });
|
|
48
|
+
}
|
|
49
|
+
function addAction() {
|
|
50
|
+
const def = actionTypes.find((t) => t.value === newActionType);
|
|
51
|
+
if (!def)
|
|
52
|
+
return;
|
|
53
|
+
patch({ actions: [...value.actions, def.createDefault()] });
|
|
54
|
+
}
|
|
55
|
+
function updateAction(index, next) {
|
|
56
|
+
patch({ actions: value.actions.map((a, i) => (i === index ? next : a)) });
|
|
57
|
+
}
|
|
58
|
+
function removeAction(index) {
|
|
59
|
+
patch({ actions: value.actions.filter((_, i) => i !== index) });
|
|
60
|
+
}
|
|
61
|
+
return (_jsxs("div", { className: "flex flex-col gap-5", children: [_jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Conditions" }), _jsx("div", { className: "flex flex-col gap-4", children: conditionFields.map((field) => {
|
|
62
|
+
if (field.kind === "boolean") {
|
|
63
|
+
return (_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: !!conditions[field.key], onChange: () => toggleBoolean(field.key) }), field.label] }, field.key));
|
|
64
|
+
}
|
|
65
|
+
if (field.kind === "select") {
|
|
66
|
+
return (_jsxs("div", { children: [_jsx("label", { className: "text-sm font-semibold mb-1.5 block", htmlFor: `condition-${field.key}`, children: field.label }), _jsxs("select", { id: `condition-${field.key}`, className: INPUT_CLASS, value: conditions[field.key] ?? "", onChange: (e) => setSelect(field.key, e.target.value), children: [_jsx("option", { value: "", children: "Any" }), (field.options ?? []).map((opt) => (_jsx("option", { value: opt.value, children: opt.label }, opt.value)))] })] }, field.key));
|
|
67
|
+
}
|
|
68
|
+
return (_jsxs("div", { children: [_jsx("div", { className: "text-sm font-semibold mb-1.5", children: field.label }), _jsx("div", { className: "flex flex-wrap gap-2 mb-2", children: (conditions[field.key] ?? []).map((entry) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs py-1 px-2.5 rounded-pill bg-surface-alt", children: [entry, _jsx("button", { type: "button", "aria-label": `Remove ${entry}`, onClick: () => removeListEntry(field.key, entry), className: "text-text-muted hover:text-danger", children: "\u00D7" })] }, entry))) }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "text", "aria-label": field.label, className: INPUT_CLASS, placeholder: field.placeholder ?? "Add a value", value: draftText[field.key] ?? "", onChange: (e) => setDraftText({ ...draftText, [field.key]: e.target.value }), onKeyDown: (e) => {
|
|
69
|
+
if (e.key === "Enter") {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
addListEntry(field.key);
|
|
72
|
+
}
|
|
73
|
+
} }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => addListEntry(field.key), children: "Add" })] })] }, field.key));
|
|
74
|
+
}) })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Actions" }), _jsxs("div", { className: "flex flex-col gap-3 mb-4", children: [value.actions.length === 0 && _jsx("p", { className: "text-sm text-text-muted", children: "No actions yet." }), value.actions.map((action, index) => {
|
|
75
|
+
const def = actionTypes.find((t) => t.value === action.type);
|
|
76
|
+
return (_jsxs("div", { className: "border border-border rounded-sm p-3", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-semibold", children: def?.label ?? action.type }), _jsx("button", { type: "button", onClick: () => removeAction(index), className: "text-sm text-danger hover:underline", children: "Remove" })] }), def?.render(action, (next) => updateAction(index, next))] }, index));
|
|
77
|
+
})] }), _jsxs("div", { className: "flex gap-2", children: [_jsx("select", { "aria-label": "New action type", className: INPUT_CLASS, value: newActionType, onChange: (e) => setNewActionType(e.target.value), children: actionTypes.map((t) => (_jsx("option", { value: t.value, children: t.label }, t.value))) }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: addAction, children: "Add action" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsx("h2", { className: "text-base font-bold uppercase tracking-wide mb-4", children: "Settings" }), _jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: value.enabled, onChange: (e) => patch({ enabled: e.target.checked }) }), "Enabled"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "checkbox", checked: value.stopProcessingRules, onChange: (e) => patch({ stopProcessingRules: e.target.checked }) }), "Stop processing more rules once this one matches"] }), _jsxs("label", { className: "flex items-center gap-2 text-sm", children: ["Sequence (evaluation order, ascending)", _jsx("input", { type: "number", className: "w-20 text-sm py-1.5 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: value.sequence, onChange: (e) => patch({ sequence: Number(e.target.value) }) })] })] })] })] }));
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
|
+
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
/** Every settings section with its own sidebar entry. */
|
|
16
|
+
export const SETTINGS_SECTIONS = [
|
|
17
|
+
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
18
|
+
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
19
|
+
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
20
|
+
{ id: "focused-inbox", href: "/settings/focused-inbox", label: "Focused Inbox" },
|
|
21
|
+
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
22
|
+
{ id: "booking-types", href: "/settings/booking-types", label: "Booking Links" },
|
|
23
|
+
];
|
|
24
|
+
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
25
|
+
/** Reads the mailbox Settings is currently scoped to, as resolved by the enclosing `SettingsShell`. */
|
|
26
|
+
export function useSettingsShell() {
|
|
27
|
+
return useContext(SettingsShellContext);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The Settings area's shell — reached via a `UserMenu` entry rather than a 5th `AppShell` icon (per
|
|
31
|
+
* this plan's own confirmed decision), so it always passes `active="settings"` to `AppShell` regardless
|
|
32
|
+
* of which section is showing. Unlike `ContactsShell`/`TasksShell` (whose sidebar is just a mailbox
|
|
33
|
+
* switcher, since each has exactly one well-known folder), this shell's sidebar is a real list of
|
|
34
|
+
* settings sections (`SETTINGS_SECTIONS`) — full-page navigation between them, same as the top-level
|
|
35
|
+
* app rail, since this framework has no client-side router.
|
|
36
|
+
*/
|
|
37
|
+
export default function SettingsShell({ active, userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
38
|
+
const [status, setStatus] = useState("checking");
|
|
39
|
+
const [error, setError] = useState(null);
|
|
40
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
41
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
42
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
|
|
45
|
+
}, []);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!userUid) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
listMailboxes({ limit: 100 })
|
|
51
|
+
.then((result) => {
|
|
52
|
+
setMailboxes(result);
|
|
53
|
+
setStatus("ready");
|
|
54
|
+
})
|
|
55
|
+
.catch((err) => {
|
|
56
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
57
|
+
setStatus("error");
|
|
58
|
+
});
|
|
59
|
+
}, [userUid]);
|
|
60
|
+
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
61
|
+
mailboxes[0]?.uid;
|
|
62
|
+
const contextValue = useMemo(() => ({ mailboxUid, mailboxes }), [mailboxUid, mailboxes]);
|
|
63
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — same as ContactsShell/
|
|
64
|
+
// TasksShell, there's nothing else for a mailbox-less caller to configure here yet.
|
|
65
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
66
|
+
return _jsx(MailboxProvisioning, {});
|
|
67
|
+
}
|
|
68
|
+
let inner = null;
|
|
69
|
+
if (userUid && status === "checking") {
|
|
70
|
+
inner = (_jsxs("div", { className: "w-56 shrink-0 border-r border-border flex flex-col gap-4 p-3", children: [_jsx(Skeleton, { height: "h-9" }), _jsx(SkeletonList, { count: 3 })] }));
|
|
71
|
+
}
|
|
72
|
+
else if (userUid && status === "error") {
|
|
73
|
+
inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
74
|
+
}
|
|
75
|
+
else if (userUid && status === "ready") {
|
|
76
|
+
// A function, not a plain JSX constant — same reasoning as ContactsShell's identical comment:
|
|
77
|
+
// rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
|
|
78
|
+
// `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
|
|
79
|
+
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-settings-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-settings-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
80
|
+
// `active` always names a real entry in `SETTINGS_SECTIONS` — every settings
|
|
81
|
+
// page passes its own section id explicitly (see `SettingsShellProps.active`'s
|
|
82
|
+
// own doc comment), so this lookup can't miss.
|
|
83
|
+
const section = SETTINGS_SECTIONS.find((s) => s.id === active);
|
|
84
|
+
window.location.href = `${section.href}?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
85
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: SETTINGS_SECTIONS.map((section) => (_jsx("a", {
|
|
86
|
+
// `inner` (this whole branch) is only ever computed once `status === "ready"`
|
|
87
|
+
// and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
|
|
88
|
+
// `<MailboxProvisioning />` above instead (and TypeScript's own narrowing of
|
|
89
|
+
// this `const` already reflects that, no `!` needed) — so no fallback is
|
|
90
|
+
// needed here, matching this plan's established "dead guard the UI
|
|
91
|
+
// structurally can't trigger" removal precedent (e.g.
|
|
92
|
+
// `DomainDetailContent.handleVerify`).
|
|
93
|
+
href: `${section.href}?mailboxUid=${encodeURIComponent(mailboxUid)}`, "aria-current": section.id === active ? "page" : undefined, className: [
|
|
94
|
+
"block px-2.5 py-1.5 rounded-sm text-sm",
|
|
95
|
+
section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
|
|
96
|
+
].join(" "), children: section.label }, section.id))) })] }));
|
|
97
|
+
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
98
|
+
}
|
|
99
|
+
return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import { createTaskList, listTaskLists } from "@rapidmx/react-shared/tasks/tasksApi.js";
|
|
10
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
11
|
+
export function tasksViewKey(view) {
|
|
12
|
+
return view.type === "list" ? `list:${view.uid}` : view.type;
|
|
13
|
+
}
|
|
14
|
+
function NavItem({ label, count, active, onClick }) {
|
|
15
|
+
return (_jsxs("button", { type: "button", onClick: onClick, "aria-current": active ? "true" : undefined, className: [
|
|
16
|
+
"w-full flex items-center justify-between gap-2 text-sm text-left px-2.5 py-1.5 rounded-sm",
|
|
17
|
+
active ? "bg-primary/10 text-primary-dark font-semibold" : "text-text hover:bg-surface-alt",
|
|
18
|
+
].join(" "), children: [_jsx("span", { className: "truncate", children: label }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tasks' Outlook To-Do-style left sidebar. "My Day"/"Assigned to me" are real persisted fields
|
|
22
|
+
* (`Task.myDay`/`assignedTo`, from Phase 0); "Important"/"Planned" are pure smart-filters derived from
|
|
23
|
+
* already-loaded fields (`priority === "high"`/`dueDate != null`) with no backend concept of their own;
|
|
24
|
+
* "Flagged email" is a `Message` smart-filter (see `flaggedMessages.ts`), not a `Task` one at all — the
|
|
25
|
+
* page renders it as a distinct list, not the task table. "Tasks" is the flat, unfiltered default. Custom
|
|
26
|
+
* lists are real `TaskList` records, fetched here (same pattern as `ContactsSidebar`'s contact lists).
|
|
27
|
+
*/
|
|
28
|
+
export default function TasksSidebar({ mailboxUid, tasks, userUid, active, onSelect, refreshToken }) {
|
|
29
|
+
const [lists, setLists] = useState([]);
|
|
30
|
+
const [listsError, setListsError] = useState(null);
|
|
31
|
+
const [addingList, setAddingList] = useState(false);
|
|
32
|
+
const [newListName, setNewListName] = useState("");
|
|
33
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!mailboxUid) {
|
|
36
|
+
setLists([]);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setListsError(null);
|
|
40
|
+
listTaskLists(mailboxUid, { limit: 200 })
|
|
41
|
+
.then(setLists)
|
|
42
|
+
.catch((err) => setListsError(err instanceof ApiRequestError ? err.message : "Could not load task lists."));
|
|
43
|
+
}, [mailboxUid, refreshToken]);
|
|
44
|
+
const myDayCount = useMemo(() => tasks.filter((t) => t.myDay).length, [tasks]);
|
|
45
|
+
const importantCount = useMemo(() => tasks.filter((t) => t.priority === "high").length, [tasks]);
|
|
46
|
+
const plannedCount = useMemo(() => tasks.filter((t) => t.dueDate != null).length, [tasks]);
|
|
47
|
+
const assignedToMeCount = useMemo(() => tasks.filter((t) => t.assignedTo === userUid).length, [tasks, userUid]);
|
|
48
|
+
const listCounts = useMemo(() => {
|
|
49
|
+
const counts = new Map();
|
|
50
|
+
for (const t of tasks) {
|
|
51
|
+
if (t.taskListUid) {
|
|
52
|
+
counts.set(t.taskListUid, (counts.get(t.taskListUid) ?? 0) + 1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return counts;
|
|
56
|
+
}, [tasks]);
|
|
57
|
+
async function handleAddList(e) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
if (!mailboxUid || !newListName.trim()) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const created = await createTaskList({ mailboxUid, name: newListName.trim() });
|
|
64
|
+
setLists((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)));
|
|
65
|
+
setNewListName("");
|
|
66
|
+
setAddingList(false);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
setListsError(err instanceof ApiRequestError ? err.message : "Could not create this list.");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function handleSelect(view) {
|
|
73
|
+
onSelect(view);
|
|
74
|
+
setDrawerOpen(false);
|
|
75
|
+
}
|
|
76
|
+
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
77
|
+
// `Drawer` (see `ContactsSidebar` for the identical pattern and the duplicate-id reasoning behind
|
|
78
|
+
// the `idPrefix`).
|
|
79
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "My Day", count: myDayCount, active: active.type === "myDay", onClick: () => handleSelect({ type: "myDay" }) }), _jsx(NavItem, { label: "Important", count: importantCount, active: active.type === "important", onClick: () => handleSelect({ type: "important" }) }), _jsx(NavItem, { label: "Planned", count: plannedCount, active: active.type === "planned", onClick: () => handleSelect({ type: "planned" }) }), _jsx(NavItem, { label: "Assigned to me", count: assignedToMeCount, active: active.type === "assignedToMe", onClick: () => handleSelect({ type: "assignedToMe" }) }), _jsx(NavItem, { label: "Flagged email", active: active.type === "flagged", onClick: () => handleSelect({ type: "flagged" }) })] }), _jsx(NavItem, { label: "Tasks", count: tasks.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] })] }));
|
|
80
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", 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", "aria-label": "Open tasks menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Tasks", className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Tasks", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HiOutlineCheckCircle, HiOutlineListBullet, HiOutlineSun, HiOutlineTableCells, HiOutlineTrash } from "react-icons/hi2";
|
|
3
|
+
function ToolbarButton({ label, icon: Icon, active, disabled, onClick, }) {
|
|
4
|
+
return (_jsxs("button", { type: "button", "aria-label": label, "aria-pressed": active, title: label, disabled: disabled, onClick: onClick, className: [
|
|
5
|
+
"flex flex-col items-center gap-1 text-xs px-2.5 py-1.5 rounded-sm disabled:opacity-40 disabled:cursor-not-allowed",
|
|
6
|
+
active ? "bg-primary/10 text-primary-dark" : "text-text-muted hover:not-disabled:bg-surface-alt hover:not-disabled:text-text",
|
|
7
|
+
].join(" "), children: [_jsx(Icon, { size: 18, "aria-hidden": "true" }), _jsx("span", { children: label })] }));
|
|
8
|
+
}
|
|
9
|
+
/** Tasks' toolbar: a Grid/List view-mode toggle (local, not persisted) plus bulk actions for whatever
|
|
10
|
+
* rows are checked (Complete, Add to My Day, Delete) — deliberately presentational, matching
|
|
11
|
+
* `ContactsToolbar`'s convention, since the page owns the actual task data/selection. */
|
|
12
|
+
export default function TasksToolbar({ viewMode, onViewModeChange, selectedCount, onComplete, onDelete, onAddToMyDay }) {
|
|
13
|
+
const hasSelection = selectedCount > 0;
|
|
14
|
+
return (_jsxs("div", { role: "toolbar", "aria-label": "Tasks actions", className: "border-b border-border bg-surface-alt flex items-center gap-0.5 px-2 py-1", children: [_jsx(ToolbarButton, { label: "List", icon: HiOutlineListBullet, active: viewMode === "list", onClick: () => onViewModeChange("list") }), _jsx(ToolbarButton, { label: "Grid", icon: HiOutlineTableCells, active: viewMode === "grid", onClick: () => onViewModeChange("grid") }), _jsx("div", { className: "w-px self-stretch my-1 bg-border", "aria-hidden": "true" }), _jsx(ToolbarButton, { label: "Complete", icon: HiOutlineCheckCircle, disabled: !hasSelection, onClick: onComplete }), _jsx(ToolbarButton, { label: "Add to My Day", icon: HiOutlineSun, disabled: !hasSelection, onClick: onAddToMyDay }), _jsx(ToolbarButton, { label: "Delete", icon: HiOutlineTrash, disabled: !hasSelection, onClick: onDelete })] }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
7
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
8
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
10
|
+
import { listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
12
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
13
|
+
import AppShell from "../../layout/AppShell.js";
|
|
14
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
const TasksShellContext = createContext({ mailboxes: [] });
|
|
16
|
+
/** Reads the mailbox/folder Tasks is currently showing, as resolved by the enclosing `TasksShell`. */
|
|
17
|
+
export function useTasksShell() {
|
|
18
|
+
return useContext(TasksShellContext);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The Tasks app's shell — structurally identical to `ContactsShell` (a mailbox has exactly one
|
|
22
|
+
* well-known `tasks` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s eager provisioning
|
|
23
|
+
* in `@rapidmx/restapi`), just bound to a different folder type. Kept as its own small component
|
|
24
|
+
* (rather than a single generic "SingleFolderShell" parameterized by type) so each app's sidebar can
|
|
25
|
+
* grow its own app-specific controls later without threading extra props through a shared one.
|
|
26
|
+
*/
|
|
27
|
+
export default function TasksShell({ userUid, authServerUrl, impersonating, impersonationBaseUrl, trusted, children, }) {
|
|
28
|
+
const [status, setStatus] = useState("checking");
|
|
29
|
+
const [error, setError] = useState(null);
|
|
30
|
+
const [mailboxes, setMailboxes] = useState([]);
|
|
31
|
+
const [folders, setFolders] = useState([]);
|
|
32
|
+
const [folderError, setFolderError] = useState(null);
|
|
33
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState(null);
|
|
34
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
|
|
37
|
+
}, []);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!userUid) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
listMailboxes({ limit: 100 })
|
|
43
|
+
.then((result) => {
|
|
44
|
+
setMailboxes(result);
|
|
45
|
+
setStatus("ready");
|
|
46
|
+
})
|
|
47
|
+
.catch((err) => {
|
|
48
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
49
|
+
setStatus("error");
|
|
50
|
+
});
|
|
51
|
+
}, [userUid]);
|
|
52
|
+
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
53
|
+
mailboxes[0]?.uid;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!mailboxUid) {
|
|
56
|
+
setFolders([]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setFolderError(null);
|
|
60
|
+
listFolders(mailboxUid)
|
|
61
|
+
.then(setFolders)
|
|
62
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's tasks folder."));
|
|
63
|
+
}, [mailboxUid]);
|
|
64
|
+
const folderUid = folders.find((f) => f.type === "tasks")?.uid;
|
|
65
|
+
const contextValue = useMemo(() => ({ mailboxUid, folderUid, mailboxes, userUid }), [mailboxUid, folderUid, mailboxes, userUid]);
|
|
66
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
67
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
|
|
68
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
69
|
+
return _jsx(MailboxProvisioning, {});
|
|
70
|
+
}
|
|
71
|
+
let inner = null;
|
|
72
|
+
if (userUid && status === "checking") {
|
|
73
|
+
// Renders immediately (no network round trip needed) so switching into Tasks never shows a
|
|
74
|
+
// blank pane while `listMailboxes()` is in flight — the real sidebar (My Day/Important/Planned/
|
|
75
|
+
// lists) lives in `apps/www/tasks/index.tsx`'s own content, so this mimics its rough shape rather
|
|
76
|
+
// than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
|
|
77
|
+
inner = (_jsxs("aside", { className: "w-56 shrink-0 bg-surface border-r border-border flex flex-col p-3 gap-4", children: [_jsx(Skeleton, { height: "h-9" }), _jsx(SkeletonList, { count: 5 })] }));
|
|
78
|
+
}
|
|
79
|
+
else if (userUid && status === "error") {
|
|
80
|
+
inner = (_jsx("div", { className: "flex-1 flex items-center justify-center p-8", children: _jsx("div", { className: "w-full max-w-md", children: _jsx(Alert, { children: error }) }) }));
|
|
81
|
+
}
|
|
82
|
+
else if (userUid && status === "ready") {
|
|
83
|
+
// A function, not a plain JSX constant — see MailShell/ContactsShell/CalendarShell's identical
|
|
84
|
+
// comment: it's rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously
|
|
85
|
+
// mounted, so the `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
|
|
86
|
+
const sidebarContent = (idPrefix) => (_jsxs(_Fragment, { children: [mailboxes.length > 1 && (_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold uppercase tracking-wide text-text-muted mb-1", htmlFor: `${idPrefix}-tasks-mailbox-switcher`, children: "Mailbox" }), _jsx("select", { id: `${idPrefix}-tasks-mailbox-switcher`, className: "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface", value: mailboxUid, onChange: (e) => {
|
|
87
|
+
window.location.href = `/tasks?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
88
|
+
}, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), folderError && _jsx(Alert, { children: folderError })] }));
|
|
89
|
+
inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [(mailboxes.length > 1 || folderError) && (_jsx("button", { type: "button", 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", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(TasksShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
90
|
+
}
|
|
91
|
+
return (_jsx(AppShell, { active: "tasks", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, children: inner }));
|
|
92
|
+
}
|