@nocobase/portal-template-default 1.0.15 → 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 (153) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.MD +4 -0
  3. package/dist/assets/{LineDraw-iG-ADes-.js → LineDraw-BBB5hwuJ.js} +1 -1
  4. package/dist/assets/{SymbolDraw-DXoSw1tM.js → SymbolDraw-CKS3uUb_.js} +1 -1
  5. package/dist/assets/{VisualMapping-C_ee4Eht.js → VisualMapping-BCwaW3TD.js} +1 -1
  6. package/dist/assets/auth-method-demo-BdCCvEEz.js +1 -0
  7. package/dist/assets/{auto-login-provider-5Uxza9Ms.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-CyqG1QME.js → cas-sign-in-button-BZCa4lR6.js} +1 -1
  14. package/dist/assets/{components-D-ptYjX_.js → components-_g-GjQeV.js} +3 -3
  15. package/dist/assets/{configuration-gate-C08mkkiX.js → configuration-gate-B4XVz7tS.js} +1 -1
  16. package/dist/assets/{createClipPathFromCoordSys-wj34-b1B.js → createClipPathFromCoordSys-z9Kmh-tu.js} +1 -1
  17. package/dist/assets/createSeriesDataSimply-CPqysF0K.js +1 -0
  18. package/dist/assets/{customGraphicKeyframeAnimation-DWrhrHL_.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-DSWsSruv.js → dingtalk-sign-in-button-DMPgE5kT.js} +1 -1
  27. package/dist/assets/{echarts-preview-DxHNfq1C.js → echarts-preview-R96Pbqmz.js} +1 -1
  28. package/dist/assets/{echarts-runtime-D8kXn6ME.js → echarts-runtime-Ce_z2eX_.js} +3 -3
  29. package/dist/assets/{echarts-runtime-advanced-QY8v95qK.js → echarts-runtime-advanced-Dr80KHJR.js} +1 -1
  30. package/dist/assets/{echarts-runtime-common-D-yqAK-r.js → echarts-runtime-common-DB95Yfc1.js} +1 -1
  31. package/dist/assets/{echarts-runtime-components-P4-i8jPU.js → echarts-runtime-components-DCJEaTJk.js} +1 -1
  32. package/dist/assets/{echarts-runtime-hierarchy-ByQZ-DbP.js → echarts-runtime-hierarchy-CezgZs9O.js} +1 -1
  33. package/dist/assets/{floating-BFVEyPEp.js → floating-O9WmiDXk.js} +2 -2
  34. package/dist/assets/index-BBKV6O5d.css +1 -0
  35. package/dist/assets/{index-Btk35Nx-.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-um5hdKdZ.js → index-DgB7uTMs.js} +2 -2
  39. package/dist/assets/{index-CwNxd66J.js → index-DroUAK80.js} +1 -1
  40. package/dist/assets/index-WKvygTBp.js +59 -0
  41. package/dist/assets/{labelHelper-DQqRy3a_.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-CQdRcR_p.js → oidc-sign-in-button-BIRonrpC.js} +1 -1
  44. package/dist/assets/{page-context-CnQ4GYM7.js → page-context-DAiW_dId.js} +3 -3
  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-DN_Vv6OL.js → sectorHelper-BuxrrJrA.js} +1 -1
  49. package/dist/assets/shortcut-BhKXxJLs.js +47 -0
  50. package/dist/assets/{sms-sign-in-form-DTX9GV2I.js → sms-sign-in-form-DKpxRDdZ.js} +1 -1
  51. package/dist/assets/{static.browser-jI1psK0d.js → static.browser-kzzpH0Xt.js} +8 -8
  52. package/dist/assets/switch-BdVxPIVU.js +1 -0
  53. package/dist/assets/{tool-cards-DFttLAfo.js → tool-cards-CpczIke-.js} +3 -3
  54. package/dist/assets/{use-authenticator-sign-in-CRGjzXju.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-C9j8wfCD.js → use-wecom-sign-in-dIqMtcN9.js} +1 -1
  57. package/dist/assets/wecom-sign-in-button-Dn_2zDab.js +1 -0
  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/components/auth/sign-up-form.tsx +287 -59
  70. package/src/components/auth/types.ts +23 -1
  71. package/src/extensions/nocobase-acl/README.md +28 -14
  72. package/src/extensions/nocobase-acl/demo/index.tsx +87 -15
  73. package/src/extensions/nocobase-acl/demo/prompt-generator.tsx +1 -0
  74. package/src/extensions/nocobase-mail/README.md +74 -0
  75. package/src/extensions/nocobase-mail/components/index.ts +79 -0
  76. package/src/extensions/nocobase-mail/components/mail-api.ts +423 -0
  77. package/src/extensions/nocobase-mail/components/mail-attachment-list.tsx +67 -0
  78. package/src/extensions/nocobase-mail/components/mail-compose-attachments.tsx +166 -0
  79. package/src/extensions/nocobase-mail/components/mail-compose.tsx +1071 -0
  80. package/src/extensions/nocobase-mail/components/mail-detail.tsx +653 -0
  81. package/src/extensions/nocobase-mail/components/mail-draft-autosave.ts +26 -0
  82. package/src/extensions/nocobase-mail/components/mail-empty.tsx +48 -0
  83. package/src/extensions/nocobase-mail/components/mail-filters.tsx +277 -0
  84. package/src/extensions/nocobase-mail/components/mail-inbox.tsx +544 -0
  85. package/src/extensions/nocobase-mail/components/mail-inline-images.ts +69 -0
  86. package/src/extensions/nocobase-mail/components/mail-label-badge.tsx +25 -0
  87. package/src/extensions/nocobase-mail/components/mail-label-manager.tsx +271 -0
  88. package/src/extensions/nocobase-mail/components/mail-labels-editor.tsx +267 -0
  89. package/src/extensions/nocobase-mail/components/mail-mass-tracking.tsx +271 -0
  90. package/src/extensions/nocobase-mail/components/mail-note-editor.tsx +99 -0
  91. package/src/extensions/nocobase-mail/components/mail-recipient-input.tsx +260 -0
  92. package/src/extensions/nocobase-mail/components/mail-reply-quote.ts +28 -0
  93. package/src/extensions/nocobase-mail/components/mail-rich-editor.tsx +287 -0
  94. package/src/extensions/nocobase-mail/components/mail-send-actions.tsx +238 -0
  95. package/src/extensions/nocobase-mail/components/mail-senders.ts +61 -0
  96. package/src/extensions/nocobase-mail/components/mail-settings-drawer.tsx +708 -0
  97. package/src/extensions/nocobase-mail/components/mail-showcase-layout.tsx +64 -0
  98. package/src/extensions/nocobase-mail/components/mail-signature-manager.tsx +258 -0
  99. package/src/extensions/nocobase-mail/components/mail-signatures.ts +25 -0
  100. package/src/extensions/nocobase-mail/components/mail-table.tsx +476 -0
  101. package/src/extensions/nocobase-mail/components/mail-template-manager.tsx +204 -0
  102. package/src/extensions/nocobase-mail/components/mail-toolbar.tsx +155 -0
  103. package/src/extensions/nocobase-mail/components/mail-unread.tsx +111 -0
  104. package/src/extensions/nocobase-mail/components/types.ts +297 -0
  105. package/src/extensions/nocobase-mail/components/use-mail-compose.tsx +262 -0
  106. package/src/extensions/nocobase-mail/components/use-mail-mass-messages.ts +54 -0
  107. package/src/extensions/nocobase-mail/components/use-mail-messages.ts +113 -0
  108. package/src/extensions/nocobase-mail/components/use-mail-signatures.ts +122 -0
  109. package/src/extensions/nocobase-mail/components/use-mail-templates.ts +54 -0
  110. package/src/extensions/nocobase-mail/extension.tsx +105 -0
  111. package/src/extensions/nocobase-mail/index.ts +1 -0
  112. package/src/extensions/nocobase-mail/mail-demo-pages.tsx +503 -0
  113. package/src/extensions/nocobase-mail/mail-pages.tsx +201 -0
  114. package/src/extensions/nocobase-mail/tests/mail-quote-html-regression.mjs +51 -0
  115. package/src/extensions/nocobase-mail/tests/mail-regression.mjs +649 -0
  116. package/src/extensions/nocobase-users-example/app-routes.tsx +121 -0
  117. package/src/extensions/nocobase-users-example/extension.tsx +2 -100
  118. package/src/extensions/nocobase-users-example/index.ts +1 -0
  119. package/src/lib/nocobase/acl/menu.ts +17 -3
  120. package/src/lib/nocobase/acl/types.ts +4 -0
  121. package/src/locales/en-US.ts +2 -1
  122. package/src/locales/zh-CN.ts +2 -1
  123. package/src/providers/runtime-config.ts +18 -0
  124. package/src/routes.tsx +10 -0
  125. package/src/vite-env.d.ts +3 -0
  126. package/vite.config.ts +8 -0
  127. package/dist/assets/auth-method-demo-DaOCuvsq.js +0 -1
  128. package/dist/assets/auto-login-provider-DyaPGK2D.js +0 -1
  129. package/dist/assets/auto-redirect-provider-CfwRvYOI.js +0 -1
  130. package/dist/assets/auto-redirect-provider-Cr3jmYts.js +0 -1
  131. package/dist/assets/auto-redirect-provider-QkJQRBbG.js +0 -1
  132. package/dist/assets/calendar-DjDRfD5t.js +0 -1
  133. package/dist/assets/card-DuQBOS6A.js +0 -1
  134. package/dist/assets/createSeriesDataSimply-D0A6SAVQ.js +0 -1
  135. package/dist/assets/demo-BYWHeKUe.js +0 -1
  136. package/dist/assets/demo-CPWoNtx8.js +0 -1
  137. package/dist/assets/demo-CyvJk5zV.js +0 -1
  138. package/dist/assets/demo-D4OiKplc.js +0 -1
  139. package/dist/assets/demo-DBueJg0m.js +0 -1
  140. package/dist/assets/demo-DkIhyiRK.js +0 -1
  141. package/dist/assets/demo-tpTjO0sC.js +0 -1
  142. package/dist/assets/index-BgCbGDMc.js +0 -43
  143. package/dist/assets/index-MB-jfQld.js +0 -686
  144. package/dist/assets/index-NH3aqqQz.css +0 -1
  145. package/dist/assets/index-gFtCXH3o.js +0 -58
  146. package/dist/assets/ldap-sign-in-form--6Gt3yGq.js +0 -1
  147. package/dist/assets/prompt-card-B2RS4nt2.js +0 -1
  148. package/dist/assets/prompt-output-DZHRM2ft.js +0 -1
  149. package/dist/assets/saml-sign-in-button-BHL5a0JV.js +0 -1
  150. package/dist/assets/shortcut-C5x5LQWU.js +0 -47
  151. package/dist/assets/switch-CRL8PmRI.js +0 -1
  152. package/dist/assets/use-dingtalk-sign-in-8WYzJ2jf.js +0 -1
  153. package/dist/assets/wecom-sign-in-button-mIS1zGSU.js +0 -1
@@ -0,0 +1,476 @@
1
+ import { useMemo, useState, type ReactNode } from "react";
2
+ import {
3
+ type ColumnDef,
4
+ type VisibilityState,
5
+ createColumnHelper,
6
+ flexRender,
7
+ getCoreRowModel,
8
+ useReactTable,
9
+ } from "@tanstack/react-table";
10
+ import { format, isToday, isYesterday, parseISO } from "date-fns";
11
+ import { ChevronLeft, ChevronRight, MessagesSquare, Paperclip, SlidersHorizontal } from "lucide-react";
12
+ import type { MailColumnId, MailMessage } from "./types";
13
+ import { DEFAULT_MAIL_COLUMNS, MAIL_COLUMN_LABELS, MailBoxType } from "./types";
14
+ import { MailLabelBadge } from "./mail-label-badge";
15
+ import { Badge } from "@/components/ui/badge";
16
+ import { Button } from "@/components/ui/button";
17
+ import { Checkbox } from "@/components/ui/checkbox";
18
+ import {
19
+ DropdownMenu,
20
+ DropdownMenuCheckboxItem,
21
+ DropdownMenuContent,
22
+ DropdownMenuGroup,
23
+ DropdownMenuLabel,
24
+ DropdownMenuSeparator,
25
+ DropdownMenuTrigger,
26
+ } from "@/components/ui/dropdown-menu";
27
+ import {
28
+ Table,
29
+ TableBody,
30
+ TableCell,
31
+ TableHead,
32
+ TableHeader,
33
+ TableRow,
34
+ } from "@/components/ui/table";
35
+ import { Skeleton } from "@/components/ui/skeleton";
36
+ import { MailEmpty } from "./mail-empty";
37
+ import { cn } from "@/lib/utils";
38
+
39
+ function formatDate(dateStr?: string) {
40
+ if (!dateStr) return "";
41
+ try {
42
+ const date = parseISO(dateStr);
43
+ if (isToday(date)) return format(date, "HH:mm");
44
+ if (isYesterday(date)) return "Yesterday";
45
+ return format(date, "MMM d, yyyy");
46
+ } catch {
47
+ return "";
48
+ }
49
+ }
50
+
51
+ function senderName(message: MailMessage) {
52
+ return message.fromUser?.name || message.from || "Unknown";
53
+ }
54
+
55
+ function recipientSummary(message: MailMessage) {
56
+ if (message.toUsers?.length) {
57
+ return message.toUsers.map((u) => u.name || u.address).join(", ");
58
+ }
59
+ return message.to || "";
60
+ }
61
+
62
+ function threadCount(message: MailMessage) {
63
+ const children =
64
+ message.relatedMessageIds?.length ?? message.childrenMessages?.length ?? 0;
65
+ return children > 0 ? children + 1 : 0;
66
+ }
67
+
68
+ const boxTypeLabels: Record<string, string> = {
69
+ [MailBoxType.IN]: "Inbox",
70
+ [MailBoxType.OUT]: "Sent",
71
+ [MailBoxType.DRAFT]: "Draft",
72
+ [MailBoxType.TRASH]: "Trash",
73
+ [MailBoxType.SPAM]: "Spam",
74
+ [MailBoxType.ARCHIVE]: "Archive",
75
+ [MailBoxType.SCHEDULED]: "Scheduled",
76
+ };
77
+
78
+ const boxTypeStyles: Record<string, string> = {
79
+ [MailBoxType.IN]: "border-blue-500/25 bg-blue-500/12 text-blue-600 dark:text-blue-400",
80
+ [MailBoxType.OUT]: "border-green-500/25 bg-green-500/12 text-green-600 dark:text-green-400",
81
+ [MailBoxType.DRAFT]: "border-cyan-500/25 bg-cyan-500/12 text-cyan-600 dark:text-cyan-400",
82
+ [MailBoxType.SCHEDULED]: "border-amber-500/25 bg-amber-500/12 text-amber-600 dark:text-amber-400",
83
+ [MailBoxType.SPAM]: "border-red-500/25 bg-red-500/12 text-red-600 dark:text-red-400",
84
+ [MailBoxType.MASS]: "border-purple-500/25 bg-purple-500/12 text-purple-600 dark:text-purple-400",
85
+ [MailBoxType.TRASH]: "border-border bg-muted text-muted-foreground",
86
+ [MailBoxType.ARCHIVE]: "border-border bg-muted text-muted-foreground",
87
+ };
88
+
89
+ const columnHelper = createColumnHelper<MailMessage>();
90
+
91
+ // TanStack column defs are heterogeneous in TValue; `any` matches the library's
92
+ // own `ColumnDef<TData, any>` convention for mixed-value column collections.
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ type MailColumnDef = ColumnDef<MailMessage, any>;
95
+
96
+ const DATA_COLUMN_DEFS: Record<MailColumnId, MailColumnDef> = {
97
+ from: columnHelper.accessor((row) => senderName(row), {
98
+ id: "from",
99
+ header: MAIL_COLUMN_LABELS.from,
100
+ cell: (info) => (
101
+ <div className="min-w-0">
102
+ <div className="truncate font-medium">{senderName(info.row.original)}</div>
103
+ <div className="truncate text-xs text-muted-foreground">
104
+ {info.row.original.from}
105
+ </div>
106
+ </div>
107
+ ),
108
+ }),
109
+ to: columnHelper.accessor((row) => recipientSummary(row), {
110
+ id: "to",
111
+ header: MAIL_COLUMN_LABELS.to,
112
+ cell: (info) => (
113
+ <span className="block max-w-[220px] truncate">
114
+ {recipientSummary(info.row.original) || "-"}
115
+ </span>
116
+ ),
117
+ }),
118
+ subject: columnHelper.accessor((row) => row.subject, {
119
+ id: "subject",
120
+ header: MAIL_COLUMN_LABELS.subject,
121
+ cell: (info) => {
122
+ const message = info.row.original;
123
+ const count = threadCount(message);
124
+ return (
125
+ <div className="flex items-center gap-1.5">
126
+ <span className="block max-w-[280px] truncate">
127
+ {message.isDraft ? "Draft: " : ""}
128
+ {message.subject || "(no subject)"}
129
+ </span>
130
+ {count > 0 && (
131
+ <span
132
+ title={`${count} messages in this conversation`}
133
+ className="inline-flex shrink-0 items-center gap-0.5 rounded-full bg-muted px-1.5 py-px text-[10px] font-medium text-muted-foreground"
134
+ >
135
+ <MessagesSquare className="size-2.5" />
136
+ {count}
137
+ </span>
138
+ )}
139
+ </div>
140
+ );
141
+ },
142
+ }),
143
+ date: columnHelper.accessor((row) => row.relatedMessageLatestDate ?? row.date, {
144
+ id: "date",
145
+ header: MAIL_COLUMN_LABELS.date,
146
+ cell: (info) => (
147
+ <span className="tabular-nums text-muted-foreground">
148
+ {formatDate(info.row.original.relatedMessageLatestDate ?? info.row.original.date)}
149
+ </span>
150
+ ),
151
+ }),
152
+ boxType: columnHelper.accessor((row) => row.boxType, {
153
+ id: "boxType",
154
+ header: MAIL_COLUMN_LABELS.boxType,
155
+ cell: (info) => {
156
+ const boxType = info.row.original.boxType;
157
+ return (
158
+ <Badge
159
+ variant="outline"
160
+ className={cn(
161
+ "capitalize",
162
+ boxTypeStyles[boxType] ?? boxTypeStyles[MailBoxType.TRASH]
163
+ )}
164
+ >
165
+ {boxTypeLabels[boxType] ?? boxType}
166
+ </Badge>
167
+ );
168
+ },
169
+ }),
170
+ isRead: columnHelper.accessor((row) => row.isRead, {
171
+ id: "isRead",
172
+ header: MAIL_COLUMN_LABELS.isRead,
173
+ cell: (info) => {
174
+ const unread =
175
+ !info.row.original.isRead ||
176
+ info.row.original.relatedMessagesIsRead === false;
177
+ return (
178
+ <span className="flex items-center gap-1.5 text-xs">
179
+ <span
180
+ className={cn(
181
+ "size-2 rounded-full",
182
+ unread ? "bg-primary" : "bg-muted-foreground/30"
183
+ )}
184
+ />
185
+ {unread ? "Unread" : "Read"}
186
+ </span>
187
+ );
188
+ },
189
+ }),
190
+ email: columnHelper.accessor((row) => row.email, {
191
+ id: "email",
192
+ header: MAIL_COLUMN_LABELS.email,
193
+ cell: (info) => (
194
+ <span className="block max-w-[180px] truncate text-muted-foreground">
195
+ {info.row.original.email}
196
+ </span>
197
+ ),
198
+ }),
199
+ user: columnHelper.accessor((row) => row.user?.nickname ?? row.user?.email ?? "", {
200
+ id: "user",
201
+ header: MAIL_COLUMN_LABELS.user,
202
+ cell: (info) => (
203
+ <span className="block max-w-[140px] truncate">
204
+ {info.row.original.user?.nickname ||
205
+ info.row.original.user?.email ||
206
+ `#${info.row.original.userId ?? ""}`}
207
+ </span>
208
+ ),
209
+ }),
210
+ labels: columnHelper.display({
211
+ id: "labels",
212
+ header: MAIL_COLUMN_LABELS.labels,
213
+ cell: (info) => (
214
+ <div className="flex flex-wrap gap-1">
215
+ {info.row.original.labels?.length ? (
216
+ info.row.original.labels.map((label) => (
217
+ <MailLabelBadge key={label.id} label={label} />
218
+ ))
219
+ ) : (
220
+ <span className="text-muted-foreground">-</span>
221
+ )}
222
+ </div>
223
+ ),
224
+ }),
225
+ attachments: columnHelper.display({
226
+ id: "attachments",
227
+ header: MAIL_COLUMN_LABELS.attachments,
228
+ cell: (info) =>
229
+ info.row.original.attachments?.length ? (
230
+ <span className="flex items-center gap-1 text-muted-foreground">
231
+ <Paperclip className="size-3.5" />
232
+ {info.row.original.attachments.length}
233
+ </span>
234
+ ) : (
235
+ <span className="text-muted-foreground">-</span>
236
+ ),
237
+ }),
238
+ };
239
+
240
+ export function MailTable({
241
+ messages,
242
+ loading,
243
+ total,
244
+ page,
245
+ pageSize,
246
+ onPageChange,
247
+ selectedIds,
248
+ activeId,
249
+ onOpen,
250
+ onSelect,
251
+ columns = DEFAULT_MAIL_COLUMNS,
252
+ emptyVariant = "inbox",
253
+ toolbar,
254
+ trailingAction,
255
+ className,
256
+ }: {
257
+ messages: MailMessage[];
258
+ loading: boolean;
259
+ total?: number;
260
+ page?: number;
261
+ pageSize?: number;
262
+ onPageChange?: (page: number) => void;
263
+ selectedIds: Set<number>;
264
+ activeId?: number;
265
+ onOpen: (message: MailMessage) => void;
266
+ onSelect: (id: number, checked: boolean) => void;
267
+ columns?: MailColumnId[];
268
+ emptyVariant?: "inbox" | "search";
269
+ toolbar?: ReactNode;
270
+ trailingAction?: ReactNode;
271
+ className?: string;
272
+ }) {
273
+ const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(() =>
274
+ Object.fromEntries(columns.map((id) => [id, true]))
275
+ );
276
+
277
+ const tableColumns = useMemo(() => {
278
+ const allSelected =
279
+ messages.length > 0 && messages.every((m) => selectedIds.has(m.id));
280
+
281
+ const selectColumn: MailColumnDef = columnHelper.display({
282
+ id: "select",
283
+ header: () => (
284
+ <Checkbox
285
+ checked={allSelected}
286
+ onCheckedChange={(checked) =>
287
+ messages.forEach((m) => onSelect(m.id, Boolean(checked)))
288
+ }
289
+ aria-label="Select all"
290
+ />
291
+ ),
292
+ cell: (info) => (
293
+ <div
294
+ className="flex w-fit items-center"
295
+ onClick={(e) => e.stopPropagation()}
296
+ >
297
+ <Checkbox
298
+ checked={selectedIds.has(info.row.original.id)}
299
+ onCheckedChange={(checked) =>
300
+ onSelect(info.row.original.id, Boolean(checked))
301
+ }
302
+ aria-label={`Select ${info.row.original.subject}`}
303
+ />
304
+ </div>
305
+ ),
306
+ });
307
+
308
+ return [
309
+ selectColumn,
310
+ ...columns
311
+ .map((id) => DATA_COLUMN_DEFS[id])
312
+ .filter((def): def is MailColumnDef => Boolean(def)),
313
+ ];
314
+ }, [columns, messages, selectedIds, onSelect]);
315
+
316
+ const table = useReactTable({
317
+ data: messages,
318
+ columns: tableColumns,
319
+ state: { columnVisibility },
320
+ onColumnVisibilityChange: setColumnVisibility,
321
+ getCoreRowModel: getCoreRowModel(),
322
+ getRowId: (row) => String(row.id),
323
+ });
324
+
325
+ const pageCount =
326
+ total && pageSize ? Math.max(1, Math.ceil(total / pageSize)) : 1;
327
+
328
+ return (
329
+ <div className={cn("flex flex-col gap-3", className)}>
330
+ <div className="flex items-center gap-2">
331
+ {toolbar && <div className="min-w-0 flex-1">{toolbar}</div>}
332
+ <DropdownMenu>
333
+ <DropdownMenuTrigger
334
+ render={
335
+ <Button variant="outline" size="sm">
336
+ <SlidersHorizontal />
337
+ Columns
338
+ </Button>
339
+ }
340
+ />
341
+ <DropdownMenuContent align="end" className="w-44">
342
+ <DropdownMenuGroup>
343
+ <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
344
+ <DropdownMenuSeparator />
345
+ {columns.map((id) => {
346
+ const column = table.getColumn(id);
347
+ if (!column) return null;
348
+ return (
349
+ <DropdownMenuCheckboxItem
350
+ key={id}
351
+ checked={column.getIsVisible()}
352
+ onCheckedChange={(value) =>
353
+ column.toggleVisibility(Boolean(value))
354
+ }
355
+ >
356
+ {MAIL_COLUMN_LABELS[id]}
357
+ </DropdownMenuCheckboxItem>
358
+ );
359
+ })}
360
+ </DropdownMenuGroup>
361
+ </DropdownMenuContent>
362
+ </DropdownMenu>
363
+ {trailingAction}
364
+ </div>
365
+
366
+ <div className="overflow-hidden rounded-xl border bg-card shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
367
+ <Table>
368
+ <TableHeader className="bg-muted/45">
369
+ {table.getHeaderGroups().map((headerGroup) => (
370
+ <TableRow key={headerGroup.id}>
371
+ {headerGroup.headers.map((header) => (
372
+ <TableHead key={header.id}>
373
+ {header.isPlaceholder
374
+ ? null
375
+ : flexRender(
376
+ header.column.columnDef.header,
377
+ header.getContext()
378
+ )}
379
+ </TableHead>
380
+ ))}
381
+ </TableRow>
382
+ ))}
383
+ </TableHeader>
384
+ <TableBody>
385
+ {loading ? (
386
+ Array.from({ length: pageSize ?? 8 }).map((_, rowIndex) => (
387
+ <TableRow key={`skeleton-${rowIndex}`}>
388
+ {table.getAllLeafColumns().map((column) => (
389
+ <TableCell key={`skeleton-${rowIndex}-${column.id}`}>
390
+ <Skeleton className="h-4 w-full" />
391
+ </TableCell>
392
+ ))}
393
+ </TableRow>
394
+ ))
395
+ ) : table.getRowModel().rows.length ? (
396
+ table.getRowModel().rows.map((row) => {
397
+ const message = row.original;
398
+ const unread =
399
+ !message.isRead || message.relatedMessagesIsRead === false;
400
+ return (
401
+ <TableRow
402
+ key={row.id}
403
+ data-state={selectedIds.has(message.id) ? "selected" : undefined}
404
+ className={cn(
405
+ "cursor-pointer",
406
+ activeId === message.id && "bg-muted"
407
+ )}
408
+ onClick={() => onOpen(message)}
409
+ >
410
+ {row.getVisibleCells().map((cell) => (
411
+ <TableCell
412
+ key={cell.id}
413
+ className={cn(
414
+ cell.column.id === "select" ? "w-10" : undefined,
415
+ unread &&
416
+ cell.column.id !== "select" &&
417
+ cell.column.id !== "isRead" &&
418
+ cell.column.id !== "date"
419
+ ? "font-bold"
420
+ : undefined
421
+ )}
422
+ >
423
+ {flexRender(
424
+ cell.column.columnDef.cell,
425
+ cell.getContext()
426
+ )}
427
+ </TableCell>
428
+ ))}
429
+ </TableRow>
430
+ );
431
+ })
432
+ ) : (
433
+ <TableRow className="hover:bg-transparent">
434
+ <TableCell
435
+ colSpan={table.getAllLeafColumns().length}
436
+ className="p-0"
437
+ >
438
+ <MailEmpty variant={emptyVariant} />
439
+ </TableCell>
440
+ </TableRow>
441
+ )}
442
+ </TableBody>
443
+ </Table>
444
+ </div>
445
+
446
+ {onPageChange && total ? (
447
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
448
+ <span className="tabular-nums">
449
+ {total} message{total === 1 ? "" : "s"}
450
+ </span>
451
+ <div className="flex items-center gap-2">
452
+ <Button
453
+ variant="outline"
454
+ size="icon-sm"
455
+ disabled={!page || page <= 1}
456
+ onClick={() => onPageChange((page ?? 1) - 1)}
457
+ >
458
+ <ChevronLeft />
459
+ </Button>
460
+ <span className="tabular-nums">
461
+ {page ?? 1} / {pageCount}
462
+ </span>
463
+ <Button
464
+ variant="outline"
465
+ size="icon-sm"
466
+ disabled={!page || page >= pageCount}
467
+ onClick={() => onPageChange((page ?? 1) + 1)}
468
+ >
469
+ <ChevronRight />
470
+ </Button>
471
+ </div>
472
+ </div>
473
+ ) : null}
474
+ </div>
475
+ );
476
+ }
@@ -0,0 +1,204 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Plus, Trash2 } from "lucide-react";
3
+ import { toast } from "sonner";
4
+ import type { MailTemplate } from "./types";
5
+ import type { MailTemplateValues } from "./use-mail-templates";
6
+ import { MailRichEditor } from "./mail-rich-editor";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Label } from "@/components/ui/label";
10
+ import {
11
+ Dialog,
12
+ DialogContent,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from "@/components/ui/dialog";
16
+ import { cn } from "@/lib/utils";
17
+
18
+ export interface MailTemplateManagerProps {
19
+ open: boolean;
20
+ onOpenChange: (open: boolean) => void;
21
+ templates: MailTemplate[];
22
+ onCreate: (values: MailTemplateValues) => Promise<MailTemplate>;
23
+ onUpdate: (
24
+ id: number | string,
25
+ values: MailTemplateValues
26
+ ) => Promise<unknown>;
27
+ onRemove: (id: number | string) => Promise<unknown>;
28
+ embedded?: boolean;
29
+ }
30
+
31
+ const EMPTY: MailTemplateValues = { name: "", content: "" };
32
+
33
+ export function MailTemplateManager({
34
+ open,
35
+ onOpenChange,
36
+ templates,
37
+ onCreate,
38
+ onUpdate,
39
+ onRemove,
40
+ embedded = false,
41
+ }: MailTemplateManagerProps) {
42
+ const [selectedId, setSelectedId] = useState<number | string | null>(null);
43
+ const [form, setForm] = useState<MailTemplateValues>(EMPTY);
44
+ const [busy, setBusy] = useState(false);
45
+
46
+ useEffect(() => {
47
+ if (!open) return;
48
+ const first = templates[0];
49
+ if (first) {
50
+ setSelectedId(first.id);
51
+ setForm({
52
+ name: first.name,
53
+ content: first.content,
54
+ });
55
+ } else {
56
+ setSelectedId(null);
57
+ setForm(EMPTY);
58
+ }
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [open]);
61
+
62
+ const selectTemplate = (template: MailTemplate) => {
63
+ setSelectedId(template.id);
64
+ setForm({
65
+ name: template.name,
66
+ content: template.content,
67
+ });
68
+ };
69
+
70
+ const startNew = () => {
71
+ setSelectedId(null);
72
+ setForm(EMPTY);
73
+ };
74
+
75
+ const canSave = form.name.trim().length > 0;
76
+
77
+ const handleSave = async () => {
78
+ if (!canSave) return;
79
+ setBusy(true);
80
+ try {
81
+ if (selectedId !== null) await onUpdate(selectedId, form);
82
+ else setSelectedId((await onCreate(form)).id);
83
+ toast.success("Template saved");
84
+ } catch (error) {
85
+ toast.error(
86
+ error instanceof Error ? error.message : "Failed to save template"
87
+ );
88
+ } finally {
89
+ setBusy(false);
90
+ }
91
+ };
92
+
93
+ const handleDelete = async (id: number | string) => {
94
+ setBusy(true);
95
+ try {
96
+ await onRemove(id);
97
+ if (selectedId === id) {
98
+ setSelectedId(null);
99
+ setForm(EMPTY);
100
+ }
101
+ toast.success("Template deleted");
102
+ } catch (error) {
103
+ toast.error(
104
+ error instanceof Error ? error.message : "Failed to delete template"
105
+ );
106
+ } finally {
107
+ setBusy(false);
108
+ }
109
+ };
110
+
111
+ const editor = (
112
+ <div className="grid gap-4 sm:grid-cols-[180px_1fr]">
113
+ <div className="flex flex-col gap-2">
114
+ <div className="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
115
+ {templates.map((template) => (
116
+ <div
117
+ key={template.id}
118
+ className={cn(
119
+ "group flex items-center rounded-md text-sm transition-colors",
120
+ selectedId === template.id
121
+ ? "bg-muted font-medium"
122
+ : "hover:bg-muted/50"
123
+ )}
124
+ >
125
+ <button
126
+ type="button"
127
+ onClick={() => selectTemplate(template)}
128
+ className="min-w-0 flex-1 px-2.5 py-2 text-left"
129
+ >
130
+ <span className="block truncate">{template.name}</span>
131
+ </button>
132
+ <Button
133
+ variant="ghost"
134
+ size="icon-sm"
135
+ title={`Delete ${template.name}`}
136
+ aria-label={`Delete ${template.name}`}
137
+ disabled={busy}
138
+ onClick={() => void handleDelete(template.id)}
139
+ className="mr-1 shrink-0 text-muted-foreground opacity-60 hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
140
+ >
141
+ <Trash2 />
142
+ </Button>
143
+ </div>
144
+ ))}
145
+ {!templates.length && (
146
+ <p className="px-2.5 py-2 text-xs text-muted-foreground">
147
+ No templates yet
148
+ </p>
149
+ )}
150
+ </div>
151
+ <Button variant="outline" size="sm" onClick={startNew}>
152
+ <Plus />
153
+ New
154
+ </Button>
155
+ </div>
156
+
157
+ <div className="flex flex-col gap-3">
158
+ <div className="space-y-1.5">
159
+ <Label className="text-xs text-muted-foreground">Name</Label>
160
+ <Input
161
+ value={form.name}
162
+ onChange={(e) =>
163
+ setForm((prev) => ({ ...prev, name: e.target.value }))
164
+ }
165
+ placeholder="e.g. Welcome"
166
+ className="h-9"
167
+ />
168
+ </div>
169
+
170
+ <div className="space-y-1.5">
171
+ <Label className="text-xs text-muted-foreground">Content</Label>
172
+ <MailRichEditor
173
+ value={form.content}
174
+ onChange={(content) => setForm((prev) => ({ ...prev, content }))}
175
+ placeholder="Template content…"
176
+ />
177
+ </div>
178
+
179
+ <div className="flex items-center justify-end">
180
+ <Button
181
+ size="sm"
182
+ onClick={() => void handleSave()}
183
+ disabled={!canSave || busy}
184
+ >
185
+ Save
186
+ </Button>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+
192
+ if (embedded) return editor;
193
+
194
+ return (
195
+ <Dialog open={open} onOpenChange={onOpenChange}>
196
+ <DialogContent className="sm:max-w-3xl">
197
+ <DialogHeader>
198
+ <DialogTitle>Templates</DialogTitle>
199
+ </DialogHeader>
200
+ {editor}
201
+ </DialogContent>
202
+ </Dialog>
203
+ );
204
+ }