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:
+44
-8
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user