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
+44 -8
View File
@@ -33,6 +33,7 @@ import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { ResizeHandle } from "@/components/layout/resize-handle";
export default function Home() {
const router = useRouter();
@@ -53,7 +54,7 @@ export default function Home() {
// Mobile/tablet responsive hooks
const { isMobile, isTablet } = useDeviceDetection();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore();
const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore();
const {
emails,
mailboxes,
@@ -238,6 +239,19 @@ export default function Home() {
});
}, [checkAuth]);
// Hydrate persisted column widths from localStorage
useEffect(() => {
try {
const stored = localStorage.getItem("column-widths");
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth);
if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth);
}
} catch { /* ignore parse errors */ }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Redirect to login if not authenticated
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
@@ -735,8 +749,13 @@ export default function Home() {
<div className="flex h-screen bg-background overflow-hidden">
{/* Desktop Navigation Rail */}
{!isMobile && !isTablet && (
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
<NavigationRail collapsed />
<div className="w-14 border-r border-border bg-secondary flex flex-col flex-shrink-0">
<NavigationRail
collapsed
quota={quota}
isPushConnected={isPushConnected}
onLogout={handleLogout}
/>
</div>
)}
@@ -751,7 +770,7 @@ export default function Home() {
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
<div
className={cn(
"flex-shrink-0 h-full z-50",
"flex-shrink-0 h-full z-50 transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
@@ -759,6 +778,7 @@ export default function Home() {
// Desktop: normal flow
"lg:relative lg:translate-x-0"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
>
<ErrorBoundary fallback={SidebarErrorFallback}>
<Sidebar
@@ -770,17 +790,23 @@ export default function Home() {
setShowComposer(true);
if (isMobile) setSidebarOpen(false);
}}
onLogout={handleLogout}
onSidebarClose={() => setSidebarOpen(false)}
onSearch={handleSearch}
onClearSearch={handleClearSearch}
activeSearchQuery={searchQuery}
quota={quota}
isPushConnected={isPushConnected}
/>
</ErrorBoundary>
</div>
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
{!isMobile && !isTablet && !sidebarCollapsed && (
<ResizeHandle
onResize={(delta) => setSidebarWidth(sidebarWidth + delta)}
onResizeEnd={persistColumnWidths}
onDoubleClick={resetSidebarWidth}
/>
)}
{/* Main Content Area */}
<div className="flex flex-col flex-1 min-w-0 h-full">
<div className="flex flex-1 min-h-0">
@@ -792,11 +818,12 @@ export default function Home() {
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm",
"md:flex-shrink-0 md:shadow-sm",
"transition-all duration-200 ease-out",
// Tablet: collapse when email selected
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
)}
style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined}
>
{/* Mobile Header for List View */}
<MobileHeader
@@ -880,6 +907,15 @@ export default function Home() {
</ErrorBoundary>
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && (
<ResizeHandle
onResize={(delta) => setEmailListWidth(emailListWidth + delta)}
onResizeEnd={persistColumnWidths}
onDoubleClick={resetEmailListWidth}
/>
)}
{/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
<div
className={cn(
+6 -6
View File
@@ -66,9 +66,9 @@ export default function SettingsPage() {
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150',
activeTab === tab.id
? 'bg-accent text-accent-foreground'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
)}
>
@@ -83,10 +83,10 @@ export default function SettingsPage() {
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto p-8">
{/* Page Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-2">
<SettingsIcon className="w-8 h-8 text-foreground" />
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
<div className="mb-6">
<div className="flex items-center gap-2.5 mb-2">
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
</div>
File diff suppressed because one or more lines are too long