@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,378 @@
1
+ import { Avatar, AvatarFallback, AvatarImage } from "@mdxui/primitives/avatar";
2
+ import { Button } from "@mdxui/primitives/button";
3
+ import {
4
+ Collapsible,
5
+ CollapsibleContent,
6
+ CollapsibleTrigger,
7
+ } from "@mdxui/primitives/collapsible";
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ } from "@mdxui/primitives/dropdown-menu";
14
+ import { cn } from "@mdxui/primitives/lib/utils";
15
+ import {
16
+ Tooltip,
17
+ TooltipContent,
18
+ TooltipProvider,
19
+ TooltipTrigger,
20
+ } from "@mdxui/primitives/tooltip";
21
+ import {
22
+ ChevronDown,
23
+ ChevronUp,
24
+ Download,
25
+ Forward,
26
+ MoreHorizontal,
27
+ Paperclip,
28
+ Reply,
29
+ ReplyAll,
30
+ Star,
31
+ } from "lucide-react";
32
+ import type { Message, MessageHeaderProps, MessageViewProps } from "mdxui";
33
+ import * as React from "react";
34
+
35
+ /**
36
+ * MessageHeader - Displays message metadata (from, to, date).
37
+ */
38
+ function MessageHeader({
39
+ message,
40
+ expanded = false,
41
+ onToggle,
42
+ className,
43
+ }: MessageHeaderProps) {
44
+ const formatDate = (dateStr: string) => {
45
+ const date = new Date(dateStr);
46
+ return date.toLocaleString([], {
47
+ weekday: "short",
48
+ month: "short",
49
+ day: "numeric",
50
+ year: "numeric",
51
+ hour: "numeric",
52
+ minute: "2-digit",
53
+ });
54
+ };
55
+
56
+ const getInitials = (name?: string, email?: string) => {
57
+ if (name) {
58
+ return name
59
+ .split(" ")
60
+ .map((n) => n[0])
61
+ .join("")
62
+ .toUpperCase()
63
+ .slice(0, 2);
64
+ }
65
+ return email?.charAt(0).toUpperCase() || "?";
66
+ };
67
+
68
+ return (
69
+ <div
70
+ data-slot="message-header"
71
+ className={cn("flex items-start gap-3", className)}
72
+ >
73
+ <Avatar className="size-10 shrink-0">
74
+ <AvatarImage
75
+ src={undefined}
76
+ alt={message.from.name || message.from.address}
77
+ />
78
+ <AvatarFallback>
79
+ {getInitials(message.from.name, message.from.address)}
80
+ </AvatarFallback>
81
+ </Avatar>
82
+
83
+ <div className="min-w-0 flex-1">
84
+ <div className="flex items-center justify-between gap-2">
85
+ <div className="flex items-center gap-2">
86
+ <span className="font-medium">
87
+ {message.from.name || message.from.address}
88
+ </span>
89
+ {message.isStarred && (
90
+ <Star className="size-4 fill-yellow-500 text-yellow-500" />
91
+ )}
92
+ </div>
93
+ <span className="text-muted-foreground shrink-0 text-sm">
94
+ {formatDate(message.date)}
95
+ </span>
96
+ </div>
97
+
98
+ <Collapsible open={expanded} onOpenChange={onToggle}>
99
+ <CollapsibleTrigger asChild>
100
+ <button
101
+ type="button"
102
+ className="text-muted-foreground flex items-center gap-1 text-sm hover:underline"
103
+ >
104
+ to {message.to[0]?.name || message.to[0]?.address}
105
+ {message.to.length > 1 && ` and ${message.to.length - 1} others`}
106
+ {expanded ? (
107
+ <ChevronUp className="size-3" />
108
+ ) : (
109
+ <ChevronDown className="size-3" />
110
+ )}
111
+ </button>
112
+ </CollapsibleTrigger>
113
+ <CollapsibleContent className="mt-2 space-y-1 text-sm">
114
+ <div className="text-muted-foreground">
115
+ <span className="font-medium">From:</span>{" "}
116
+ {message.from.name ? (
117
+ <>
118
+ {message.from.name} &lt;{message.from.address}&gt;
119
+ </>
120
+ ) : (
121
+ message.from.address
122
+ )}
123
+ </div>
124
+ <div className="text-muted-foreground">
125
+ <span className="font-medium">To:</span>{" "}
126
+ {message.to
127
+ .map((r) => (r.name ? `${r.name} <${r.address}>` : r.address))
128
+ .join(", ")}
129
+ </div>
130
+ {message.cc && message.cc.length > 0 && (
131
+ <div className="text-muted-foreground">
132
+ <span className="font-medium">Cc:</span>{" "}
133
+ {message.cc
134
+ .map((r) => (r.name ? `${r.name} <${r.address}>` : r.address))
135
+ .join(", ")}
136
+ </div>
137
+ )}
138
+ {message.replyTo && (
139
+ <div className="text-muted-foreground">
140
+ <span className="font-medium">Reply-To:</span>{" "}
141
+ {message.replyTo.name
142
+ ? `${message.replyTo.name} <${message.replyTo.address}>`
143
+ : message.replyTo.address}
144
+ </div>
145
+ )}
146
+ </CollapsibleContent>
147
+ </Collapsible>
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ /**
154
+ * AttachmentItem - Displays a single attachment.
155
+ */
156
+ function AttachmentItem({
157
+ attachment,
158
+ onDownload,
159
+ onClick,
160
+ }: {
161
+ attachment: Message["attachments"][number];
162
+ onDownload?: () => void;
163
+ onClick?: () => void;
164
+ }) {
165
+ const formatSize = (bytes: number) => {
166
+ if (bytes < 1024) return `${bytes} B`;
167
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
168
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
169
+ };
170
+
171
+ const isImage = attachment.mimeType.startsWith("image/");
172
+
173
+ return (
174
+ <button
175
+ type="button"
176
+ className="bg-muted/50 hover:bg-muted flex items-center gap-2 rounded-lg border p-2 transition-colors"
177
+ tabIndex={0}
178
+ onClick={onClick}
179
+ onKeyDown={(e: React.KeyboardEvent<HTMLButtonElement>) => e.key === "Enter" && onClick?.()}
180
+ >
181
+ {isImage && attachment.thumbnailUrl ? (
182
+ <img
183
+ src={attachment.thumbnailUrl}
184
+ alt={attachment.filename}
185
+ className="size-10 rounded object-cover"
186
+ />
187
+ ) : (
188
+ <div className="bg-background flex size-10 items-center justify-center rounded">
189
+ <Paperclip className="text-muted-foreground size-5" />
190
+ </div>
191
+ )}
192
+ <div className="min-w-0 flex-1">
193
+ <div className="truncate text-sm font-medium">
194
+ {attachment.filename}
195
+ </div>
196
+ <div className="text-muted-foreground text-xs">
197
+ {formatSize(attachment.size)}
198
+ </div>
199
+ </div>
200
+ <TooltipProvider>
201
+ <Tooltip>
202
+ <TooltipTrigger asChild>
203
+ <Button
204
+ variant="ghost"
205
+ size="icon"
206
+ className="size-8 shrink-0"
207
+ onClick={(e) => {
208
+ e.stopPropagation();
209
+ onDownload?.();
210
+ }}
211
+ >
212
+ <Download className="size-4" />
213
+ </Button>
214
+ </TooltipTrigger>
215
+ <TooltipContent>Download</TooltipContent>
216
+ </Tooltip>
217
+ </TooltipProvider>
218
+ </button>
219
+ );
220
+ }
221
+
222
+ /**
223
+ * MessageView - Displays a single email message.
224
+ */
225
+ function MessageView({
226
+ message,
227
+ showHeaders = false,
228
+ isExpanded = true,
229
+ onToggleExpand,
230
+ onReply,
231
+ onReplyAll,
232
+ onForward,
233
+ onStar,
234
+ onAttachmentClick,
235
+ showReplyComposer: _showReplyComposer = false,
236
+ className,
237
+ }: MessageViewProps) {
238
+ const [headersExpanded, setHeadersExpanded] = React.useState(showHeaders);
239
+
240
+ return (
241
+ <div
242
+ data-slot="message-view"
243
+ className={cn("border-b last:border-b-0", className)}
244
+ >
245
+ {/* Collapsed State */}
246
+ {!isExpanded ? (
247
+ <button
248
+ type="button"
249
+ onClick={() => onToggleExpand?.()}
250
+ className="hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors"
251
+ >
252
+ <Avatar className="size-8 shrink-0">
253
+ <AvatarFallback className="text-xs">
254
+ {message.from.name?.charAt(0) || message.from.address.charAt(0)}
255
+ </AvatarFallback>
256
+ </Avatar>
257
+ <span className="text-muted-foreground flex-1 truncate text-sm">
258
+ {message.snippet || message.textBody?.slice(0, 100)}
259
+ </span>
260
+ </button>
261
+ ) : (
262
+ <div className="space-y-4 px-4 py-4">
263
+ {/* Header */}
264
+ <div className="flex items-start justify-between gap-4">
265
+ <MessageHeader
266
+ message={message}
267
+ expanded={headersExpanded}
268
+ onToggle={() => setHeadersExpanded(!headersExpanded)}
269
+ />
270
+
271
+ {/* Actions */}
272
+ <div className="flex shrink-0 items-center gap-1">
273
+ <TooltipProvider>
274
+ <Tooltip>
275
+ <TooltipTrigger asChild>
276
+ <Button
277
+ variant="ghost"
278
+ size="icon"
279
+ className="size-8"
280
+ onClick={() => onReply?.()}
281
+ >
282
+ <Reply className="size-4" />
283
+ </Button>
284
+ </TooltipTrigger>
285
+ <TooltipContent>Reply</TooltipContent>
286
+ </Tooltip>
287
+
288
+ <Tooltip>
289
+ <TooltipTrigger asChild>
290
+ <Button
291
+ variant="ghost"
292
+ size="icon"
293
+ className="size-8"
294
+ onClick={() => onReplyAll?.()}
295
+ >
296
+ <ReplyAll className="size-4" />
297
+ </Button>
298
+ </TooltipTrigger>
299
+ <TooltipContent>Reply All</TooltipContent>
300
+ </Tooltip>
301
+
302
+ <Tooltip>
303
+ <TooltipTrigger asChild>
304
+ <Button
305
+ variant="ghost"
306
+ size="icon"
307
+ className="size-8"
308
+ onClick={() => onForward?.()}
309
+ >
310
+ <Forward className="size-4" />
311
+ </Button>
312
+ </TooltipTrigger>
313
+ <TooltipContent>Forward</TooltipContent>
314
+ </Tooltip>
315
+
316
+ <DropdownMenu>
317
+ <DropdownMenuTrigger asChild>
318
+ <Button variant="ghost" size="icon" className="size-8">
319
+ <MoreHorizontal className="size-4" />
320
+ </Button>
321
+ </DropdownMenuTrigger>
322
+ <DropdownMenuContent align="end">
323
+ <DropdownMenuItem onClick={() => onStar?.()}>
324
+ <Star className="mr-2 size-4" />
325
+ {message.isStarred ? "Unstar" : "Star"}
326
+ </DropdownMenuItem>
327
+ </DropdownMenuContent>
328
+ </DropdownMenu>
329
+ </TooltipProvider>
330
+ </div>
331
+ </div>
332
+
333
+ {/* Body */}
334
+ <div className="prose prose-sm dark:prose-invert max-w-none">
335
+ {message.htmlBody ? (
336
+ <div
337
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: MessageView is a trusted component
338
+ dangerouslySetInnerHTML={{ __html: message.htmlBody }}
339
+ className="overflow-hidden"
340
+ />
341
+ ) : (
342
+ <pre className="whitespace-pre-wrap font-sans">
343
+ {message.textBody}
344
+ </pre>
345
+ )}
346
+ </div>
347
+
348
+ {/* Attachments */}
349
+ {message.attachments.length > 0 && (
350
+ <div className="space-y-2">
351
+ <div className="text-muted-foreground flex items-center gap-2 text-sm">
352
+ <Paperclip className="size-4" />
353
+ {message.attachments.length} attachment
354
+ {message.attachments.length > 1 ? "s" : ""}
355
+ </div>
356
+ <div className="grid gap-2 sm:grid-cols-2">
357
+ {message.attachments.map((attachment) => (
358
+ <AttachmentItem
359
+ key={attachment.id}
360
+ attachment={attachment}
361
+ onClick={() => onAttachmentClick?.(attachment)}
362
+ onDownload={() => {
363
+ if (attachment.url) {
364
+ window.open(attachment.url, "_blank");
365
+ }
366
+ }}
367
+ />
368
+ ))}
369
+ </div>
370
+ </div>
371
+ )}
372
+ </div>
373
+ )}
374
+ </div>
375
+ );
376
+ }
377
+
378
+ export { MessageView, MessageHeader };
@@ -0,0 +1,260 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import type { Message, MailThread as Thread } from "mdxui";
3
+ import { MessageHeader, MessageView } from "./message-view";
4
+ import { ThreadDisplay } from "./thread-display";
5
+
6
+ // Sample message with full content
7
+ const sampleMessage: Message = {
8
+ id: "m1",
9
+ threadId: "t1",
10
+ subject: "Re: Product Launch Strategy - Q1 2025",
11
+ from: { address: "alex@startup.io", name: "Alex Rivera" },
12
+ to: [{ address: "me@company.com", name: "Me" }],
13
+ cc: [
14
+ { address: "marketing@startup.io", name: "Marketing Team" },
15
+ { address: "sales@startup.io", name: "Sales Team" },
16
+ ],
17
+ date: new Date(Date.now() - 3600000).toISOString(),
18
+ snippet:
19
+ "Great ideas! I think we should focus on the enterprise segment first...",
20
+ textBody: `Hi there,
21
+
22
+ Thanks for sharing the product launch strategy. I've reviewed the document and have some thoughts:
23
+
24
+ 1. **Enterprise Focus**: I strongly agree we should target enterprise customers first. They have the budget and the pain points our product solves.
25
+
26
+ 2. **Timeline**: Q1 might be aggressive given the holiday freeze. Consider a soft launch in late January with full GA in February.
27
+
28
+ 3. **Marketing Channels**:
29
+ - LinkedIn ads for B2B reach
30
+ - Content marketing with case studies
31
+ - Webinar series with industry experts
32
+
33
+ Let me know what you think about these suggestions. Happy to hop on a call to discuss further.
34
+
35
+ Best,
36
+ Alex`,
37
+ htmlBody: `<div>
38
+ <p>Hi there,</p>
39
+ <p>Thanks for sharing the product launch strategy. I've reviewed the document and have some thoughts:</p>
40
+ <ol>
41
+ <li><strong>Enterprise Focus</strong>: I strongly agree we should target enterprise customers first. They have the budget and the pain points our product solves.</li>
42
+ <li><strong>Timeline</strong>: Q1 might be aggressive given the holiday freeze. Consider a soft launch in late January with full GA in February.</li>
43
+ <li><strong>Marketing Channels</strong>:
44
+ <ul>
45
+ <li>LinkedIn ads for B2B reach</li>
46
+ <li>Content marketing with case studies</li>
47
+ <li>Webinar series with industry experts</li>
48
+ </ul>
49
+ </li>
50
+ </ol>
51
+ <p>Let me know what you think about these suggestions. Happy to hop on a call to discuss further.</p>
52
+ <p>Best,<br/>Alex</p>
53
+ </div>`,
54
+ attachments: [
55
+ {
56
+ id: "a1",
57
+ filename: "launch-timeline.pdf",
58
+ mimeType: "application/pdf",
59
+ size: 245000,
60
+ url: "#",
61
+ inline: false,
62
+ },
63
+ {
64
+ id: "a2",
65
+ filename: "market-research.xlsx",
66
+ mimeType:
67
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
68
+ size: 89000,
69
+ url: "#",
70
+ inline: false,
71
+ },
72
+ ],
73
+ labels: [{ id: "l1", name: "Important", color: "#ef4444", type: "user" }],
74
+ isRead: true,
75
+ isStarred: true,
76
+ isImportant: true,
77
+ isDraft: false,
78
+ };
79
+
80
+ // Sample thread with multiple messages
81
+ const sampleThread: Thread = {
82
+ id: "t1",
83
+ subject: "Product Launch Strategy - Q1 2025",
84
+ messages: [
85
+ {
86
+ id: "m0",
87
+ threadId: "t1",
88
+ subject: "Product Launch Strategy - Q1 2025",
89
+ from: { address: "me@company.com", name: "Me" },
90
+ to: [
91
+ { address: "alex@startup.io", name: "Alex Rivera" },
92
+ { address: "marketing@startup.io", name: "Marketing Team" },
93
+ ],
94
+ date: new Date(Date.now() - 86400000).toISOString(),
95
+ snippet: "Team, I wanted to share our product launch strategy for Q1...",
96
+ textBody: `Team,
97
+
98
+ I wanted to share our product launch strategy for Q1 2025. Please review the attached document and share your thoughts.
99
+
100
+ Key points:
101
+ - Target audience: Mid-market to enterprise
102
+ - Launch date: February 15, 2025
103
+ - Key features to highlight: AI automation, integrations, analytics
104
+
105
+ Looking forward to your feedback!
106
+
107
+ Best,
108
+ Me`,
109
+ attachments: [
110
+ {
111
+ id: "a0",
112
+ filename: "launch-strategy-v1.docx",
113
+ mimeType:
114
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
115
+ size: 156000,
116
+ url: "#",
117
+ inline: false,
118
+ },
119
+ ],
120
+ labels: [],
121
+ isRead: true,
122
+ isStarred: false,
123
+ isImportant: false,
124
+ isDraft: false,
125
+ },
126
+ sampleMessage,
127
+ {
128
+ id: "m2",
129
+ threadId: "t1",
130
+ subject: "Re: Product Launch Strategy - Q1 2025",
131
+ from: { address: "marketing@startup.io", name: "Marketing Team" },
132
+ to: [
133
+ { address: "me@company.com", name: "Me" },
134
+ { address: "alex@startup.io", name: "Alex Rivera" },
135
+ ],
136
+ date: new Date(Date.now() - 1800000).toISOString(),
137
+ snippet: "Adding to Alex's points, we should also consider...",
138
+ textBody: `Adding to Alex's points, we should also consider:
139
+
140
+ - Pre-launch email campaign to existing leads
141
+ - Partner co-marketing opportunities
142
+ - Press release timing
143
+
144
+ I've updated the timeline doc with these additions.`,
145
+ attachments: [],
146
+ labels: [],
147
+ isRead: false,
148
+ isStarred: false,
149
+ isImportant: false,
150
+ isDraft: false,
151
+ },
152
+ ],
153
+ latestDate: new Date(Date.now() - 1800000).toISOString(),
154
+ participants: [
155
+ { address: "me@company.com", name: "Me" },
156
+ { address: "alex@startup.io", name: "Alex Rivera" },
157
+ { address: "marketing@startup.io", name: "Marketing Team" },
158
+ ],
159
+ unreadCount: 1,
160
+ labels: [{ id: "l1", name: "Important", color: "#ef4444", type: "user" }],
161
+ isStarred: true,
162
+ isImportant: true,
163
+ hasAttachments: true,
164
+ snippet: "Adding to Alex's points, we should also consider...",
165
+ };
166
+
167
+ const meta: Meta<typeof ThreadDisplay> = {
168
+ title: "Zero/Mail/ThreadDisplay",
169
+ component: ThreadDisplay,
170
+ parameters: {
171
+ layout: "fullscreen",
172
+ },
173
+ tags: ["autodocs"],
174
+ decorators: [
175
+ (Story) => (
176
+ <div className="h-screen w-full bg-background">
177
+ <Story />
178
+ </div>
179
+ ),
180
+ ],
181
+ };
182
+
183
+ export default meta;
184
+ type Story = StoryObj<typeof meta>;
185
+
186
+ /**
187
+ * Full thread view with navigation and actions.
188
+ * Matches the original Zero thread display panel.
189
+ */
190
+ export const Default: Story = {
191
+ args: {
192
+ thread: sampleThread,
193
+ showNavigation: true,
194
+ hasPrevious: true,
195
+ hasNext: true,
196
+ enableAnimations: true,
197
+ },
198
+ };
199
+
200
+ /**
201
+ * Without navigation controls.
202
+ */
203
+ export const NoNavigation: Story = {
204
+ args: {
205
+ thread: sampleThread,
206
+ showNavigation: false,
207
+ },
208
+ };
209
+
210
+ /**
211
+ * Single message thread.
212
+ */
213
+ export const SingleMessage: Story = {
214
+ args: {
215
+ thread: {
216
+ ...sampleThread,
217
+ messages: [sampleMessage],
218
+ },
219
+ showNavigation: true,
220
+ hasPrevious: false,
221
+ hasNext: true,
222
+ },
223
+ };
224
+
225
+ // MessageView story
226
+ export const SingleMessageView: StoryObj<typeof MessageView> = {
227
+ render: () => (
228
+ <div className="max-w-4xl p-4 bg-background">
229
+ <MessageView
230
+ message={sampleMessage}
231
+ isExpanded={true}
232
+ showHeaders={false}
233
+ showReplyComposer={false}
234
+ />
235
+ </div>
236
+ ),
237
+ };
238
+
239
+ // MessageView collapsed
240
+ export const CollapsedMessageView: StoryObj<typeof MessageView> = {
241
+ render: () => (
242
+ <div className="max-w-4xl p-4 bg-background">
243
+ <MessageView
244
+ message={sampleMessage}
245
+ isExpanded={false}
246
+ showHeaders={false}
247
+ showReplyComposer={false}
248
+ />
249
+ </div>
250
+ ),
251
+ };
252
+
253
+ // MessageHeader story
254
+ export const MessageHeaderExpanded: StoryObj<typeof MessageHeader> = {
255
+ render: () => (
256
+ <div className="max-w-4xl p-4 bg-background border rounded-lg">
257
+ <MessageHeader message={sampleMessage} expanded={true} />
258
+ </div>
259
+ ),
260
+ };