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 { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||||
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -53,7 +54,7 @@ export default function Home() {
|
|||||||
|
|
||||||
// Mobile/tablet responsive hooks
|
// Mobile/tablet responsive hooks
|
||||||
const { isMobile, isTablet } = useDeviceDetection();
|
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 {
|
const {
|
||||||
emails,
|
emails,
|
||||||
mailboxes,
|
mailboxes,
|
||||||
@@ -238,6 +239,19 @@ export default function Home() {
|
|||||||
});
|
});
|
||||||
}, [checkAuth]);
|
}, [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
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||||
@@ -735,8 +749,13 @@ export default function Home() {
|
|||||||
<div className="flex h-screen bg-background overflow-hidden">
|
<div className="flex h-screen bg-background overflow-hidden">
|
||||||
{/* Desktop Navigation Rail */}
|
{/* Desktop Navigation Rail */}
|
||||||
{!isMobile && !isTablet && (
|
{!isMobile && !isTablet && (
|
||||||
<div className="w-14 border-r border-border bg-secondary flex flex-col items-center flex-shrink-0">
|
<div className="w-14 border-r border-border bg-secondary flex flex-col flex-shrink-0">
|
||||||
<NavigationRail collapsed />
|
<NavigationRail
|
||||||
|
collapsed
|
||||||
|
quota={quota}
|
||||||
|
isPushConnected={isPushConnected}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -751,7 +770,7 @@ export default function Home() {
|
|||||||
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 h-full z-50",
|
"flex-shrink-0 h-full z-50 transition-[width] duration-300",
|
||||||
// Mobile/Tablet: fixed overlay
|
// Mobile/Tablet: fixed overlay
|
||||||
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72",
|
"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",
|
"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
|
// Desktop: normal flow
|
||||||
"lg:relative lg:translate-x-0"
|
"lg:relative lg:translate-x-0"
|
||||||
)}
|
)}
|
||||||
|
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
|
||||||
>
|
>
|
||||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
@@ -770,17 +790,23 @@ export default function Home() {
|
|||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onLogout={handleLogout}
|
|
||||||
onSidebarClose={() => setSidebarOpen(false)}
|
onSidebarClose={() => setSidebarOpen(false)}
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onClearSearch={handleClearSearch}
|
onClearSearch={handleClearSearch}
|
||||||
activeSearchQuery={searchQuery}
|
activeSearchQuery={searchQuery}
|
||||||
quota={quota}
|
|
||||||
isPushConnected={isPushConnected}
|
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</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 */}
|
{/* Main Content Area */}
|
||||||
<div className="flex flex-col flex-1 min-w-0 h-full">
|
<div className="flex flex-col flex-1 min-w-0 h-full">
|
||||||
<div className="flex flex-1 min-h-0">
|
<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",
|
"max-md:flex-1 max-md:border-r-0",
|
||||||
isMobile && activeView !== "list" && "max-md:hidden",
|
isMobile && activeView !== "list" && "max-md:hidden",
|
||||||
// Tablet/Desktop: fixed width with collapse animation
|
// 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",
|
"transition-all duration-200 ease-out",
|
||||||
// Tablet: collapse when email selected
|
// Tablet: collapse when email selected
|
||||||
isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
|
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 */}
|
{/* Mobile Header for List View */}
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
@@ -880,6 +907,15 @@ export default function Home() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</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 */}
|
{/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ export default function SettingsPage() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
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
|
activeTab === tab.id
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground font-medium'
|
||||||
: 'hover:bg-muted text-foreground'
|
: 'hover:bg-muted text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -83,10 +83,10 @@ export default function SettingsPage() {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto p-8">
|
<div className="max-w-3xl mx-auto p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-6">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-2.5 mb-2">
|
||||||
<SettingsIcon className="w-8 h-8 text-foreground" />
|
<SettingsIcon className="w-6 h-6 text-muted-foreground" />
|
||||||
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -34,7 +34,7 @@ export function CalendarSidebarPanel({
|
|||||||
key={cal.id}
|
key={cal.id}
|
||||||
onClick={() => onToggleVisibility(cal.id)}
|
onClick={() => onToggleVisibility(cal.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 w-full px-1.5 py-1 rounded text-sm transition-colors",
|
"flex items-center gap-2 w-full px-1.5 py-1 rounded-md text-sm transition-colors duration-150",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -293,10 +293,10 @@ export function EventDetailPopover({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors flex-shrink-0 mt-0.5"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t("form.cancel")}
|
aria-label={t("form.cancel")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-muted-foreground" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -375,16 +375,16 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={event.title || t("events.no_title")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-3">
|
<div className="px-6 py-4 space-y-3">
|
||||||
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
<div className="flex items-start gap-3 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/50 px-4 py-3">
|
||||||
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
<CalendarDays className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
@@ -432,7 +432,7 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 border-t border-border">
|
<div className="px-6 py-4 border-t border-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
<span className="text-sm font-medium">{t("participants.rsvp_label")}</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -479,18 +479,18 @@ export function EventModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={isEdit ? t("events.edit") : t("events.create")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
<div ref={modalRef} role="dialog" aria-modal="true" aria-label={isEdit ? t("events.edit") : t("events.create")} className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-lg font-semibold">
|
||||||
{isEdit ? t("events.edit") : t("events.create")}
|
{isEdit ? t("events.edit") : t("events.create")}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 rounded hover:bg-muted transition-colors" aria-label={t("form.cancel")}>
|
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -666,7 +666,7 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isEdit && onDelete && (
|
{isEdit && onDelete && (
|
||||||
showDeleteConfirm ? (
|
showDeleteConfirm ? (
|
||||||
|
|||||||
@@ -181,26 +181,26 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("title")}
|
aria-label={t("title")}
|
||||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
<h2 className="text-lg font-semibold">{t("title")}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={tCommon("close")}
|
aria-label={tCommon("close")}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-4">
|
<div className="px-6 py-4 space-y-4">
|
||||||
{step === "select" && !isParsing && (
|
{step === "select" && !isParsing && (
|
||||||
<div
|
<div
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@@ -318,7 +318,7 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step !== "importing" && (
|
{step !== "importing" && (
|
||||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{tForm("cancel")}
|
{tForm("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
aria-pressed={currentRsvp === 'accepted'}
|
aria-pressed={currentRsvp === 'accepted'}
|
||||||
className={cn(
|
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'
|
currentRsvp === 'accepted'
|
||||||
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 font-medium"
|
? "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"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
@@ -300,7 +300,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
aria-pressed={currentRsvp === 'tentative'}
|
aria-pressed={currentRsvp === 'tentative'}
|
||||||
className={cn(
|
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'
|
currentRsvp === 'tentative'
|
||||||
? "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-medium"
|
? "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"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
@@ -314,7 +314,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
aria-pressed={currentRsvp === 'declined'}
|
aria-pressed={currentRsvp === 'declined'}
|
||||||
className={cn(
|
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'
|
currentRsvp === 'declined'
|
||||||
? "bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 font-medium"
|
? "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"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||||
@@ -346,7 +346,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isProcessing}
|
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" />
|
<CalendarCheck className="w-3.5 h-3.5" />
|
||||||
{t('add_to_calendar')}
|
{t('add_to_calendar')}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const getEmailColor = (keywords: Record<string, boolean> | undefined) => {
|
|||||||
|
|
||||||
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
export function EmailListItem({ email, selected, onClick, onContextMenu }: EmailListItemProps) {
|
||||||
const t = useTranslations('email_viewer');
|
const t = useTranslations('email_viewer');
|
||||||
const { selectedEmailIds, toggleEmailSelection, selectedMailbox } = useEmailStore();
|
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox } = useEmailStore();
|
||||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||||
const { identities } = useAuthStore();
|
const { identities } = useAuthStore();
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -88,7 +88,17 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
|
|||||||
// Drag state visual feedback
|
// Drag state visual feedback
|
||||||
isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30"
|
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}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email } from "@/lib/jmap/types";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { formatFileSize, cn } from "@/lib/utils";
|
import { formatFileSize, cn } from "@/lib/utils";
|
||||||
@@ -403,9 +403,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
|
useHtmlVersion = !!htmlContent;
|
||||||
// Use safe parsing instead of innerHTML to detect rich formatting
|
|
||||||
useHtmlVersion = hasRichFormatting(htmlContent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we should use HTML version and it exists
|
// If we should use HTML version and it exists
|
||||||
@@ -599,8 +597,8 @@ export function EmailViewer({
|
|||||||
|
|
||||||
{/* Loading Content Skeleton */}
|
{/* Loading Content Skeleton */}
|
||||||
<div className="flex-1 overflow-auto bg-muted/20">
|
<div className="flex-1 overflow-auto bg-muted/20">
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="px-6 pt-4 pb-6">
|
||||||
<div className="bg-background rounded-lg shadow-sm border border-border overflow-hidden p-6 space-y-3">
|
<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-full"></div>
|
||||||
<div className="h-4 bg-muted/60 rounded w-5/6"></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>
|
<div className="h-4 bg-muted/60 rounded w-4/6"></div>
|
||||||
@@ -1388,11 +1386,11 @@ export function EmailViewer({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div>
|
||||||
|
|
||||||
{/* Inline Attachments */}
|
{/* Inline Attachments */}
|
||||||
{email.attachments && email.attachments.length > 0 && (
|
{email.attachments && email.attachments.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4 px-6">
|
||||||
{/* Image attachments as thumbnails */}
|
{/* Image attachments as thumbnails */}
|
||||||
{email.attachments.filter(a =>
|
{email.attachments.filter(a =>
|
||||||
a.type?.startsWith('image/') ||
|
a.type?.startsWith('image/') ||
|
||||||
@@ -1474,36 +1472,34 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Body */}
|
{/* Email Body */}
|
||||||
<div className="bg-background rounded-lg shadow-sm border border-border overflow-x-auto">
|
<div className="email-content-wrapper overflow-x-auto">
|
||||||
<div className="email-content-wrapper p-6">
|
{emailContent.isHtml ? (
|
||||||
{emailContent.isHtml ? (
|
<div
|
||||||
<div
|
className="email-content prose dark:prose-invert max-w-none"
|
||||||
className="email-content prose dark:prose-invert max-w-none"
|
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
style={{
|
||||||
style={{
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
fontSize: '14px',
|
||||||
fontSize: '14px',
|
lineHeight: '1.6',
|
||||||
lineHeight: '1.6',
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<div
|
||||||
<div
|
className="email-content-text text-foreground"
|
||||||
className="email-content-text text-foreground"
|
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
||||||
dangerouslySetInnerHTML={{ __html: emailContent.html }}
|
style={{
|
||||||
style={{
|
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
||||||
fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace',
|
fontSize: '14px',
|
||||||
fontSize: '14px',
|
lineHeight: '1.6',
|
||||||
lineHeight: '1.6',
|
wordBreak: 'break-word',
|
||||||
wordBreak: 'break-word',
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Reply Section */}
|
{/* Quick Reply Section */}
|
||||||
<div className={cn(
|
<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"
|
isQuickReplyFocused || quickReplyText ? "border-primary" : "border-border"
|
||||||
)}>
|
)}>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
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 { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||||
import { useThemeStore } from "@/stores/theme-store";
|
import { useThemeStore } from "@/stores/theme-store";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
@@ -263,9 +263,7 @@ function EmailCard({
|
|||||||
|
|
||||||
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
if (email.htmlBody?.[0]?.partId && email.bodyValues[email.htmlBody[0].partId]) {
|
||||||
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
htmlContent = email.bodyValues[email.htmlBody[0].partId].value;
|
||||||
|
useHtmlVersion = !!htmlContent;
|
||||||
// Use safe parsing instead of innerHTML to detect rich formatting
|
|
||||||
useHtmlVersion = hasRichFormatting(htmlContent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useHtmlVersion && htmlContent) {
|
if (useHtmlVersion && htmlContent) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Email } from "@/lib/jmap/types";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Avatar } from "@/components/ui/avatar";
|
import { Avatar } from "@/components/ui/avatar";
|
||||||
import { Paperclip, Star, Circle } from "lucide-react";
|
import { Paperclip, Star, Circle } from "lucide-react";
|
||||||
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
|
||||||
interface ThreadEmailItemProps {
|
interface ThreadEmailItemProps {
|
||||||
email: Email;
|
email: Email;
|
||||||
@@ -24,24 +26,46 @@ export function ThreadEmailItem({
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
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) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
onContextMenu?.(e, email);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
{...dragHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative cursor-pointer transition-all duration-150",
|
"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",
|
"border-l-2 border-l-transparent",
|
||||||
selected
|
selected
|
||||||
? "bg-accent border-l-primary"
|
? "bg-accent border-l-primary"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
isUnread && !selected && "bg-accent/20",
|
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}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<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 { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare } from "lucide-react";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { getThreadColorTag } from "@/lib/thread-utils";
|
import { getThreadColorTag } from "@/lib/thread-utils";
|
||||||
|
import { useEmailDrag } from "@/hooks/use-email-drag";
|
||||||
import { ThreadEmailItem } from "./thread-email-item";
|
import { ThreadEmailItem } from "./thread-email-item";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -48,14 +50,34 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
const isUnread = !email.keywords?.$seen;
|
const isUnread = !email.keywords?.$seen;
|
||||||
const isStarred = email.keywords?.$flagged;
|
const isStarred = email.keywords?.$flagged;
|
||||||
const sender = email.from?.[0];
|
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) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
onContextMenu?.(e, email);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
{...dragHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
"relative group cursor-pointer transition-all duration-200 border-b border-border",
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
@@ -66,9 +88,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
|||||||
selected && !colorTag && "shadow-sm",
|
selected && !colorTag && "shadow-sm",
|
||||||
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !selected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
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}
|
onContextMenu={handleContextMenu}
|
||||||
style={{ minHeight: 'var(--list-item-height)' }}
|
style={{ minHeight: 'var(--list-item-height)' }}
|
||||||
>
|
>
|
||||||
@@ -164,12 +188,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, emailCount } = thread;
|
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 threadColor = getThreadColorTag(thread.emails);
|
||||||
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
const colorTag = threadColor ? colorTags[threadColor as keyof typeof colorTags] : null;
|
||||||
|
|
||||||
const isSelected = selectedEmailId === latestEmail.id ||
|
const isSelected = selectedEmailId === latestEmail.id ||
|
||||||
thread.emails.some(e => e.id === selectedEmailId);
|
thread.emails.some(e => e.id === selectedEmailId);
|
||||||
|
|
||||||
|
const isChecked = thread.emails.some(e => selectedEmailIds.has(e.id));
|
||||||
|
|
||||||
if (emailCount === 1) {
|
if (emailCount === 1) {
|
||||||
return (
|
return (
|
||||||
<SingleEmailItem
|
<SingleEmailItem
|
||||||
@@ -187,6 +221,18 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
const emailsToShow = expandedEmails || thread.emails;
|
const emailsToShow = expandedEmails || thread.emails;
|
||||||
|
|
||||||
const handleHeaderClick = (e: React.MouseEvent) => {
|
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) {
|
if (isMobile && onOpenConversation) {
|
||||||
onOpenConversation(thread);
|
onOpenConversation(thread);
|
||||||
return;
|
return;
|
||||||
@@ -208,8 +254,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
|
{...dragHandlers}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative group cursor-pointer transition-all duration-200",
|
"relative group cursor-pointer transition-all duration-200",
|
||||||
colorTag ? colorTag : (
|
colorTag ? colorTag : (
|
||||||
@@ -221,7 +268,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
!colorTag && !isSelected && "hover:bg-muted hover:shadow-sm",
|
||||||
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
colorTag && "hover:brightness-95 dark:hover:brightness-110",
|
||||||
hasUnread && !colorTag && !isSelected && "bg-accent/30",
|
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}
|
onClick={handleHeaderClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
|
|||||||
@@ -156,32 +156,32 @@ export function FilterRuleModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectClass =
|
const selectClass =
|
||||||
"px-2 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer";
|
"px-2.5 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={isEdit ? t("edit_rule") : t("new_rule")}
|
aria-label={isEdit ? t("edit_rule") : t("new_rule")}
|
||||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto"
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
{isEdit ? t("edit_rule") : t("new_rule")}
|
{isEdit ? t("edit_rule") : t("new_rule")}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t("cancel")}
|
aria-label={t("cancel")}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-6">
|
<div className="px-6 py-4 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium mb-1 block text-foreground">
|
<label className="text-sm font-medium mb-1 block text-foreground">
|
||||||
{t("rule_name")}
|
{t("rule_name")}
|
||||||
@@ -203,9 +203,9 @@ export function FilterRuleModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMatchType("all")}
|
onClick={() => setMatchType("all")}
|
||||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs rounded-md transition-colors duration-150 ${
|
||||||
matchType === "all"
|
matchType === "all"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground font-medium"
|
||||||
: "bg-muted hover:bg-accent text-foreground"
|
: "bg-muted hover:bg-accent text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -214,9 +214,9 @@ export function FilterRuleModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMatchType("any")}
|
onClick={() => setMatchType("any")}
|
||||||
className={`px-3 py-1.5 text-xs rounded transition-colors ${
|
className={`px-3 py-1.5 text-xs rounded-md transition-colors duration-150 ${
|
||||||
matchType === "any"
|
matchType === "any"
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground font-medium"
|
||||||
: "bg-muted hover:bg-accent text-foreground"
|
: "bg-muted hover:bg-accent text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -409,7 +409,7 @@ export function FilterRuleModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -73,26 +73,26 @@ export function SieveEditorModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} aria-hidden="true" />
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t("title")}
|
aria-label={t("title")}
|
||||||
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col"
|
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-4xl mx-4 max-h-[90vh] flex flex-col animate-in zoom-in-95 duration-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
<h2 className="text-lg font-semibold text-foreground">{t("title")}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-muted transition-colors"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t("cancel")}
|
aria-label={t("cancel")}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
|
<div className="px-6 py-4 flex-1 overflow-hidden flex flex-col space-y-4">
|
||||||
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
|
<div className="flex items-start gap-2 p-3 rounded-md bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-sm text-amber-700 dark:text-amber-400">
|
||||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
<p>{t("warning")}</p>
|
<p>{t("warning")}</p>
|
||||||
@@ -164,7 +164,7 @@ export function SieveEditorModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleValidate}
|
onClick={handleValidate}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -167,7 +167,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
|
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -48,7 +48,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t("common.close")}
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"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 { usePathname, Link } from "@/i18n/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, formatFileSize } from "@/lib/utils";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,12 +21,100 @@ interface NavigationRailProps {
|
|||||||
orientation?: "vertical" | "horizontal";
|
orientation?: "vertical" | "horizontal";
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
className?: string;
|
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({
|
export function NavigationRail({
|
||||||
orientation = "vertical",
|
orientation = "vertical",
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
className,
|
className,
|
||||||
|
quota,
|
||||||
|
isPushConnected,
|
||||||
|
onLogout,
|
||||||
}: NavigationRailProps) {
|
}: NavigationRailProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<nav
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col",
|
"flex flex-col h-full",
|
||||||
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
collapsed ? "items-center" : "",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="navigation"
|
|
||||||
aria-label={t("nav_label")}
|
|
||||||
>
|
>
|
||||||
{visibleItems.map((item) => {
|
<nav
|
||||||
const isActive = getIsActive(item.href);
|
className={cn(
|
||||||
const Icon = item.icon;
|
"flex flex-col",
|
||||||
return (
|
collapsed ? "items-center gap-1 py-3 px-1" : "gap-0.5 py-2 px-2",
|
||||||
<Link
|
)}
|
||||||
key={item.id}
|
role="navigation"
|
||||||
href={item.href}
|
aria-label={t("nav_label")}
|
||||||
className={cn(
|
>
|
||||||
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
{visibleItems.map((item) => {
|
||||||
collapsed
|
const isActive = getIsActive(item.href);
|
||||||
? "justify-center w-10 h-10"
|
const Icon = item.icon;
|
||||||
: "px-2.5 py-1.5 text-sm",
|
return (
|
||||||
"max-lg:min-h-[44px]",
|
<Link
|
||||||
isActive
|
key={item.id}
|
||||||
? "bg-primary/10 text-primary font-medium"
|
href={item.href}
|
||||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
className={cn(
|
||||||
)}
|
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
|
||||||
aria-current={isActive ? "page" : undefined}
|
collapsed
|
||||||
title={collapsed ? t(item.labelKey) : undefined}
|
? "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")} />
|
<span
|
||||||
{!collapsed && <span className="truncate">{t(item.labelKey)}</span>}
|
className={cn(
|
||||||
{item.badge != null && item.badge > 0 && (
|
"inline-block w-1.5 h-1.5 rounded-full transition-all duration-300",
|
||||||
<span className={cn(
|
isPushConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||||
"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"
|
/>
|
||||||
)}>
|
</span>
|
||||||
{item.badge > 99 ? "99+" : item.badge}
|
)}
|
||||||
</span>
|
|
||||||
)}
|
{onLogout && (
|
||||||
</Link>
|
<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"
|
||||||
</nav>
|
title={t("sign_out")}
|
||||||
|
>
|
||||||
|
<LogOut className="w-[18px] h-[18px]" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -15,7 +15,6 @@ import {
|
|||||||
PenSquare,
|
PenSquare,
|
||||||
Search,
|
Search,
|
||||||
Menu,
|
Menu,
|
||||||
LogOut,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Folder,
|
Folder,
|
||||||
@@ -27,11 +26,12 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} 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 { Mailbox } from "@/lib/jmap/types";
|
||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { activeFilterCount } from "@/lib/jmap/search-utils";
|
import { activeFilterCount } from "@/lib/jmap/search-utils";
|
||||||
import { useVacationStore } from "@/stores/vacation-store";
|
import { useVacationStore } from "@/stores/vacation-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -42,13 +42,10 @@ interface SidebarProps {
|
|||||||
selectedMailbox?: string;
|
selectedMailbox?: string;
|
||||||
onMailboxSelect?: (mailboxId: string) => void;
|
onMailboxSelect?: (mailboxId: string) => void;
|
||||||
onCompose?: () => void;
|
onCompose?: () => void;
|
||||||
onLogout?: () => void;
|
|
||||||
onSidebarClose?: () => void;
|
onSidebarClose?: () => void;
|
||||||
onSearch?: (query: string) => void;
|
onSearch?: (query: string) => void;
|
||||||
onClearSearch?: () => void;
|
onClearSearch?: () => void;
|
||||||
activeSearchQuery?: string;
|
activeSearchQuery?: string;
|
||||||
quota?: { used: number; total: number } | null;
|
|
||||||
isPushConnected?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,16 +181,21 @@ function MailboxTreeItem({
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 truncate">{node.name}</span>
|
<span className="flex-1 truncate">{node.name}</span>
|
||||||
{node.unreadEmails > 0 && (
|
<span className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
||||||
<span className={cn(
|
{node.unreadEmails > 0 && (
|
||||||
"text-xs rounded-full px-2 py-0.5 ml-2 font-medium",
|
<span className={cn(
|
||||||
selectedMailbox === node.id
|
"text-xs rounded-full px-2 py-0.5 font-medium",
|
||||||
? "bg-primary text-primary-foreground"
|
selectedMailbox === node.id
|
||||||
: "bg-foreground text-background"
|
? "bg-primary text-primary-foreground"
|
||||||
)}>
|
: "bg-foreground text-background"
|
||||||
{node.unreadEmails}
|
)}>
|
||||||
|
{node.unreadEmails}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
|
{node.totalEmails}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</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({
|
export function Sidebar({
|
||||||
mailboxes = [],
|
mailboxes = [],
|
||||||
selectedMailbox = "",
|
selectedMailbox = "",
|
||||||
onMailboxSelect,
|
onMailboxSelect,
|
||||||
onCompose,
|
onCompose,
|
||||||
onLogout,
|
|
||||||
onSidebarClose,
|
onSidebarClose,
|
||||||
onSearch,
|
onSearch,
|
||||||
onClearSearch,
|
onClearSearch,
|
||||||
activeSearchQuery = "",
|
activeSearchQuery = "",
|
||||||
quota,
|
|
||||||
isPushConnected = false,
|
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||||
const t = useTranslations('sidebar');
|
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",
|
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
|
||||||
"bg-secondary border-border",
|
"bg-secondary border-border",
|
||||||
"max-lg:w-full",
|
"max-lg:w-full",
|
||||||
isCollapsed ? "lg:w-16" : "lg:w-64",
|
isCollapsed ? "lg:w-16" : "lg:w-full",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -426,7 +388,7 @@ export function Sidebar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={toggleSidebarCollapsed}
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
>
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-5 h-5" />
|
||||||
@@ -501,51 +463,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: Storage Quota + Sign Out + Push Status */}
|
{/* Footer removed - storage quota and sign out moved to NavigationRail */}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
interface SettingsSectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -54,17 +55,17 @@ export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps)
|
|||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`
|
className={cn(
|
||||||
relative inline-flex h-6 w-11 items-center rounded-full transition-colors
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-150',
|
||||||
${checked ? 'bg-primary' : 'bg-muted'}
|
checked ? 'bg-primary' : 'bg-muted',
|
||||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||||
`}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`
|
className={cn(
|
||||||
inline-block h-4 w-4 transform rounded-full bg-background transition-transform
|
'inline-block h-4 w-4 transform rounded-full bg-background transition-transform duration-150',
|
||||||
${checked ? 'translate-x-6' : 'translate-x-1'}
|
checked ? 'translate-x-6' : 'translate-x-1'
|
||||||
`}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -78,20 +79,18 @@ interface RadioGroupProps {
|
|||||||
|
|
||||||
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
|
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-1.5">
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
className={`
|
className={cn(
|
||||||
px-3 py-1.5 text-xs rounded transition-colors
|
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
|
||||||
${
|
value === option.value
|
||||||
value === option.value
|
? 'bg-primary text-primary-foreground font-medium'
|
||||||
? 'bg-primary text-primary-foreground'
|
: 'bg-muted hover:bg-accent text-foreground'
|
||||||
: 'bg-muted hover:bg-accent text-foreground'
|
)}
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -112,7 +111,7 @@ export function Select({ value, onChange, options }: SelectProps) {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
dir="auto"
|
dir="auto"
|
||||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary cursor-pointer"
|
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer hover:border-muted-foreground"
|
||||||
>
|
>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function VacationSettings() {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
|
value={localFromDate ? utcToLocalDatetime(localFromDate) : ''}
|
||||||
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
onChange={(e) => setLocalFromDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
@@ -180,7 +180,7 @@ export function VacationSettings() {
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
|
value={localToDate ? utcToLocalDatetime(localToDate) : ''}
|
||||||
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
onChange={(e) => setLocalToDate(e.target.value ? new Date(e.target.value).toISOString() : '')}
|
||||||
className="px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
className="px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -195,7 +195,7 @@ export function VacationSettings() {
|
|||||||
value={localSubject}
|
value={localSubject}
|
||||||
onChange={(e) => setLocalSubject(e.target.value)}
|
onChange={(e) => setLocalSubject(e.target.value)}
|
||||||
placeholder={t('message.subject_placeholder')}
|
placeholder={t('message.subject_placeholder')}
|
||||||
className="w-64 px-3 py-1.5 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-64 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground"
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
<div className="py-3">
|
<div className="py-3">
|
||||||
@@ -211,7 +211,7 @@ export function VacationSettings() {
|
|||||||
onChange={(e) => setLocalTextBody(e.target.value)}
|
onChange={(e) => setLocalTextBody(e.target.value)}
|
||||||
placeholder={t('message.body_placeholder')}
|
placeholder={t('message.body_placeholder')}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full px-3 py-2 text-sm rounded bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary resize-y"
|
className="w-full px-3 py-2 text-sm rounded-md bg-muted border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 hover:border-muted-foreground resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function PlaceholderFillModal({
|
|||||||
}, [template.body, values]);
|
}, [template.body, values]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -66,7 +66,7 @@ export function PlaceholderFillModal({
|
|||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -89,7 +89,7 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -146,7 +146,7 @@ export function TrustedSendersModal({ isOpen, onClose }: TrustedSendersModalProp
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-label={t("close")}
|
aria-label={t("close")}
|
||||||
className="p-2 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function ConfirmDialog({
|
|||||||
const resolvedCancelText = cancelText || t("cancel");
|
const resolvedCancelText = cancelText || t("cancel");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
|
||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
role="alertdialog"
|
role="alertdialog"
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export function ContextMenuItem({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
|
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2",
|
||||||
"transition-colors duration-100",
|
"transition-colors duration-150",
|
||||||
"focus:outline-none focus:bg-muted",
|
"focus:outline-none focus:bg-muted",
|
||||||
disabled && "opacity-50 cursor-not-allowed",
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
!disabled && "hover:bg-muted cursor-pointer",
|
!disabled && "hover:bg-muted cursor-pointer",
|
||||||
@@ -157,8 +157,8 @@ export function ContextMenuSubMenu({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-2 text-sm flex items-center gap-2",
|
"w-full px-3 py-1.5 text-sm flex items-center gap-2",
|
||||||
"transition-colors duration-100 cursor-pointer",
|
"transition-colors duration-150 cursor-pointer",
|
||||||
"hover:bg-muted"
|
"hover:bg-muted"
|
||||||
)}
|
)}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ export function WelcomeBanner() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={dismiss}
|
onClick={dismiss}
|
||||||
className="flex-shrink-0 p-1 rounded hover:bg-muted transition-colors"
|
className="flex-shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||||
aria-label={t("dismiss")}
|
aria-label={t("dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-muted-foreground" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useDragDropContext } from "@/contexts/drag-drop-context";
|
|||||||
interface UseEmailDragOptions {
|
interface UseEmailDragOptions {
|
||||||
email: Email;
|
email: Email;
|
||||||
sourceMailboxId: string;
|
sourceMailboxId: string;
|
||||||
|
threadEmails?: Email[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseEmailDragReturn {
|
interface UseEmailDragReturn {
|
||||||
@@ -42,18 +43,19 @@ function createDragPreview(count: number): HTMLElement {
|
|||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): UseEmailDragReturn {
|
export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailDragOptions): UseEmailDragReturn {
|
||||||
const { selectedEmailIds, emails } = useEmailStore();
|
const { selectedEmailIds, emails } = useEmailStore();
|
||||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
// Determine which emails to drag:
|
// Determine which emails to drag:
|
||||||
// - If current email is selected, drag all selected
|
// - If current email is selected, drag all selected
|
||||||
|
// - If threadEmails provided (thread header), drag all thread emails
|
||||||
// - Otherwise, drag only this email
|
// - Otherwise, drag only this email
|
||||||
const isSelected = selectedEmailIds.has(email.id);
|
const isSelected = selectedEmailIds.has(email.id);
|
||||||
const emailsToDrag = isSelected
|
const emailsToDrag = isSelected
|
||||||
? emails.filter(em => selectedEmailIds.has(em.id))
|
? emails.filter(em => selectedEmailIds.has(em.id))
|
||||||
: [email];
|
: threadEmails || [email];
|
||||||
|
|
||||||
// Set data transfer
|
// Set data transfer
|
||||||
e.dataTransfer.effectAllowed = "move";
|
e.dataTransfer.effectAllowed = "move";
|
||||||
@@ -76,7 +78,7 @@ export function useEmailDrag({ email, sourceMailboxId }: UseEmailDragOptions): U
|
|||||||
});
|
});
|
||||||
|
|
||||||
startDrag(emailsToDrag, sourceMailboxId);
|
startDrag(emailsToDrag, sourceMailboxId);
|
||||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag]);
|
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails]);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
endDrag();
|
endDrag();
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, it, expect, beforeAll, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the environment variable
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubEnv('DEV_MOCK_JMAP', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// We test via dynamic import to get a fresh module with env set
|
||||||
|
async function loadRoute() {
|
||||||
|
// Clear module cache to reset mock email state between test suites
|
||||||
|
const mod = await import('../../app/api/dev-jmap/[...path]/route');
|
||||||
|
return mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(url: string, options?: globalThis.RequestInit): Request {
|
||||||
|
return new Request(url, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dev-jmap mock server', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let GET: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let POST: (request: any, ctx: { params: Promise<{ path: string[] }> }) => Promise<Response>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mod = await loadRoute();
|
||||||
|
GET = mod.GET as typeof GET;
|
||||||
|
POST = mod.POST as typeof POST;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /.well-known/jmap', () => {
|
||||||
|
it('should return session object with capabilities', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/.well-known/jmap', {
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
});
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ path: ['.well-known', 'jmap'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(data.capabilities).toBeDefined();
|
||||||
|
expect(data.capabilities['urn:ietf:params:jmap:core']).toBeDefined();
|
||||||
|
expect(data.accounts).toBeDefined();
|
||||||
|
expect(data.apiUrl).toContain('/api/dev-jmap/api');
|
||||||
|
expect(data.downloadUrl).toContain('/download/');
|
||||||
|
expect(data.eventSourceUrl).toContain('/eventsource');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /download', () => {
|
||||||
|
it('should return a response with attachment disposition', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/download/dev-account-001/blob-att-001/Q1-Report.pdf?accept=application/pdf', {
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
});
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ path: ['download', 'dev-account-001', 'blob-att-001', 'Q1-Report.pdf'] }) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('Content-Type')).toBe('application/pdf');
|
||||||
|
expect(res.headers.get('Content-Disposition')).toContain('Q1-Report.pdf');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /eventsource', () => {
|
||||||
|
it('should return text/event-stream content type', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/eventsource?types=*&ping=30', {
|
||||||
|
headers: { host: 'localhost:3000' },
|
||||||
|
});
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ path: ['eventsource'] }) });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('Content-Type')).toBe('text/event-stream');
|
||||||
|
expect(res.headers.get('Cache-Control')).toBe('no-cache');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — Mailbox/get', () => {
|
||||||
|
it('should return list of mailboxes', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Mailbox/get', { accountId: 'dev-account-001' }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses).toBeDefined();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('Mailbox/get');
|
||||||
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
||||||
|
const inbox = data.methodResponses[0][1].list.find((m: { role: string }) => m.role === 'inbox');
|
||||||
|
expect(inbox).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — Email/query', () => {
|
||||||
|
it('should filter by mailbox', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' } }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('Email/query');
|
||||||
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by text search', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Email/query', { accountId: 'dev-account-001', filter: { text: 'welcome' } }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][1].ids.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — Email/get', () => {
|
||||||
|
it('should return emails by ids', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'] }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('Email/get');
|
||||||
|
expect(data.methodResponses[0][1].list).toHaveLength(1);
|
||||||
|
expect(data.methodResponses[0][1].list[0].subject).toContain('Welcome');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter properties when specified', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Email/get', { accountId: 'dev-account-001', ids: ['email-001'], properties: ['id', 'subject'] }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
const email = data.methodResponses[0][1].list[0];
|
||||||
|
expect(email.id).toBe('email-001');
|
||||||
|
expect(email.subject).toBeDefined();
|
||||||
|
expect(email.bodyValues).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — Email/set', () => {
|
||||||
|
it('should update email keywords', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Email/set', { accountId: 'dev-account-001', update: { 'email-001': { 'keywords/$seen': true } } }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('Email/set');
|
||||||
|
expect(data.methodResponses[0][1].updated['email-001']).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — Identity/get', () => {
|
||||||
|
it('should return identities', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['Identity/get', { accountId: 'dev-account-001' }, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('Identity/get');
|
||||||
|
expect(data.methodResponses[0][1].list.length).toBeGreaterThan(0);
|
||||||
|
expect(data.methodResponses[0][1].list[0].email).toBe('dev@localhost');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — unknown method', () => {
|
||||||
|
it('should return error for unknown methods', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [['FakeMethod/get', {}, 'c0']],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses[0][0]).toBe('error');
|
||||||
|
expect(data.methodResponses[0][1].type).toBe('unknownMethod');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — back-references', () => {
|
||||||
|
it('should resolve #ids from Email/query result', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
methodCalls: [
|
||||||
|
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 2 }, 'q0'],
|
||||||
|
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: 'q0', name: 'Email/query', path: 'ids' }, properties: ['id', 'subject'] }, 'g0'],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.methodResponses).toHaveLength(2);
|
||||||
|
expect(data.methodResponses[1][0]).toBe('Email/get');
|
||||||
|
// The get should have resolved ids from the query
|
||||||
|
expect(data.methodResponses[1][1].list.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api — invalid request', () => {
|
||||||
|
it('should return 400 for missing methodCalls', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /upload', () => {
|
||||||
|
it('should return a fake blob response', async () => {
|
||||||
|
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'image/png', 'Content-Length': '1024', host: 'localhost:3000' },
|
||||||
|
body: 'fake-data',
|
||||||
|
});
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['upload', 'dev-account-001'] }) });
|
||||||
|
const data = await res.json();
|
||||||
|
expect(data.accountId).toBe('dev-account-001');
|
||||||
|
expect(data.blobId).toBeDefined();
|
||||||
|
expect(data.type).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -48,6 +48,9 @@
|
|||||||
"compose": "Verfassen",
|
"compose": "Verfassen",
|
||||||
"search_placeholder": "E-Mails durchsuchen...",
|
"search_placeholder": "E-Mails durchsuchen...",
|
||||||
"storage": "Speicher",
|
"storage": "Speicher",
|
||||||
|
"storage_used": "Belegt",
|
||||||
|
"storage_free": "Frei",
|
||||||
|
"storage_total": "Gesamt",
|
||||||
"sign_out": "Abmelden",
|
"sign_out": "Abmelden",
|
||||||
"contacts": "Kontakte",
|
"contacts": "Kontakte",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"search_placeholder": "Search mail...",
|
"search_placeholder": "Search mail...",
|
||||||
"search_placeholder_hint": "Search mail... (press /)",
|
"search_placeholder_hint": "Search mail... (press /)",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
|
"storage_used": "Used",
|
||||||
|
"storage_free": "Free",
|
||||||
|
"storage_total": "Total",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"compose": "Redactar",
|
"compose": "Redactar",
|
||||||
"search_placeholder": "Buscar correo...",
|
"search_placeholder": "Buscar correo...",
|
||||||
"storage": "Almacenamiento",
|
"storage": "Almacenamiento",
|
||||||
|
"storage_used": "Usado",
|
||||||
|
"storage_free": "Libre",
|
||||||
|
"storage_total": "Total",
|
||||||
"sign_out": "Cerrar sesión",
|
"sign_out": "Cerrar sesión",
|
||||||
"contacts": "Contactos",
|
"contacts": "Contactos",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"search_placeholder": "Rechercher un email...",
|
"search_placeholder": "Rechercher un email...",
|
||||||
"search_placeholder_hint": "Rechercher... (appuyez sur /)",
|
"search_placeholder_hint": "Rechercher... (appuyez sur /)",
|
||||||
"storage": "Stockage",
|
"storage": "Stockage",
|
||||||
|
"storage_used": "Utilisé",
|
||||||
|
"storage_free": "Libre",
|
||||||
|
"storage_total": "Total",
|
||||||
"sign_out": "Se déconnecter",
|
"sign_out": "Se déconnecter",
|
||||||
"contacts": "Contacts",
|
"contacts": "Contacts",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"compose": "Scrivi",
|
"compose": "Scrivi",
|
||||||
"search_placeholder": "Cerca nella posta...",
|
"search_placeholder": "Cerca nella posta...",
|
||||||
"storage": "Spazio di archiviazione",
|
"storage": "Spazio di archiviazione",
|
||||||
|
"storage_used": "Utilizzato",
|
||||||
|
"storage_free": "Libero",
|
||||||
|
"storage_total": "Totale",
|
||||||
"sign_out": "Esci",
|
"sign_out": "Esci",
|
||||||
"contacts": "Contatti",
|
"contacts": "Contatti",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"search_placeholder": "メールを検索...",
|
"search_placeholder": "メールを検索...",
|
||||||
"search_placeholder_hint": "メールを検索... (/ を押す)",
|
"search_placeholder_hint": "メールを検索... (/ を押す)",
|
||||||
"storage": "ストレージ",
|
"storage": "ストレージ",
|
||||||
|
"storage_used": "使用中",
|
||||||
|
"storage_free": "空き",
|
||||||
|
"storage_total": "合計",
|
||||||
"sign_out": "サインアウト",
|
"sign_out": "サインアウト",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"compose": "Nieuw bericht",
|
"compose": "Nieuw bericht",
|
||||||
"search_placeholder": "Zoeken in e-mail...",
|
"search_placeholder": "Zoeken in e-mail...",
|
||||||
"storage": "Opslag",
|
"storage": "Opslag",
|
||||||
|
"storage_used": "Gebruikt",
|
||||||
|
"storage_free": "Vrij",
|
||||||
|
"storage_total": "Totaal",
|
||||||
"sign_out": "Afmelden",
|
"sign_out": "Afmelden",
|
||||||
"contacts": "Contacten",
|
"contacts": "Contacten",
|
||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"compose": "Escrever",
|
"compose": "Escrever",
|
||||||
"search_placeholder": "Buscar e-mails...",
|
"search_placeholder": "Buscar e-mails...",
|
||||||
"storage": "Armazenamento",
|
"storage": "Armazenamento",
|
||||||
|
"storage_used": "Usado",
|
||||||
|
"storage_free": "Livre",
|
||||||
|
"storage_total": "Total",
|
||||||
"sign_out": "Sair",
|
"sign_out": "Sair",
|
||||||
"contacts": "Contatos",
|
"contacts": "Contatos",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
|
|||||||
Generated
+13
-24
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "jmap-webmail",
|
"name": "jmap-webmail",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jmap-webmail",
|
"name": "jmap-webmail",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
@@ -160,7 +160,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -543,7 +542,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@@ -584,7 +582,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@@ -2364,7 +2361,6 @@
|
|||||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.58.2"
|
"playwright": "1.58.2"
|
||||||
},
|
},
|
||||||
@@ -3240,7 +3236,6 @@
|
|||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@@ -3411,7 +3406,6 @@
|
|||||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
@@ -3422,7 +3416,6 @@
|
|||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -3433,7 +3426,6 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -3480,7 +3472,6 @@
|
|||||||
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.56.1",
|
"@typescript-eslint/scope-manager": "8.56.1",
|
||||||
"@typescript-eslint/types": "8.56.1",
|
"@typescript-eslint/types": "8.56.1",
|
||||||
@@ -3803,7 +3794,6 @@
|
|||||||
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.0.18",
|
"@vitest/utils": "4.0.18",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
@@ -3840,7 +3830,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4174,7 +4163,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -4911,7 +4899,6 @@
|
|||||||
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -6254,7 +6241,6 @@
|
|||||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@acemir/cssom": "^0.9.31",
|
||||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||||
@@ -6936,6 +6922,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||||
|
"version": "0.5.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||||
|
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -7408,7 +7405,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -7418,7 +7414,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -8184,7 +8179,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -8374,7 +8368,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -8487,7 +8480,6 @@
|
|||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -8596,7 +8588,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -8610,7 +8601,6 @@
|
|||||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.0.18",
|
"@vitest/expect": "4.0.18",
|
||||||
"@vitest/mocker": "4.0.18",
|
"@vitest/mocker": "4.0.18",
|
||||||
@@ -8919,7 +8909,6 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { useEmailStore } from '../email-store';
|
||||||
|
|
||||||
|
function makeEmail(id: string, threadId = `thread-${id}`) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
threadId,
|
||||||
|
mailboxIds: { inbox: true },
|
||||||
|
keywords: {},
|
||||||
|
size: 100,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Test', email: 'test@example.com' }],
|
||||||
|
to: [{ name: 'User', email: 'user@example.com' }],
|
||||||
|
subject: `Email ${id}`,
|
||||||
|
preview: 'preview',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [],
|
||||||
|
htmlBody: [],
|
||||||
|
bodyValues: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('email-store selection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [makeEmail('a'), makeEmail('b'), makeEmail('c'), makeEmail('d'), makeEmail('e')],
|
||||||
|
selectedEmailIds: new Set(),
|
||||||
|
lastSelectedEmailId: null,
|
||||||
|
selectedEmail: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toggleEmailSelection', () => {
|
||||||
|
it('should add email to selection', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b');
|
||||||
|
expect(useEmailStore.getState().selectedEmailIds.has('b')).toBe(true);
|
||||||
|
expect(useEmailStore.getState().lastSelectedEmailId).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove email from selection when toggled again', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b');
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b');
|
||||||
|
expect(useEmailStore.getState().selectedEmailIds.has('b')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support selecting multiple emails', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('a');
|
||||||
|
useEmailStore.getState().toggleEmailSelection('c');
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(ids.has('a')).toBe(true);
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectRangeEmails', () => {
|
||||||
|
it('should select range from last selected to target (forward)', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b'); // anchor at index 1
|
||||||
|
useEmailStore.getState().selectRangeEmails('d'); // target at index 3
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(ids.has('b')).toBe(true);
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.has('d')).toBe(true);
|
||||||
|
expect(ids.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should select range backward', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('d'); // anchor at index 3
|
||||||
|
useEmailStore.getState().selectRangeEmails('b'); // target at index 1
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(ids.has('b')).toBe(true);
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.has('d')).toBe(true);
|
||||||
|
expect(ids.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use first email as anchor when no previous selection', () => {
|
||||||
|
useEmailStore.getState().selectRangeEmails('c'); // no anchor → uses first email 'a'
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(ids.has('a')).toBe(true);
|
||||||
|
expect(ids.has('b')).toBe(true);
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add to existing selection', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('a');
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b'); // anchor now at 'b'
|
||||||
|
useEmailStore.getState().selectRangeEmails('d');
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
// 'a' still selected, plus b-d range
|
||||||
|
expect(ids.has('a')).toBe(true);
|
||||||
|
expect(ids.has('b')).toBe(true);
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.has('d')).toBe(true);
|
||||||
|
expect(ids.size).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-item range', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('c');
|
||||||
|
useEmailStore.getState().selectRangeEmails('c');
|
||||||
|
const ids = useEmailStore.getState().selectedEmailIds;
|
||||||
|
expect(ids.has('c')).toBe(true);
|
||||||
|
expect(ids.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectAllEmails', () => {
|
||||||
|
it('should select all emails', () => {
|
||||||
|
useEmailStore.getState().selectAllEmails();
|
||||||
|
expect(useEmailStore.getState().selectedEmailIds.size).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearSelection', () => {
|
||||||
|
it('should clear all selections and reset anchor', () => {
|
||||||
|
useEmailStore.getState().toggleEmailSelection('a');
|
||||||
|
useEmailStore.getState().toggleEmailSelection('b');
|
||||||
|
useEmailStore.getState().clearSelection();
|
||||||
|
expect(useEmailStore.getState().selectedEmailIds.size).toBe(0);
|
||||||
|
expect(useEmailStore.getState().lastSelectedEmailId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { useUIStore } from '../ui-store';
|
||||||
|
|
||||||
|
describe('ui-store', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useUIStore.setState({
|
||||||
|
activeView: 'list',
|
||||||
|
sidebarOpen: false,
|
||||||
|
tabletListVisible: true,
|
||||||
|
isMobile: false,
|
||||||
|
isTablet: false,
|
||||||
|
isDesktop: true,
|
||||||
|
sidebarWidth: 256,
|
||||||
|
emailListWidth: 384,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
});
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setSidebarWidth', () => {
|
||||||
|
it('should clamp to minimum', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(50);
|
||||||
|
expect(useUIStore.getState().sidebarWidth).toBe(180);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp to maximum', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(999);
|
||||||
|
expect(useUIStore.getState().sidebarWidth).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept values within range', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(300);
|
||||||
|
expect(useUIStore.getState().sidebarWidth).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setEmailListWidth', () => {
|
||||||
|
it('should clamp to minimum', () => {
|
||||||
|
useUIStore.getState().setEmailListWidth(100);
|
||||||
|
expect(useUIStore.getState().emailListWidth).toBe(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clamp to maximum', () => {
|
||||||
|
useUIStore.getState().setEmailListWidth(1000);
|
||||||
|
expect(useUIStore.getState().emailListWidth).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept values within range', () => {
|
||||||
|
useUIStore.getState().setEmailListWidth(450);
|
||||||
|
expect(useUIStore.getState().emailListWidth).toBe(450);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetSidebarWidth', () => {
|
||||||
|
it('should reset to default (256)', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(350);
|
||||||
|
useUIStore.getState().resetSidebarWidth();
|
||||||
|
expect(useUIStore.getState().sidebarWidth).toBe(256);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist to localStorage', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(350);
|
||||||
|
useUIStore.getState().setEmailListWidth(500);
|
||||||
|
useUIStore.getState().resetSidebarWidth();
|
||||||
|
const stored = JSON.parse(localStorage.getItem('column-widths')!);
|
||||||
|
expect(stored.sidebarWidth).toBe(256);
|
||||||
|
expect(stored.emailListWidth).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetEmailListWidth', () => {
|
||||||
|
it('should reset to default (384)', () => {
|
||||||
|
useUIStore.getState().setEmailListWidth(550);
|
||||||
|
useUIStore.getState().resetEmailListWidth();
|
||||||
|
expect(useUIStore.getState().emailListWidth).toBe(384);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist to localStorage', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(300);
|
||||||
|
useUIStore.getState().setEmailListWidth(550);
|
||||||
|
useUIStore.getState().resetEmailListWidth();
|
||||||
|
const stored = JSON.parse(localStorage.getItem('column-widths')!);
|
||||||
|
expect(stored.sidebarWidth).toBe(300);
|
||||||
|
expect(stored.emailListWidth).toBe(384);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('persistColumnWidths', () => {
|
||||||
|
it('should save current widths to localStorage', () => {
|
||||||
|
useUIStore.getState().setSidebarWidth(280);
|
||||||
|
useUIStore.getState().setEmailListWidth(420);
|
||||||
|
useUIStore.getState().persistColumnWidths();
|
||||||
|
const stored = JSON.parse(localStorage.getItem('column-widths')!);
|
||||||
|
expect(stored.sidebarWidth).toBe(280);
|
||||||
|
expect(stored.emailListWidth).toBe(420);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sidebarCollapsed', () => {
|
||||||
|
it('should toggle collapsed state', () => {
|
||||||
|
expect(useUIStore.getState().sidebarCollapsed).toBe(false);
|
||||||
|
useUIStore.getState().toggleSidebarCollapsed();
|
||||||
|
expect(useUIStore.getState().sidebarCollapsed).toBe(true);
|
||||||
|
useUIStore.getState().toggleSidebarCollapsed();
|
||||||
|
expect(useUIStore.getState().sidebarCollapsed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set collapsed directly', () => {
|
||||||
|
useUIStore.getState().setSidebarCollapsed(true);
|
||||||
|
expect(useUIStore.getState().sidebarCollapsed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+21
-2
@@ -44,6 +44,8 @@ interface EmailStore {
|
|||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
setQuota: (quota: { used: number; total: number } | null) => void;
|
setQuota: (quota: { used: number; total: number } | null) => void;
|
||||||
toggleEmailSelection: (emailId: string) => void;
|
toggleEmailSelection: (emailId: string) => void;
|
||||||
|
selectRangeEmails: (targetEmailId: string) => void;
|
||||||
|
lastSelectedEmailId: string | null;
|
||||||
selectAllEmails: () => void;
|
selectAllEmails: () => void;
|
||||||
clearSelection: () => void;
|
clearSelection: () => void;
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
quota: null,
|
quota: null,
|
||||||
processingReadStatus: new Set(),
|
processingReadStatus: new Set(),
|
||||||
selectedEmailIds: new Set(),
|
selectedEmailIds: new Set(),
|
||||||
|
lastSelectedEmailId: null,
|
||||||
hasMoreEmails: false,
|
hasMoreEmails: false,
|
||||||
totalEmails: 0,
|
totalEmails: 0,
|
||||||
isPushConnected: false,
|
isPushConnected: false,
|
||||||
@@ -127,7 +130,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
setEmails: (emails) => set({ emails }),
|
setEmails: (emails) => set({ emails }),
|
||||||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||||||
selectEmail: (email) => set({ selectedEmail: email }),
|
selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }),
|
||||||
selectMailbox: (mailboxId) => set({
|
selectMailbox: (mailboxId) => set({
|
||||||
selectedMailbox: mailboxId,
|
selectedMailbox: mailboxId,
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
@@ -150,6 +153,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
} else {
|
} else {
|
||||||
newSelection.add(emailId);
|
newSelection.add(emailId);
|
||||||
}
|
}
|
||||||
|
set({ selectedEmailIds: newSelection, lastSelectedEmailId: emailId });
|
||||||
|
},
|
||||||
|
|
||||||
|
selectRangeEmails: (targetEmailId) => {
|
||||||
|
const { emails, lastSelectedEmailId, selectedEmailIds } = get();
|
||||||
|
const anchorId = lastSelectedEmailId || emails[0]?.id;
|
||||||
|
if (!anchorId) return;
|
||||||
|
const anchorIndex = emails.findIndex(e => e.id === anchorId);
|
||||||
|
const targetIndex = emails.findIndex(e => e.id === targetEmailId);
|
||||||
|
if (anchorIndex === -1 || targetIndex === -1) return;
|
||||||
|
const start = Math.min(anchorIndex, targetIndex);
|
||||||
|
const end = Math.max(anchorIndex, targetIndex);
|
||||||
|
const newSelection = new Set(selectedEmailIds);
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
newSelection.add(emails[i].id);
|
||||||
|
}
|
||||||
set({ selectedEmailIds: newSelection });
|
set({ selectedEmailIds: newSelection });
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -160,7 +179,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
clearSelection: () => {
|
clearSelection: () => {
|
||||||
set({ selectedEmailIds: new Set() });
|
set({ selectedEmailIds: new Set(), lastSelectedEmailId: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
// JMAP operations
|
// JMAP operations
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
export type ActiveView = "sidebar" | "list" | "viewer";
|
export type ActiveView = "sidebar" | "list" | "viewer";
|
||||||
|
|
||||||
|
// Column width constraints (in pixels)
|
||||||
|
const SIDEBAR_MIN = 180;
|
||||||
|
const SIDEBAR_MAX = 400;
|
||||||
|
const SIDEBAR_DEFAULT = 256;
|
||||||
|
const EMAIL_LIST_MIN = 240;
|
||||||
|
const EMAIL_LIST_MAX = 600;
|
||||||
|
const EMAIL_LIST_DEFAULT = 384;
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
// Mobile view state
|
// Mobile view state
|
||||||
activeView: ActiveView;
|
activeView: ActiveView;
|
||||||
@@ -17,12 +25,26 @@ interface UIState {
|
|||||||
isTablet: boolean;
|
isTablet: boolean;
|
||||||
isDesktop: boolean;
|
isDesktop: boolean;
|
||||||
|
|
||||||
|
// Resizable column widths (desktop only)
|
||||||
|
sidebarWidth: number;
|
||||||
|
emailListWidth: number;
|
||||||
|
|
||||||
|
// Sidebar collapsed state (desktop)
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveView: (view: ActiveView) => void;
|
setActiveView: (view: ActiveView) => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setTabletListVisible: (visible: boolean) => void;
|
setTabletListVisible: (visible: boolean) => void;
|
||||||
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
|
||||||
|
setSidebarWidth: (width: number) => void;
|
||||||
|
setEmailListWidth: (width: number) => void;
|
||||||
|
resetSidebarWidth: () => void;
|
||||||
|
resetEmailListWidth: () => void;
|
||||||
|
persistColumnWidths: () => void;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
toggleSidebarCollapsed: () => void;
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
showEmailList: () => void;
|
showEmailList: () => void;
|
||||||
@@ -30,6 +52,8 @@ interface UIState {
|
|||||||
goBack: () => void;
|
goBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Column widths are hydrated from localStorage by the page component on mount
|
||||||
|
|
||||||
export const useUIStore = create<UIState>((set, get) => ({
|
export const useUIStore = create<UIState>((set, get) => ({
|
||||||
// Initial state (SSR-safe defaults)
|
// Initial state (SSR-safe defaults)
|
||||||
activeView: "list",
|
activeView: "list",
|
||||||
@@ -38,6 +62,9 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
isMobile: false,
|
isMobile: false,
|
||||||
isTablet: false,
|
isTablet: false,
|
||||||
isDesktop: true,
|
isDesktop: true,
|
||||||
|
sidebarWidth: SIDEBAR_DEFAULT,
|
||||||
|
emailListWidth: EMAIL_LIST_DEFAULT,
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveView: (view) => set({ activeView: view }),
|
setActiveView: (view) => set({ activeView: view }),
|
||||||
@@ -51,6 +78,38 @@ export const useUIStore = create<UIState>((set, get) => ({
|
|||||||
setDeviceType: (isMobile, isTablet, isDesktop) =>
|
setDeviceType: (isMobile, isTablet, isDesktop) =>
|
||||||
set({ isMobile, isTablet, isDesktop }),
|
set({ isMobile, isTablet, isDesktop }),
|
||||||
|
|
||||||
|
setSidebarWidth: (width) =>
|
||||||
|
set({ sidebarWidth: Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, width)) }),
|
||||||
|
|
||||||
|
setEmailListWidth: (width) =>
|
||||||
|
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
|
||||||
|
|
||||||
|
resetSidebarWidth: () => {
|
||||||
|
set({ sidebarWidth: SIDEBAR_DEFAULT });
|
||||||
|
const { emailListWidth } = get();
|
||||||
|
try {
|
||||||
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth }));
|
||||||
|
} catch { /* localStorage may be unavailable */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
resetEmailListWidth: () => {
|
||||||
|
set({ emailListWidth: EMAIL_LIST_DEFAULT });
|
||||||
|
const { sidebarWidth } = get();
|
||||||
|
try {
|
||||||
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT }));
|
||||||
|
} catch { /* localStorage may be unavailable */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
persistColumnWidths: () => {
|
||||||
|
const { sidebarWidth, emailListWidth } = get();
|
||||||
|
try {
|
||||||
|
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth }));
|
||||||
|
} catch { /* localStorage may be unavailable */ }
|
||||||
|
},
|
||||||
|
|
||||||
|
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||||
|
toggleSidebarCollapsed: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||||
|
|
||||||
// Navigation helpers for mobile
|
// Navigation helpers for mobile
|
||||||
showEmailList: () => {
|
showEmailList: () => {
|
||||||
const { isMobile } = get();
|
const { isMobile } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user