@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,271 @@
1
+ import { useEffect, useState } from "react";
2
+ import { Loader2, Plus, Trash2 } from "lucide-react";
3
+ import { toast } from "sonner";
4
+ import { mailApi } from "./mail-api";
5
+ import type { MailLabel } from "./types";
6
+ import { LABEL_COLOR_OPTIONS, LABEL_SWATCH_CLASSES } from "./types";
7
+ import { Button } from "@/components/ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import { Input } from "@/components/ui/input";
15
+ import { Label } from "@/components/ui/label";
16
+ import {
17
+ AlertDialog,
18
+ AlertDialogAction,
19
+ AlertDialogCancel,
20
+ AlertDialogContent,
21
+ AlertDialogDescription,
22
+ AlertDialogFooter,
23
+ AlertDialogHeader,
24
+ AlertDialogTitle,
25
+ } from "@/components/ui/alert-dialog";
26
+ import { cn } from "@/lib/utils";
27
+
28
+ type LabelValues = Pick<MailLabel, "label" | "color" | "description">;
29
+ const EMPTY: LabelValues = { label: "", color: "default", description: "" };
30
+
31
+ export interface MailLabelManagerProps {
32
+ open: boolean;
33
+ onOpenChange: (open: boolean) => void;
34
+ labels: MailLabel[];
35
+ onChange: (labels: MailLabel[]) => void;
36
+ embedded?: boolean;
37
+ }
38
+
39
+ export function MailLabelManager({
40
+ open,
41
+ onOpenChange,
42
+ labels,
43
+ onChange,
44
+ embedded = false,
45
+ }: MailLabelManagerProps) {
46
+ const [selectedId, setSelectedId] = useState<number | null>(null);
47
+ const [form, setForm] = useState<LabelValues>(EMPTY);
48
+ const [busy, setBusy] = useState(false);
49
+ const [confirmDeleteId, setConfirmDeleteId] = useState<number | null>(null);
50
+
51
+ useEffect(() => {
52
+ if (!open) return;
53
+ const first = labels[0];
54
+ setSelectedId(first?.id ?? null);
55
+ setForm(
56
+ first
57
+ ? {
58
+ label: first.label,
59
+ color: first.color,
60
+ description: first.description ?? "",
61
+ }
62
+ : EMPTY
63
+ );
64
+ // Initialize once per opening; keep the current edit stable after a mutation.
65
+ // eslint-disable-next-line react-hooks/exhaustive-deps
66
+ }, [open]);
67
+
68
+ const select = (label: MailLabel) => {
69
+ setSelectedId(label.id);
70
+ setForm({
71
+ label: label.label,
72
+ color: label.color,
73
+ description: label.description ?? "",
74
+ });
75
+ };
76
+
77
+ const save = async () => {
78
+ const name = form.label.trim();
79
+ if (!name) return;
80
+ setBusy(true);
81
+ try {
82
+ if (selectedId === null) {
83
+ const created = await mailApi.createLabel({ ...form, label: name });
84
+ onChange([...labels, created]);
85
+ setSelectedId(created.id);
86
+ } else {
87
+ const updated = await mailApi.updateLabel(selectedId, {
88
+ ...form,
89
+ label: name,
90
+ });
91
+ onChange(
92
+ labels.map((item) =>
93
+ item.id === selectedId ? { ...item, ...updated } : item
94
+ )
95
+ );
96
+ }
97
+ toast.success("Label saved");
98
+ } catch (error) {
99
+ toast.error(
100
+ error instanceof Error ? error.message : "Failed to save label"
101
+ );
102
+ } finally {
103
+ setBusy(false);
104
+ }
105
+ };
106
+
107
+ const remove = async () => {
108
+ if (confirmDeleteId === null) return;
109
+ setBusy(true);
110
+ try {
111
+ await mailApi.deleteLabel(confirmDeleteId);
112
+ onChange(labels.filter((item) => item.id !== confirmDeleteId));
113
+ if (selectedId === confirmDeleteId) {
114
+ setSelectedId(null);
115
+ setForm(EMPTY);
116
+ }
117
+ setConfirmDeleteId(null);
118
+ toast.success("Label deleted");
119
+ } catch (error) {
120
+ toast.error(
121
+ error instanceof Error ? error.message : "Failed to delete label"
122
+ );
123
+ } finally {
124
+ setBusy(false);
125
+ }
126
+ };
127
+
128
+ const editor = (
129
+ <div className="grid gap-4 sm:grid-cols-[180px_1fr]">
130
+ <div className="flex flex-col gap-2">
131
+ <div className="flex max-h-80 flex-col gap-0.5 overflow-y-auto">
132
+ {labels.map((label) => (
133
+ <div
134
+ key={label.id}
135
+ className={cn(
136
+ "group flex items-center rounded-md text-sm hover:bg-muted/60",
137
+ selectedId === label.id && "bg-muted font-medium"
138
+ )}
139
+ >
140
+ <button
141
+ type="button"
142
+ onClick={() => select(label)}
143
+ className="flex min-w-0 flex-1 items-center gap-2 px-2.5 py-2 text-left"
144
+ >
145
+ <span
146
+ className={cn(
147
+ "size-2.5 shrink-0 rounded-full",
148
+ LABEL_SWATCH_CLASSES[label.color] ??
149
+ LABEL_SWATCH_CLASSES.default
150
+ )}
151
+ />
152
+ <span className="truncate">{label.label}</span>
153
+ </button>
154
+ <Button
155
+ variant="ghost"
156
+ size="icon-sm"
157
+ title={`Delete ${label.label}`}
158
+ aria-label={`Delete ${label.label}`}
159
+ disabled={busy}
160
+ onClick={() => setConfirmDeleteId(label.id)}
161
+ 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"
162
+ >
163
+ <Trash2 />
164
+ </Button>
165
+ </div>
166
+ ))}
167
+ </div>
168
+ <Button
169
+ variant="outline"
170
+ size="sm"
171
+ onClick={() => {
172
+ setSelectedId(null);
173
+ setForm(EMPTY);
174
+ }}
175
+ >
176
+ <Plus /> New
177
+ </Button>
178
+ </div>
179
+ <div className="flex flex-col gap-3">
180
+ <div className="space-y-1.5">
181
+ <Label>Name</Label>
182
+ <Input
183
+ value={form.label}
184
+ onChange={(event) =>
185
+ setForm((prev) => ({ ...prev, label: event.target.value }))
186
+ }
187
+ />
188
+ </div>
189
+ <div className="space-y-1.5">
190
+ <Label>Description</Label>
191
+ <Input
192
+ value={form.description ?? ""}
193
+ onChange={(event) =>
194
+ setForm((prev) => ({ ...prev, description: event.target.value }))
195
+ }
196
+ />
197
+ </div>
198
+ <div className="space-y-2">
199
+ <Label>Color</Label>
200
+ <div className="flex items-center gap-2">
201
+ {LABEL_COLOR_OPTIONS.map((color) => (
202
+ <button
203
+ key={color}
204
+ type="button"
205
+ title={color}
206
+ onClick={() => setForm((prev) => ({ ...prev, color }))}
207
+ className={cn(
208
+ "size-5 rounded-full",
209
+ LABEL_SWATCH_CLASSES[color],
210
+ form.color === color &&
211
+ "ring-2 ring-foreground ring-offset-2 ring-offset-background"
212
+ )}
213
+ />
214
+ ))}
215
+ </div>
216
+ </div>
217
+ <div className="mt-2 flex items-center justify-end">
218
+ <Button
219
+ size="sm"
220
+ disabled={!form.label.trim() || busy}
221
+ onClick={() => void save()}
222
+ >
223
+ {busy && <Loader2 className="animate-spin" />} Save
224
+ </Button>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ );
229
+
230
+ return (
231
+ <>
232
+ {embedded ? (
233
+ editor
234
+ ) : (
235
+ <Dialog open={open} onOpenChange={onOpenChange}>
236
+ <DialogContent className="sm:max-w-2xl">
237
+ <DialogHeader>
238
+ <DialogTitle>Manage labels</DialogTitle>
239
+ </DialogHeader>
240
+ {editor}
241
+ </DialogContent>
242
+ </Dialog>
243
+ )}
244
+ <AlertDialog
245
+ open={confirmDeleteId !== null}
246
+ onOpenChange={(nextOpen) => {
247
+ if (!nextOpen) setConfirmDeleteId(null);
248
+ }}
249
+ >
250
+ <AlertDialogContent>
251
+ <AlertDialogHeader>
252
+ <AlertDialogTitle>Delete this label?</AlertDialogTitle>
253
+ <AlertDialogDescription>
254
+ This removes the label from every message that uses it.
255
+ </AlertDialogDescription>
256
+ </AlertDialogHeader>
257
+ <AlertDialogFooter>
258
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
259
+ <AlertDialogAction
260
+ variant="destructive"
261
+ disabled={busy}
262
+ onClick={() => void remove()}
263
+ >
264
+ Delete
265
+ </AlertDialogAction>
266
+ </AlertDialogFooter>
267
+ </AlertDialogContent>
268
+ </AlertDialog>
269
+ </>
270
+ );
271
+ }
@@ -0,0 +1,267 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useGetIdentity } from "@refinedev/core";
3
+ import { Check, Loader2, Plus, Settings2, Tag as TagIcon } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import type { MailLabel, MailMessage } from "./types";
6
+ import { LABEL_COLOR_OPTIONS, LABEL_SWATCH_CLASSES } from "./types";
7
+ import { mailApi } from "./mail-api";
8
+ import { MailLabelBadge } from "./mail-label-badge";
9
+ import { MailLabelManager } from "./mail-label-manager";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Input } from "@/components/ui/input";
12
+ import {
13
+ Popover,
14
+ PopoverContent,
15
+ PopoverTrigger,
16
+ } from "@/components/ui/popover";
17
+ import { cn } from "@/lib/utils";
18
+
19
+ export function MailLabelsEditor({
20
+ message,
21
+ onChange,
22
+ }: {
23
+ message: MailMessage;
24
+ onChange?: (labels: MailLabel[]) => void;
25
+ }) {
26
+ const { data: identity } = useGetIdentity<{ id: number | string }>();
27
+ const userId = identity?.id;
28
+ const [open, setOpen] = useState(false);
29
+ const [allLabels, setAllLabels] = useState<MailLabel[]>([]);
30
+ const [loading, setLoading] = useState(false);
31
+ const [selectedIds, setSelectedIds] = useState<number[]>(
32
+ message.labels?.map((l) => l.id) ?? []
33
+ );
34
+ const [newLabel, setNewLabel] = useState("");
35
+ const [newColor, setNewColor] = useState<string>("default");
36
+ const [creating, setCreating] = useState(false);
37
+ const [managerOpen, setManagerOpen] = useState(false);
38
+ const commitQueue = useRef<Promise<unknown>>(Promise.resolve());
39
+ const commitSequence = useRef(0);
40
+
41
+ useEffect(() => {
42
+ setSelectedIds(message.labels?.map((l) => l.id) ?? []);
43
+ }, [message.labels]);
44
+
45
+ useEffect(() => {
46
+ if (!open) return;
47
+ let active = true;
48
+ setLoading(true);
49
+ mailApi
50
+ .getLabels(userId)
51
+ .then((labels) => active && setAllLabels(labels))
52
+ .catch(() => active && setAllLabels([]))
53
+ .finally(() => active && setLoading(false));
54
+ return () => {
55
+ active = false;
56
+ };
57
+ }, [open, userId]);
58
+
59
+ const resolveLabels = (ids: number[], from?: MailLabel[]): MailLabel[] => {
60
+ const source = from ?? allLabels;
61
+ return ids
62
+ .map(
63
+ (id) =>
64
+ source.find((l) => l.id === id) ??
65
+ message.labels?.find((l) => l.id === id)
66
+ )
67
+ .filter((l): l is MailLabel => Boolean(l));
68
+ };
69
+
70
+ const commit = (
71
+ ids: number[],
72
+ from?: MailLabel[],
73
+ rollbackIds = selectedIds
74
+ ) => {
75
+ const sequence = ++commitSequence.current;
76
+ const request = commitQueue.current
77
+ .catch(() => undefined)
78
+ .then(() => mailApi.setMessageLabels(message.id, ids));
79
+ commitQueue.current = request;
80
+ return request
81
+ .then(() => {
82
+ if (sequence === commitSequence.current) {
83
+ onChange?.(resolveLabels(ids, from));
84
+ }
85
+ })
86
+ .catch((error) => {
87
+ if (sequence === commitSequence.current) {
88
+ setSelectedIds(rollbackIds);
89
+ onChange?.(resolveLabels(rollbackIds, from));
90
+ toast.error(
91
+ error instanceof Error ? error.message : "Failed to update labels"
92
+ );
93
+ }
94
+ });
95
+ };
96
+
97
+ const toggle = (id: number) => {
98
+ const next = selectedIds.includes(id)
99
+ ? selectedIds.filter((x) => x !== id)
100
+ : [...selectedIds, id];
101
+ setSelectedIds(next);
102
+ void commit(next, undefined, selectedIds);
103
+ };
104
+
105
+ const createLabel = async () => {
106
+ const name = newLabel.trim();
107
+ if (!name) return;
108
+ setCreating(true);
109
+ try {
110
+ const label = await mailApi.createLabel({ label: name, color: newColor });
111
+ const updatedAll = [...allLabels, label];
112
+ setAllLabels(updatedAll);
113
+ const next = [...selectedIds, label.id];
114
+ setSelectedIds(next);
115
+ await commit(next, updatedAll);
116
+ setNewLabel("");
117
+ setNewColor("default");
118
+ } catch (error) {
119
+ toast.error(
120
+ error instanceof Error ? error.message : "Failed to create label"
121
+ );
122
+ } finally {
123
+ setCreating(false);
124
+ }
125
+ };
126
+
127
+ const handleLabelsManaged = (labels: MailLabel[]) => {
128
+ setAllLabels(labels);
129
+ const validIds = selectedIds.filter((id) => labels.some((label) => label.id === id));
130
+ if (validIds.length !== selectedIds.length) {
131
+ setSelectedIds(validIds);
132
+ void commit(validIds, labels, selectedIds);
133
+ } else {
134
+ onChange?.(resolveLabels(validIds, labels));
135
+ }
136
+ };
137
+
138
+ return (
139
+ <>
140
+ <Popover open={open} onOpenChange={setOpen}>
141
+ <PopoverTrigger
142
+ render={
143
+ <button
144
+ type="button"
145
+ className="inline-flex flex-wrap items-center gap-1.5 rounded-md py-0.5 transition-opacity hover:opacity-80"
146
+ />
147
+ }
148
+ >
149
+ {message.labels?.length ? (
150
+ message.labels.map((label) => (
151
+ <MailLabelBadge key={label.id} label={label} />
152
+ ))
153
+ ) : (
154
+ <span className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground">
155
+ <TagIcon className="size-3" />
156
+ Add label
157
+ </span>
158
+ )}
159
+ </PopoverTrigger>
160
+ <PopoverContent align="start" className="w-64 gap-2">
161
+ <div className="px-1 text-xs font-semibold text-muted-foreground">
162
+ Labels
163
+ </div>
164
+ <div className="flex max-h-52 flex-col gap-0.5 overflow-y-auto">
165
+ {loading ? (
166
+ <div className="flex items-center justify-center py-4">
167
+ <Loader2 className="size-4 animate-spin text-muted-foreground" />
168
+ </div>
169
+ ) : allLabels.length ? (
170
+ allLabels.map((label) => {
171
+ const checked = selectedIds.includes(label.id);
172
+ return (
173
+ <div
174
+ key={label.id}
175
+ role="button"
176
+ tabIndex={0}
177
+ onClick={() => toggle(label.id)}
178
+ onKeyDown={(e) => {
179
+ if (e.key === "Enter" || e.key === " ") {
180
+ e.preventDefault();
181
+ toggle(label.id);
182
+ }
183
+ }}
184
+ className={cn(
185
+ "flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-muted/60",
186
+ checked && "bg-muted/40"
187
+ )}
188
+ >
189
+ <span
190
+ className={cn(
191
+ "flex size-4 shrink-0 items-center justify-center rounded-[4px] border transition-colors",
192
+ checked
193
+ ? "border-primary bg-primary text-primary-foreground"
194
+ : "border-input"
195
+ )}
196
+ >
197
+ {checked && <Check className="size-3" />}
198
+ </span>
199
+ <MailLabelBadge label={label} />
200
+ </div>
201
+ );
202
+ })
203
+ ) : (
204
+ <div className="py-3 text-center text-xs text-muted-foreground">
205
+ No labels yet
206
+ </div>
207
+ )}
208
+ </div>
209
+ <div className="border-t border-border/60 pt-2">
210
+ <div className="mb-1.5 flex items-center gap-1.5">
211
+ {LABEL_COLOR_OPTIONS.map((color) => (
212
+ <button
213
+ key={color}
214
+ type="button"
215
+ title={color}
216
+ onClick={() => setNewColor(color)}
217
+ className={cn(
218
+ "size-4 rounded-full transition-all",
219
+ LABEL_SWATCH_CLASSES[color],
220
+ newColor === color
221
+ ? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
222
+ : "hover:scale-110"
223
+ )}
224
+ />
225
+ ))}
226
+ </div>
227
+ <div className="flex items-center gap-1.5">
228
+ <Input
229
+ value={newLabel}
230
+ onChange={(e) => setNewLabel(e.target.value)}
231
+ onKeyDown={(e) => {
232
+ if (e.key === "Enter") {
233
+ e.preventDefault();
234
+ void createLabel();
235
+ }
236
+ }}
237
+ placeholder="New label…"
238
+ className="h-8 text-sm"
239
+ />
240
+ <Button
241
+ size="icon-sm"
242
+ onClick={() => void createLabel()}
243
+ disabled={creating || !newLabel.trim()}
244
+ >
245
+ {creating ? <Loader2 className="animate-spin" /> : <Plus />}
246
+ </Button>
247
+ </div>
248
+ <Button
249
+ variant="ghost"
250
+ size="sm"
251
+ className="mt-2 w-full justify-start"
252
+ onClick={() => { setOpen(false); setManagerOpen(true); }}
253
+ >
254
+ <Settings2 /> Manage labels
255
+ </Button>
256
+ </div>
257
+ </PopoverContent>
258
+ </Popover>
259
+ <MailLabelManager
260
+ open={managerOpen}
261
+ onOpenChange={setManagerOpen}
262
+ labels={allLabels}
263
+ onChange={handleLabelsManaged}
264
+ />
265
+ </>
266
+ );
267
+ }