@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.
@@ -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'