Files
SRCmail/components/email/email-list-item.tsx
T
Linus Rath cf02f587de feat: resizable columns, nav rail overhaul, multi-select & drag-drop, UI polish
Resizable Columns
- Add ResizeHandle component with mouse drag, keyboard (Arrow keys),
  and double-click to reset to default width
- Add sidebarWidth/emailListWidth to ui-store with clamping + persistence
- Wire resize handles between sidebar/email-list panels on desktop

Navigation Rail Overhaul
- Move StorageQuotaCircle, push-status, sign-out from Sidebar to NavigationRail
- Interactive SVG ring with popover breakdown (used/free/total)
- Sidebar collapse state lifted to ui-store
- Show total email count per mailbox alongside unread badge

Email Multi-Selection & Drag-and-Drop
- Ctrl+Click (toggle) and Shift+Click (range) on all list items
- Add selectRangeEmails and lastSelectedEmailId to email-store
- Enable drag-and-drop on thread items and thread headers
- useEmailDrag accepts optional threadEmails for full-thread drag

Email Viewer Layout
- Remove card wrapper for cleaner full-width reading
- Always render HTML body when available
- Adjust skeleton loader to match flat layout

Modal & UI Polish
- Standardise backdrops, close buttons, padding, border-radius, transitions
- Migrate template-string classNames to cn() in settings
- Unify focus-ring token to ring-ring on form controls

i18n
- Add storage_used/free/total keys to all 8 locales

Dev Mock JMAP Server (new, gated by DEV_MOCK_JMAP=true)
- Session, Mailbox/Email/Thread/Identity CRUD, back-references, upload
- GET /download with Content-Disposition, GET /eventsource SSE

Tests (46 new)
- ui-store (13), email-selection (10), resize-handle (9), mock-server (14)
2026-03-09 16:46:25 +01:00

204 lines
7.1 KiB
TypeScript

"use client";
import { useTranslations } from "next-intl";
import { formatDate } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailDrag } from "@/hooks/use-email-drag";
import { EmailIdentityBadge } from "./email-identity-badge";
interface EmailListItemProps {
email: Email;
selected?: boolean;
onClick?: () => void;
onContextMenu?: (e: React.MouseEvent, email: Email) => void;
}
// Color tag mapping - using lighter backgrounds for better readability
const colorTags = {
red: "bg-red-50 dark:bg-red-950/30",
orange: "bg-orange-50 dark:bg-orange-950/30",
yellow: "bg-yellow-50 dark:bg-yellow-950/30",
green: "bg-green-50 dark:bg-green-950/30",
blue: "bg-blue-50 dark:bg-blue-950/30",
purple: "bg-purple-50 dark:bg-purple-950/30",
pink: "bg-pink-50 dark:bg-pink-950/30",
} as const;
const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
if (!keywords) return null;
for (const key of Object.keys(keywords)) {
if (key.startsWith("$color:") && keywords[key] === true) {
const color = key.replace("$color:", "");
return colorTags[color as keyof typeof colorTags] || null;
}
}
return null;
};
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isImportant = email.keywords?.["$important"];
const sender = email.from?.[0];
const colorTag = getEmailColor(email.keywords);
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
email,
sourceMailboxId: selectedMailbox,
});
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
};
const handleContextMenu = (e: React.MouseEvent) => {
onContextMenu?.(e, email);
};
return (
<div
{...dragHandlers}
className={cn(
"relative group cursor-pointer transition-all duration-200 border-b border-border",
// Apply color tag as background, with selected and unread states
colorTag ? colorTag : (
selected
? "bg-accent"
: "bg-background"
),
selected && !colorTag && "shadow-sm",
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
colorTag && "hover:brightness-95 dark:hover:brightness-110",
isUnread && !colorTag && "bg-accent/30",
// Add visual feedback for checked state
isChecked && "ring-2 ring-primary/20 bg-accent/40",
// Drag state visual feedback
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
)}
onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
toggleEmailSelection(email.id);
} else if (e.shiftKey) {
e.preventDefault();
selectRangeEmails(email.id);
} else {
onClick?.();
}
}}
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
className={cn(
"p-3 lg:p-1 rounded mt-2 flex-shrink-0 transition-all duration-200",
"hover:bg-muted/50 hover:scale-110",
"active:scale-95",
isChecked && "text-primary"
)}
>
{isChecked ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4 text-muted-foreground opacity-60 hover:opacity-100 transition-opacity" />
)}
</button>
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
</div>
)}
{/* Avatar */}
<Avatar
name={sender?.name}
email={sender?.email}
size="md"
className="flex-shrink-0 shadow-sm"
/>
{/* Content */}
<div className="flex-1 min-w-0">
{/* First Line: Sender and Date */}
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className={cn(
"truncate text-sm",
isUnread
? "font-bold text-foreground"
: "font-medium text-muted-foreground"
)}>
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
{isImportant && (
<span className="px-1.5 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded font-medium">
Important
</span>
)}
<EmailIdentityBadge email={email} identities={identities} compact={true} />
{email.hasAttachment && (
<Paperclip className="w-3.5 h-3.5 text-muted-foreground" />
)}
</div>
</div>
<span className={cn(
"text-xs flex-shrink-0 tabular-nums",
isUnread
? "text-foreground font-semibold"
: "text-muted-foreground"
)}>
{formatDate(email.receivedAt)}
</span>
</div>
{/* Second Line: Subject */}
<div className={cn(
"mb-1 line-clamp-1 text-sm",
isUnread
? "font-semibold text-foreground"
: "font-normal text-foreground/90"
)}>
{email.subject || t('no_subject')}
</div>
{/* Third Line: Preview (controlled by showPreview setting) */}
{showPreview && (
<p className={cn(
"text-sm leading-relaxed line-clamp-2",
isUnread
? "text-muted-foreground"
: "text-muted-foreground/80"
)}>
{email.preview || "No preview available"}
</p>
)}
</div>
</div>
</div>
);
}