refactor: compact MailList UI for higher density

This commit is contained in:
rustmailer
2025-11-09 00:02:02 +08:00
parent 566beabb7a
commit c60f2cd444
2 changed files with 98 additions and 139 deletions
+97 -138
View File
@@ -1,9 +1,3 @@
/*
* Copyright © 2025 rustmailer.com
* Licensed under RustMailer License Agreement v1.0
* Unauthorized use or distribution is prohibited.
*/
import { cn, formatFileSize } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import { formatDistanceToNow } from "date-fns"
@@ -14,14 +8,14 @@ import { Checkbox } from "@/components/ui/checkbox"
import { MailboxDialogType } from "../context"
interface MailListProps {
items: EmailEnvelope[],
items: EmailEnvelope[]
isLoading: boolean
currentEnvelope: EmailEnvelope | undefined
onEnvelopeChanged: (envelope: EmailEnvelope) => void
setOpen: (str: MailboxDialogType | null) => void,
setSelectedIds: React.Dispatch<React.SetStateAction<string[]>>;
setDeleteIds: React.Dispatch<React.SetStateAction<string[]>>;
selectedIds: string[];
setOpen: (str: MailboxDialogType | null) => void
setSelectedIds: React.Dispatch<React.SetStateAction<string[]>>
setDeleteIds: React.Dispatch<React.SetStateAction<string[]>>
selectedIds: string[]
}
export function MailList({
@@ -35,31 +29,26 @@ export function MailList({
selectedIds
}: MailListProps) {
const handleDelete = (envelope: EmailEnvelope) => {
setDeleteIds([envelope.id]);
setOpen("move-to-trash");
setDeleteIds([envelope.id])
setOpen("move-to-trash")
}
const handleCheckboxChange = (value: boolean | 'indeterminate', id: string) => {
if (value === true) {
setSelectedIds((prev) => [...prev, id]);
setSelectedIds(prev => [...prev, id])
} else if (value === false) {
setSelectedIds((prev) => prev.filter((x) => x !== id));
setSelectedIds(prev => prev.filter(x => x !== id))
}
};
}
if (isLoading) {
return (
<div className="flex flex-col gap-2 p-2">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="flex flex-col gap-2 p-2 rounded-lg border">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-3 w-24 rounded" />
<Skeleton className="h-2 w-2 rounded-full ml-auto" />
</div>
<div className="flex flex-col gap-1 pl-6">
<Skeleton className="h-3 w-3/4 rounded" />
</div>
<div className="space-y-1 p-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 p-1.5 rounded border">
<Skeleton className="h-3 w-3 rounded" />
<Skeleton className="h-3 flex-1" />
<Skeleton className="h-2 w-2 rounded-full ml-auto" />
</div>
))}
</div>
@@ -67,135 +56,105 @@ export function MailList({
}
return (
<div className="grid grid-cols-1 gap-1.5 p-1 sm:p-2">
<div className="divide-y divide-border">
{items.map((item) => {
const isUnread = !item.is_read;
const isUnread = !item.is_read
const hasAttachments = item.attachments && item.attachments.length > 0;
const attachmentCount = item.attachments?.length || 0;
const isSelected = selectedIds.includes(item.id)
const isActive = currentEnvelope?.id === item.id
return (
<div
key={item.id}
className={cn(
"flex flex-col gap-1.5 p-2 rounded-lg border transition-all cursor-pointer",
"hover:bg-accent/50",
currentEnvelope?.id === item.id && "bg-accent",
selectedIds.includes(item.id) && "bg-primary/5"
"group flex items-center gap-2 p-1.5 pr-2 rounded-md border transition-all cursor-pointer text-xs",
"hover:bg-accent/70",
isActive && "bg-accent",
isSelected && "bg-primary/10 ring-1 ring-primary/20"
)}
onClick={() => onEnvelopeChanged(item)}
>
<div className="flex items-center gap-1.5">
<Checkbox
checked={
selectedIds.includes(item.id)
}
onCheckedChange={(checked) => {
handleCheckboxChange(checked, item.id)
}}
onClick={(e) => e.stopPropagation()}
className="h-4 w-3 shrink-0"
/>
{/* Checkbox */}
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleCheckboxChange(checked, item.id)}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
<div className="flex items-center gap-1 min-w-0 flex-1">
{isUnread ? (
<MailIcon className="h-3.5 w-3.5 text-blue-600 shrink-0" />
) : (
<MailOpen className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
{/* <span className="text-xs text-muted-foreground">
{isGmailApi ? `mid: ${item.id}` : `uid: ${item.id}`}
</span> */}
{/* Unread Icon */}
{isUnread ? (
<MailIcon className="h-3 w-3 text-blue-600 shrink-0" />
) : (
<MailOpen className="h-3 w-3 text-muted-foreground shrink-0" />
)}
<p className={cn(
"text-xs font-medium truncate ml-1",
isUnread && "font-semibold"
)}>
{item.from ? `${item.from.name || ""} <${item.from.address}>` : "Unknown"}
</p>
{/* Sender */}
<span
className={cn(
"font-medium truncate max-w-[140px]",
isUnread && "font-semibold"
)}
title={item.from?.address}
>
{item.from?.name || item.from?.address || "Unknown"}
</span>
{isUnread && (
<span className="flex h-1.5 w-1.5 rounded-full bg-blue-600 shrink-0 ml-1" />
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{hasAttachments && (
<div className="flex items-center gap-0.5 mr-1">
<Paperclip className={cn(
"h-3 w-3",
isUnread ? "text-blue-600" : "text-muted-foreground"
)} />
<span className={cn(
"text-xs",
isUnread ? "text-blue-600 font-medium" : "text-muted-foreground"
)}>
{attachmentCount}
</span>
</div>
)}
<span className="text-xs text-muted-foreground">
{formatFileSize(item.size)}
</span>
<span className={cn(
"text-xs",
currentEnvelope?.id === item.id
? "text-foreground font-medium"
: "text-muted-foreground"
)}>
{item.date && formatDistanceToNow(new Date(item.date), {
addSuffix: true,
})}
</span>
</div>
</div>
<div className="flex items-center pl-7">
<h3 className={cn(
"text-xs line-clamp-1",
isUnread ? "font-medium" : "text-muted-foreground"
)}>
{/* Subject + Attachments */}
<div className="flex-1 flex items-center gap-1 min-w-0">
{hasAttachments && (
<Paperclip className={cn("h-2.5 w-2.5 shrink-0", isUnread ? "text-blue-600" : "text-muted-foreground")} />
)}
<span className={cn("truncate", isUnread ? "font-medium" : "text-muted-foreground")}>
{item.subject || "(No Subject)"}
</h3>
</span>
</div>
<div className="flex items-center justify-between pl-7">
<div className="flex flex-wrap gap-1">
{item.flags?.map((flag) => (
<Badge
key={flag.flag + (flag.custom || "")}
variant={getBadgeVariantFromFlag(flag.flag)}
className="h-4 px-1 text-[11px] leading-none"
>
{isCustomFlag(flag.flag) ? flag.custom : flag.flag}
</Badge>
))}
{item.labels?.length > 0 &&
item.labels.map((label) => (
<Badge
key={label}
variant="outline"
className="h-4 px-1 text-[11px] leading-none"
>
{label}
</Badge>
))}
</div>
<button
className="text-muted-foreground hover:text-destructive transition-colors p-0.5"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
{/* Flags / Labels */}
<div className="flex items-center gap-0.5 shrink-0">
{item.flags?.slice(0, 2).map((flag) => (
<Badge
key={flag.flag + (flag.custom || "")}
variant={getBadgeVariantFromFlag(flag.flag)}
className="h-3.5 px-1 text-xs leading-none"
>
{isCustomFlag(flag.flag) ? flag.custom : flag.flag}
</Badge>
))}
{item.labels?.[0] && (
<Badge variant="outline" className="h-3.5 px-1 text-xs leading-none">
{item.labels[0]}
</Badge>
)}
{(item.flags?.length ?? 0) > 2 || item.labels?.length > 1 ? (
<span className="text-xs text-muted-foreground">+{((item.flags?.length ?? 0) - 2) + (item.labels?.length - 1)}</span>
) : null}
</div>
{/* Size & Time */}
<span className="text-xs text-muted-foreground shrink-0 ml-1">
{formatFileSize(item.size)}
</span>
<span className={cn(
"text-xs shrink-0",
isActive ? "text-foreground font-medium" : "text-muted-foreground"
)}>
{item.date && formatDistanceToNow(new Date(item.date), { addSuffix: true })}
</span>
{/* Delete Button */}
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDelete(item)
}}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
)
})}
</div>
);
)
}
+1 -1
View File
@@ -177,7 +177,7 @@ export function Mail({
const pageTokenMapRef = React.useRef<Record<number, string | undefined>>({});
const [page, setPage] = React.useState(0);
const [pageSize, setPageSize] = React.useState(10);
const [pageSize, setPageSize] = React.useState(30);
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [deleteIds, setDeleteIds] = React.useState<string[]>([]);
const [currentFilter, setCurrentFilter] = React.useState<FilterForm | undefined>(undefined);