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:
Linus Rath
2026-03-09 16:46:25 +01:00
parent 58b9b70871
commit cf02f587de
44 changed files with 2181 additions and 314 deletions
+52 -4
View File
@@ -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}