@nocobase/portal-template-default 1.0.16 → 1.0.17
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/AGENTS.md +8 -0
- package/README.MD +4 -0
- package/dist/assets/{LineDraw-vPRTWiQ5.js → LineDraw-BBB5hwuJ.js} +1 -1
- package/dist/assets/{SymbolDraw-DOl_Tss3.js → SymbolDraw-CKS3uUb_.js} +1 -1
- package/dist/assets/{VisualMapping-j08W3KBm.js → VisualMapping-BCwaW3TD.js} +1 -1
- package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
- package/dist/assets/{auto-login-provider-Bukw7zEe.js → auto-login-provider-DO5Il6Dp.js} +2 -2
- package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
- package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
- package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
- package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
- package/dist/assets/calendar-C7xnRgOT.js +1 -0
- package/dist/assets/{cas-sign-in-button-BQn3Mttx.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
- package/dist/assets/{components-BT5mL9gW.js → components-_g-GjQeV.js} +3 -3
- package/dist/assets/{configuration-gate-BJoadoA6.js → configuration-gate-B4XVz7tS.js} +1 -1
- package/dist/assets/{createClipPathFromCoordSys-m8IbnJ1z.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
- package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
- package/dist/assets/{customGraphicKeyframeAnimation-CYLPurwK.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
- package/dist/assets/demo-BPlae2bp.js +1 -0
- package/dist/assets/demo-Bj_Y0RrW.js +1 -0
- package/dist/assets/demo-CKPAQOib.js +1 -0
- package/dist/assets/demo-CXRTPvIx.js +1 -0
- package/dist/assets/demo-DJuVDu1n.js +1 -0
- package/dist/assets/demo-DUmWU7ik.js +1 -0
- package/dist/assets/demo-NhIzFRE1.js +1 -0
- package/dist/assets/{dingtalk-sign-in-button-DSluFaP9.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
- package/dist/assets/{echarts-preview-mRXoEciY.js → echarts-preview-R96Pbqmz.js} +1 -1
- package/dist/assets/{echarts-runtime-CvLr4Slh.js → echarts-runtime-Ce_z2eX_.js} +3 -3
- package/dist/assets/{echarts-runtime-advanced-BC1tvfQ0.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
- package/dist/assets/{echarts-runtime-common-DhNaV5B8.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
- package/dist/assets/{echarts-runtime-components-BiioS8t1.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
- package/dist/assets/{echarts-runtime-hierarchy-CAWqYvFh.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
- package/dist/assets/{floating-RVbHvIiD.js → floating-O9WmiDXk.js} +2 -2
- package/dist/assets/index-BBKV6O5d.css +1 -0
- package/dist/assets/{index-Dw20Lzpp.js → index-CPzehPGe.js} +3 -3
- package/dist/assets/index-Cc6awvdg.js +1064 -0
- package/dist/assets/index-ChA0z9ke.js +38 -0
- package/dist/assets/{index-BysibBZu.js → index-DgB7uTMs.js} +2 -2
- package/dist/assets/{index-C-tdHirk.js → index-DroUAK80.js} +1 -1
- package/dist/assets/index-WKvygTBp.js +59 -0
- package/dist/assets/{labelHelper-DbITmOUW.js → labelHelper-Dk5jkiD-.js} +1 -1
- package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
- package/dist/assets/{oidc-sign-in-button-Cgpa8Qzj.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
- package/dist/assets/{page-context-AU6RU0Qi.js → page-context-DAiW_dId.js} +4 -4
- package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
- package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
- package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
- package/dist/assets/{sectorHelper-C6L782Ue.js → sectorHelper-BuxrrJrA.js} +1 -1
- package/dist/assets/shortcut-BhKXxJLs.js +47 -0
- package/dist/assets/{sms-sign-in-form-Bo2WLZuP.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
- package/dist/assets/{static.browser-CoPxsICt.js → static.browser-kzzpH0Xt.js} +1 -1
- package/dist/assets/switch-BdVxPIVU.js +1 -0
- package/dist/assets/{tool-cards-ipgJ_3Ec.js → tool-cards-CpczIke-.js} +3 -3
- package/dist/assets/{use-authenticator-sign-in-BzFeh7qe.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
- package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
- package/dist/assets/{use-wecom-sign-in-PPIMLvGa.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
- package/dist/assets/{wecom-sign-in-button-rUq9RW8R.js → wecom-sign-in-button-Dn_2zDab.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.raw.html +2 -2
- package/package.json +9 -1
- package/src/App.tsx +5 -2
- package/src/app/extension.ts +2 -0
- package/src/app/extensions.tsx +18 -6
- package/src/app/route-access-guard.tsx +22 -0
- package/src/app/route-runtime.ts +173 -0
- package/src/app/routes.tsx +2 -2
- package/src/components/app-shell/header.tsx +56 -1
- package/src/components/app-shell/sidebar.tsx +14 -2
- package/src/extensions/nocobase-acl/README.md +28 -14
- package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
- package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
- package/src/extensions/nocobase-mail/README.md +74 -0
- package/src/extensions/nocobase-mail/components/index.ts +79 -0
- package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
- package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
- package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
- package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
- package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
- package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
- package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
- package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
- package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
- package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
- package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
- package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
- package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
- package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
- package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
- package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
- package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
- package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
- package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
- package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
- package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
- package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
- package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
- package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
- package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
- package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
- package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
- package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
- package/src/extensions/nocobase-mail/components/types.ts +297 -0
- package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
- package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
- package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
- package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
- package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
- package/src/extensions/nocobase-mail/extension.tsx +105 -0
- package/src/extensions/nocobase-mail/index.ts +1 -0
- package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
- package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
- package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
- package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
- package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
- package/src/extensions/nocobase-users-example/extension.tsx +2 -100
- package/src/extensions/nocobase-users-example/index.ts +1 -0
- package/src/lib/nocobase/acl/menu.ts +17 -3
- package/src/lib/nocobase/acl/types.ts +4 -0
- package/src/locales/en-US.ts +2 -1
- package/src/locales/zh-CN.ts +2 -1
- package/src/providers/runtime-config.ts +18 -0
- package/src/routes.tsx +10 -0
- package/src/vite-env.d.ts +3 -0
- package/vite.config.ts +8 -0
- package/dist/assets/auth-method-demo-B2qcpY8z.js +0 -1
- package/dist/assets/auto-login-provider-BMtzvdxG.js +0 -1
- package/dist/assets/auto-redirect-provider-C6GNPg5r.js +0 -1
- package/dist/assets/auto-redirect-provider-D5c6kN2R.js +0 -1
- package/dist/assets/auto-redirect-provider-iTzGEDpU.js +0 -1
- package/dist/assets/calendar-Dv2RNnsr.js +0 -1
- package/dist/assets/card-C9Hpeq1c.js +0 -1
- package/dist/assets/createSeriesDataSimply-DKUV5b5e.js +0 -1
- package/dist/assets/demo-BWGOwqtJ.js +0 -1
- package/dist/assets/demo-BWbcvkya.js +0 -1
- package/dist/assets/demo-DIASSPgn.js +0 -1
- package/dist/assets/demo-DuZ6asUU.js +0 -1
- package/dist/assets/demo-Dxcy4bDE.js +0 -1
- package/dist/assets/demo-YpEC76uw.js +0 -1
- package/dist/assets/demo-wzkoR8T2.js +0 -1
- package/dist/assets/index-CBZbj45X.js +0 -58
- package/dist/assets/index-CgkdZ6Oc.js +0 -686
- package/dist/assets/index-NH3aqqQz.css +0 -1
- package/dist/assets/index-hSBzW-VA.js +0 -43
- package/dist/assets/ldap-sign-in-form-BOMNDTkK.js +0 -1
- package/dist/assets/prompt-card-FWugRgi-.js +0 -1
- package/dist/assets/prompt-output-DMTuTNab.js +0 -1
- package/dist/assets/saml-sign-in-button-qt8bMeoQ.js +0 -1
- package/dist/assets/shortcut-Bu7jT_-w.js +0 -47
- package/dist/assets/switch-DQkkIE7G.js +0 -1
- package/dist/assets/use-dingtalk-sign-in-mrq6K5Yx.js +0 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { format, parseISO } from "date-fns";
|
|
3
|
+
import { ChevronDown, Loader2, RefreshCw, RotateCcw, StopCircle } from "lucide-react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { mailApi } from "./mail-api";
|
|
6
|
+
import { useMailMassMessages } from "./use-mail-mass-messages";
|
|
7
|
+
import { MailMassMessageStatus, type MailMassMessage } from "./types";
|
|
8
|
+
import { Badge } from "@/components/ui/badge";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import {
|
|
11
|
+
AlertDialog,
|
|
12
|
+
AlertDialogAction,
|
|
13
|
+
AlertDialogCancel,
|
|
14
|
+
AlertDialogContent,
|
|
15
|
+
AlertDialogDescription,
|
|
16
|
+
AlertDialogFooter,
|
|
17
|
+
AlertDialogHeader,
|
|
18
|
+
AlertDialogTitle,
|
|
19
|
+
} from "@/components/ui/alert-dialog";
|
|
20
|
+
import { cn } from "@/lib/utils";
|
|
21
|
+
|
|
22
|
+
const STATUS_STYLES: Record<MailMassMessageStatus, string> = {
|
|
23
|
+
pending: "border-blue-500/25 bg-blue-500/10 text-blue-600",
|
|
24
|
+
sending: "border-blue-500/25 bg-blue-500/10 text-blue-600",
|
|
25
|
+
sent: "border-green-500/25 bg-green-500/10 text-green-600",
|
|
26
|
+
failed: "border-red-500/25 bg-red-500/10 text-red-600",
|
|
27
|
+
canceled: "border-border bg-muted text-muted-foreground",
|
|
28
|
+
some_sent: "border-amber-500/25 bg-amber-500/10 text-amber-600",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const STATUS_LABELS: Record<MailMassMessageStatus, string> = {
|
|
32
|
+
pending: "Pending",
|
|
33
|
+
sending: "Sending",
|
|
34
|
+
sent: "Sent",
|
|
35
|
+
failed: "Failed",
|
|
36
|
+
canceled: "Canceled",
|
|
37
|
+
some_sent: "Partially sent",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function formatDate(value?: string | null) {
|
|
41
|
+
if (!value) return "—";
|
|
42
|
+
try {
|
|
43
|
+
return format(parseISO(value), "MMM d, yyyy · HH:mm");
|
|
44
|
+
} catch {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resultText(result: unknown) {
|
|
50
|
+
if (!result) return undefined;
|
|
51
|
+
if (typeof result === "string") return result;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.stringify(result);
|
|
54
|
+
} catch {
|
|
55
|
+
return "Unknown error";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function canCancel(task: MailMassMessage) {
|
|
60
|
+
return [MailMassMessageStatus.PENDING, MailMassMessageStatus.SENDING].includes(
|
|
61
|
+
task.status
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function canResend(task: MailMassMessage) {
|
|
66
|
+
return [
|
|
67
|
+
MailMassMessageStatus.FAILED,
|
|
68
|
+
MailMassMessageStatus.CANCELED,
|
|
69
|
+
MailMassMessageStatus.SOME_SENT,
|
|
70
|
+
].includes(task.status);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function MailMassTracking() {
|
|
74
|
+
const roots = useMailMassMessages(null);
|
|
75
|
+
const [expandedId, setExpandedId] = useState<number>();
|
|
76
|
+
const children = useMailMassMessages(expandedId ?? -1, {
|
|
77
|
+
enabled: expandedId !== undefined,
|
|
78
|
+
});
|
|
79
|
+
const [confirm, setConfirm] = useState<{
|
|
80
|
+
action: "cancel" | "resend";
|
|
81
|
+
task: MailMassMessage;
|
|
82
|
+
}>();
|
|
83
|
+
const [working, setWorking] = useState(false);
|
|
84
|
+
|
|
85
|
+
const childProgress = useMemo(() => {
|
|
86
|
+
if (!expandedId || !children.messages.length) return undefined;
|
|
87
|
+
const sent = children.messages.filter(
|
|
88
|
+
(message) => message.status === MailMassMessageStatus.SENT
|
|
89
|
+
).length;
|
|
90
|
+
return { sent, total: children.messages.length };
|
|
91
|
+
}, [children.messages, expandedId]);
|
|
92
|
+
|
|
93
|
+
const runAction = async () => {
|
|
94
|
+
if (!confirm) return;
|
|
95
|
+
setWorking(true);
|
|
96
|
+
try {
|
|
97
|
+
if (confirm.action === "cancel") {
|
|
98
|
+
await mailApi.cancelMassMessage(confirm.task.id);
|
|
99
|
+
toast.success("Bulk send task canceled");
|
|
100
|
+
} else {
|
|
101
|
+
await mailApi.resendMassMessage(confirm.task.id);
|
|
102
|
+
toast.success("Bulk send task queued again");
|
|
103
|
+
}
|
|
104
|
+
setConfirm(undefined);
|
|
105
|
+
await Promise.all([roots.refresh(), children.refresh()]);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
toast.error(error instanceof Error ? error.message : "Bulk task action failed");
|
|
108
|
+
} finally {
|
|
109
|
+
setWorking(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const taskActions = (task: MailMassMessage) => (
|
|
114
|
+
<div className="flex items-center gap-1">
|
|
115
|
+
{canCancel(task) && (
|
|
116
|
+
<Button
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="xs"
|
|
119
|
+
onClick={(event) => {
|
|
120
|
+
event.stopPropagation();
|
|
121
|
+
setConfirm({ action: "cancel", task });
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<StopCircle /> Cancel
|
|
125
|
+
</Button>
|
|
126
|
+
)}
|
|
127
|
+
{canResend(task) && (
|
|
128
|
+
<Button
|
|
129
|
+
variant="ghost"
|
|
130
|
+
size="xs"
|
|
131
|
+
onClick={(event) => {
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
setConfirm({ action: "resend", task });
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<RotateCcw /> Resend
|
|
137
|
+
</Button>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
<div className="flex items-center justify-between">
|
|
145
|
+
<p className="text-sm text-muted-foreground">
|
|
146
|
+
Track delivery, cancel active jobs, and retry failed recipients.
|
|
147
|
+
</p>
|
|
148
|
+
<Button variant="outline" size="sm" onClick={() => void roots.refresh()}>
|
|
149
|
+
<RefreshCw /> Refresh
|
|
150
|
+
</Button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="overflow-hidden rounded-lg border">
|
|
154
|
+
{roots.loading && !roots.messages.length ? (
|
|
155
|
+
<div className="flex items-center justify-center py-12">
|
|
156
|
+
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
157
|
+
</div>
|
|
158
|
+
) : roots.messages.length ? (
|
|
159
|
+
roots.messages.map((task) => {
|
|
160
|
+
const expanded = expandedId === task.id;
|
|
161
|
+
const recipients = task.to.split(",").filter(Boolean).length;
|
|
162
|
+
return (
|
|
163
|
+
<div key={task.id} className="border-b last:border-0">
|
|
164
|
+
<div className="grid w-full grid-cols-[minmax(0,1fr)_120px_150px_auto] items-center gap-4 px-4 py-3 hover:bg-muted/40">
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className="col-span-3 grid grid-cols-subgrid items-center text-left"
|
|
168
|
+
onClick={() => setExpandedId(expanded ? undefined : task.id)}
|
|
169
|
+
>
|
|
170
|
+
<span className="min-w-0">
|
|
171
|
+
<span className="block truncate text-sm font-medium">
|
|
172
|
+
{task.message?.subject || "(no subject)"}
|
|
173
|
+
</span>
|
|
174
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
175
|
+
{recipients} recipients · from {task.from}
|
|
176
|
+
</span>
|
|
177
|
+
</span>
|
|
178
|
+
<Badge variant="outline" className={STATUS_STYLES[task.status]}>
|
|
179
|
+
{STATUS_LABELS[task.status]}
|
|
180
|
+
</Badge>
|
|
181
|
+
<span className="text-xs text-muted-foreground">
|
|
182
|
+
{formatDate(task.createdAt)}
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
<span className="flex items-center gap-2">
|
|
186
|
+
{taskActions(task)}
|
|
187
|
+
<Button
|
|
188
|
+
variant="ghost"
|
|
189
|
+
size="icon-sm"
|
|
190
|
+
title={expanded ? "Hide recipients" : "Show recipients"}
|
|
191
|
+
onClick={() => setExpandedId(expanded ? undefined : task.id)}
|
|
192
|
+
>
|
|
193
|
+
<ChevronDown
|
|
194
|
+
className={cn("size-4 transition-transform", expanded && "rotate-180")}
|
|
195
|
+
/>
|
|
196
|
+
</Button>
|
|
197
|
+
</span>
|
|
198
|
+
</div>
|
|
199
|
+
{expanded && (
|
|
200
|
+
<div className="border-t bg-muted/20 px-4 py-3">
|
|
201
|
+
{children.loading && !children.messages.length ? (
|
|
202
|
+
<Loader2 className="mx-auto size-4 animate-spin text-muted-foreground" />
|
|
203
|
+
) : children.messages.length ? (
|
|
204
|
+
<div className="space-y-2">
|
|
205
|
+
{childProgress && (
|
|
206
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
207
|
+
{childProgress.sent} of {childProgress.total} sent
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
{children.messages.map((child) => (
|
|
211
|
+
<div
|
|
212
|
+
key={child.id}
|
|
213
|
+
className="grid grid-cols-[minmax(0,1fr)_120px_150px_auto] items-center gap-4 rounded-md bg-background px-3 py-2"
|
|
214
|
+
>
|
|
215
|
+
<span className="truncate text-sm">{child.to}</span>
|
|
216
|
+
<Badge variant="outline" className={STATUS_STYLES[child.status]}>
|
|
217
|
+
{STATUS_LABELS[child.status]}
|
|
218
|
+
</Badge>
|
|
219
|
+
<span className="text-xs text-muted-foreground">
|
|
220
|
+
{formatDate(child.sendAt)}
|
|
221
|
+
</span>
|
|
222
|
+
{taskActions(child)}
|
|
223
|
+
{resultText(child.result) && (
|
|
224
|
+
<p className="col-span-full text-xs text-destructive">
|
|
225
|
+
{resultText(child.result)}
|
|
226
|
+
</p>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
) : (
|
|
232
|
+
<p className="py-3 text-center text-xs text-muted-foreground">
|
|
233
|
+
No recipient tasks found.
|
|
234
|
+
</p>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
})
|
|
241
|
+
) : (
|
|
242
|
+
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
243
|
+
No bulk send tasks yet.
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<AlertDialog open={Boolean(confirm)} onOpenChange={(open) => !open && setConfirm(undefined)}>
|
|
249
|
+
<AlertDialogContent>
|
|
250
|
+
<AlertDialogHeader>
|
|
251
|
+
<AlertDialogTitle>
|
|
252
|
+
{confirm?.action === "cancel" ? "Cancel bulk send?" : "Resend failed mail?"}
|
|
253
|
+
</AlertDialogTitle>
|
|
254
|
+
<AlertDialogDescription>
|
|
255
|
+
{confirm?.action === "cancel"
|
|
256
|
+
? "Recipients that were already sent will not be recalled."
|
|
257
|
+
: "Only failed or canceled recipients will be queued again."}
|
|
258
|
+
</AlertDialogDescription>
|
|
259
|
+
</AlertDialogHeader>
|
|
260
|
+
<AlertDialogFooter>
|
|
261
|
+
<AlertDialogCancel>Back</AlertDialogCancel>
|
|
262
|
+
<AlertDialogAction disabled={working} onClick={() => void runAction()}>
|
|
263
|
+
{working && <Loader2 className="animate-spin" />}
|
|
264
|
+
{confirm?.action === "cancel" ? "Cancel task" : "Resend"}
|
|
265
|
+
</AlertDialogAction>
|
|
266
|
+
</AlertDialogFooter>
|
|
267
|
+
</AlertDialogContent>
|
|
268
|
+
</AlertDialog>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Loader2, StickyNote } from "lucide-react";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import type { MailMessage, MailNote } from "./types";
|
|
5
|
+
import { mailApi } from "./mail-api";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
8
|
+
import {
|
|
9
|
+
Popover,
|
|
10
|
+
PopoverContent,
|
|
11
|
+
PopoverTrigger,
|
|
12
|
+
} from "@/components/ui/popover";
|
|
13
|
+
import { cn } from "@/lib/utils";
|
|
14
|
+
|
|
15
|
+
export function MailNoteEditor({
|
|
16
|
+
message,
|
|
17
|
+
onChange,
|
|
18
|
+
}: {
|
|
19
|
+
message: MailMessage;
|
|
20
|
+
onChange?: (note: MailNote | undefined) => void;
|
|
21
|
+
}) {
|
|
22
|
+
const note = message.note?.[0];
|
|
23
|
+
const hasNote = Boolean(note?.note);
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [draft, setDraft] = useState(note?.note ?? "");
|
|
26
|
+
const [saving, setSaving] = useState(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setDraft(note?.note ?? "");
|
|
30
|
+
}, [note]);
|
|
31
|
+
|
|
32
|
+
const save = async () => {
|
|
33
|
+
setSaving(true);
|
|
34
|
+
try {
|
|
35
|
+
if (note) {
|
|
36
|
+
await mailApi.updateNote(note.id, message.id, draft);
|
|
37
|
+
onChange?.(draft.trim() ? { ...note, note: draft } : undefined);
|
|
38
|
+
} else {
|
|
39
|
+
const created = await mailApi.createNote(message.id, draft);
|
|
40
|
+
onChange?.(created);
|
|
41
|
+
}
|
|
42
|
+
setOpen(false);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
toast.error(error instanceof Error ? error.message : "Failed to save note");
|
|
45
|
+
} finally {
|
|
46
|
+
setSaving(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
52
|
+
<PopoverTrigger
|
|
53
|
+
render={
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className={cn("text-left", hasNote ? "block w-full" : "inline-flex")}
|
|
57
|
+
/>
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
{hasNote ? (
|
|
61
|
+
<span className="block w-full rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 transition-colors hover:bg-amber-500/15 dark:border-amber-400/25 dark:bg-amber-400/10 dark:hover:bg-amber-400/15">
|
|
62
|
+
<span className="flex items-start gap-2">
|
|
63
|
+
<StickyNote className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
64
|
+
<span className="line-clamp-3 text-xs leading-5 whitespace-pre-wrap text-foreground/90">
|
|
65
|
+
{note?.note}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
</span>
|
|
69
|
+
) : (
|
|
70
|
+
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground">
|
|
71
|
+
<StickyNote className="size-3.5" />
|
|
72
|
+
Add note
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</PopoverTrigger>
|
|
76
|
+
<PopoverContent align="start" className="w-80 gap-2">
|
|
77
|
+
<div className="px-1 text-xs font-semibold text-muted-foreground">
|
|
78
|
+
{hasNote ? "Edit note" : "Add note"}
|
|
79
|
+
</div>
|
|
80
|
+
<Textarea
|
|
81
|
+
value={draft}
|
|
82
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
83
|
+
placeholder="Leave yourself a note about this email…"
|
|
84
|
+
className="min-h-24 text-sm"
|
|
85
|
+
autoFocus
|
|
86
|
+
/>
|
|
87
|
+
<div className="flex items-center justify-end gap-1.5">
|
|
88
|
+
<Button variant="ghost" size="xs" onClick={() => setOpen(false)}>
|
|
89
|
+
Cancel
|
|
90
|
+
</Button>
|
|
91
|
+
<Button size="xs" onClick={() => void save()} disabled={saving}>
|
|
92
|
+
{saving ? <Loader2 className="animate-spin" /> : null}
|
|
93
|
+
Save
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</PopoverContent>
|
|
97
|
+
</Popover>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Combobox } from "@base-ui/react/combobox";
|
|
3
|
+
import { Check, Loader2, UserRound, X } from "lucide-react";
|
|
4
|
+
import { mailApi } from "./mail-api";
|
|
5
|
+
import type { MailRecipientOption } from "./types";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
export function currentToken(value: string) {
|
|
9
|
+
return value.split(/[,;\n]/).at(-1)?.trim().toLocaleLowerCase() ?? "";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function appendRecipient(value: string, email: string) {
|
|
13
|
+
const endsWithSeparator = /[,;\n]\s*$/.test(value);
|
|
14
|
+
const entries = value
|
|
15
|
+
.split(/[,;\n]/)
|
|
16
|
+
.map((item) => item.trim())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
if (!endsWithSeparator && entries.length) entries.pop();
|
|
19
|
+
if (!entries.some((item) => item.toLocaleLowerCase() === email.toLocaleLowerCase())) {
|
|
20
|
+
entries.push(email);
|
|
21
|
+
}
|
|
22
|
+
return entries.join(", ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function mergeRecipients(value: string, additions: string) {
|
|
26
|
+
const entries = `${value},${additions}`
|
|
27
|
+
.split(/[,;\n]/)
|
|
28
|
+
.map((item) => item.trim())
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
return entries
|
|
31
|
+
.filter(
|
|
32
|
+
(item, index) =>
|
|
33
|
+
entries.findIndex(
|
|
34
|
+
(candidate) =>
|
|
35
|
+
candidate.toLocaleLowerCase() === item.toLocaleLowerCase()
|
|
36
|
+
) === index
|
|
37
|
+
)
|
|
38
|
+
.join(", ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MailRecipientInputProps {
|
|
42
|
+
value: string;
|
|
43
|
+
onChange: (value: string) => void;
|
|
44
|
+
options?: MailRecipientOption[];
|
|
45
|
+
placeholder?: string;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function MailRecipientInput({
|
|
50
|
+
value,
|
|
51
|
+
onChange,
|
|
52
|
+
options,
|
|
53
|
+
placeholder,
|
|
54
|
+
className,
|
|
55
|
+
}: MailRecipientInputProps) {
|
|
56
|
+
const [users, setUsers] = useState<MailRecipientOption[]>([]);
|
|
57
|
+
const [loading, setLoading] = useState(true);
|
|
58
|
+
const [open, setOpen] = useState(false);
|
|
59
|
+
const [query, setQuery] = useState("");
|
|
60
|
+
const selectingOption = useRef(false);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
let active = true;
|
|
64
|
+
setLoading(true);
|
|
65
|
+
mailApi
|
|
66
|
+
.getUsers()
|
|
67
|
+
.then((records) => {
|
|
68
|
+
if (!active) return;
|
|
69
|
+
const nocobaseUsers = records.flatMap((record) =>
|
|
70
|
+
record.email
|
|
71
|
+
? [{ email: record.email, name: record.nickname || record.username }]
|
|
72
|
+
: []
|
|
73
|
+
);
|
|
74
|
+
const merged = [...nocobaseUsers, ...(options ?? [])];
|
|
75
|
+
setUsers(
|
|
76
|
+
merged.filter(
|
|
77
|
+
(item, index) =>
|
|
78
|
+
merged.findIndex(
|
|
79
|
+
(candidate) =>
|
|
80
|
+
candidate.email.toLocaleLowerCase() === item.email.toLocaleLowerCase()
|
|
81
|
+
) === index
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
})
|
|
85
|
+
.catch(() => active && setUsers(options ?? []))
|
|
86
|
+
.finally(() => active && setLoading(false));
|
|
87
|
+
return () => {
|
|
88
|
+
active = false;
|
|
89
|
+
};
|
|
90
|
+
}, [options]);
|
|
91
|
+
|
|
92
|
+
const recipients = useMemo(
|
|
93
|
+
() =>
|
|
94
|
+
value
|
|
95
|
+
.split(/[,;\n]/)
|
|
96
|
+
.map((item) => item.trim())
|
|
97
|
+
.filter(
|
|
98
|
+
(item, index, entries) =>
|
|
99
|
+
item &&
|
|
100
|
+
entries.findIndex(
|
|
101
|
+
(candidate) =>
|
|
102
|
+
candidate.toLocaleLowerCase() === item.toLocaleLowerCase()
|
|
103
|
+
) === index
|
|
104
|
+
),
|
|
105
|
+
[value]
|
|
106
|
+
);
|
|
107
|
+
const selected = useMemo(
|
|
108
|
+
() => new Set(recipients.map((item) => item.toLocaleLowerCase())),
|
|
109
|
+
[recipients]
|
|
110
|
+
);
|
|
111
|
+
const matches = users
|
|
112
|
+
.filter((item) => {
|
|
113
|
+
const token = query.trim().toLocaleLowerCase();
|
|
114
|
+
if (!token) return true;
|
|
115
|
+
return `${item.name ?? ""} ${item.email} ${item.description ?? ""}`
|
|
116
|
+
.toLocaleLowerCase()
|
|
117
|
+
.includes(token);
|
|
118
|
+
})
|
|
119
|
+
.slice(0, 8);
|
|
120
|
+
|
|
121
|
+
const commitRecipients = useCallback((rawValue: string) => {
|
|
122
|
+
onChange(mergeRecipients(value, rawValue));
|
|
123
|
+
setQuery("");
|
|
124
|
+
}, [onChange, value]);
|
|
125
|
+
|
|
126
|
+
const commitQuery = useCallback(() => {
|
|
127
|
+
commitRecipients(query);
|
|
128
|
+
}, [commitRecipients, query]);
|
|
129
|
+
|
|
130
|
+
const removeRecipient = useCallback(
|
|
131
|
+
(email: string) => {
|
|
132
|
+
onChange(
|
|
133
|
+
recipients
|
|
134
|
+
.filter((item) => item.toLocaleLowerCase() !== email.toLocaleLowerCase())
|
|
135
|
+
.join(", ")
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
[onChange, recipients]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Combobox.Root
|
|
143
|
+
multiple
|
|
144
|
+
open={open}
|
|
145
|
+
onOpenChange={setOpen}
|
|
146
|
+
inputValue={query}
|
|
147
|
+
onInputValueChange={(nextValue, details) => {
|
|
148
|
+
if (details.reason !== "input-change" && details.reason !== "none") return;
|
|
149
|
+
if (/[,;\n]\s*$/.test(nextValue)) {
|
|
150
|
+
commitRecipients(nextValue);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setQuery(nextValue);
|
|
154
|
+
setOpen(true);
|
|
155
|
+
}}
|
|
156
|
+
value={recipients}
|
|
157
|
+
onValueChange={(nextRecipients) => {
|
|
158
|
+
onChange(nextRecipients.join(", "));
|
|
159
|
+
setQuery("");
|
|
160
|
+
selectingOption.current = false;
|
|
161
|
+
}}
|
|
162
|
+
filter={null}
|
|
163
|
+
autoHighlight
|
|
164
|
+
>
|
|
165
|
+
<Combobox.InputGroup
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex min-h-9 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-2 py-1 text-sm transition-colors outline-none focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/30",
|
|
168
|
+
className
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
{recipients.map((email) => (
|
|
172
|
+
<span
|
|
173
|
+
key={email.toLocaleLowerCase()}
|
|
174
|
+
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs"
|
|
175
|
+
>
|
|
176
|
+
<span className="max-w-48 truncate">{email}</span>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
title={`Remove ${email}`}
|
|
180
|
+
aria-label={`Remove ${email}`}
|
|
181
|
+
className="rounded-sm text-muted-foreground hover:text-foreground"
|
|
182
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
183
|
+
onClick={() => removeRecipient(email)}
|
|
184
|
+
>
|
|
185
|
+
<X className="size-3" />
|
|
186
|
+
</button>
|
|
187
|
+
</span>
|
|
188
|
+
))}
|
|
189
|
+
<Combobox.Input
|
|
190
|
+
aria-label={placeholder}
|
|
191
|
+
placeholder={recipients.length ? undefined : placeholder}
|
|
192
|
+
className="h-6 min-w-28 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground"
|
|
193
|
+
onFocus={() => setOpen(true)}
|
|
194
|
+
onBlur={() => {
|
|
195
|
+
if (!selectingOption.current) commitQuery();
|
|
196
|
+
}}
|
|
197
|
+
onKeyDown={(event) => {
|
|
198
|
+
if ((event.key === "," || event.key === ";") && query.trim()) {
|
|
199
|
+
event.preventDefault();
|
|
200
|
+
commitQuery();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (event.key === "Enter" && query.trim() && matches.length === 0) {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
commitQuery();
|
|
206
|
+
}
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
</Combobox.InputGroup>
|
|
210
|
+
<Combobox.Portal>
|
|
211
|
+
<Combobox.Positioner
|
|
212
|
+
side="bottom"
|
|
213
|
+
align="start"
|
|
214
|
+
sideOffset={4}
|
|
215
|
+
className="isolate z-50 w-[var(--anchor-width)] min-w-72"
|
|
216
|
+
>
|
|
217
|
+
<Combobox.Popup
|
|
218
|
+
initialFocus={false}
|
|
219
|
+
className="rounded-lg bg-popover p-1.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden"
|
|
220
|
+
>
|
|
221
|
+
<Combobox.List className="max-h-72 overflow-y-auto outline-hidden">
|
|
222
|
+
{loading ? (
|
|
223
|
+
<div className="flex items-center justify-center py-4">
|
|
224
|
+
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
|
225
|
+
</div>
|
|
226
|
+
) : matches.length ? (
|
|
227
|
+
matches.map((item) => {
|
|
228
|
+
const isSelected = selected.has(item.email.toLocaleLowerCase());
|
|
229
|
+
return (
|
|
230
|
+
<Combobox.Item
|
|
231
|
+
key={item.email}
|
|
232
|
+
value={item.email}
|
|
233
|
+
onMouseDown={() => {
|
|
234
|
+
selectingOption.current = true;
|
|
235
|
+
}}
|
|
236
|
+
className="flex w-full cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm outline-hidden data-highlighted:bg-muted"
|
|
237
|
+
>
|
|
238
|
+
<UserRound className="size-4 shrink-0 text-muted-foreground" />
|
|
239
|
+
<span className="min-w-0 flex-1">
|
|
240
|
+
<span className="block truncate font-medium">{item.name || item.email}</span>
|
|
241
|
+
{item.name && (
|
|
242
|
+
<span className="block truncate text-xs text-muted-foreground">{item.email}</span>
|
|
243
|
+
)}
|
|
244
|
+
</span>
|
|
245
|
+
{isSelected && <Check className="size-4 text-primary" />}
|
|
246
|
+
</Combobox.Item>
|
|
247
|
+
);
|
|
248
|
+
})
|
|
249
|
+
) : (
|
|
250
|
+
<p className="px-2.5 py-3 text-xs text-muted-foreground">
|
|
251
|
+
Type a complete email address to add it directly.
|
|
252
|
+
</p>
|
|
253
|
+
)}
|
|
254
|
+
</Combobox.List>
|
|
255
|
+
</Combobox.Popup>
|
|
256
|
+
</Combobox.Positioner>
|
|
257
|
+
</Combobox.Portal>
|
|
258
|
+
</Combobox.Root>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface MailReplyContent {
|
|
2
|
+
body: string;
|
|
3
|
+
replyBody?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const replyQuotePattern =
|
|
7
|
+
/<div\b(?=[^>]*\bdata-role=["']reply-quote["'])[^>]*>\s*<blockquote\b(?=[^>]*\btype=["']cite["'])[^>]*>([\s\S]*?)<\/blockquote>\s*<\/div>\s*$/i;
|
|
8
|
+
|
|
9
|
+
export function serializeReplyQuote(body: string, replyBody?: string) {
|
|
10
|
+
if (!replyBody) return body;
|
|
11
|
+
return [
|
|
12
|
+
body,
|
|
13
|
+
'<div class="nocobase-quote nb-mail-quote" data-role="reply-quote">',
|
|
14
|
+
'<blockquote type="cite" style="border-left: 1px solid #ccc; padding-left: 8px; margin: 0;">',
|
|
15
|
+
replyBody,
|
|
16
|
+
"</blockquote>",
|
|
17
|
+
"</div>",
|
|
18
|
+
].join("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function splitReplyQuote(value: string): MailReplyContent {
|
|
22
|
+
const match = replyQuotePattern.exec(value);
|
|
23
|
+
if (!match || match.index === undefined) return { body: value };
|
|
24
|
+
return {
|
|
25
|
+
body: value.slice(0, match.index),
|
|
26
|
+
replyBody: match[1],
|
|
27
|
+
};
|
|
28
|
+
}
|