@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.
Files changed (150) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-vPRTWiQ5.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DOl_Tss3.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-j08W3KBm.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-Bukw7zEe.js → auto-login-provider-DO5Il6Dp.js} +2 -2
  8. package/dist/assets/auto-login-provider-TQ08B6-i.js +1 -0
  9. package/dist/assets/auto-redirect-provider-ButXp0A3.js +1 -0
  10. package/dist/assets/auto-redirect-provider-DnGGP2Vn.js +1 -0
  11. package/dist/assets/auto-redirect-provider-ja-vFPaF.js +1 -0
  12. package/dist/assets/calendar-C7xnRgOT.js +1 -0
  13. package/dist/assets/{cas-sign-in-button-BQn3Mttx.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-BT5mL9gW.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-BJoadoA6.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-m8IbnJ1z.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-CYLPurwK.js → customGraphicKeyframeAnimation-DMU5crAu.js} +1 -1
  19. package/dist/assets/demo-BPlae2bp.js +1 -0
  20. package/dist/assets/demo-Bj_Y0RrW.js +1 -0
  21. package/dist/assets/demo-CKPAQOib.js +1 -0
  22. package/dist/assets/demo-CXRTPvIx.js +1 -0
  23. package/dist/assets/demo-DJuVDu1n.js +1 -0
  24. package/dist/assets/demo-DUmWU7ik.js +1 -0
  25. package/dist/assets/demo-NhIzFRE1.js +1 -0
  26. package/dist/assets/{dingtalk-sign-in-button-DSluFaP9.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-mRXoEciY.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-CvLr4Slh.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-BC1tvfQ0.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-DhNaV5B8.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-BiioS8t1.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-CAWqYvFh.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-RVbHvIiD.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Dw20Lzpp.js → index-CPzehPGe.js} +3 -3
  36. package/dist/assets/index-Cc6awvdg.js +1064 -0
  37. package/dist/assets/index-ChA0z9ke.js +38 -0
  38. package/dist/assets/{index-BysibBZu.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-C-tdHirk.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DbITmOUW.js → labelHelper-Dk5jkiD-.js} +1 -1
  42. package/dist/assets/ldap-sign-in-form-CKN4l7qM.js +1 -0
  43. package/dist/assets/{oidc-sign-in-button-Cgpa8Qzj.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-AU6RU0Qi.js → page-context-DAiW_dId.js} +4 -4
  45. package/dist/assets/prompt-card-rGZqpqkF.js +1 -0
  46. package/dist/assets/prompt-output-B4h3uLWf.js +1 -0
  47. package/dist/assets/saml-sign-in-button-B4ugaLIb.js +1 -0
  48. package/dist/assets/{sectorHelper-C6L782Ue.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-Bo2WLZuP.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-CoPxsICt.js → static.browser-kzzpH0Xt.js} +1 -1
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-ipgJ_3Ec.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-BzFeh7qe.js → use-authenticator-sign-in-CIj210ry.js} +1 -1
  55. package/dist/assets/use-dingtalk-sign-in-BW4kkXD3.js +1 -0
  56. package/dist/assets/{use-wecom-sign-in-PPIMLvGa.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/{wecom-sign-in-button-rUq9RW8R.js → wecom-sign-in-button-Dn_2zDab.js} +1 -1
  58. package/dist/index.html +2 -2
  59. package/dist/index.raw.html +2 -2
  60. package/package.json +9 -1
  61. package/src/App.tsx +5 -2
  62. package/src/app/extension.ts +2 -0
  63. package/src/app/extensions.tsx +18 -6
  64. package/src/app/route-access-guard.tsx +22 -0
  65. package/src/app/route-runtime.ts +173 -0
  66. package/src/app/routes.tsx +2 -2
  67. package/src/components/app-shell/header.tsx +56 -1
  68. package/src/components/app-shell/sidebar.tsx +14 -2
  69. package/src/extensions/nocobase-acl/README.md +28 -14
  70. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  71. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  72. package/src/extensions/nocobase-mail/README.md +74 -0
  73. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  74. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  75. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  76. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  77. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  78. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  79. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  80. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  81. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  82. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  83. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  84. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  85. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  86. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  87. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  89. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  90. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  91. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  92. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  93. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  94. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  95. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  96. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  97. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  98. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  99. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  100. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  101. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  102. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  103. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  104. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  108. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  109. package/src/extensions/nocobase-mail/index.ts +1 -0
  110. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  111. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  112. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  113. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  114. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  115. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  116. package/src/extensions/nocobase-users-example/index.ts +1 -0
  117. package/src/lib/nocobase/acl/menu.ts +17 -3
  118. package/src/lib/nocobase/acl/types.ts +4 -0
  119. package/src/locales/en-US.ts +2 -1
  120. package/src/locales/zh-CN.ts +2 -1
  121. package/src/providers/runtime-config.ts +18 -0
  122. package/src/routes.tsx +10 -0
  123. package/src/vite-env.d.ts +3 -0
  124. package/vite.config.ts +8 -0
  125. package/dist/assets/auth-method-demo-B2qcpY8z.js +0 -1
  126. package/dist/assets/auto-login-provider-BMtzvdxG.js +0 -1
  127. package/dist/assets/auto-redirect-provider-C6GNPg5r.js +0 -1
  128. package/dist/assets/auto-redirect-provider-D5c6kN2R.js +0 -1
  129. package/dist/assets/auto-redirect-provider-iTzGEDpU.js +0 -1
  130. package/dist/assets/calendar-Dv2RNnsr.js +0 -1
  131. package/dist/assets/card-C9Hpeq1c.js +0 -1
  132. package/dist/assets/createSeriesDataSimply-DKUV5b5e.js +0 -1
  133. package/dist/assets/demo-BWGOwqtJ.js +0 -1
  134. package/dist/assets/demo-BWbcvkya.js +0 -1
  135. package/dist/assets/demo-DIASSPgn.js +0 -1
  136. package/dist/assets/demo-DuZ6asUU.js +0 -1
  137. package/dist/assets/demo-Dxcy4bDE.js +0 -1
  138. package/dist/assets/demo-YpEC76uw.js +0 -1
  139. package/dist/assets/demo-wzkoR8T2.js +0 -1
  140. package/dist/assets/index-CBZbj45X.js +0 -58
  141. package/dist/assets/index-CgkdZ6Oc.js +0 -686
  142. package/dist/assets/index-NH3aqqQz.css +0 -1
  143. package/dist/assets/index-hSBzW-VA.js +0 -43
  144. package/dist/assets/ldap-sign-in-form-BOMNDTkK.js +0 -1
  145. package/dist/assets/prompt-card-FWugRgi-.js +0 -1
  146. package/dist/assets/prompt-output-DMTuTNab.js +0 -1
  147. package/dist/assets/saml-sign-in-button-qt8bMeoQ.js +0 -1
  148. package/dist/assets/shortcut-Bu7jT_-w.js +0 -47
  149. package/dist/assets/switch-DQkkIE7G.js +0 -1
  150. 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
+ }