@mdxui/zero 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/preview.ts +20 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/ARCHITECTURE.md +415 -0
- package/CHANGELOG.md +80 -0
- package/README.md +205 -0
- package/package.json +43 -0
- package/playwright.config.ts +55 -0
- package/src/components/index.ts +20 -0
- package/src/compose/email-composer.stories.tsx +219 -0
- package/src/compose/email-composer.tsx +619 -0
- package/src/compose/index.ts +14 -0
- package/src/dashboard/index.ts +14 -0
- package/src/dashboard/mail-shell.stories.tsx +272 -0
- package/src/dashboard/mail-shell.tsx +199 -0
- package/src/dashboard/mail-sidebar.stories.tsx +158 -0
- package/src/dashboard/mail-sidebar.tsx +388 -0
- package/src/index.ts +24 -0
- package/src/landing/index.ts +24 -0
- package/src/mail/index.ts +15 -0
- package/src/mail/mail-item.stories.tsx +422 -0
- package/src/mail/mail-item.tsx +229 -0
- package/src/mail/mail-list.stories.tsx +320 -0
- package/src/mail/mail-list.tsx +262 -0
- package/src/mail/message-view.stories.tsx +459 -0
- package/src/mail/message-view.tsx +378 -0
- package/src/mail/thread-display.stories.tsx +260 -0
- package/src/mail/thread-display.tsx +392 -0
- package/src/pages/index.ts +9 -0
- package/src/pages/mail-zero-page.stories.tsx +251 -0
- package/src/pages/mail-zero-page.tsx +334 -0
- package/tests/visual/report/index.html +85 -0
- package/tests/visual/snapshots/zero-components.spec.ts/mail-shell-default.png +0 -0
- package/tests/visual/zero-components.spec.ts +321 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { Badge } from "@mdxui/primitives/badge";
|
|
2
|
+
import { Button } from "@mdxui/primitives/button";
|
|
3
|
+
import {
|
|
4
|
+
DropdownMenu,
|
|
5
|
+
DropdownMenuContent,
|
|
6
|
+
DropdownMenuItem,
|
|
7
|
+
DropdownMenuTrigger,
|
|
8
|
+
} from "@mdxui/primitives/dropdown-menu";
|
|
9
|
+
import { Input } from "@mdxui/primitives/input";
|
|
10
|
+
import { cn } from "@mdxui/primitives/lib/utils";
|
|
11
|
+
import { Separator } from "@mdxui/primitives/separator";
|
|
12
|
+
import { Textarea } from "@mdxui/primitives/textarea";
|
|
13
|
+
import {
|
|
14
|
+
Tooltip,
|
|
15
|
+
TooltipContent,
|
|
16
|
+
TooltipProvider,
|
|
17
|
+
TooltipTrigger,
|
|
18
|
+
} from "@mdxui/primitives/tooltip";
|
|
19
|
+
import {
|
|
20
|
+
Bold,
|
|
21
|
+
ChevronDown,
|
|
22
|
+
Italic,
|
|
23
|
+
Link,
|
|
24
|
+
List,
|
|
25
|
+
ListOrdered,
|
|
26
|
+
Maximize2,
|
|
27
|
+
Minimize2,
|
|
28
|
+
Minus,
|
|
29
|
+
Paperclip,
|
|
30
|
+
Send,
|
|
31
|
+
Sparkles,
|
|
32
|
+
Trash2,
|
|
33
|
+
Underline,
|
|
34
|
+
X,
|
|
35
|
+
} from "lucide-react";
|
|
36
|
+
import type {
|
|
37
|
+
EditorToolbarProps as BaseEditorToolbarProps,
|
|
38
|
+
RecipientInputProps as BaseRecipientInputProps,
|
|
39
|
+
EmailAddress,
|
|
40
|
+
EmailComposerProps,
|
|
41
|
+
} from "mdxui";
|
|
42
|
+
import * as React from "react";
|
|
43
|
+
|
|
44
|
+
// Local type overrides to fix Zod function type inference issues
|
|
45
|
+
// Zod .default() makes props required in output type, but we want them optional in component props
|
|
46
|
+
interface RecipientInputProps
|
|
47
|
+
extends Omit<
|
|
48
|
+
BaseRecipientInputProps,
|
|
49
|
+
"onChange" | "disabled" | "autoFocus" | "showAvatars" | "allowFreeEntry"
|
|
50
|
+
> {
|
|
51
|
+
onChange?: (value: EmailAddress[]) => void;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
autoFocus?: boolean;
|
|
54
|
+
showAvatars?: boolean;
|
|
55
|
+
allowFreeEntry?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface EditorToolbarProps
|
|
59
|
+
extends Omit<
|
|
60
|
+
BaseEditorToolbarProps,
|
|
61
|
+
"onAction" | "onAIClick" | "showAI" | "disabled" | "sticky" | "compact"
|
|
62
|
+
> {
|
|
63
|
+
onAction?: (actionId: string) => void;
|
|
64
|
+
onAIClick?: () => void;
|
|
65
|
+
showAI?: boolean;
|
|
66
|
+
disabled?: boolean;
|
|
67
|
+
sticky?: boolean;
|
|
68
|
+
compact?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* RecipientChip - Individual recipient badge.
|
|
73
|
+
*/
|
|
74
|
+
function RecipientChip({
|
|
75
|
+
recipient,
|
|
76
|
+
onRemove,
|
|
77
|
+
}: {
|
|
78
|
+
recipient: EmailAddress;
|
|
79
|
+
onRemove?: () => void;
|
|
80
|
+
}) {
|
|
81
|
+
return (
|
|
82
|
+
<Badge variant="secondary" className="gap-1 py-1 pl-2 pr-1">
|
|
83
|
+
<span className="max-w-[150px] truncate">
|
|
84
|
+
{recipient.name || recipient.address}
|
|
85
|
+
</span>
|
|
86
|
+
{onRemove && (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
onClick={onRemove}
|
|
90
|
+
className="hover:bg-muted rounded-full p-0.5"
|
|
91
|
+
>
|
|
92
|
+
<X className="size-3" />
|
|
93
|
+
</button>
|
|
94
|
+
)}
|
|
95
|
+
</Badge>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* RecipientInput - Input field for email recipients with autocomplete.
|
|
101
|
+
*/
|
|
102
|
+
function RecipientInput({
|
|
103
|
+
field,
|
|
104
|
+
value = [],
|
|
105
|
+
onChange,
|
|
106
|
+
placeholder,
|
|
107
|
+
disabled = false,
|
|
108
|
+
autoFocus = false,
|
|
109
|
+
className,
|
|
110
|
+
}: RecipientInputProps) {
|
|
111
|
+
const [inputValue, setInputValue] = React.useState("");
|
|
112
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
113
|
+
|
|
114
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
115
|
+
if (e.key === "Enter" || e.key === "," || e.key === "Tab") {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
addRecipient();
|
|
118
|
+
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
|
119
|
+
// Remove last recipient
|
|
120
|
+
const newValue = value.slice(0, -1);
|
|
121
|
+
onChange?.(newValue);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const addRecipient = () => {
|
|
126
|
+
const email = inputValue.trim();
|
|
127
|
+
if (!email) return;
|
|
128
|
+
|
|
129
|
+
// Basic email validation
|
|
130
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
131
|
+
if (emailRegex.test(email)) {
|
|
132
|
+
const newRecipient: EmailAddress = { address: email };
|
|
133
|
+
onChange?.([...value, newRecipient]);
|
|
134
|
+
setInputValue("");
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const removeRecipient = (index: number) => {
|
|
139
|
+
const newValue = value.filter((_, i) => i !== index);
|
|
140
|
+
onChange?.(newValue);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: RecipientInput is interactive
|
|
145
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: RecipientInput is interactive
|
|
146
|
+
<div
|
|
147
|
+
className={cn(
|
|
148
|
+
"flex min-h-[40px] flex-wrap items-center gap-1 rounded-md border px-3 py-2",
|
|
149
|
+
"focus-within:ring-ring focus-within:ring-2",
|
|
150
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
151
|
+
className,
|
|
152
|
+
)}
|
|
153
|
+
onClick={() => inputRef.current?.focus()}
|
|
154
|
+
>
|
|
155
|
+
<span className="text-muted-foreground shrink-0 text-sm font-medium capitalize">
|
|
156
|
+
{field}:
|
|
157
|
+
</span>
|
|
158
|
+
{value.map((recipient, index) => (
|
|
159
|
+
<RecipientChip
|
|
160
|
+
key={`${recipient.address}-${index}`}
|
|
161
|
+
recipient={recipient}
|
|
162
|
+
onRemove={() => removeRecipient(index)}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
|
|
166
|
+
<input
|
|
167
|
+
ref={inputRef}
|
|
168
|
+
type="email"
|
|
169
|
+
value={inputValue}
|
|
170
|
+
onChange={(e) => setInputValue(e.target.value)}
|
|
171
|
+
onKeyDown={handleKeyDown}
|
|
172
|
+
onBlur={addRecipient}
|
|
173
|
+
placeholder={value.length === 0 ? placeholder : undefined}
|
|
174
|
+
disabled={disabled}
|
|
175
|
+
// biome-ignore lint/a11y/noAutofocus: We need to autofocus the input for the user to type in the email address
|
|
176
|
+
autoFocus={autoFocus}
|
|
177
|
+
className="min-w-[120px] flex-1 border-0 bg-transparent p-0 text-sm outline-none placeholder:text-muted-foreground"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* EditorToolbar - Formatting toolbar for the email editor.
|
|
185
|
+
*/
|
|
186
|
+
function EditorToolbar({
|
|
187
|
+
onAction,
|
|
188
|
+
showAI = true,
|
|
189
|
+
onAIClick,
|
|
190
|
+
disabled = false,
|
|
191
|
+
compact = false,
|
|
192
|
+
sticky: _sticky = false,
|
|
193
|
+
className,
|
|
194
|
+
}: EditorToolbarProps) {
|
|
195
|
+
const actions = [
|
|
196
|
+
{ id: "bold", icon: Bold, label: "Bold" },
|
|
197
|
+
{ id: "italic", icon: Italic, label: "Italic" },
|
|
198
|
+
{ id: "underline", icon: Underline, label: "Underline" },
|
|
199
|
+
{ id: "link", icon: Link, label: "Insert link" },
|
|
200
|
+
{ id: "bulletList", icon: List, label: "Bullet list" },
|
|
201
|
+
{ id: "orderedList", icon: ListOrdered, label: "Numbered list" },
|
|
202
|
+
] as const;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div
|
|
206
|
+
data-slot="editor-toolbar"
|
|
207
|
+
className={cn(
|
|
208
|
+
"flex items-center gap-1 border-t px-2 py-1",
|
|
209
|
+
disabled && "pointer-events-none opacity-50",
|
|
210
|
+
className,
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
<TooltipProvider>
|
|
214
|
+
{actions.map((action) => (
|
|
215
|
+
<Tooltip key={action.id}>
|
|
216
|
+
<TooltipTrigger asChild>
|
|
217
|
+
<Button
|
|
218
|
+
variant="ghost"
|
|
219
|
+
size="icon"
|
|
220
|
+
className={cn("size-8", compact && "size-7")}
|
|
221
|
+
onClick={() => onAction?.(action.id)}
|
|
222
|
+
>
|
|
223
|
+
<action.icon className={cn("size-4", compact && "size-3")} />
|
|
224
|
+
</Button>
|
|
225
|
+
</TooltipTrigger>
|
|
226
|
+
<TooltipContent>{action.label}</TooltipContent>
|
|
227
|
+
</Tooltip>
|
|
228
|
+
))}
|
|
229
|
+
|
|
230
|
+
{showAI && (
|
|
231
|
+
<>
|
|
232
|
+
<Separator orientation="vertical" className="mx-1 h-6" />
|
|
233
|
+
<Tooltip>
|
|
234
|
+
<TooltipTrigger asChild>
|
|
235
|
+
<Button
|
|
236
|
+
variant="ghost"
|
|
237
|
+
size="sm"
|
|
238
|
+
className="gap-1"
|
|
239
|
+
onClick={() => onAIClick?.()}
|
|
240
|
+
>
|
|
241
|
+
<Sparkles className="size-4" />
|
|
242
|
+
{!compact && "AI"}
|
|
243
|
+
</Button>
|
|
244
|
+
</TooltipTrigger>
|
|
245
|
+
<TooltipContent>AI writing assistance</TooltipContent>
|
|
246
|
+
</Tooltip>
|
|
247
|
+
</>
|
|
248
|
+
)}
|
|
249
|
+
</TooltipProvider>
|
|
250
|
+
</div>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* EmailComposer - Full email composition interface.
|
|
256
|
+
*
|
|
257
|
+
* Features:
|
|
258
|
+
* - Recipient fields (To, Cc, Bcc)
|
|
259
|
+
* - Subject line
|
|
260
|
+
* - Rich text editor (placeholder for now)
|
|
261
|
+
* - Attachments
|
|
262
|
+
* - Send/discard actions
|
|
263
|
+
* - AI assistance button
|
|
264
|
+
* - Minimize/maximize
|
|
265
|
+
*/
|
|
266
|
+
function EmailComposer({
|
|
267
|
+
mode = "new",
|
|
268
|
+
initialDraft,
|
|
269
|
+
replyTo,
|
|
270
|
+
fromAliases,
|
|
271
|
+
selectedFrom,
|
|
272
|
+
onFromChange,
|
|
273
|
+
onSend,
|
|
274
|
+
onSaveDraft,
|
|
275
|
+
onDiscard,
|
|
276
|
+
onClose,
|
|
277
|
+
autoSaveInterval = 30000,
|
|
278
|
+
showCcBcc = false,
|
|
279
|
+
isMaximized = false,
|
|
280
|
+
onToggleMaximize,
|
|
281
|
+
enableAI = true,
|
|
282
|
+
enableSignature = true,
|
|
283
|
+
signature,
|
|
284
|
+
isSending = false,
|
|
285
|
+
enableShortcuts = true,
|
|
286
|
+
className,
|
|
287
|
+
}: EmailComposerProps) {
|
|
288
|
+
const [to, setTo] = React.useState<EmailAddress[]>(initialDraft?.to || []);
|
|
289
|
+
const [cc, setCc] = React.useState<EmailAddress[]>(initialDraft?.cc || []);
|
|
290
|
+
const [bcc, setBcc] = React.useState<EmailAddress[]>(initialDraft?.bcc || []);
|
|
291
|
+
const [subject, setSubject] = React.useState(initialDraft?.subject || "");
|
|
292
|
+
const [body, setBody] = React.useState(initialDraft?.body || "");
|
|
293
|
+
const [showCc, setShowCc] = React.useState(
|
|
294
|
+
showCcBcc || (initialDraft?.cc?.length ?? 0) > 0,
|
|
295
|
+
);
|
|
296
|
+
const [showBcc, setShowBcc] = React.useState(
|
|
297
|
+
(initialDraft?.bcc?.length ?? 0) > 0,
|
|
298
|
+
);
|
|
299
|
+
const [isMinimized, setIsMinimized] = React.useState(false);
|
|
300
|
+
|
|
301
|
+
// Auto-save draft
|
|
302
|
+
React.useEffect(() => {
|
|
303
|
+
if (!onSaveDraft || autoSaveInterval <= 0) return;
|
|
304
|
+
|
|
305
|
+
const timer = setInterval(() => {
|
|
306
|
+
onSaveDraft({
|
|
307
|
+
to,
|
|
308
|
+
cc,
|
|
309
|
+
bcc,
|
|
310
|
+
subject,
|
|
311
|
+
body,
|
|
312
|
+
});
|
|
313
|
+
}, autoSaveInterval);
|
|
314
|
+
|
|
315
|
+
return () => clearInterval(timer);
|
|
316
|
+
}, [to, cc, bcc, subject, body, onSaveDraft, autoSaveInterval]);
|
|
317
|
+
|
|
318
|
+
const handleSend = React.useCallback(() => {
|
|
319
|
+
if (to.length === 0) return;
|
|
320
|
+
onSend?.({
|
|
321
|
+
to,
|
|
322
|
+
cc: showCc ? cc : [],
|
|
323
|
+
bcc: showBcc ? bcc : [],
|
|
324
|
+
subject,
|
|
325
|
+
body: enableSignature && signature ? `${body}\n\n${signature}` : body,
|
|
326
|
+
});
|
|
327
|
+
}, [
|
|
328
|
+
to,
|
|
329
|
+
cc,
|
|
330
|
+
bcc,
|
|
331
|
+
subject,
|
|
332
|
+
body,
|
|
333
|
+
onSend,
|
|
334
|
+
enableSignature,
|
|
335
|
+
signature,
|
|
336
|
+
showBcc,
|
|
337
|
+
showCc,
|
|
338
|
+
]);
|
|
339
|
+
// Keyboard shortcuts
|
|
340
|
+
React.useEffect(() => {
|
|
341
|
+
if (!enableShortcuts) return;
|
|
342
|
+
|
|
343
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
344
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
handleSend();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
351
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
352
|
+
}, [enableShortcuts, handleSend]);
|
|
353
|
+
|
|
354
|
+
// Reply subject prefix
|
|
355
|
+
const getSubjectPrefix = React.useCallback(() => {
|
|
356
|
+
if (mode === "reply" || mode === "reply-all") return "Re: ";
|
|
357
|
+
if (mode === "forward") return "Fwd: ";
|
|
358
|
+
return "";
|
|
359
|
+
}, [mode]);
|
|
360
|
+
|
|
361
|
+
// Set subject from reply
|
|
362
|
+
React.useEffect(() => {
|
|
363
|
+
if (replyTo && !initialDraft?.subject) {
|
|
364
|
+
const prefix = getSubjectPrefix();
|
|
365
|
+
const cleanSubject = replyTo.subject.replace(/^(Re:|Fwd:)\s*/i, "");
|
|
366
|
+
setSubject(`${prefix}${cleanSubject}`);
|
|
367
|
+
}
|
|
368
|
+
}, [replyTo, getSubjectPrefix, initialDraft?.subject]);
|
|
369
|
+
|
|
370
|
+
if (isMinimized) {
|
|
371
|
+
return (
|
|
372
|
+
<div
|
|
373
|
+
className={cn(
|
|
374
|
+
"bg-background fixed bottom-0 right-4 z-50 w-72 rounded-t-lg border shadow-lg",
|
|
375
|
+
className,
|
|
376
|
+
)}
|
|
377
|
+
>
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
onClick={() => setIsMinimized(false)}
|
|
381
|
+
className="hover:bg-muted flex w-full items-center justify-between px-4 py-3"
|
|
382
|
+
>
|
|
383
|
+
<span className="truncate text-sm font-medium">
|
|
384
|
+
{subject || "New Message"}
|
|
385
|
+
</span>
|
|
386
|
+
<div className="flex items-center gap-1">
|
|
387
|
+
<Maximize2 className="size-4" />
|
|
388
|
+
</div>
|
|
389
|
+
</button>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div
|
|
396
|
+
data-slot="email-composer"
|
|
397
|
+
className={cn(
|
|
398
|
+
"bg-background flex flex-col rounded-lg border shadow-xl",
|
|
399
|
+
isMaximized ? "fixed inset-4 z-50" : "max-h-[80vh] w-full max-w-2xl",
|
|
400
|
+
className,
|
|
401
|
+
)}
|
|
402
|
+
>
|
|
403
|
+
{/* Header */}
|
|
404
|
+
<div className="bg-muted/30 flex items-center justify-between rounded-t-lg border-b px-4 py-2">
|
|
405
|
+
<span className="text-sm font-medium">
|
|
406
|
+
{mode === "new" && "New Message"}
|
|
407
|
+
{mode === "reply" && "Reply"}
|
|
408
|
+
{mode === "reply-all" && "Reply All"}
|
|
409
|
+
{mode === "forward" && "Forward"}
|
|
410
|
+
{mode === "draft" && "Draft"}
|
|
411
|
+
</span>
|
|
412
|
+
<div className="flex items-center gap-1">
|
|
413
|
+
<TooltipProvider>
|
|
414
|
+
<Tooltip>
|
|
415
|
+
<TooltipTrigger asChild>
|
|
416
|
+
<Button
|
|
417
|
+
variant="ghost"
|
|
418
|
+
size="icon"
|
|
419
|
+
className="size-7"
|
|
420
|
+
onClick={() => setIsMinimized(true)}
|
|
421
|
+
>
|
|
422
|
+
<Minus className="size-4" />
|
|
423
|
+
</Button>
|
|
424
|
+
</TooltipTrigger>
|
|
425
|
+
<TooltipContent>Minimize</TooltipContent>
|
|
426
|
+
</Tooltip>
|
|
427
|
+
|
|
428
|
+
{onToggleMaximize && (
|
|
429
|
+
<Tooltip>
|
|
430
|
+
<TooltipTrigger asChild>
|
|
431
|
+
<Button
|
|
432
|
+
variant="ghost"
|
|
433
|
+
size="icon"
|
|
434
|
+
className="size-7"
|
|
435
|
+
onClick={onToggleMaximize}
|
|
436
|
+
>
|
|
437
|
+
{isMaximized ? (
|
|
438
|
+
<Minimize2 className="size-4" />
|
|
439
|
+
) : (
|
|
440
|
+
<Maximize2 className="size-4" />
|
|
441
|
+
)}
|
|
442
|
+
</Button>
|
|
443
|
+
</TooltipTrigger>
|
|
444
|
+
<TooltipContent>
|
|
445
|
+
{isMaximized ? "Exit fullscreen" : "Fullscreen"}
|
|
446
|
+
</TooltipContent>
|
|
447
|
+
</Tooltip>
|
|
448
|
+
)}
|
|
449
|
+
|
|
450
|
+
<Tooltip>
|
|
451
|
+
<TooltipTrigger asChild>
|
|
452
|
+
<Button
|
|
453
|
+
variant="ghost"
|
|
454
|
+
size="icon"
|
|
455
|
+
className="size-7"
|
|
456
|
+
onClick={() => onClose?.()}
|
|
457
|
+
>
|
|
458
|
+
<X className="size-4" />
|
|
459
|
+
</Button>
|
|
460
|
+
</TooltipTrigger>
|
|
461
|
+
<TooltipContent>Close</TooltipContent>
|
|
462
|
+
</Tooltip>
|
|
463
|
+
</TooltipProvider>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
|
|
467
|
+
{/* Recipients */}
|
|
468
|
+
<div className="space-y-2 border-b px-4 py-3">
|
|
469
|
+
{/* From selector */}
|
|
470
|
+
{fromAliases && fromAliases.length > 1 && (
|
|
471
|
+
<div className="flex items-center gap-2">
|
|
472
|
+
<span className="text-muted-foreground text-sm font-medium">
|
|
473
|
+
From:
|
|
474
|
+
</span>
|
|
475
|
+
<DropdownMenu>
|
|
476
|
+
<DropdownMenuTrigger asChild>
|
|
477
|
+
<Button variant="ghost" size="sm" className="h-auto gap-1 py-1">
|
|
478
|
+
{selectedFrom || fromAliases[0]?.address}
|
|
479
|
+
<ChevronDown className="size-3" />
|
|
480
|
+
</Button>
|
|
481
|
+
</DropdownMenuTrigger>
|
|
482
|
+
<DropdownMenuContent>
|
|
483
|
+
{fromAliases.map((alias) => (
|
|
484
|
+
<DropdownMenuItem
|
|
485
|
+
key={alias.address}
|
|
486
|
+
onClick={() => onFromChange?.(alias.address)}
|
|
487
|
+
>
|
|
488
|
+
{alias.name
|
|
489
|
+
? `${alias.name} <${alias.address}>`
|
|
490
|
+
: alias.address}
|
|
491
|
+
</DropdownMenuItem>
|
|
492
|
+
))}
|
|
493
|
+
</DropdownMenuContent>
|
|
494
|
+
</DropdownMenu>
|
|
495
|
+
</div>
|
|
496
|
+
)}
|
|
497
|
+
|
|
498
|
+
<RecipientInput
|
|
499
|
+
field="to"
|
|
500
|
+
value={to}
|
|
501
|
+
onChange={setTo}
|
|
502
|
+
placeholder="Recipients"
|
|
503
|
+
autoFocus={mode === "new"}
|
|
504
|
+
/>
|
|
505
|
+
|
|
506
|
+
{showCc && (
|
|
507
|
+
<RecipientInput
|
|
508
|
+
field="cc"
|
|
509
|
+
value={cc}
|
|
510
|
+
onChange={setCc}
|
|
511
|
+
placeholder="Cc"
|
|
512
|
+
/>
|
|
513
|
+
)}
|
|
514
|
+
|
|
515
|
+
{showBcc && (
|
|
516
|
+
<RecipientInput
|
|
517
|
+
field="bcc"
|
|
518
|
+
value={bcc}
|
|
519
|
+
onChange={setBcc}
|
|
520
|
+
placeholder="Bcc"
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
|
|
524
|
+
{/* Cc/Bcc toggle */}
|
|
525
|
+
{(!showCc || !showBcc) && (
|
|
526
|
+
<div className="flex gap-2">
|
|
527
|
+
{!showCc && (
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
onClick={() => setShowCc(true)}
|
|
531
|
+
className="text-muted-foreground text-xs hover:underline"
|
|
532
|
+
>
|
|
533
|
+
Cc
|
|
534
|
+
</button>
|
|
535
|
+
)}
|
|
536
|
+
{!showBcc && (
|
|
537
|
+
<button
|
|
538
|
+
type="button"
|
|
539
|
+
onClick={() => setShowBcc(true)}
|
|
540
|
+
className="text-muted-foreground text-xs hover:underline"
|
|
541
|
+
>
|
|
542
|
+
Bcc
|
|
543
|
+
</button>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
)}
|
|
547
|
+
</div>
|
|
548
|
+
|
|
549
|
+
{/* Subject */}
|
|
550
|
+
<div className="border-b px-4 py-2">
|
|
551
|
+
<Input
|
|
552
|
+
value={subject}
|
|
553
|
+
onChange={(e) => setSubject(e.target.value)}
|
|
554
|
+
placeholder="Subject"
|
|
555
|
+
className="border-0 px-0 text-base shadow-none focus-visible:ring-0"
|
|
556
|
+
/>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* Body */}
|
|
560
|
+
<div className="flex-1 overflow-hidden">
|
|
561
|
+
<Textarea
|
|
562
|
+
value={body}
|
|
563
|
+
onChange={(e) => setBody(e.target.value)}
|
|
564
|
+
placeholder="Write your message..."
|
|
565
|
+
className="min-h-[200px] resize-none rounded-none border-0 p-4 shadow-none focus-visible:ring-0"
|
|
566
|
+
/>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
{/* Toolbar */}
|
|
570
|
+
<EditorToolbar
|
|
571
|
+
showAI={enableAI}
|
|
572
|
+
compact={false}
|
|
573
|
+
disabled={isSending}
|
|
574
|
+
sticky={false}
|
|
575
|
+
onAIClick={() => {
|
|
576
|
+
// AI assistance placeholder
|
|
577
|
+
}}
|
|
578
|
+
/>
|
|
579
|
+
|
|
580
|
+
{/* Footer Actions */}
|
|
581
|
+
<div className="flex items-center justify-between border-t px-4 py-3">
|
|
582
|
+
<div className="flex items-center gap-2">
|
|
583
|
+
<Button
|
|
584
|
+
onClick={handleSend}
|
|
585
|
+
disabled={isSending || to.length === 0}
|
|
586
|
+
className="gap-2"
|
|
587
|
+
>
|
|
588
|
+
<Send className="size-4" />
|
|
589
|
+
{isSending ? "Sending..." : "Send"}
|
|
590
|
+
</Button>
|
|
591
|
+
|
|
592
|
+
<TooltipProvider>
|
|
593
|
+
<Tooltip>
|
|
594
|
+
<TooltipTrigger asChild>
|
|
595
|
+
<Button variant="ghost" size="icon">
|
|
596
|
+
<Paperclip className="size-4" />
|
|
597
|
+
</Button>
|
|
598
|
+
</TooltipTrigger>
|
|
599
|
+
<TooltipContent>Attach files</TooltipContent>
|
|
600
|
+
</Tooltip>
|
|
601
|
+
</TooltipProvider>
|
|
602
|
+
</div>
|
|
603
|
+
|
|
604
|
+
<TooltipProvider>
|
|
605
|
+
<Tooltip>
|
|
606
|
+
<TooltipTrigger asChild>
|
|
607
|
+
<Button variant="ghost" size="icon" onClick={() => onDiscard?.()}>
|
|
608
|
+
<Trash2 className="size-4" />
|
|
609
|
+
</Button>
|
|
610
|
+
</TooltipTrigger>
|
|
611
|
+
<TooltipContent>Discard</TooltipContent>
|
|
612
|
+
</Tooltip>
|
|
613
|
+
</TooltipProvider>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export { EmailComposer, RecipientInput, EditorToolbar };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose Components
|
|
3
|
+
*
|
|
4
|
+
* Email composition and editing components:
|
|
5
|
+
* - EmailComposer: Full-featured email composition
|
|
6
|
+
* - RecipientInput: Email recipient input with autocomplete
|
|
7
|
+
* - EditorToolbar: Formatting toolbar
|
|
8
|
+
* - ReplyComposer: In-thread reply composition (planned)
|
|
9
|
+
* - AITextarea: AI-assisted text input (planned)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export { EditorToolbar, EmailComposer, RecipientInput } from "./email-composer";
|
|
13
|
+
// export { ReplyComposer } from './reply-composer'
|
|
14
|
+
// export { AITextarea } from './ai-textarea'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Components
|
|
3
|
+
*
|
|
4
|
+
* Email app shell and dashboard components:
|
|
5
|
+
* - MailShell: Main app layout with sidebar + content
|
|
6
|
+
* - MailSidebar: Folder navigation sidebar
|
|
7
|
+
* - MailHeader: App header with search
|
|
8
|
+
* - AISidebar: AI assistant panel
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { MailShell } from "./mail-shell";
|
|
12
|
+
export { MailSidebar } from "./mail-sidebar";
|
|
13
|
+
// export { MailHeader } from './mail-header'
|
|
14
|
+
// export { AISidebar } from './ai-sidebar'
|