@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,422 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import type { MailThread as Thread } from "mdxui";
|
|
3
|
+
import { MailItem } from "./mail-item";
|
|
4
|
+
|
|
5
|
+
// Sample thread data for testing different MailItem states
|
|
6
|
+
const unreadThread: Thread = {
|
|
7
|
+
id: "1",
|
|
8
|
+
subject: "Weekly Team Sync - Q4 Planning",
|
|
9
|
+
messages: [
|
|
10
|
+
{
|
|
11
|
+
id: "m1",
|
|
12
|
+
threadId: "1",
|
|
13
|
+
subject: "Weekly Team Sync - Q4 Planning",
|
|
14
|
+
from: { address: "sarah@company.com", name: "Sarah Chen" },
|
|
15
|
+
to: [{ address: "team@company.com", name: "Engineering Team" }],
|
|
16
|
+
date: new Date(Date.now() - 3600000).toISOString(),
|
|
17
|
+
snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
|
|
18
|
+
isRead: false,
|
|
19
|
+
isStarred: true,
|
|
20
|
+
isImportant: true,
|
|
21
|
+
isDraft: false,
|
|
22
|
+
attachments: [],
|
|
23
|
+
labels: [],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
latestDate: new Date(Date.now() - 3600000).toISOString(),
|
|
27
|
+
participants: [
|
|
28
|
+
{ address: "sarah@company.com", name: "Sarah Chen" },
|
|
29
|
+
{ address: "team@company.com", name: "Engineering Team" },
|
|
30
|
+
],
|
|
31
|
+
unreadCount: 1,
|
|
32
|
+
labels: [{ id: "l1", name: "Work", color: "#3b82f6", type: "user" }],
|
|
33
|
+
isStarred: true,
|
|
34
|
+
isImportant: true,
|
|
35
|
+
hasAttachments: false,
|
|
36
|
+
snippet: "Hey team, let's discuss our Q4 roadmap priorities...",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const readThread: Thread = {
|
|
40
|
+
id: "2",
|
|
41
|
+
subject: "Re: Design Review - Homepage Redesign",
|
|
42
|
+
messages: [
|
|
43
|
+
{
|
|
44
|
+
id: "m2",
|
|
45
|
+
threadId: "2",
|
|
46
|
+
subject: "Re: Design Review - Homepage Redesign",
|
|
47
|
+
from: { address: "mike@design.co", name: "Mike Johnson" },
|
|
48
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
49
|
+
date: new Date(Date.now() - 7200000).toISOString(),
|
|
50
|
+
snippet:
|
|
51
|
+
"I've updated the mockups based on your feedback. The new hero section...",
|
|
52
|
+
isRead: true,
|
|
53
|
+
isStarred: false,
|
|
54
|
+
isImportant: false,
|
|
55
|
+
isDraft: false,
|
|
56
|
+
attachments: [
|
|
57
|
+
{
|
|
58
|
+
id: "a1",
|
|
59
|
+
filename: "homepage-v3.fig",
|
|
60
|
+
mimeType: "application/figma",
|
|
61
|
+
size: 2400000,
|
|
62
|
+
inline: false,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
labels: [],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
latestDate: new Date(Date.now() - 7200000).toISOString(),
|
|
69
|
+
participants: [
|
|
70
|
+
{ address: "mike@design.co", name: "Mike Johnson" },
|
|
71
|
+
{ address: "me@company.com", name: "Me" },
|
|
72
|
+
],
|
|
73
|
+
unreadCount: 0,
|
|
74
|
+
labels: [{ id: "l2", name: "Design", color: "#10b981", type: "user" }],
|
|
75
|
+
isStarred: false,
|
|
76
|
+
isImportant: false,
|
|
77
|
+
hasAttachments: true,
|
|
78
|
+
snippet:
|
|
79
|
+
"I've updated the mockups based on your feedback. The new hero section...",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const conversationThread: Thread = {
|
|
83
|
+
id: "3",
|
|
84
|
+
subject: "Project Timeline Discussion",
|
|
85
|
+
messages: [
|
|
86
|
+
{
|
|
87
|
+
id: "m3a",
|
|
88
|
+
threadId: "3",
|
|
89
|
+
subject: "Project Timeline Discussion",
|
|
90
|
+
from: { address: "alex@startup.io", name: "Alex Rivera" },
|
|
91
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
92
|
+
date: new Date(Date.now() - 172800000).toISOString(),
|
|
93
|
+
snippet: "Initial message...",
|
|
94
|
+
isRead: true,
|
|
95
|
+
isStarred: false,
|
|
96
|
+
isImportant: false,
|
|
97
|
+
isDraft: false,
|
|
98
|
+
attachments: [],
|
|
99
|
+
labels: [],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: "m3b",
|
|
103
|
+
threadId: "3",
|
|
104
|
+
subject: "Re: Project Timeline Discussion",
|
|
105
|
+
from: { address: "me@company.com", name: "Me" },
|
|
106
|
+
to: [{ address: "alex@startup.io", name: "Alex Rivera" }],
|
|
107
|
+
date: new Date(Date.now() - 86400000).toISOString(),
|
|
108
|
+
snippet: "My reply...",
|
|
109
|
+
isRead: true,
|
|
110
|
+
isStarred: false,
|
|
111
|
+
isImportant: false,
|
|
112
|
+
isDraft: false,
|
|
113
|
+
attachments: [],
|
|
114
|
+
labels: [],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "m3c",
|
|
118
|
+
threadId: "3",
|
|
119
|
+
subject: "Re: Project Timeline Discussion",
|
|
120
|
+
from: { address: "alex@startup.io", name: "Alex Rivera" },
|
|
121
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
122
|
+
date: new Date(Date.now() - 43200000).toISOString(),
|
|
123
|
+
snippet: "Thanks for the update! I agree with your approach.",
|
|
124
|
+
isRead: false,
|
|
125
|
+
isStarred: false,
|
|
126
|
+
isImportant: true,
|
|
127
|
+
isDraft: false,
|
|
128
|
+
attachments: [],
|
|
129
|
+
labels: [],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
latestDate: new Date(Date.now() - 43200000).toISOString(),
|
|
133
|
+
participants: [
|
|
134
|
+
{ address: "alex@startup.io", name: "Alex Rivera" },
|
|
135
|
+
{ address: "me@company.com", name: "Me" },
|
|
136
|
+
],
|
|
137
|
+
unreadCount: 1,
|
|
138
|
+
labels: [
|
|
139
|
+
{ id: "l3", name: "Project", color: "#8b5cf6", type: "user" },
|
|
140
|
+
{ id: "l4", name: "Urgent", color: "#ef4444", type: "user" },
|
|
141
|
+
],
|
|
142
|
+
isStarred: false,
|
|
143
|
+
isImportant: true,
|
|
144
|
+
hasAttachments: false,
|
|
145
|
+
snippet: "Thanks for the update! I agree with your approach.",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const multiLabelThread: Thread = {
|
|
149
|
+
id: "4",
|
|
150
|
+
subject: "Invoice #12345 - Payment Confirmation",
|
|
151
|
+
messages: [
|
|
152
|
+
{
|
|
153
|
+
id: "m4",
|
|
154
|
+
threadId: "4",
|
|
155
|
+
subject: "Invoice #12345 - Payment Confirmation",
|
|
156
|
+
from: { address: "billing@stripe.com", name: "Stripe" },
|
|
157
|
+
to: [{ address: "me@company.com", name: "Me" }],
|
|
158
|
+
date: new Date(Date.now() - 86400000).toISOString(),
|
|
159
|
+
snippet: "Your payment of $299.00 has been successfully processed...",
|
|
160
|
+
isRead: true,
|
|
161
|
+
isStarred: false,
|
|
162
|
+
isImportant: false,
|
|
163
|
+
isDraft: false,
|
|
164
|
+
attachments: [
|
|
165
|
+
{
|
|
166
|
+
id: "a2",
|
|
167
|
+
filename: "invoice-12345.pdf",
|
|
168
|
+
mimeType: "application/pdf",
|
|
169
|
+
size: 45000,
|
|
170
|
+
inline: false,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
labels: [],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
latestDate: new Date(Date.now() - 86400000).toISOString(),
|
|
177
|
+
participants: [{ address: "billing@stripe.com", name: "Stripe" }],
|
|
178
|
+
unreadCount: 0,
|
|
179
|
+
labels: [
|
|
180
|
+
{ id: "l5", name: "Finance", color: "#f59e0b", type: "user" },
|
|
181
|
+
{ id: "l6", name: "Important", color: "#ef4444", type: "user" },
|
|
182
|
+
{ id: "l7", name: "Archive", color: "#6b7280", type: "user" },
|
|
183
|
+
{ id: "l8", name: "Tax", color: "#10b981", type: "user" },
|
|
184
|
+
{ id: "l9", name: "Receipts", color: "#3b82f6", type: "user" },
|
|
185
|
+
],
|
|
186
|
+
isStarred: false,
|
|
187
|
+
isImportant: false,
|
|
188
|
+
hasAttachments: true,
|
|
189
|
+
snippet: "Your payment of $299.00 has been successfully processed...",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const meta: Meta<typeof MailItem> = {
|
|
193
|
+
title: "Zero/Mail/MailItem",
|
|
194
|
+
component: MailItem,
|
|
195
|
+
parameters: {
|
|
196
|
+
layout: "centered",
|
|
197
|
+
},
|
|
198
|
+
tags: ["autodocs"],
|
|
199
|
+
argTypes: {
|
|
200
|
+
compact: {
|
|
201
|
+
control: "boolean",
|
|
202
|
+
},
|
|
203
|
+
showCheckbox: {
|
|
204
|
+
control: "boolean",
|
|
205
|
+
},
|
|
206
|
+
showAvatar: {
|
|
207
|
+
control: "boolean",
|
|
208
|
+
},
|
|
209
|
+
isSelected: {
|
|
210
|
+
control: "boolean",
|
|
211
|
+
},
|
|
212
|
+
isFocused: {
|
|
213
|
+
control: "boolean",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
decorators: [
|
|
217
|
+
(Story) => (
|
|
218
|
+
<div className="w-full max-w-2xl border bg-background">
|
|
219
|
+
<Story />
|
|
220
|
+
</div>
|
|
221
|
+
),
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export default meta;
|
|
226
|
+
type Story = StoryObj<typeof meta>;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Default unread mail item with star and label.
|
|
230
|
+
* Shows checkbox on hover, avatar, subject, snippet, and timestamp.
|
|
231
|
+
*/
|
|
232
|
+
export const Default: Story = {
|
|
233
|
+
args: {
|
|
234
|
+
thread: unreadThread,
|
|
235
|
+
isSelected: false,
|
|
236
|
+
isFocused: false,
|
|
237
|
+
showCheckbox: true,
|
|
238
|
+
showAvatar: true,
|
|
239
|
+
compact: false,
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Read mail item without star.
|
|
245
|
+
* Shows muted appearance for read messages.
|
|
246
|
+
*/
|
|
247
|
+
export const Read: Story = {
|
|
248
|
+
args: {
|
|
249
|
+
thread: readThread,
|
|
250
|
+
isSelected: false,
|
|
251
|
+
isFocused: false,
|
|
252
|
+
showCheckbox: true,
|
|
253
|
+
showAvatar: true,
|
|
254
|
+
compact: false,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Selected mail item with checkbox visible.
|
|
260
|
+
* Highlights the item with accent background.
|
|
261
|
+
*/
|
|
262
|
+
export const Selected: Story = {
|
|
263
|
+
args: {
|
|
264
|
+
thread: unreadThread,
|
|
265
|
+
isSelected: true,
|
|
266
|
+
isFocused: false,
|
|
267
|
+
showCheckbox: true,
|
|
268
|
+
showAvatar: true,
|
|
269
|
+
compact: false,
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Focused mail item with keyboard navigation.
|
|
275
|
+
* Shows ring outline for accessibility.
|
|
276
|
+
*/
|
|
277
|
+
export const Focused: Story = {
|
|
278
|
+
args: {
|
|
279
|
+
thread: unreadThread,
|
|
280
|
+
isSelected: false,
|
|
281
|
+
isFocused: true,
|
|
282
|
+
showCheckbox: true,
|
|
283
|
+
showAvatar: true,
|
|
284
|
+
compact: false,
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Compact display mode for higher density.
|
|
290
|
+
* Reduced padding and no snippet shown.
|
|
291
|
+
*/
|
|
292
|
+
export const Compact: Story = {
|
|
293
|
+
args: {
|
|
294
|
+
thread: unreadThread,
|
|
295
|
+
isSelected: false,
|
|
296
|
+
isFocused: false,
|
|
297
|
+
showCheckbox: true,
|
|
298
|
+
showAvatar: true,
|
|
299
|
+
compact: true,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Mail thread with multiple messages.
|
|
305
|
+
* Shows message count next to sender name.
|
|
306
|
+
*/
|
|
307
|
+
export const Conversation: Story = {
|
|
308
|
+
args: {
|
|
309
|
+
thread: conversationThread,
|
|
310
|
+
isSelected: false,
|
|
311
|
+
isFocused: false,
|
|
312
|
+
showCheckbox: true,
|
|
313
|
+
showAvatar: true,
|
|
314
|
+
compact: false,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Mail item with attachment indicator.
|
|
320
|
+
* Shows paperclip icon.
|
|
321
|
+
*/
|
|
322
|
+
export const WithAttachment: Story = {
|
|
323
|
+
args: {
|
|
324
|
+
thread: readThread,
|
|
325
|
+
isSelected: false,
|
|
326
|
+
isFocused: false,
|
|
327
|
+
showCheckbox: true,
|
|
328
|
+
showAvatar: true,
|
|
329
|
+
compact: false,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Mail item with multiple labels.
|
|
335
|
+
* Shows first 3 labels with "+N more" badge.
|
|
336
|
+
*/
|
|
337
|
+
export const MultipleLabels: Story = {
|
|
338
|
+
args: {
|
|
339
|
+
thread: multiLabelThread,
|
|
340
|
+
isSelected: false,
|
|
341
|
+
isFocused: false,
|
|
342
|
+
showCheckbox: true,
|
|
343
|
+
showAvatar: true,
|
|
344
|
+
compact: false,
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Without checkbox control.
|
|
350
|
+
* Simpler view for read-only lists.
|
|
351
|
+
*/
|
|
352
|
+
export const NoCheckbox: Story = {
|
|
353
|
+
args: {
|
|
354
|
+
thread: unreadThread,
|
|
355
|
+
isSelected: false,
|
|
356
|
+
isFocused: false,
|
|
357
|
+
showCheckbox: false,
|
|
358
|
+
showAvatar: true,
|
|
359
|
+
compact: false,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Without avatar display.
|
|
365
|
+
* More compact horizontal layout.
|
|
366
|
+
*/
|
|
367
|
+
export const NoAvatar: Story = {
|
|
368
|
+
args: {
|
|
369
|
+
thread: unreadThread,
|
|
370
|
+
isSelected: false,
|
|
371
|
+
isFocused: false,
|
|
372
|
+
showCheckbox: true,
|
|
373
|
+
showAvatar: false,
|
|
374
|
+
compact: false,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* With search term highlighting.
|
|
380
|
+
* Highlights matching text in subject and snippet.
|
|
381
|
+
*/
|
|
382
|
+
export const WithHighlight: Story = {
|
|
383
|
+
args: {
|
|
384
|
+
thread: unreadThread,
|
|
385
|
+
isSelected: false,
|
|
386
|
+
isFocused: false,
|
|
387
|
+
showCheckbox: true,
|
|
388
|
+
showAvatar: true,
|
|
389
|
+
compact: false,
|
|
390
|
+
highlightTerms: ["Q4", "roadmap"],
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Selected and focused together.
|
|
396
|
+
* Common state when navigating with keyboard.
|
|
397
|
+
*/
|
|
398
|
+
export const SelectedAndFocused: Story = {
|
|
399
|
+
args: {
|
|
400
|
+
thread: unreadThread,
|
|
401
|
+
isSelected: true,
|
|
402
|
+
isFocused: true,
|
|
403
|
+
showCheckbox: true,
|
|
404
|
+
showAvatar: true,
|
|
405
|
+
compact: false,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Minimal configuration.
|
|
411
|
+
* Compact, no checkbox, no avatar.
|
|
412
|
+
*/
|
|
413
|
+
export const Minimal: Story = {
|
|
414
|
+
args: {
|
|
415
|
+
thread: readThread,
|
|
416
|
+
isSelected: false,
|
|
417
|
+
isFocused: false,
|
|
418
|
+
showCheckbox: false,
|
|
419
|
+
showAvatar: false,
|
|
420
|
+
compact: true,
|
|
421
|
+
},
|
|
422
|
+
};
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@mdxui/primitives/avatar";
|
|
2
|
+
import { Badge } from "@mdxui/primitives/badge";
|
|
3
|
+
import { Checkbox } from "@mdxui/primitives/checkbox";
|
|
4
|
+
import { cn } from "@mdxui/primitives/lib/utils";
|
|
5
|
+
import { Paperclip, Star } from "lucide-react";
|
|
6
|
+
import type { MailItemProps } from "mdxui";
|
|
7
|
+
import type * as React from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* MailItem - Individual email thread item in the mail list.
|
|
11
|
+
*
|
|
12
|
+
* Displays:
|
|
13
|
+
* - Selection checkbox
|
|
14
|
+
* - Sender avatar
|
|
15
|
+
* - Subject and snippet
|
|
16
|
+
* - Labels/tags
|
|
17
|
+
* - Star indicator
|
|
18
|
+
* - Attachment indicator
|
|
19
|
+
* - Timestamp
|
|
20
|
+
*/
|
|
21
|
+
function MailItem({
|
|
22
|
+
thread,
|
|
23
|
+
isSelected = false,
|
|
24
|
+
isFocused = false,
|
|
25
|
+
onClick,
|
|
26
|
+
onSelect,
|
|
27
|
+
onStar,
|
|
28
|
+
compact = false,
|
|
29
|
+
showCheckbox = true,
|
|
30
|
+
showAvatar = true,
|
|
31
|
+
highlightTerms,
|
|
32
|
+
className,
|
|
33
|
+
}: MailItemProps) {
|
|
34
|
+
const latestMessage = thread.messages[thread.messages.length - 1];
|
|
35
|
+
const sender = latestMessage?.from;
|
|
36
|
+
const isUnread = thread.unreadCount > 0;
|
|
37
|
+
|
|
38
|
+
// Format date relative to now
|
|
39
|
+
const formatDate = (dateStr: string) => {
|
|
40
|
+
const date = new Date(dateStr);
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const diffMs = now.getTime() - date.getTime();
|
|
43
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
44
|
+
|
|
45
|
+
if (diffDays === 0) {
|
|
46
|
+
return date.toLocaleTimeString([], {
|
|
47
|
+
hour: "numeric",
|
|
48
|
+
minute: "2-digit",
|
|
49
|
+
});
|
|
50
|
+
} else if (diffDays === 1) {
|
|
51
|
+
return "Yesterday";
|
|
52
|
+
} else if (diffDays < 7) {
|
|
53
|
+
return date.toLocaleDateString([], { weekday: "short" });
|
|
54
|
+
} else {
|
|
55
|
+
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Get initials for avatar fallback
|
|
60
|
+
const getInitials = (name?: string, email?: string) => {
|
|
61
|
+
if (name) {
|
|
62
|
+
return name
|
|
63
|
+
.split(" ")
|
|
64
|
+
.map((n) => n[0])
|
|
65
|
+
.join("")
|
|
66
|
+
.toUpperCase()
|
|
67
|
+
.slice(0, 2);
|
|
68
|
+
}
|
|
69
|
+
return email?.charAt(0).toUpperCase() || "?";
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Highlight search terms in text
|
|
73
|
+
const highlightText = (text: string) => {
|
|
74
|
+
if (!highlightTerms?.length) return text;
|
|
75
|
+
const regex = new RegExp(`(${highlightTerms.join("|")})`, "gi");
|
|
76
|
+
return text.replace(
|
|
77
|
+
regex,
|
|
78
|
+
'<mark class="bg-yellow-200 dark:bg-yellow-800">$1</mark>',
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
83
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
onClick?.();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
// biome-ignore lint/a11y/useSemanticElements: MailItem contains nested interactive elements (checkbox, star button) which can't be inside a <button>
|
|
91
|
+
<div
|
|
92
|
+
role="button"
|
|
93
|
+
data-slot="mail-item"
|
|
94
|
+
data-selected={isSelected || undefined}
|
|
95
|
+
tabIndex={0}
|
|
96
|
+
onClick={() => onClick?.()}
|
|
97
|
+
onKeyDown={handleKeyDown}
|
|
98
|
+
className={cn(
|
|
99
|
+
"group relative flex cursor-pointer items-start gap-3 border-b px-4 transition-colors",
|
|
100
|
+
compact ? "py-2" : "py-3",
|
|
101
|
+
isUnread && "bg-muted/50",
|
|
102
|
+
isSelected && "bg-accent",
|
|
103
|
+
isFocused && "ring-2 ring-ring ring-inset",
|
|
104
|
+
"hover:bg-accent/50",
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
{/* Selection Checkbox */}
|
|
109
|
+
{showCheckbox && (
|
|
110
|
+
<div className="flex items-center pt-1">
|
|
111
|
+
<Checkbox
|
|
112
|
+
checked={isSelected}
|
|
113
|
+
onCheckedChange={() => onSelect?.()}
|
|
114
|
+
onClick={(e) => e.stopPropagation()}
|
|
115
|
+
className="opacity-0 group-hover:opacity-100 data-[state=checked]:opacity-100"
|
|
116
|
+
/>
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
|
|
120
|
+
{/* Avatar */}
|
|
121
|
+
{showAvatar && (
|
|
122
|
+
<Avatar className={cn("shrink-0", compact ? "size-8" : "size-10")}>
|
|
123
|
+
<AvatarImage src={undefined} alt={sender?.name || sender?.address} />
|
|
124
|
+
<AvatarFallback className={cn(isUnread && "font-semibold")}>
|
|
125
|
+
{getInitials(sender?.name, sender?.address)}
|
|
126
|
+
</AvatarFallback>
|
|
127
|
+
</Avatar>
|
|
128
|
+
)}
|
|
129
|
+
|
|
130
|
+
{/* Content */}
|
|
131
|
+
<div className="min-w-0 flex-1">
|
|
132
|
+
{/* Header Row */}
|
|
133
|
+
<div className="flex items-center justify-between gap-2">
|
|
134
|
+
<span
|
|
135
|
+
className={cn(
|
|
136
|
+
"truncate text-sm",
|
|
137
|
+
isUnread ? "font-semibold" : "text-muted-foreground",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
{sender?.name || sender?.address}
|
|
141
|
+
{thread.messages.length > 1 && (
|
|
142
|
+
<span className="text-muted-foreground ml-1 font-normal">
|
|
143
|
+
({thread.messages.length})
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="text-muted-foreground shrink-0 text-xs">
|
|
148
|
+
{formatDate(thread.latestDate)}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{/* Subject */}
|
|
153
|
+
<div
|
|
154
|
+
className={cn(
|
|
155
|
+
"truncate text-sm",
|
|
156
|
+
isUnread ? "font-medium" : "text-muted-foreground",
|
|
157
|
+
)}
|
|
158
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: MailItem is a trusted component
|
|
159
|
+
dangerouslySetInnerHTML={{
|
|
160
|
+
__html: highlightText(thread.subject || "(no subject)"),
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
{/* Snippet */}
|
|
165
|
+
{!compact && thread.snippet && (
|
|
166
|
+
<div
|
|
167
|
+
className="text-muted-foreground line-clamp-1 text-xs"
|
|
168
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: MailItem is a trusted component
|
|
169
|
+
dangerouslySetInnerHTML={{ __html: highlightText(thread.snippet) }}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{/* Labels */}
|
|
174
|
+
{thread.labels.length > 0 && (
|
|
175
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
176
|
+
{thread.labels.slice(0, 3).map((label) => (
|
|
177
|
+
<Badge
|
|
178
|
+
key={label.id}
|
|
179
|
+
variant="secondary"
|
|
180
|
+
className="text-xs"
|
|
181
|
+
style={
|
|
182
|
+
label.color ? { backgroundColor: label.color } : undefined
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
{label.name}
|
|
186
|
+
</Badge>
|
|
187
|
+
))}
|
|
188
|
+
{thread.labels.length > 3 && (
|
|
189
|
+
<Badge variant="outline" className="text-xs">
|
|
190
|
+
+{thread.labels.length - 3}
|
|
191
|
+
</Badge>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Action Indicators */}
|
|
198
|
+
<div className="flex shrink-0 items-center gap-1 pt-1">
|
|
199
|
+
{/* Attachment indicator */}
|
|
200
|
+
{thread.hasAttachments && (
|
|
201
|
+
<Paperclip className="text-muted-foreground size-4" />
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{/* Star button */}
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
onClick={(e) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
onStar?.();
|
|
210
|
+
}}
|
|
211
|
+
className={cn(
|
|
212
|
+
"rounded p-1 transition-colors hover:bg-accent",
|
|
213
|
+
thread.isStarred
|
|
214
|
+
? "text-yellow-500"
|
|
215
|
+
: "text-muted-foreground opacity-0 group-hover:opacity-100",
|
|
216
|
+
)}
|
|
217
|
+
aria-label={thread.isStarred ? "Unstar" : "Star"}
|
|
218
|
+
>
|
|
219
|
+
<Star
|
|
220
|
+
className="size-4"
|
|
221
|
+
fill={thread.isStarred ? "currentColor" : "none"}
|
|
222
|
+
/>
|
|
223
|
+
</button>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { MailItem };
|