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
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { ResizeHandle } from '../resize-handle';
describe('ResizeHandle', () => {
it('should render with separator role', () => {
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} />
);
const handle = getByRole('separator');
expect(handle).toBeInTheDocument();
expect(handle).toHaveAttribute('aria-orientation', 'vertical');
expect(handle).toHaveAttribute('tabindex', '0');
});
describe('keyboard interaction', () => {
it('should call onResize with negative delta on ArrowLeft', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowLeft' });
expect(onResize).toHaveBeenCalledWith(-10);
expect(onResizeEnd).toHaveBeenCalled();
});
it('should call onResize with positive delta on ArrowRight', () => {
const onResize = vi.fn();
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowRight' });
expect(onResize).toHaveBeenCalledWith(10);
expect(onResizeEnd).toHaveBeenCalled();
});
it('should not respond to other keys', () => {
const onResize = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} />
);
const handle = getByRole('separator');
fireEvent.keyDown(handle, { key: 'ArrowUp' });
fireEvent.keyDown(handle, { key: 'Enter' });
fireEvent.keyDown(handle, { key: 'a' });
expect(onResize).not.toHaveBeenCalled();
});
});
describe('double-click', () => {
it('should call onDoubleClick when provided', () => {
const onDoubleClick = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} onDoubleClick={onDoubleClick} />
);
const handle = getByRole('separator');
fireEvent.doubleClick(handle);
expect(onDoubleClick).toHaveBeenCalledOnce();
});
it('should not error when onDoubleClick is not provided', () => {
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} />
);
const handle = getByRole('separator');
expect(() => fireEvent.doubleClick(handle)).not.toThrow();
});
});
describe('mouse drag', () => {
it('should call onResize during mousemove after mousedown', () => {
const onResize = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={onResize} />
);
const handle = getByRole('separator');
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseMove(document, { clientX: 115 });
expect(onResize).toHaveBeenCalledWith(15);
});
it('should call onResizeEnd on mouseup', () => {
const onResizeEnd = vi.fn();
const { getByRole } = render(
<ResizeHandle onResize={vi.fn()} onResizeEnd={onResizeEnd} />
);
const handle = getByRole('separator');
fireEvent.mouseDown(handle, { clientX: 100 });
fireEvent.mouseUp(document);
expect(onResizeEnd).toHaveBeenCalledOnce();
});
it('should not call onResize on mousemove without mousedown', () => {
const onResize = vi.fn();
render(<ResizeHandle onResize={onResize} />);
fireEvent.mouseMove(document, { clientX: 200 });
expect(onResize).not.toHaveBeenCalled();
});
});
});
+169 -40
View File
@@ -1,11 +1,12 @@
"use client";
import { Mail, Calendar, BookUser, Settings } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Mail, Calendar, BookUser, Settings, LogOut } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
import { useEmailStore } from "@/stores/email-store";
import { cn } from "@/lib/utils";
import { cn, formatFileSize } from "@/lib/utils";
interface NavItem {
id: string;
@@ -20,12 +21,100 @@ interface NavigationRailProps {
orientation?: "vertical" | "horizontal";
collapsed?: boolean;
className?: string;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
onLogout?: () => void;
}
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
const t = useTranslations("sidebar");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
const free = quota.total - quota.used;
const strokeColor = usagePercent > 90
? "stroke-red-500 dark:stroke-red-400"
: usagePercent > 70
? "stroke-amber-500 dark:stroke-amber-400"
: "stroke-green-500 dark:stroke-green-400";
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
>
<svg className="w-8 h-8 -rotate-90" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="12" fill="none" className="stroke-muted" strokeWidth="3" />
<circle
cx="16" cy="16" r="12" fill="none"
className={cn(strokeColor)}
strokeWidth="3" strokeLinecap="round"
strokeDasharray={`${(usagePercent / 100) * 75.4} 75.4`}
style={{ transition: "stroke-dasharray 0.3s" }}
/>
</svg>
<span className="absolute text-[7px] font-bold text-muted-foreground tabular-nums">
{Math.round(usagePercent)}%
</span>
</button>
{open && (
<div className="absolute left-full bottom-0 ml-2 w-52 rounded-lg border border-border bg-popover text-popover-foreground shadow-lg p-3 z-50">
<p className="text-xs font-semibold mb-2">{t("storage")}</p>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_used")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.used)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_free")}</span>
<span className="font-medium tabular-nums">{formatFileSize(free)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t("storage_total")}</span>
<span className="font-medium tabular-nums">{formatFileSize(quota.total)}</span>
</div>
</div>
<div className="mt-2.5 w-full bg-muted rounded-full h-1.5">
<div
className={cn(
"h-1.5 rounded-full transition-all",
usagePercent > 90
? "bg-red-500 dark:bg-red-400"
: usagePercent > 70
? "bg-amber-500 dark:bg-amber-400"
: "bg-green-500 dark:bg-green-400"
)}
style={{ width: `${usagePercent}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground mt-1 tabular-nums">
{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}
</p>
</div>
)}
</div>
);
}
export function NavigationRail({
orientation = "vertical",
collapsed = false,
className,
quota,
isPushConnected,
onLogout,
}: NavigationRailProps) {
const t = useTranslations("sidebar");
const pathname = usePathname();
@@ -91,49 +180,89 @@ export function NavigationRail({
);
}
const quotaUsagePercent = quota && quota.total > 0 ? Math.min((quota.used / quota.total) * 100, 100) : 0;
return (
<nav
<div
className={cn(
"flex flex-col",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
"flex flex-col h-full",
collapsed ? "items-center" : "",
className
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 py-1.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
title={collapsed ? t(item.labelKey) : undefined}
<nav
className={cn(
"flex flex-col",
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
)}
role="navigation"
aria-label={t("nav_label")}
>
{visibleItems.map((item) => {
const isActive = getIsActive(item.href);
const Icon = item.icon;
return (
<Link
key={item.id}
href={item.href}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
? "justify-center w-10 h-10"
: "px-2.5 py-1.5 text-sm",
"max-lg:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
aria-current={isActive ? "page" : undefined}
title={collapsed ? t(item.labelKey) : undefined}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
</nav>
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="mt-auto flex flex-col items-center gap-2 pb-3 px-1 border-t border-border pt-2">
{quota && quota.total > 0 && (
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
)}
{isPushConnected != null && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<Icon className={cn("w-[18px] h-[18px] flex-shrink-0", isActive && "text-primary")} />
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
{item.badge != null && item.badge > 0 && (
<span className={cn(
"absolute flex items-center justify-center min-w-[16px] h-4 text-[10px] font-bold rounded-full bg-red-500 text-white px-1",
collapsed ? "-top-0.5 -right-0.5" : "right-1.5"
)}>
{item.badge > 99 ? "99+" : item.badge}
</span>
)}
</Link>
);
})}
</nav>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
</span>
)}
{onLogout && (
<button
onClick={onLogout}
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
>
<LogOut className="w-[18px] h-[18px]" />
</button>
)}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ResizeHandleProps {
onResize: (delta: number) => void;
onResizeEnd?: () => void;
onDoubleClick?: () => void;
className?: string;
}
const KEYBOARD_STEP = 10;
export function ResizeHandle({ onResize, onResizeEnd, onDoubleClick, className }: ResizeHandleProps) {
const isDragging = useRef(false);
const lastX = useRef(0);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
lastX.current = e.clientX;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = -KEYBOARD_STEP;
else if (e.key === "ArrowRight") delta = KEYBOARD_STEP;
else return;
e.preventDefault();
onResize(delta);
onResizeEnd?.();
}, [onResize, onResizeEnd]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const delta = e.clientX - lastX.current;
lastX.current = e.clientX;
onResize(delta);
};
const handleMouseUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
onResizeEnd?.();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [onResize, onResizeEnd]);
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize"
tabIndex={0}
onMouseDown={handleMouseDown}
onKeyDown={handleKeyDown}
onDoubleClick={onDoubleClick}
className={cn(
"w-1 flex-shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group",
"focus-visible:outline-none focus-visible:bg-primary/40 focus-visible:ring-2 focus-visible:ring-primary/50",
className
)}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
);
}
+20 -102
View File
@@ -15,7 +15,6 @@ import {
PenSquare,
Search,
Menu,
LogOut,
ChevronRight,
ChevronDown,
Folder,
@@ -27,11 +26,12 @@ import {
Settings,
X,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { useEmailStore } from "@/stores/email-store";
import { useUIStore } from "@/stores/ui-store";
import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useVacationStore } from "@/stores/vacation-store";
import { toast } from "@/stores/toast-store";
@@ -42,13 +42,10 @@ interface SidebarProps {
selectedMailbox?: string;
onMailboxSelect?: (mailboxId: string) => void;
onCompose?: () => void;
onLogout?: () => void;
onSidebarClose?: () => void;
onSearch?: (query: string) => void;
onClearSearch?: () => void;
activeSearchQuery?: string;
quota?: { used: number; total: number } | null;
isPushConnected?: boolean;
className?: string;
}
@@ -184,16 +181,21 @@ function MailboxTreeItem({
{!isCollapsed && (
<>
<span className="flex-1 truncate">{node.name}</span>
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{node.unreadEmails}
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
{node.unreadEmails > 0 && (
<span className={cn(
"text-xs rounded-full px-2 py-0.5 font-medium",
selectedMailbox === node.id
? "bg-primary text-primary-foreground"
: "bg-foreground text-background"
)}>
{node.unreadEmails}
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{node.totalEmails}
</span>
)}
</span>
</>
)}
</button>
@@ -268,58 +270,18 @@ function AdvancedSearchToggle() {
);
}
function StorageQuota({ quota, isCollapsed }: { quota: { used: number; total: number } | null; isCollapsed: boolean }) {
const t = useTranslations('sidebar');
if (!quota || quota.total <= 0) return null;
const usagePercent = Math.min((quota.used / quota.total) * 100, 100);
const barColor = usagePercent > 90
? "bg-red-500 dark:bg-red-400"
: usagePercent > 70
? "bg-amber-500 dark:bg-amber-400"
: "bg-green-500 dark:bg-green-400";
if (isCollapsed) {
return (
<div className="px-2 py-2" title={`${formatFileSize(quota.used)} / ${formatFileSize(quota.total)}`}>
<div className="w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
return (
<div className="px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{t("storage")}</span>
<span className="text-foreground tabular-nums">
{formatFileSize(quota.used)} / {formatFileSize(quota.total)}
</span>
</div>
<div className="mt-1 w-full bg-muted rounded-full h-1">
<div className={cn(barColor, "h-1 rounded-full transition-all")} style={{ width: `${usagePercent}%` }} />
</div>
</div>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
onMailboxSelect,
onCompose,
onLogout,
onSidebarClose,
onSearch,
onClearSearch,
activeSearchQuery = "",
quota,
isPushConnected = false,
className,
}: SidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const [searchQuery, setSearchQuery] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const t = useTranslations('sidebar');
@@ -407,7 +369,7 @@ export function Sidebar({
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
"max-lg:w-full",
isCollapsed ? "lg:w-16" : "lg:w-64",
isCollapsed ? "lg:w-16" : "lg:w-full",
className
)}
>
@@ -426,7 +388,7 @@ export function Sidebar({
<Button
variant="ghost"
size="icon"
onClick={() => setIsCollapsed(!isCollapsed)}
onClick={toggleSidebarCollapsed}
className="hidden lg:flex"
>
<Menu className="w-5 h-5" />
@@ -501,51 +463,7 @@ export function Sidebar({
</div>
</div>
{/* Footer: Storage Quota + Sign Out + Push Status */}
<div className="border-t border-border">
<StorageQuota quota={quota ?? null} isCollapsed={isCollapsed} />
<div className={cn(
"flex items-center border-t border-border",
isCollapsed ? "justify-center py-2" : "justify-between px-3 py-2"
)}>
{onLogout && (
<button
onClick={onLogout}
className={cn(
"flex items-center gap-2 rounded-md transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-muted",
isCollapsed ? "p-2" : "px-2 py-1.5"
)}
title={t("sign_out")}
>
<LogOut className="w-4 h-4" />
{!isCollapsed && t("sign_out")}
</button>
)}
{!isCollapsed && (
<span
className="relative group"
title={isPushConnected ? t("push_connected") : t("push_disconnected")}
>
<span
className={cn(
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
)}
/>
<span className={cn(
"absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1",
"bg-popover text-popover-foreground text-xs rounded shadow-lg",
"whitespace-nowrap opacity-0 group-hover:opacity-100",
"pointer-events-none transition-opacity duration-200 z-50"
)}>
{isPushConnected ? t("push_connected") : t("push_disconnected")}
</span>
</span>
)}
</div>
</div>
{/* Footer removed - storage quota and sign out moved to NavigationRail */}
</div>
);
}