@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,287 @@
1
+ import { useCallback, useEffect, type ReactNode } from "react";
2
+ import { EditorContent, useEditor } from "@tiptap/react";
3
+ import { StarterKit } from "@tiptap/starter-kit";
4
+ import { Link } from "@tiptap/extension-link";
5
+ import { Placeholder } from "@tiptap/extension-placeholder";
6
+ import { FontSize, TextStyle } from "@tiptap/extension-text-style";
7
+ import {
8
+ Bold,
9
+ Italic,
10
+ Underline,
11
+ List,
12
+ ListOrdered,
13
+ Link2,
14
+ RemoveFormatting,
15
+ } from "lucide-react";
16
+ import { Toggle } from "@/components/ui/toggle";
17
+ import { Separator } from "@/components/ui/separator";
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from "@/components/ui/select";
25
+ import { cn } from "@/lib/utils";
26
+
27
+ export interface MailRichEditorProps {
28
+ value: string;
29
+ onChange: (html: string) => void;
30
+ placeholder?: string;
31
+ disabled?: boolean;
32
+ toolbarActions?: ReactNode;
33
+ className?: string;
34
+ }
35
+
36
+ const placeholderRules = [
37
+ "before:content-[attr(data-placeholder)]",
38
+ "before:text-muted-foreground",
39
+ "before:float-left",
40
+ "before:h-0",
41
+ "before:pointer-events-none",
42
+ ].map((rule) => `[&_p.is-editor-empty:first-child]:${rule}`);
43
+
44
+ const editorContentClass = cn(
45
+ "min-h-44 w-full resize-y bg-transparent px-3 py-2.5 text-sm leading-6 outline-none",
46
+ "[&_p]:min-h-[1.5em]",
47
+ "[&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5",
48
+ "[&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5",
49
+ "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
50
+ "[&_strong]:font-semibold",
51
+ "[&_h1]:my-2 [&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:leading-tight",
52
+ "[&_h2]:my-2 [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:leading-tight",
53
+ "[&_h3]:my-1.5 [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:leading-snug",
54
+ "[&_h4]:my-1.5 [&_h4]:text-base [&_h4]:font-semibold [&_h4]:leading-snug",
55
+ "[&_h5]:my-1 [&_h5]:text-sm [&_h5]:font-semibold [&_h5]:leading-normal",
56
+ "[&_blockquote]:my-1 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
57
+ ...placeholderRules
58
+ );
59
+
60
+ function ToolbarButton({
61
+ pressed,
62
+ disabled,
63
+ onClick,
64
+ title,
65
+ children,
66
+ }: {
67
+ pressed?: boolean;
68
+ disabled?: boolean;
69
+ onClick: () => void;
70
+ title: string;
71
+ children: ReactNode;
72
+ }) {
73
+ return (
74
+ <Toggle
75
+ size="sm"
76
+ pressed={pressed}
77
+ disabled={disabled}
78
+ onPressedChange={onClick}
79
+ title={title}
80
+ aria-label={title}
81
+ className="size-7"
82
+ >
83
+ {children}
84
+ </Toggle>
85
+ );
86
+ }
87
+
88
+ export function MailRichEditor({
89
+ value,
90
+ onChange,
91
+ placeholder = "Write your message…",
92
+ disabled,
93
+ toolbarActions,
94
+ className,
95
+ }: MailRichEditorProps) {
96
+ const editor = useEditor({
97
+ extensions: [
98
+ StarterKit.configure({ link: false }),
99
+ TextStyle,
100
+ FontSize,
101
+ Link.configure({ openOnClick: false, autolink: true }),
102
+ Placeholder.configure({ placeholder }),
103
+ ],
104
+ content: value,
105
+ editable: !disabled,
106
+ editorProps: {
107
+ attributes: {
108
+ class: editorContentClass,
109
+ },
110
+ },
111
+ onUpdate: ({ editor }) => {
112
+ onChange(editor.isEmpty ? "" : editor.getHTML());
113
+ },
114
+ });
115
+
116
+ useEffect(() => {
117
+ if (!editor) return;
118
+ const current = editor.isEmpty ? "" : editor.getHTML();
119
+ if (value !== current) {
120
+ editor.commands.setContent(value || "", { emitUpdate: false });
121
+ }
122
+ }, [editor, value]);
123
+
124
+ useEffect(() => {
125
+ if (!editor) return;
126
+ editor.setEditable(!disabled);
127
+ }, [editor, disabled]);
128
+
129
+ const setLink = useCallback(() => {
130
+ if (!editor) return;
131
+ const previous = editor.getAttributes("link").href as string | undefined;
132
+ const url = window.prompt("Link URL", previous ?? "https://");
133
+ if (url === null) return;
134
+ if (url === "") {
135
+ editor.chain().focus().extendMarkRange("link").unsetLink().run();
136
+ return;
137
+ }
138
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
139
+ }, [editor]);
140
+
141
+ const headingValue = ([1, 2, 3, 4, 5] as const)
142
+ .find((level) => editor?.isActive("heading", { level }))
143
+ ?.toString() ?? "paragraph";
144
+ const fontSize = (editor?.getAttributes("textStyle").fontSize as string | undefined) ?? "default";
145
+ const headingLabel = headingValue === "paragraph" ? "P" : `H${headingValue}`;
146
+ const fontSizeLabel = fontSize === "default" ? "14" : fontSize.replace("px", "");
147
+
148
+ return (
149
+ <div
150
+ className={cn(
151
+ "overflow-hidden rounded-md border border-input bg-background transition-colors focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
152
+ className
153
+ )}
154
+ >
155
+ <div className="flex flex-wrap items-center gap-0.5 border-b border-border/70 bg-muted/40 px-1.5 py-1">
156
+ <Select
157
+ value={headingValue}
158
+ onValueChange={(next) => {
159
+ if (!editor || !next) return;
160
+ if (next === "paragraph") {
161
+ editor.chain().focus().setParagraph().run();
162
+ return;
163
+ }
164
+ editor
165
+ .chain()
166
+ .focus()
167
+ .setHeading({ level: Number(next) as 1 | 2 | 3 | 4 | 5 })
168
+ .run();
169
+ }}
170
+ disabled={disabled}
171
+ >
172
+ <SelectTrigger size="sm" className="w-14" aria-label="Text style">
173
+ <SelectValue>{headingLabel}</SelectValue>
174
+ </SelectTrigger>
175
+ <SelectContent>
176
+ <SelectItem value="paragraph">P</SelectItem>
177
+ <SelectItem value="1">H1</SelectItem>
178
+ <SelectItem value="2">H2</SelectItem>
179
+ <SelectItem value="3">H3</SelectItem>
180
+ <SelectItem value="4">H4</SelectItem>
181
+ <SelectItem value="5">H5</SelectItem>
182
+ </SelectContent>
183
+ </Select>
184
+
185
+ <Select
186
+ value={fontSize}
187
+ onValueChange={(next) => {
188
+ if (!editor || !next) return;
189
+ const chain = editor.chain().focus();
190
+ if (next === "default") chain.unsetFontSize().run();
191
+ else chain.setFontSize(next).run();
192
+ }}
193
+ disabled={disabled}
194
+ >
195
+ <SelectTrigger size="sm" className="w-20" aria-label="Font size">
196
+ <SelectValue>{fontSizeLabel}</SelectValue>
197
+ </SelectTrigger>
198
+ <SelectContent>
199
+ {[
200
+ { value: "12px", label: "12" },
201
+ { value: "default", label: "14" },
202
+ { value: "16px", label: "16" },
203
+ { value: "18px", label: "18" },
204
+ { value: "24px", label: "24" },
205
+ { value: "32px", label: "32" },
206
+ ].map((size) => (
207
+ <SelectItem key={size.value} value={size.value}>{size.label}</SelectItem>
208
+ ))}
209
+ </SelectContent>
210
+ </Select>
211
+
212
+ <Separator orientation="vertical" className="mx-1 h-5" />
213
+
214
+ <ToolbarButton
215
+ title="Bold"
216
+ pressed={editor?.isActive("bold")}
217
+ disabled={disabled}
218
+ onClick={() => editor?.chain().focus().toggleBold().run()}
219
+ >
220
+ <Bold />
221
+ </ToolbarButton>
222
+ <ToolbarButton
223
+ title="Italic"
224
+ pressed={editor?.isActive("italic")}
225
+ disabled={disabled}
226
+ onClick={() => editor?.chain().focus().toggleItalic().run()}
227
+ >
228
+ <Italic />
229
+ </ToolbarButton>
230
+ <ToolbarButton
231
+ title="Underline"
232
+ pressed={editor?.isActive("underline")}
233
+ disabled={disabled}
234
+ onClick={() => editor?.chain().focus().toggleUnderline().run()}
235
+ >
236
+ <Underline />
237
+ </ToolbarButton>
238
+
239
+ <Separator orientation="vertical" className="mx-1 h-5" />
240
+
241
+ <ToolbarButton
242
+ title="Bulleted list"
243
+ pressed={editor?.isActive("bulletList")}
244
+ disabled={disabled}
245
+ onClick={() => editor?.chain().focus().toggleBulletList().run()}
246
+ >
247
+ <List />
248
+ </ToolbarButton>
249
+ <ToolbarButton
250
+ title="Numbered list"
251
+ pressed={editor?.isActive("orderedList")}
252
+ disabled={disabled}
253
+ onClick={() => editor?.chain().focus().toggleOrderedList().run()}
254
+ >
255
+ <ListOrdered />
256
+ </ToolbarButton>
257
+
258
+ <Separator orientation="vertical" className="mx-1 h-5" />
259
+
260
+ <ToolbarButton
261
+ title="Link"
262
+ pressed={editor?.isActive("link")}
263
+ disabled={disabled}
264
+ onClick={setLink}
265
+ >
266
+ <Link2 />
267
+ </ToolbarButton>
268
+ <ToolbarButton
269
+ title="Clear formatting"
270
+ disabled={disabled}
271
+ onClick={() =>
272
+ editor?.chain().focus().clearNodes().unsetAllMarks().run()
273
+ }
274
+ >
275
+ <RemoveFormatting />
276
+ </ToolbarButton>
277
+ {toolbarActions && (
278
+ <div className="ml-auto flex items-center gap-1 border-l border-border/70 pl-1.5">
279
+ {toolbarActions}
280
+ </div>
281
+ )}
282
+ </div>
283
+
284
+ <EditorContent editor={editor} />
285
+ </div>
286
+ );
287
+ }
@@ -0,0 +1,238 @@
1
+ import { useMemo, useState } from "react";
2
+ import { CalendarClock, ChevronDown, Loader2, Send, Users } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger,
17
+ } from "@/components/ui/dropdown-menu";
18
+ import { Input } from "@/components/ui/input";
19
+ import { Label } from "@/components/ui/label";
20
+
21
+ const MINUTE = 60_000;
22
+
23
+ function toDateTimeLocalValue(date: Date) {
24
+ const localTime = date.getTime() - date.getTimezoneOffset() * MINUTE;
25
+ return new Date(localTime).toISOString().slice(0, 16);
26
+ }
27
+
28
+ function defaultScheduleTime() {
29
+ const date = new Date(Date.now() + 10 * MINUTE);
30
+ date.setSeconds(0, 0);
31
+ return toDateTimeLocalValue(date);
32
+ }
33
+
34
+ export interface MailSendActionsProps {
35
+ disabled?: boolean;
36
+ loading?: boolean;
37
+ recipientCount: number;
38
+ allowScheduleSend?: boolean;
39
+ allowBulkSend?: boolean;
40
+ defaultBulkIntervalMs?: number;
41
+ primaryMode?: "send" | "bulk";
42
+ onSend: () => void;
43
+ onScheduleSend: (sendAt: Date) => Promise<boolean>;
44
+ onBulkSend: (intervalMs: number) => Promise<boolean>;
45
+ }
46
+
47
+ export function MailSendActions({
48
+ disabled,
49
+ loading,
50
+ recipientCount,
51
+ allowScheduleSend = true,
52
+ allowBulkSend = true,
53
+ defaultBulkIntervalMs = 2_000,
54
+ primaryMode = "send",
55
+ onSend,
56
+ onScheduleSend,
57
+ onBulkSend,
58
+ }: MailSendActionsProps) {
59
+ const [scheduleOpen, setScheduleOpen] = useState(false);
60
+ const [scheduleTime, setScheduleTime] = useState(defaultScheduleTime);
61
+ const [bulkOpen, setBulkOpen] = useState(false);
62
+ const [bulkIntervalSeconds, setBulkIntervalSeconds] = useState(
63
+ Math.max(0.1, defaultBulkIntervalMs / 1_000)
64
+ );
65
+
66
+ const scheduleDate = useMemo(() => new Date(scheduleTime), [scheduleTime]);
67
+ const validScheduleTime =
68
+ !Number.isNaN(scheduleDate.getTime()) && scheduleDate.getTime() > Date.now();
69
+ const validBulkInterval =
70
+ Number.isFinite(bulkIntervalSeconds) && bulkIntervalSeconds >= 0.1;
71
+
72
+ const openSchedule = () => {
73
+ setScheduleTime(defaultScheduleTime());
74
+ setScheduleOpen(true);
75
+ };
76
+
77
+ const handleScheduleSend = async () => {
78
+ if (!validScheduleTime) return;
79
+ if (await onScheduleSend(scheduleDate)) setScheduleOpen(false);
80
+ };
81
+
82
+ const handleBulkSend = async () => {
83
+ if (!validBulkInterval) return;
84
+ if (await onBulkSend(Math.round(bulkIntervalSeconds * 1_000))) {
85
+ setBulkOpen(false);
86
+ }
87
+ };
88
+
89
+ const bulkPrimary = primaryMode === "bulk";
90
+ const hasAlternatives = !bulkPrimary && (allowScheduleSend || allowBulkSend);
91
+
92
+ return (
93
+ <>
94
+ <div className="inline-flex">
95
+ <Button
96
+ size="sm"
97
+ className={hasAlternatives ? "rounded-r-none" : undefined}
98
+ onClick={bulkPrimary ? () => setBulkOpen(true) : onSend}
99
+ disabled={disabled}
100
+ >
101
+ {loading ? (
102
+ <Loader2 className="animate-spin" />
103
+ ) : bulkPrimary ? (
104
+ <Users />
105
+ ) : (
106
+ <Send />
107
+ )}
108
+ {bulkPrimary ? "Bulk send" : "Send"}
109
+ </Button>
110
+
111
+ {hasAlternatives && (
112
+ <DropdownMenu>
113
+ <DropdownMenuTrigger
114
+ render={
115
+ <Button
116
+ size="sm"
117
+ className="-ml-px rounded-l-none border-l border-primary-foreground/20 px-1.5"
118
+ disabled={disabled}
119
+ aria-label="More send options"
120
+ />
121
+ }
122
+ >
123
+ <ChevronDown />
124
+ </DropdownMenuTrigger>
125
+ <DropdownMenuContent align="end" className="min-w-44">
126
+ {allowScheduleSend && (
127
+ <DropdownMenuItem onClick={openSchedule}>
128
+ <CalendarClock />
129
+ Schedule send
130
+ </DropdownMenuItem>
131
+ )}
132
+ {allowBulkSend && (
133
+ <DropdownMenuItem onClick={() => setBulkOpen(true)}>
134
+ <Users />
135
+ Bulk send
136
+ </DropdownMenuItem>
137
+ )}
138
+ </DropdownMenuContent>
139
+ </DropdownMenu>
140
+ )}
141
+ </div>
142
+
143
+ <Dialog open={scheduleOpen} onOpenChange={setScheduleOpen}>
144
+ <DialogContent>
145
+ <DialogHeader>
146
+ <DialogTitle>Schedule send</DialogTitle>
147
+ <DialogDescription>
148
+ The message will be queued and sent at the selected local time.
149
+ </DialogDescription>
150
+ </DialogHeader>
151
+ <div className="grid gap-2">
152
+ <Label htmlFor="mail-schedule-time">Send at</Label>
153
+ <Input
154
+ id="mail-schedule-time"
155
+ type="datetime-local"
156
+ min={toDateTimeLocalValue(new Date())}
157
+ value={scheduleTime}
158
+ onChange={(event) => setScheduleTime(event.target.value)}
159
+ aria-invalid={scheduleTime.length > 0 && !validScheduleTime}
160
+ />
161
+ {scheduleTime.length > 0 && !validScheduleTime && (
162
+ <p className="text-xs text-destructive">Select a future time.</p>
163
+ )}
164
+ </div>
165
+ <DialogFooter>
166
+ <Button
167
+ variant="outline"
168
+ onClick={() => setScheduleOpen(false)}
169
+ disabled={loading}
170
+ >
171
+ Cancel
172
+ </Button>
173
+ <Button
174
+ onClick={handleScheduleSend}
175
+ disabled={disabled || !validScheduleTime}
176
+ >
177
+ {loading ? <Loader2 className="animate-spin" /> : <CalendarClock />}
178
+ Schedule send
179
+ </Button>
180
+ </DialogFooter>
181
+ </DialogContent>
182
+ </Dialog>
183
+
184
+ <Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
185
+ <DialogContent>
186
+ <DialogHeader>
187
+ <DialogTitle>Bulk send</DialogTitle>
188
+ <DialogDescription>
189
+ Create one separate email for each To recipient. To recipients are
190
+ not listed together.
191
+ </DialogDescription>
192
+ </DialogHeader>
193
+ <div className="rounded-lg border bg-muted/40 p-3">
194
+ <p className="text-sm font-medium">
195
+ {recipientCount} {recipientCount === 1 ? "recipient" : "recipients"}
196
+ </p>
197
+ <p className="mt-1 text-xs text-muted-foreground">
198
+ Scheduled sending is not available for bulk sends.
199
+ </p>
200
+ </div>
201
+ <div className="grid gap-2">
202
+ <Label htmlFor="mail-bulk-interval">Interval between emails (seconds)</Label>
203
+ <Input
204
+ id="mail-bulk-interval"
205
+ type="number"
206
+ min="0.1"
207
+ step="0.1"
208
+ value={bulkIntervalSeconds}
209
+ onChange={(event) => setBulkIntervalSeconds(event.target.valueAsNumber)}
210
+ aria-invalid={!validBulkInterval}
211
+ />
212
+ {!validBulkInterval && (
213
+ <p className="text-xs text-destructive">
214
+ Enter an interval of at least 0.1 seconds.
215
+ </p>
216
+ )}
217
+ </div>
218
+ <DialogFooter>
219
+ <Button
220
+ variant="outline"
221
+ onClick={() => setBulkOpen(false)}
222
+ disabled={loading}
223
+ >
224
+ Cancel
225
+ </Button>
226
+ <Button
227
+ onClick={handleBulkSend}
228
+ disabled={disabled || recipientCount < 2 || !validBulkInterval}
229
+ >
230
+ {loading ? <Loader2 className="animate-spin" /> : <Users />}
231
+ Send to {recipientCount}
232
+ </Button>
233
+ </DialogFooter>
234
+ </DialogContent>
235
+ </Dialog>
236
+ </>
237
+ );
238
+ }
@@ -0,0 +1,61 @@
1
+ import type { MailAccount } from "./types";
2
+
3
+ export interface MailSenderCandidate {
4
+ key: string;
5
+ accountId: number;
6
+ accountEmail: string;
7
+ identityEmail: string;
8
+ label: string;
9
+ }
10
+
11
+ export const DEFAULT_MAIL_SENDER_KEY = "nocobase-mail:default-sender";
12
+
13
+ export function getMailSenderCandidates(
14
+ accounts: MailAccount[]
15
+ ): MailSenderCandidate[] {
16
+ return accounts.flatMap((account) => {
17
+ const identities = [
18
+ { email: account.email, name: undefined },
19
+ ...(account.identities ?? []),
20
+ ];
21
+ const seen = new Set<string>();
22
+ return identities.flatMap((identity) => {
23
+ const email = identity.email?.trim();
24
+ const normalized = email?.toLocaleLowerCase();
25
+ if (!email || seen.has(normalized)) return [];
26
+ seen.add(normalized);
27
+ const isPrimary = normalized === account.email.toLocaleLowerCase();
28
+ return [
29
+ {
30
+ key: `${account.id}:${email}`,
31
+ accountId: account.id,
32
+ accountEmail: account.email,
33
+ identityEmail: email,
34
+ label: identity.name
35
+ ? `${identity.name} <${email}>`
36
+ : isPrimary
37
+ ? `${email} (primary)`
38
+ : `${email} via ${account.email}`,
39
+ },
40
+ ];
41
+ });
42
+ });
43
+ }
44
+
45
+ export function resolveMailSender(
46
+ candidates: MailSenderCandidate[],
47
+ initial?: { from?: string; accountEmail?: string; identityEmail?: string }
48
+ ) {
49
+ const identityEmail = (initial?.identityEmail ?? initial?.from)?.toLocaleLowerCase();
50
+ const accountEmail = initial?.accountEmail?.toLocaleLowerCase();
51
+ return (
52
+ candidates.find(
53
+ (candidate) =>
54
+ candidate.identityEmail.toLocaleLowerCase() === identityEmail &&
55
+ (!accountEmail || candidate.accountEmail.toLocaleLowerCase() === accountEmail)
56
+ ) ??
57
+ candidates.find(
58
+ (candidate) => candidate.identityEmail.toLocaleLowerCase() === identityEmail
59
+ )
60
+ );
61
+ }