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)
This commit is contained in:
@@ -286,7 +286,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
disabled={isProcessing}
|
||||
aria-pressed={currentRsvp === 'accepted'}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
currentRsvp === 'accepted'
|
||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
@@ -300,7 +300,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
disabled={isProcessing}
|
||||
aria-pressed={currentRsvp === 'tentative'}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
currentRsvp === 'tentative'
|
||||
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
@@ -314,7 +314,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
disabled={isProcessing}
|
||||
aria-pressed={currentRsvp === 'declined'}
|
||||
className={cn(
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
"flex items-center gap-1 text-sm px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50",
|
||||
currentRsvp === 'declined'
|
||||
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
@@ -346,7 +346,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded transition-colors min-h-[44px] md:min-h-0 disabled:opacity-50"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground hover:bg-muted px-2 py-0.5 rounded-md transition-colors duration-150 min-h-[44px] md:min-h-0 disabled:opacity-50"
|
||||
>
|
||||
<CalendarCheck className="w-3.5 h-3.5" />
|
||||
{t('add_to_calendar')}
|
||||
|
||||
@@ -43,7 +43,7 @@ const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
||||
|
||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||
const t = useTranslations('email_viewer');
|
||||
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
|
||||
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const { identities } = useAuthStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
@@ -88,7 +88,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
||||
// Drag state visual feedback
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
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)' }}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn } from "@/lib/utils";
|
||||
@@ -403,9 +403,7 @@ export function EmailViewer({
|
||||
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
|
||||
// Use safe parsing instead of innerHTML to detect rich formatting
|
||||
useHtmlVersion = hasRichFormatting(htmlContent);
|
||||
useHtmlVersion = !!htmlContent;
|
||||
}
|
||||
|
||||
// If we should use HTML version and it exists
|
||||
@@ -599,8 +597,8 @@ export function EmailViewer({
|
||||
|
||||
{/* Loading Content Skeleton */}
|
||||
<div className="flex-1 overflow-auto bg-muted/20">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="bg-background rounded-lg shadow-sm border border-border overflow-hidden p-6 space-y-3">
|
||||
<div className="px-6 pt-4 pb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-muted/60 rounded w-full"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-5/6"></div>
|
||||
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
|
||||
@@ -1388,11 +1386,11 @@ export function EmailViewer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div>
|
||||
|
||||
{/* Inline Attachments */}
|
||||
{email.attachments && email.attachments.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 px-6">
|
||||
{/* Image attachments as thumbnails */}
|
||||
{email.attachments.filter(a =>
|
||||
a.type?.startsWith('image/') ||
|
||||
@@ -1474,36 +1472,34 @@ export function EmailViewer({
|
||||
)}
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="bg-background rounded-lg shadow-sm border border-border overflow-x-auto">
|
||||
<div className="email-content-wrapper p-6">
|
||||
{emailContent.isHtml ? (
|
||||
<div
|
||||
className="email-content prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
style={{
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="email-content-text text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="email-content-wrapper overflow-x-auto">
|
||||
{emailContent.isHtml ? (
|
||||
<div
|
||||
className="email-content prose dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
style={{
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="email-content-text text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Reply Section */}
|
||||
<div className={cn(
|
||||
"mt-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||
"mt-6 mx-6 mb-6 bg-background rounded-lg shadow-sm border transition-all",
|
||||
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||
)}>
|
||||
<div className="p-4">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { hasRichFormatting, EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers } from "@/lib/email-sanitization";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
@@ -263,9 +263,7 @@ function EmailCard({
|
||||
|
||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||
|
||||
// Use safe parsing instead of innerHTML to detect rich formatting
|
||||
useHtmlVersion = hasRichFormatting(htmlContent);
|
||||
useHtmlVersion = !!htmlContent;
|
||||
}
|
||||
|
||||
if (useHtmlVersion && htmlContent) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Email } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle } from "lucide-react";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
|
||||
interface ThreadEmailItemProps {
|
||||
email: Email;
|
||||
@@ -24,24 +26,46 @@ export function ThreadEmailItem({
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
onClick?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...dragHandlers}
|
||||
className={cn(
|
||||
"relative cursor-pointer transition-all duration-150",
|
||||
"pl-12 pr-4 py-2.5", // Indented for thread hierarchy
|
||||
"pl-12 pr-4 py-2.5",
|
||||
"border-l-2 border-l-transparent",
|
||||
selected
|
||||
? "bg-accent border-l-primary"
|
||||
: "hover:bg-muted/50",
|
||||
isUnread && !selected && "bg-accent/20",
|
||||
!isLast && "border-b border-border/30"
|
||||
!isLast && "border-b border-border/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
@@ -8,7 +8,9 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { getThreadColorTag } from "@/lib/thread-utils";
|
||||
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||
import { ThreadEmailItem } from "./thread-email-item";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -48,14 +50,34 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const isStarred = email.keywords?.$flagged;
|
||||
const sender = email.from?.[0];
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
|
||||
const { dragHandlers, isDragging } = useEmailDrag({
|
||||
email,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
onContextMenu?.(e, email);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleEmailSelection(email.id);
|
||||
} else if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(email.id);
|
||||
} else {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...dragHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||
colorTag ? colorTag : (
|
||||
@@ -66,9 +88,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
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"
|
||||
isUnread && !colorTag && "bg-accent/30",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40",
|
||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{ minHeight: 'var(--list-item-height)' }}
|
||||
>
|
||||
@@ -164,12 +188,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
||||
|
||||
const { selectedMailbox, selectedEmailIds, toggleEmailSelection, selectRangeEmails } = useEmailStore();
|
||||
|
||||
const { dragHandlers, isDragging: isThreadDragging } = useEmailDrag({
|
||||
email: latestEmail,
|
||||
sourceMailboxId: selectedMailbox,
|
||||
threadEmails: thread.emails,
|
||||
});
|
||||
|
||||
const threadColor = getThreadColorTag(thread.emails);
|
||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||
|
||||
const isSelected = selectedEmailId === latestEmail.id ||
|
||||
thread.emails.some(e => e.id === selectedEmailId);
|
||||
|
||||
const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id));
|
||||
|
||||
if (emailCount === 1) {
|
||||
return (
|
||||
<SingleEmailItem
|
||||
@@ -187,6 +221,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const emailsToShow = expandedEmails || thread.emails;
|
||||
|
||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
// Ctrl+Click: toggle selection for all thread emails
|
||||
thread.emails.forEach(em => toggleEmailSelection(em.id));
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
selectRangeEmails(latestEmail.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMobile && onOpenConversation) {
|
||||
onOpenConversation(thread);
|
||||
return;
|
||||
@@ -208,8 +254,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="border-b border-border">
|
||||
<div ref={ref} className={cn("border-b border-border", isThreadDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30")}>
|
||||
<div
|
||||
{...dragHandlers}
|
||||
className={cn(
|
||||
"relative group cursor-pointer transition-all duration-200",
|
||||
colorTag ? colorTag : (
|
||||
@@ -221,7 +268,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
||||
isExpanded && "border-b border-border/50"
|
||||
isExpanded && "border-b border-border/50",
|
||||
isChecked && "ring-2 ring-primary/20 bg-accent/40"
|
||||
)}
|
||||
onClick={handleHeaderClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
||||
Reference in New Issue
Block a user