@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,392 @@
1
+ import { Button } from "@mdxui/primitives/button";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuSeparator,
7
+ DropdownMenuTrigger,
8
+ } from "@mdxui/primitives/dropdown-menu";
9
+ import { cn } from "@mdxui/primitives/lib/utils";
10
+ import { ScrollArea } from "@mdxui/primitives/scroll-area";
11
+ import { Separator } from "@mdxui/primitives/separator";
12
+ import {
13
+ Tooltip,
14
+ TooltipContent,
15
+ TooltipProvider,
16
+ TooltipTrigger,
17
+ } from "@mdxui/primitives/tooltip";
18
+ import {
19
+ AlertOctagon,
20
+ Archive,
21
+ ArrowLeft,
22
+ ChevronLeft,
23
+ ChevronRight,
24
+ Clock,
25
+ MoreHorizontal,
26
+ Printer,
27
+ Reply,
28
+ Star,
29
+ Tag,
30
+ Trash2,
31
+ } from "lucide-react";
32
+ import type { ThreadDisplayProps } from "mdxui";
33
+ import * as React from "react";
34
+ import { MessageView } from "./message-view";
35
+
36
+ /**
37
+ * ThreadDisplay - Displays an email thread with all messages.
38
+ *
39
+ * Features:
40
+ * - Thread navigation (prev/next)
41
+ * - Message list with expand/collapse
42
+ * - Actions (reply, archive, delete, star, etc.)
43
+ * - Print functionality
44
+ * - Animations
45
+ */
46
+ function ThreadDisplay({
47
+ thread,
48
+ activeMessageId: _activeMessageId,
49
+ showNavigation = true,
50
+ onPrevious,
51
+ onNext,
52
+ onClose,
53
+ onReply,
54
+ onReplyAll,
55
+ onForward,
56
+ onStar,
57
+ onArchive,
58
+ onDelete,
59
+ onSpam,
60
+ onSnooze,
61
+ onMove,
62
+ onLabel,
63
+ onPrint,
64
+ enableAnimations = true,
65
+ hasPrevious = false,
66
+ hasNext = false,
67
+ className,
68
+ }: ThreadDisplayProps) {
69
+ const [expandedMessages, setExpandedMessages] = React.useState<Set<string>>(
70
+ () => {
71
+ // By default, expand the last message
72
+ if (thread.messages.length > 0) {
73
+ return new Set([thread.messages[thread.messages.length - 1].id]);
74
+ }
75
+ return new Set();
76
+ },
77
+ );
78
+
79
+ // Toggle message expansion
80
+ const toggleMessage = (messageId: string) => {
81
+ setExpandedMessages((prev) => {
82
+ const next = new Set(prev);
83
+ if (next.has(messageId)) {
84
+ next.delete(messageId);
85
+ } else {
86
+ next.add(messageId);
87
+ }
88
+ return next;
89
+ });
90
+ };
91
+
92
+ // Expand all messages
93
+ const expandAll = () => {
94
+ setExpandedMessages(new Set(thread.messages.map((m) => m.id)));
95
+ };
96
+
97
+ // Collapse all except latest
98
+ const collapseAll = () => {
99
+ if (thread.messages.length > 0) {
100
+ setExpandedMessages(
101
+ new Set([thread.messages[thread.messages.length - 1].id]),
102
+ );
103
+ }
104
+ };
105
+
106
+ // Handle print
107
+ const handlePrint = () => {
108
+ if (onPrint) {
109
+ onPrint();
110
+ } else {
111
+ window.print();
112
+ }
113
+ };
114
+
115
+ return (
116
+ <div
117
+ data-slot="thread-display"
118
+ className={cn("flex h-full flex-col", className)}
119
+ >
120
+ {/* Header */}
121
+ <div className="flex items-center justify-between gap-4 border-b px-4 py-2">
122
+ <div className="flex items-center gap-2">
123
+ {/* Back Button */}
124
+ <TooltipProvider>
125
+ <Tooltip>
126
+ <TooltipTrigger asChild>
127
+ <Button
128
+ variant="ghost"
129
+ size="icon"
130
+ className="size-8"
131
+ onClick={() => onClose?.()}
132
+ >
133
+ <ArrowLeft className="size-4" />
134
+ </Button>
135
+ </TooltipTrigger>
136
+ <TooltipContent>Back to inbox</TooltipContent>
137
+ </Tooltip>
138
+ </TooltipProvider>
139
+
140
+ {/* Thread Navigation */}
141
+ {showNavigation && (
142
+ <>
143
+ <Separator orientation="vertical" className="h-6" />
144
+ <div className="flex items-center gap-1">
145
+ <TooltipProvider>
146
+ <Tooltip>
147
+ <TooltipTrigger asChild>
148
+ <Button
149
+ variant="ghost"
150
+ size="icon"
151
+ className="size-8"
152
+ disabled={!hasPrevious}
153
+ onClick={() => onPrevious?.()}
154
+ >
155
+ <ChevronLeft className="size-4" />
156
+ </Button>
157
+ </TooltipTrigger>
158
+ <TooltipContent>Newer</TooltipContent>
159
+ </Tooltip>
160
+
161
+ <Tooltip>
162
+ <TooltipTrigger asChild>
163
+ <Button
164
+ variant="ghost"
165
+ size="icon"
166
+ className="size-8"
167
+ disabled={!hasNext}
168
+ onClick={() => onNext?.()}
169
+ >
170
+ <ChevronRight className="size-4" />
171
+ </Button>
172
+ </TooltipTrigger>
173
+ <TooltipContent>Older</TooltipContent>
174
+ </Tooltip>
175
+ </TooltipProvider>
176
+ </div>
177
+ </>
178
+ )}
179
+ </div>
180
+
181
+ {/* Actions */}
182
+ <div className="flex items-center gap-1">
183
+ <TooltipProvider>
184
+ <Tooltip>
185
+ <TooltipTrigger asChild>
186
+ <Button
187
+ variant="ghost"
188
+ size="icon"
189
+ className="size-8"
190
+ onClick={() => onArchive?.()}
191
+ >
192
+ <Archive className="size-4" />
193
+ </Button>
194
+ </TooltipTrigger>
195
+ <TooltipContent>Archive</TooltipContent>
196
+ </Tooltip>
197
+
198
+ <Tooltip>
199
+ <TooltipTrigger asChild>
200
+ <Button
201
+ variant="ghost"
202
+ size="icon"
203
+ className="size-8"
204
+ onClick={() => onDelete?.()}
205
+ >
206
+ <Trash2 className="size-4" />
207
+ </Button>
208
+ </TooltipTrigger>
209
+ <TooltipContent>Delete</TooltipContent>
210
+ </Tooltip>
211
+
212
+ <Tooltip>
213
+ <TooltipTrigger asChild>
214
+ <Button
215
+ variant="ghost"
216
+ size="icon"
217
+ className={cn(
218
+ "size-8",
219
+ thread.isStarred && "text-yellow-500",
220
+ )}
221
+ onClick={() => onStar?.()}
222
+ >
223
+ <Star
224
+ className="size-4"
225
+ fill={thread.isStarred ? "currentColor" : "none"}
226
+ />
227
+ </Button>
228
+ </TooltipTrigger>
229
+ <TooltipContent>
230
+ {thread.isStarred ? "Unstar" : "Star"}
231
+ </TooltipContent>
232
+ </Tooltip>
233
+
234
+ {/* Snooze dropdown with preset options */}
235
+ <DropdownMenu>
236
+ <Tooltip>
237
+ <TooltipTrigger asChild>
238
+ <DropdownMenuTrigger asChild>
239
+ <Button variant="ghost" size="icon" className="size-8">
240
+ <Clock className="size-4" />
241
+ </Button>
242
+ </DropdownMenuTrigger>
243
+ </TooltipTrigger>
244
+ <TooltipContent>Snooze</TooltipContent>
245
+ </Tooltip>
246
+ <DropdownMenuContent align="end">
247
+ <DropdownMenuItem
248
+ onClick={() => {
249
+ const tomorrow = new Date();
250
+ tomorrow.setDate(tomorrow.getDate() + 1);
251
+ tomorrow.setHours(9, 0, 0, 0);
252
+ onSnooze?.(tomorrow.toISOString());
253
+ }}
254
+ >
255
+ Tomorrow morning
256
+ </DropdownMenuItem>
257
+ <DropdownMenuItem
258
+ onClick={() => {
259
+ const nextWeek = new Date();
260
+ nextWeek.setDate(nextWeek.getDate() + 7);
261
+ nextWeek.setHours(9, 0, 0, 0);
262
+ onSnooze?.(nextWeek.toISOString());
263
+ }}
264
+ >
265
+ Next week
266
+ </DropdownMenuItem>
267
+ <DropdownMenuItem
268
+ onClick={() => {
269
+ const nextMonth = new Date();
270
+ nextMonth.setMonth(nextMonth.getMonth() + 1);
271
+ nextMonth.setHours(9, 0, 0, 0);
272
+ onSnooze?.(nextMonth.toISOString());
273
+ }}
274
+ >
275
+ Next month
276
+ </DropdownMenuItem>
277
+ </DropdownMenuContent>
278
+ </DropdownMenu>
279
+
280
+ <DropdownMenu>
281
+ <DropdownMenuTrigger asChild>
282
+ <Button variant="ghost" size="icon" className="size-8">
283
+ <MoreHorizontal className="size-4" />
284
+ </Button>
285
+ </DropdownMenuTrigger>
286
+ <DropdownMenuContent align="end">
287
+ {/* TODO: Label selection requires available labels prop */}
288
+ <DropdownMenuItem
289
+ disabled={!onLabel}
290
+ onClick={() => onLabel?.([])}
291
+ >
292
+ <Tag className="mr-2 size-4" />
293
+ Add label
294
+ </DropdownMenuItem>
295
+ {/* TODO: Move requires available folders prop */}
296
+ <DropdownMenuItem
297
+ disabled={!onMove}
298
+ onClick={() => onMove?.("inbox")}
299
+ >
300
+ <Archive className="mr-2 size-4" />
301
+ Move to folder
302
+ </DropdownMenuItem>
303
+ <DropdownMenuSeparator />
304
+ <DropdownMenuItem onClick={() => onSpam?.()}>
305
+ <AlertOctagon className="mr-2 size-4" />
306
+ Report spam
307
+ </DropdownMenuItem>
308
+ <DropdownMenuSeparator />
309
+ <DropdownMenuItem onClick={handlePrint}>
310
+ <Printer className="mr-2 size-4" />
311
+ Print
312
+ </DropdownMenuItem>
313
+ </DropdownMenuContent>
314
+ </DropdownMenu>
315
+ </TooltipProvider>
316
+ </div>
317
+ </div>
318
+
319
+ {/* Subject */}
320
+ <div className="border-b px-4 py-3">
321
+ <h1 className="text-lg font-semibold">{thread.subject}</h1>
322
+ <div className="text-muted-foreground mt-1 flex items-center gap-2 text-sm">
323
+ <span>
324
+ {thread.messages.length} message
325
+ {thread.messages.length > 1 ? "s" : ""}
326
+ </span>
327
+ {thread.messages.length > 1 && (
328
+ <>
329
+ <span>·</span>
330
+ <button
331
+ type="button"
332
+ onClick={expandAll}
333
+ className="hover:underline"
334
+ >
335
+ Expand all
336
+ </button>
337
+ <span>·</span>
338
+ <button
339
+ type="button"
340
+ onClick={collapseAll}
341
+ className="hover:underline"
342
+ >
343
+ Collapse
344
+ </button>
345
+ </>
346
+ )}
347
+ </div>
348
+ </div>
349
+
350
+ {/* Messages */}
351
+ <ScrollArea className="flex-1">
352
+ <div
353
+ className={cn(
354
+ "divide-y",
355
+ enableAnimations && "transition-all duration-200",
356
+ )}
357
+ >
358
+ {thread.messages.map((message) => (
359
+ <MessageView
360
+ key={message.id}
361
+ message={message}
362
+ isExpanded={expandedMessages.has(message.id)}
363
+ showHeaders={false}
364
+ showReplyComposer={false}
365
+ onToggleExpand={() => toggleMessage(message.id)}
366
+ onReply={() => onReply?.(message.id)}
367
+ onReplyAll={() => onReplyAll?.(message.id)}
368
+ onForward={() => onForward?.(message.id)}
369
+ onStar={() => onStar?.()}
370
+ />
371
+ ))}
372
+ </div>
373
+ </ScrollArea>
374
+
375
+ {/* Reply Footer */}
376
+ <div className="border-t p-4">
377
+ <Button
378
+ onClick={() => {
379
+ const lastMessage = thread.messages[thread.messages.length - 1];
380
+ if (lastMessage) onReply?.(lastMessage.id);
381
+ }}
382
+ className="w-full sm:w-auto"
383
+ >
384
+ <Reply className="mr-2 size-4" />
385
+ Reply
386
+ </Button>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
391
+
392
+ export { ThreadDisplay };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Page Components - Complete page compositions with state management.
3
+ *
4
+ * These are reference implementations showing how to wire up
5
+ * shell components with proper state management.
6
+ */
7
+
8
+ export { MailZeroPage } from "./mail-zero-page";
9
+ export type { MailZeroPageProps } from "./mail-zero-page";
@@ -0,0 +1,251 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { Folder, MailLabel as Label, MailThread as Thread } from "mdxui";
3
+ import { MailZeroPage } from "./mail-zero-page";
4
+
5
+ const meta: Meta<typeof MailZeroPage> = {
6
+ title: "Zero/Pages/MailZeroPage",
7
+ component: MailZeroPage,
8
+ parameters: {
9
+ layout: "fullscreen",
10
+ },
11
+ };
12
+
13
+ export default meta;
14
+ type Story = StoryObj<typeof MailZeroPage>;
15
+
16
+ // Sample data
17
+ const sampleFolders: Folder[] = [
18
+ { id: "inbox", name: "Inbox", type: "inbox", unreadCount: 12 },
19
+ { id: "starred", name: "Starred", type: "starred", unreadCount: 0 },
20
+ { id: "snoozed", name: "Snoozed", type: "snoozed", unreadCount: 2 },
21
+ { id: "sent", name: "Sent", type: "sent", unreadCount: 0 },
22
+ { id: "drafts", name: "Drafts", type: "drafts", unreadCount: 3 },
23
+ { id: "spam", name: "Spam", type: "spam", unreadCount: 5 },
24
+ { id: "trash", name: "Trash", type: "trash", unreadCount: 0 },
25
+ { id: "archive", name: "Archive", type: "archive", unreadCount: 0 },
26
+ ];
27
+
28
+ const sampleLabels: Label[] = [
29
+ { id: "work", name: "Work", color: "#3b82f6", type: "user" },
30
+ { id: "personal", name: "Personal", color: "#10b981", type: "user" },
31
+ { id: "urgent", name: "Urgent", color: "#ef4444", type: "user" },
32
+ ];
33
+
34
+ const sampleThreads: Thread[] = [
35
+ {
36
+ id: "thread-1",
37
+ subject: "Q4 Planning Meeting Notes",
38
+ messages: [
39
+ {
40
+ id: "msg-1a",
41
+ threadId: "thread-1",
42
+ subject: "Q4 Planning Meeting Notes",
43
+ from: { address: "sarah@company.com", name: "Sarah Chen" },
44
+ to: [{ address: "team@company.com", name: "Team" }],
45
+ date: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
46
+ snippet:
47
+ "Hi team, here are the notes from today's Q4 planning session...",
48
+ textBody:
49
+ "Hi team,\n\nHere are the notes from today's Q4 planning session.\n\nKey decisions:\n1. Launch new product line in October\n2. Increase marketing budget by 20%\n3. Hire 5 new engineers\n\nAction items attached.\n\nBest,\nSarah",
50
+ isRead: false,
51
+ isStarred: false,
52
+ isImportant: false,
53
+ isDraft: false,
54
+ attachments: [],
55
+ labels: [],
56
+ },
57
+ {
58
+ id: "msg-1b",
59
+ threadId: "thread-1",
60
+ subject: "Re: Q4 Planning Meeting Notes",
61
+ from: { address: "mike@company.com", name: "Mike Johnson" },
62
+ to: [{ address: "team@company.com", name: "Team" }],
63
+ date: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
64
+ snippet: "Thanks Sarah! I have a question about the timeline...",
65
+ textBody:
66
+ "Thanks Sarah!\n\nI have a question about the timeline for the product launch. Can we discuss tomorrow?\n\nMike",
67
+ isRead: false,
68
+ isStarred: false,
69
+ isImportant: false,
70
+ isDraft: false,
71
+ attachments: [],
72
+ labels: [],
73
+ inReplyTo: "msg-1a",
74
+ },
75
+ ],
76
+ latestDate: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
77
+ participants: [
78
+ { address: "sarah@company.com", name: "Sarah Chen" },
79
+ { address: "mike@company.com", name: "Mike Johnson" },
80
+ ],
81
+ unreadCount: 2,
82
+ labels: [],
83
+ isStarred: false,
84
+ isImportant: false,
85
+ hasAttachments: false,
86
+ snippet: "Thanks Sarah! I have a question about the timeline...",
87
+ },
88
+ {
89
+ id: "thread-2",
90
+ subject: "Invoice #2024-156 - Payment Confirmation",
91
+ messages: [
92
+ {
93
+ id: "msg-2a",
94
+ threadId: "thread-2",
95
+ subject: "Invoice #2024-156 - Payment Confirmation",
96
+ from: { address: "billing@vendor.com", name: "Vendor Billing" },
97
+ to: [{ address: "you@company.com", name: "You" }],
98
+ date: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
99
+ snippet:
100
+ "Your payment of $4,500.00 has been received and processed...",
101
+ textBody:
102
+ "Dear Customer,\n\nYour payment of $4,500.00 has been received and processed.\n\nInvoice: #2024-156\nAmount: $4,500.00\nStatus: Paid\n\nThank you for your business.\n\nVendor Billing Team",
103
+ isRead: true,
104
+ isStarred: true,
105
+ isImportant: false,
106
+ isDraft: false,
107
+ attachments: [
108
+ {
109
+ id: "att-1",
110
+ filename: "invoice-2024-156.pdf",
111
+ mimeType: "application/pdf",
112
+ size: 245000,
113
+ inline: false,
114
+ },
115
+ ],
116
+ labels: [],
117
+ },
118
+ ],
119
+ latestDate: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
120
+ participants: [{ address: "billing@vendor.com", name: "Vendor Billing" }],
121
+ unreadCount: 0,
122
+ labels: [],
123
+ isStarred: true,
124
+ isImportant: false,
125
+ hasAttachments: true,
126
+ snippet: "Your payment of $4,500.00 has been received and processed...",
127
+ },
128
+ {
129
+ id: "thread-3",
130
+ subject: "Weekend hiking trip?",
131
+ messages: [
132
+ {
133
+ id: "msg-3a",
134
+ threadId: "thread-3",
135
+ subject: "Weekend hiking trip?",
136
+ from: { address: "alex@personal.com", name: "Alex Rivera" },
137
+ to: [{ address: "you@personal.com", name: "You" }],
138
+ date: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
139
+ snippet:
140
+ "Hey! Want to go hiking this Saturday? I found this great trail...",
141
+ textBody:
142
+ "Hey!\n\nWant to go hiking this Saturday? I found this great trail in the mountains - about 8 miles round trip with amazing views.\n\nLet me know if you're interested!\n\nAlex",
143
+ isRead: true,
144
+ isStarred: false,
145
+ isImportant: false,
146
+ isDraft: false,
147
+ attachments: [],
148
+ labels: [],
149
+ },
150
+ ],
151
+ latestDate: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
152
+ participants: [{ address: "alex@personal.com", name: "Alex Rivera" }],
153
+ unreadCount: 0,
154
+ labels: [],
155
+ isStarred: false,
156
+ isImportant: false,
157
+ hasAttachments: false,
158
+ snippet:
159
+ "Hey! Want to go hiking this Saturday? I found this great trail...",
160
+ },
161
+ {
162
+ id: "thread-4",
163
+ subject: "Your weekly digest",
164
+ messages: [
165
+ {
166
+ id: "msg-4a",
167
+ threadId: "thread-4",
168
+ subject: "Your weekly digest",
169
+ from: { address: "digest@news.com", name: "Weekly News" },
170
+ to: [{ address: "you@company.com", name: "You" }],
171
+ date: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
172
+ snippet: "Here's what happened this week in tech...",
173
+ textBody:
174
+ "Your Weekly Tech Digest\n\n1. AI breakthrough in natural language processing\n2. New smartphone launches next month\n3. Startup raises $50M in Series B\n\nRead more at our website.",
175
+ isRead: true,
176
+ isStarred: false,
177
+ isImportant: false,
178
+ isDraft: false,
179
+ attachments: [],
180
+ labels: [],
181
+ },
182
+ ],
183
+ latestDate: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
184
+ participants: [{ address: "digest@news.com", name: "Weekly News" }],
185
+ unreadCount: 0,
186
+ labels: [],
187
+ isStarred: false,
188
+ isImportant: false,
189
+ hasAttachments: false,
190
+ snippet: "Here's what happened this week in tech...",
191
+ },
192
+ {
193
+ id: "thread-5",
194
+ subject: "Project deadline reminder",
195
+ messages: [
196
+ {
197
+ id: "msg-5a",
198
+ threadId: "thread-5",
199
+ subject: "Project deadline reminder",
200
+ from: { address: "pm@company.com", name: "Project Manager" },
201
+ to: [{ address: "team@company.com", name: "Team" }],
202
+ date: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
203
+ snippet:
204
+ "Reminder: The project milestone is due this Friday. Please ensure...",
205
+ textBody:
206
+ "Team,\n\nReminder: The project milestone is due this Friday.\n\nPlease ensure all deliverables are ready for review by Thursday EOD.\n\nThanks,\nPM",
207
+ isRead: false,
208
+ isStarred: false,
209
+ isImportant: true,
210
+ isDraft: false,
211
+ attachments: [],
212
+ labels: [],
213
+ },
214
+ ],
215
+ latestDate: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
216
+ participants: [{ address: "pm@company.com", name: "Project Manager" }],
217
+ unreadCount: 1,
218
+ labels: [],
219
+ isStarred: false,
220
+ isImportant: true,
221
+ hasAttachments: false,
222
+ snippet:
223
+ "Reminder: The project milestone is due this Friday. Please ensure...",
224
+ },
225
+ ];
226
+
227
+ const sampleUser = {
228
+ name: "John Doe",
229
+ email: "john@company.com",
230
+ avatar: "https://i.pravatar.cc/150?u=john",
231
+ };
232
+
233
+ /**
234
+ * Complete email page with internal state management.
235
+ * Click threads to view them, use navigation buttons, try actions.
236
+ * All actions are handled internally - check console for logs.
237
+ */
238
+ export const Default: Story = {
239
+ args: {
240
+ initialThreads: sampleThreads,
241
+ initialFolders: sampleFolders,
242
+ initialLabels: sampleLabels,
243
+ initialFolderId: "inbox",
244
+ user: sampleUser,
245
+ },
246
+ render: (args) => (
247
+ <div className="h-screen">
248
+ <MailZeroPage {...args} />
249
+ </div>
250
+ ),
251
+ };