From cf02f587de0281777b273defcf6f9b4173e31c59 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 9 Mar 2026 16:46:25 +0100 Subject: [PATCH] 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) --- app/[locale]/page.tsx | 52 +- app/[locale]/settings/page.tsx | 12 +- app/api/dev-jmap/[...path]/route.ts | 945 ++++++++++++++++++ .../calendar/calendar-sidebar-panel.tsx | 2 +- components/calendar/event-detail-popover.tsx | 4 +- components/calendar/event-modal.tsx | 24 +- components/calendar/ical-import-modal.tsx | 12 +- .../email/calendar-invitation-banner.tsx | 8 +- components/email/email-list-item.tsx | 14 +- components/email/email-viewer.tsx | 64 +- components/email/thread-conversation-view.tsx | 6 +- components/email/thread-email-item.tsx | 30 +- components/email/thread-list-item.tsx | 56 +- components/filters/filter-rule-modal.tsx | 22 +- components/filters/sieve-editor-modal.tsx | 12 +- .../identity/identity-manager-modal.tsx | 4 +- components/keyboard-shortcuts-modal.tsx | 4 +- .../layout/__tests__/resize-handle.test.tsx | 106 ++ components/layout/navigation-rail.tsx | 209 +++- components/layout/resize-handle.tsx | 79 ++ components/layout/sidebar.tsx | 122 +-- components/settings/settings-section.tsx | 37 +- components/settings/vacation-settings.tsx | 8 +- .../templates/placeholder-fill-modal.tsx | 4 +- .../templates/template-manager-modal.tsx | 4 +- components/trusted-senders-modal.tsx | 4 +- components/ui/confirm-dialog.tsx | 2 +- components/ui/context-menu.tsx | 8 +- components/ui/welcome-banner.tsx | 4 +- hooks/use-email-drag.ts | 8 +- lib/__tests__/dev-jmap-mock.test.ts | 249 +++++ locales/de/common.json | 3 + locales/en/common.json | 3 + locales/es/common.json | 3 + locales/fr/common.json | 3 + locales/it/common.json | 3 + locales/ja/common.json | 3 + locales/nl/common.json | 3 + locales/pt/common.json | 3 + package-lock.json | 37 +- stores/__tests__/email-selection.test.ts | 124 +++ stores/__tests__/ui-store.test.ts | 113 +++ stores/email-store.ts | 23 +- stores/ui-store.ts | 59 ++ 44 files changed, 2181 insertions(+), 314 deletions(-) create mode 100644 app/api/dev-jmap/[...path]/route.ts create mode 100644 components/layout/__tests__/resize-handle.test.tsx create mode 100644 components/layout/resize-handle.tsx create mode 100644 lib/__tests__/dev-jmap-mock.test.ts create mode 100644 stores/__tests__/email-selection.test.ts create mode 100644 stores/__tests__/ui-store.test.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 3809381e..7497b9f2 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -33,6 +33,7 @@ import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel"; import { isFilterEmpty } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { ResizeHandle } from "@/components/layout/resize-handle"; export default function Home() { const router = useRouter(); @@ -53,7 +54,7 @@ export default function Home() { // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); - const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible } = useUIStore(); + const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); const { emails, mailboxes, @@ -238,6 +239,19 @@ export default function Home() { }); }, [checkAuth]); + // Hydrate persisted column widths from localStorage + useEffect(() => { + try { + const stored = localStorage.getItem("column-widths"); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed.sidebarWidth) setSidebarWidth(parsed.sidebarWidth); + if (parsed.emailListWidth) setEmailListWidth(parsed.emailListWidth); + } + } catch { /* ignore parse errors */ } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Redirect to login if not authenticated useEffect(() => { if (initialCheckDone && !isAuthenticated && !authLoading) { @@ -735,8 +749,13 @@ export default function Home() {
{/* Desktop Navigation Rail */} {!isMobile && !isTablet && ( -
- +
+
)} @@ -751,7 +770,7 @@ export default function Home() { {/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
setSidebarOpen(false)} onSearch={handleSearch} onClearSearch={handleClearSearch} activeSearchQuery={searchQuery} - quota={quota} - isPushConnected={isPushConnected} />
+ {/* Sidebar resize handle (desktop only, hidden when collapsed) */} + {!isMobile && !isTablet && !sidebarCollapsed && ( + setSidebarWidth(sidebarWidth + delta)} + onResizeEnd={persistColumnWidths} + onDoubleClick={resetSidebarWidth} + /> + )} + {/* Main Content Area */}
@@ -792,11 +818,12 @@ export default function Home() { "max-md:flex-1 max-md:border-r-0", isMobile && activeView !== "list" && "max-md:hidden", // Tablet/Desktop: fixed width with collapse animation - "md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm", + "md:flex-shrink-0 md:shadow-sm", "transition-all duration-200 ease-out", // Tablet: collapse when email selected isTablet && !tabletListVisible && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0" )} + style={!isMobile && !(isTablet && !tabletListVisible) ? { width: emailListWidth } : undefined} > {/* Mobile Header for List View */}
+ {/* Email list resize handle (desktop only) */} + {!isMobile && !isTablet && ( + setEmailListWidth(emailListWidth + delta)} + onResizeEnd={persistColumnWidths} + onDoubleClick={resetEmailListWidth} + /> + )} + {/* Email Viewer - full screen on mobile, flex on tablet/desktop */}
setActiveTab(tab.id)} className={cn( - 'w-full text-left px-3 py-2 rounded text-sm transition-colors', + 'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150', activeTab === tab.id - ? 'bg-accent text-accent-foreground' + ? 'bg-accent text-accent-foreground font-medium' : 'hover:bg-muted text-foreground' )} > @@ -83,10 +83,10 @@ export default function SettingsPage() {
{/* Page Header */} -
-
- -

{t('title')}

+
+
+ +

{t('title')}

diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts new file mode 100644 index 00000000..f42c2baa --- /dev/null +++ b/app/api/dev-jmap/[...path]/route.ts @@ -0,0 +1,945 @@ +import { NextRequest, NextResponse } from 'next/server'; + +/** + * Mock JMAP server for local development. + * + * Enabled only when DEV_MOCK_JMAP=true. Provides realistic dummy data + * so the UI can be developed without a real JMAP mail server. + * + * Accepts any username/password — no real authentication. + */ + +const ACCOUNT_ID = 'dev-account-001'; + +// --------------------------------------------------------------------------- +// Mailboxes +// --------------------------------------------------------------------------- + +interface MockMailbox { + id: string; + name: string; + role: string | null; + sortOrder: number; + totalEmails: number; + unreadEmails: number; +} + +interface MockEmail { + id: string; + threadId: string; + mailboxIds: Record; + keywords: Record; + size: number; + receivedAt: string; + from: { name: string; email: string }[]; + to: { name: string; email: string }[]; + cc: { name: string; email: string }[]; + subject: string; + preview: string; + hasAttachment: boolean; + textBody: { partId: string; blobId: string; size: number; type: string }[]; + htmlBody: { partId: string; blobId: string; size: number; type: string }[]; + bodyValues: Record; + attachments?: { partId: string; blobId: string; size: number; name: string; type: string }[]; +} + +let stateCounter = 1; +function nextState(): string { + return `mock-state-${++stateCounter}`; +} + +const mailboxes: MockMailbox[] = [ + { id: 'mb-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 5, unreadEmails: 2 }, + { id: 'mb-drafts', name: 'Drafts', role: 'drafts', sortOrder: 2, totalEmails: 1, unreadEmails: 0 }, + { id: 'mb-sent', name: 'Sent', role: 'sent', sortOrder: 3, totalEmails: 3, unreadEmails: 0 }, + { id: 'mb-junk', name: 'Junk', role: 'junk', sortOrder: 4, totalEmails: 1, unreadEmails: 1 }, + { id: 'mb-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 0, unreadEmails: 0 }, + { id: 'mb-archive', name: 'Archive', role: 'archive', sortOrder: 6, totalEmails: 2, unreadEmails: 0 }, +]; + +function recomputeMailboxCounts(): void { + for (const mb of mailboxes) { + mb.totalEmails = emails.filter((e) => e.mailboxIds[mb.id]).length; + mb.unreadEmails = emails.filter((e) => e.mailboxIds[mb.id] && !e.keywords.$seen).length; + } +} + +// --------------------------------------------------------------------------- +// Email fixtures +// --------------------------------------------------------------------------- + +function daysAgo(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString(); +} + +const emails: MockEmail[] = [ + { + id: 'email-001', + threadId: 'thread-001', + mailboxIds: { 'mb-inbox': true }, + keywords: {}, + size: 4200, + receivedAt: daysAgo(0), + from: [{ name: 'Alice Johnson', email: 'alice@example.com' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Welcome to JMAP Webmail!', + preview: 'Hi there! This is a sample email to help you get started with the JMAP Webmail development environment.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-001', size: 280, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-002', size: 420, type: 'text/html' }], + bodyValues: { + p1: { value: 'Hi there!\n\nThis is a sample email to help you get started with the JMAP Webmail development environment.\n\nFeel free to explore the UI — all data here is mock data.\n\nBest,\nAlice' }, + p2: { value: '

Hi there!

This is a sample email to help you get started with the JMAP Webmail development environment.

Feel free to explore the UI — all data here is mock data.

Best,
Alice

' }, + }, + }, + { + id: 'email-002', + threadId: 'thread-002', + mailboxIds: { 'mb-inbox': true }, + keywords: { $seen: true, $flagged: true }, + size: 5100, + receivedAt: daysAgo(1), + from: [{ name: 'Bob Smith', email: 'bob@example.org' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [{ name: 'Charlie Brown', email: 'charlie@example.net' }], + subject: 'Project Update — Q1 Review', + preview: 'Hey team, I wanted to share the latest project numbers. We are on track to meet our targets for Q1.', + hasAttachment: true, + textBody: [{ partId: 'p1', blobId: 'blob-003', size: 640, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-004', size: 820, type: 'text/html' }], + bodyValues: { + p1: { value: 'Hey team,\n\nI wanted to share the latest project numbers. We are on track to meet our targets for Q1.\n\nKey highlights:\n- Revenue up 12%\n- New signups increased by 8%\n- Customer satisfaction at 94%\n\nLet me know if you have questions.\n\nBob' }, + p2: { value: '

Hey team,

I wanted to share the latest project numbers. We are on track to meet our targets for Q1.

  • Revenue up 12%
  • New signups increased by 8%
  • Customer satisfaction at 94%

Let me know if you have questions.

Bob

' }, + }, + attachments: [ + { partId: 'att1', blobId: 'blob-att-001', size: 24500, name: 'Q1-Report.pdf', type: 'application/pdf' }, + ], + }, + { + id: 'email-003', + threadId: 'thread-003', + mailboxIds: { 'mb-inbox': true }, + keywords: { $seen: true }, + size: 3100, + receivedAt: daysAgo(2), + from: [{ name: 'Carol Davis', email: 'carol@example.com' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Lunch tomorrow?', + preview: 'Hey! Are you free for lunch tomorrow? I was thinking we could try that new place downtown.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-005', size: 180, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-006', size: 260, type: 'text/html' }], + bodyValues: { + p1: { value: 'Hey!\n\nAre you free for lunch tomorrow? I was thinking we could try that new place downtown.\n\nLet me know!\nCarol' }, + p2: { value: '

Hey!

Are you free for lunch tomorrow? I was thinking we could try that new place downtown.

Let me know!
Carol

' }, + }, + }, + { + id: 'email-004', + threadId: 'thread-004', + mailboxIds: { 'mb-inbox': true }, + keywords: {}, + size: 6200, + receivedAt: daysAgo(0), + from: [{ name: 'GitHub Notifications', email: 'notifications@github.com' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: '[jmap-webmail] New issue: Add dark mode toggle (#42)', + preview: 'A new issue has been opened by @contributor. It would be great to have a dark mode toggle in the settings panel.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-007', size: 350, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-008', size: 500, type: 'text/html' }], + bodyValues: { + p1: { value: 'A new issue has been opened by @contributor.\n\nTitle: Add dark mode toggle\n\nIt would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.\n\n—\nReply to this email directly or view it on GitHub.' }, + p2: { value: '

A new issue has been opened by @contributor.

Add dark mode toggle

It would be great to have a dark mode toggle in the settings panel. Currently users have to rely on system preferences.


Reply to this email directly or view it on GitHub.

' }, + }, + }, + { + id: 'email-005', + threadId: 'thread-005', + mailboxIds: { 'mb-inbox': true }, + keywords: { $seen: true }, + size: 2800, + receivedAt: daysAgo(4), + from: [{ name: 'Newsletter', email: 'news@techdigest.example' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Your Weekly Tech Digest', + preview: 'This week in tech: new JavaScript runtime benchmarks, WebAssembly reaches 3.0, and more.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-009', size: 900, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-010', size: 1400, type: 'text/html' }], + bodyValues: { + p1: { value: 'This week in tech:\n\n1. New JavaScript runtime benchmarks show 30% improvement\n2. WebAssembly reaches version 3.0\n3. CSS container queries gain full browser support\n4. TypeScript 6.0 release candidate announced\n\nRead more at techdigest.example' }, + p2: { value: '

Your Weekly Tech Digest

  1. New JavaScript runtime benchmarks show 30% improvement
  2. WebAssembly reaches version 3.0
  3. CSS container queries gain full browser support
  4. TypeScript 6.0 release candidate announced

Read more at techdigest.example

' }, + }, + }, + // Newsletter with full HTML + { + id: 'email-013', + threadId: 'thread-012', + mailboxIds: { 'mb-inbox': true }, + keywords: {}, + size: 18200, + receivedAt: daysAgo(0), + from: [{ name: 'Launchpad Weekly', email: 'hello@launchpad.example' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Launchpad Weekly #47 — The future of the open web', + preview: 'This week: WebAssembly Components hit 1.0, a deep dive into privacy-first analytics, and 5 tools we can\'t stop using.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-020', size: 1200, type: 'text/plain' }], + htmlBody: [{ partId: 'p2', blobId: 'blob-021', size: 16000, type: 'text/html' }], + bodyValues: { + p1: { value: 'LAUNCHPAD WEEKLY #47\nThe future of the open web\n\nWebAssembly Components hit 1.0\nThe Component Model spec has reached 1.0, unlocking language-agnostic modules that run anywhere.\n\nDeep dive: Privacy-first analytics\nCookie banners are on their way out. We explore the next generation of analytics tools that respect user privacy by design.\n\n5 tools we can\'t stop using\n1. Vite 7 — lightning-fast builds\n2. Biome — unified lint + format\n3. Deno 4 — batteries included runtime\n4. TailwindCSS 4 — zero config styling\n5. Playwright — end-to-end testing\n\nYou received this because you subscribed at launchpad.example.\nUnsubscribe: https://launchpad.example/unsubscribe' }, + p2: { value: '
◆ LAUNCHPAD WEEKLY
ISSUE #47 • MARCH 2026

The future of the open web

WebAssembly Components hit 1.0, privacy-first analytics take center stage, and 5 tools we can’t stop using.

FEATURED

WebAssembly Components hit 1.0

The Component Model specification has officially reached 1.0, unlocking language-agnostic modules that compose and run anywhere — from the browser to the edge. This is a watershed moment for portable computing.

Read the deep dive →
ANALYSIS

Deep dive: Privacy-first analytics

Cookie banners are on their way out. We explore the next generation of analytics platforms that respect user privacy by design — no consent dialogs required. From server-side aggregation to differential privacy, the landscape is shifting fast.

Explore the guide →
TOOLBOX

5 tools we can’t stop using

1Vite 7
Lightning-fast builds with zero-config ESM support.
2Biome
Unified linting and formatting in a single blazing-fast tool.
3Deno 4
Batteries-included runtime with native TypeScript & npm compat.
4TailwindCSS 4
Zero-config utility-first CSS that just works.
5Playwright
Reliable end-to-end testing across every browser.

You received this because you subscribed at launchpad.example

UnsubscribeManage preferencesView in browser

' }, + }, + }, + // Sent + { + id: 'email-006', + threadId: 'thread-003', + mailboxIds: { 'mb-sent': true }, + keywords: { $seen: true }, + size: 1800, + receivedAt: daysAgo(2), + from: [{ name: 'Dev User', email: 'dev@localhost' }], + to: [{ name: 'Carol Davis', email: 'carol@example.com' }], + cc: [], + subject: 'Re: Lunch tomorrow?', + preview: 'Sounds great! Let\'s meet at noon.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-011', size: 80, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Sounds great! Let\'s meet at noon.\n\n— Dev User' }, + }, + }, + { + id: 'email-007', + threadId: 'thread-006', + mailboxIds: { 'mb-sent': true }, + keywords: { $seen: true }, + size: 2200, + receivedAt: daysAgo(3), + from: [{ name: 'Dev User', email: 'dev@localhost' }], + to: [{ name: 'Bob Smith', email: 'bob@example.org' }], + cc: [], + subject: 'Re: Project Update — Q1 Review', + preview: 'Thanks Bob, the numbers look great. I\'ll prepare the board presentation.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-012', size: 150, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Thanks Bob, the numbers look great. I\'ll prepare the board presentation.\n\nCheers,\nDev User' }, + }, + }, + { + id: 'email-008', + threadId: 'thread-007', + mailboxIds: { 'mb-sent': true }, + keywords: { $seen: true }, + size: 3100, + receivedAt: daysAgo(5), + from: [{ name: 'Dev User', email: 'dev@localhost' }], + to: [{ name: 'Alice Johnson', email: 'alice@example.com' }], + cc: [], + subject: 'Design review feedback', + preview: 'Hi Alice, I reviewed the new mockups and have a few suggestions.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-013', size: 300, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Hi Alice,\n\nI reviewed the new mockups and have a few suggestions:\n\n1. The sidebar could use more contrast\n2. Consider adding breadcrumbs to the settings page\n3. The compose button placement looks good\n\nOverall great work!\n\nDev User' }, + }, + }, + // Draft + { + id: 'email-009', + threadId: 'thread-008', + mailboxIds: { 'mb-drafts': true }, + keywords: { $draft: true }, + size: 1200, + receivedAt: daysAgo(0), + from: [{ name: 'Dev User', email: 'dev@localhost' }], + to: [{ name: 'Team', email: 'team@example.com' }], + cc: [], + subject: 'Meeting notes (draft)', + preview: 'Notes from today\'s standup meeting...', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-014', size: 200, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Notes from today\'s standup meeting:\n\n- TODO: fill in details\n- Action items: ...' }, + }, + }, + // Junk + { + id: 'email-010', + threadId: 'thread-009', + mailboxIds: { 'mb-junk': true }, + keywords: {}, + size: 4500, + receivedAt: daysAgo(1), + from: [{ name: 'Totally Real Prince', email: 'prince@scam.example' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'You have won $1,000,000!!!', + preview: 'Congratulations! You have been selected as the winner of our international lottery.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-015', size: 500, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Congratulations!\n\nYou have been selected as the winner of our international lottery. To claim your prize, please send your bank details to...\n\n(This is mock spam for development purposes.)' }, + }, + }, + // Archive + { + id: 'email-011', + threadId: 'thread-010', + mailboxIds: { 'mb-archive': true }, + keywords: { $seen: true }, + size: 3800, + receivedAt: daysAgo(14), + from: [{ name: 'HR Department', email: 'hr@company.example' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Updated PTO Policy', + preview: 'Please review the updated paid time off policy effective next month.', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-016', size: 600, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Hi team,\n\nPlease review the updated paid time off policy effective next month. Key changes include:\n\n- Increased annual leave by 2 days\n- New flexible Friday policy\n- Simplified approval workflow\n\nFull details in the employee handbook.\n\nBest,\nHR Department' }, + }, + }, + { + id: 'email-012', + threadId: 'thread-011', + mailboxIds: { 'mb-archive': true }, + keywords: { $seen: true, $flagged: true }, + size: 2600, + receivedAt: daysAgo(30), + from: [{ name: 'Alice Johnson', email: 'alice@example.com' }], + to: [{ name: 'Dev User', email: 'dev@localhost' }], + cc: [], + subject: 'Conference talk accepted!', + preview: 'Great news — your talk proposal for the JMAP Conf has been accepted!', + hasAttachment: false, + textBody: [{ partId: 'p1', blobId: 'blob-017', size: 350, type: 'text/plain' }], + htmlBody: [], + bodyValues: { + p1: { value: 'Great news!\n\nYour talk proposal "Building Modern Webmail with JMAP" for the JMAP Conf has been accepted!\n\nThe conference is scheduled for next month. More details to follow.\n\nCongratulations!\nAlice' }, + }, + }, +]; + +// --------------------------------------------------------------------------- +// Identities +// --------------------------------------------------------------------------- + +const IDENTITIES = [ + { + id: 'identity-001', + name: 'Dev User', + email: 'dev@localhost', + replyTo: null, + bcc: null, + textSignature: '-- \nDev User\nJMAP Webmail Developer', + htmlSignature: '

--
Dev User
JMAP Webmail Developer

', + mayDelete: false, + }, +]; + +// --------------------------------------------------------------------------- +// Threads +// --------------------------------------------------------------------------- + +function buildThreads() { + const map = new Map(); + for (const e of emails) { + const ids = map.get(e.threadId) || []; + ids.push(e.id); + map.set(e.threadId, ids); + } + return Array.from(map.entries()).map(([id, emailIds]) => ({ id, emailIds })); +} + +// --------------------------------------------------------------------------- +// JMAP method handlers +// --------------------------------------------------------------------------- + +type MethodArgs = Record; +type MethodResult = [string, Record, string]; + +function handleCoreEcho(args: MethodArgs, callId: string): MethodResult { + return ['Core/echo', args, callId]; +} + +function handleMailboxGet(_args: MethodArgs, callId: string): MethodResult { + recomputeMailboxCounts(); + return ['Mailbox/get', { accountId: ACCOUNT_ID, state: nextState(), list: mailboxes, notFound: [] }, callId]; +} + +function handleMailboxSet(args: MethodArgs, callId: string): MethodResult { + const created: Record = {}; + const updated: Record = {}; + const destroyed: string[] = []; + + const create = args.create as Record> | undefined; + if (create) { + for (const [key, data] of Object.entries(create)) { + const newId = `mb-${Date.now()}-${key}`; + mailboxes.push({ + id: newId, + name: (data.name as string) || 'New Folder', + role: null, + sortOrder: mailboxes.length + 1, + totalEmails: 0, + unreadEmails: 0, + }); + created[key] = { id: newId }; + } + } + + const update = args.update as Record> | undefined; + if (update) { + for (const [id, changes] of Object.entries(update)) { + const mb = mailboxes.find((m) => m.id === id); + if (mb) { + if (changes.name !== undefined) mb.name = changes.name as string; + if (changes.sortOrder !== undefined) mb.sortOrder = changes.sortOrder as number; + updated[id] = null; + } + } + } + + const destroy = args.destroy as string[] | undefined; + if (destroy) { + for (const id of destroy) { + const idx = mailboxes.findIndex((m) => m.id === id); + if (idx !== -1) { + mailboxes.splice(idx, 1); + // Move emails from deleted mailbox to trash + const trash = mailboxes.find((m) => m.role === 'trash'); + for (const e of emails) { + if (e.mailboxIds[id]) { + delete e.mailboxIds[id]; + if (trash) e.mailboxIds[trash.id] = true; + } + } + destroyed.push(id); + } + } + } + + recomputeMailboxCounts(); + return ['Mailbox/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; +} + +function handleEmailQuery(args: MethodArgs, callId: string): MethodResult { + const filter = args.filter as Record | undefined; + const limit = (args.limit as number) || 50; + const position = (args.position as number) || 0; + + let filtered = [...emails]; + if (filter?.inMailbox) { + filtered = filtered.filter((e) => e.mailboxIds[filter.inMailbox]); + } + if (filter?.text) { + const q = (filter.text as string).toLowerCase(); + filtered = filtered.filter( + (e) => + (e.subject?.toLowerCase().includes(q)) || + (e.preview?.toLowerCase().includes(q)) || + e.from?.some((f) => f.name?.toLowerCase().includes(q) || f.email.toLowerCase().includes(q)), + ); + } + + // Sort newest first + filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); + + const total = filtered.length; + const ids = filtered.slice(position, position + limit).map((e) => e.id); + + return ['Email/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids, total, position, canCalculateChanges: false }, callId]; +} + +function handleEmailGet(args: MethodArgs, callId: string): MethodResult { + let ids = args.ids as string[] | undefined; + const properties = args.properties as string[] | undefined; + + // Handle back-references (#ids) + if (!ids && args['#ids']) { + // Will be resolved by the caller + ids = args['#ids'] as string[]; + } + + const list = ids + ? emails.filter((e) => ids!.includes(e.id)) + : emails; + + // If specific properties requested, filter them + let result: unknown[] = list; + if (properties) { + result = list.map((e) => { + const filtered: Record = { id: e.id }; + for (const prop of properties) { + if (prop in e) { + filtered[prop] = (e as unknown as Record)[prop]; + } + } + return filtered; + }); + } + + return ['Email/get', { accountId: ACCOUNT_ID, state: nextState(), list: result, notFound: [] }, callId]; +} + +function handleEmailSet(args: MethodArgs, callId: string): MethodResult { + const updated: Record = {}; + const created: Record = {}; + const destroyed: string[] = []; + + // --- Handle updates (move, keywords, etc.) --- + const update = args.update as Record> | undefined; + if (update) { + for (const [id, changes] of Object.entries(update)) { + const email = emails.find((e) => e.id === id); + if (!email) continue; + + // Full mailboxIds replacement (move) + if (changes.mailboxIds) { + email.mailboxIds = changes.mailboxIds as Record; + } + + // Full keywords replacement + if (changes.keywords !== undefined) { + email.keywords = changes.keywords as Record; + } + + // Patch-style keyword updates: "keywords/$seen", "keywords/$flagged", etc. + for (const [key, value] of Object.entries(changes)) { + if (key.startsWith('keywords/')) { + const keyword = key.slice('keywords/'.length); + if (value) { + email.keywords[keyword] = true; + } else { + delete email.keywords[keyword]; + } + } + } + + // Subject / other fields (for drafts) + if (changes.subject !== undefined) email.subject = changes.subject as string; + + updated[id] = null; + } + } + + // --- Handle creates --- + const create = args.create as Record> | undefined; + if (create) { + for (const [key, data] of Object.entries(create)) { + const newId = `email-new-${Date.now()}-${key}`; + // Extract preview text from bodyValues using textBody partId + let previewText = ''; + const textBodyArr = data.textBody as { partId: string }[] | undefined; + const bodyVals = data.bodyValues as Record | undefined; + if (Array.isArray(textBodyArr) && textBodyArr[0]?.partId && bodyVals) { + previewText = bodyVals[textBodyArr[0].partId]?.value || ''; + } else if (typeof data.textBody === 'string') { + previewText = data.textBody; + } + + const newEmail: MockEmail = { + id: newId, + threadId: `thread-new-${Date.now()}-${key}`, + mailboxIds: (data.mailboxIds as Record) || { 'mb-drafts': true }, + keywords: (data.keywords as Record) || {}, + size: 1000, + receivedAt: new Date().toISOString(), + from: (data.from as MockEmail['from']) || [{ name: 'Dev User', email: 'dev@localhost' }], + to: (data.to as MockEmail['to']) || [], + cc: (data.cc as MockEmail['cc']) || [], + subject: (data.subject as string) || '(no subject)', + preview: (previewText || (data.subject as string) || '').slice(0, 120), + hasAttachment: false, + textBody: [], + htmlBody: [], + bodyValues: {}, + }; + emails.unshift(newEmail); + created[key] = { id: newId }; + } + } + + // --- Handle destroys (permanent delete) --- + const destroy = args.destroy as string[] | undefined; + if (destroy) { + for (const id of destroy) { + const idx = emails.findIndex((e) => e.id === id); + if (idx !== -1) { + emails.splice(idx, 1); + destroyed.push(id); + } + } + } + + recomputeMailboxCounts(); + return ['Email/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; +} + +function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult { + return ['Identity/get', { accountId: ACCOUNT_ID, state: nextState(), list: IDENTITIES, notFound: [] }, callId]; +} + +function handleIdentitySet(args: MethodArgs, callId: string): MethodResult { + const created: Record = {}; + const create = args.create as Record | undefined; + if (create) { + for (const key of Object.keys(create)) { + created[key] = { id: `identity-new-${Date.now()}-${key}` }; + } + } + return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId]; +} + +function handleThreadGet(args: MethodArgs, callId: string): MethodResult { + const ids = args.ids as string[] | undefined; + const threads = buildThreads(); + const list = ids ? threads.filter((t) => ids.includes(t.id)) : threads; + return ['Thread/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; +} + +function handleEmailSubmissionSet(_args: MethodArgs, callId: string): MethodResult { + return ['EmailSubmission/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: { 'sub-1': { id: 'sub-mock-1' } }, notCreated: null }, callId]; +} + +function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult { + return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId]; +} + +function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult { + return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId]; +} + +function handleContactCardGet(_args: MethodArgs, callId: string): MethodResult { + return ['ContactCard/get', { + accountId: ACCOUNT_ID, state: nextState(), notFound: [], + list: [ + { id: 'contact-001', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] }, emails: { e1: { address: 'alice@example.com' } } }, + { id: 'contact-002', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Smith' }] }, emails: { e1: { address: 'bob@example.org' } }, phones: { p1: { number: '+1-555-0123' } } }, + { id: 'contact-003', addressBookIds: { 'ab-1': true }, kind: 'individual', name: { components: [{ kind: 'given', value: 'Carol' }, { kind: 'surname', value: 'Davis' }] }, emails: { e1: { address: 'carol@example.com' } } }, + ], + }, callId]; +} + +function handleAddressBookGet(_args: MethodArgs, callId: string): MethodResult { + return ['AddressBook/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'ab-1', name: 'Personal', isDefault: true }], notFound: [] }, callId]; +} + +function handleCalendarGet(_args: MethodArgs, callId: string): MethodResult { + return ['Calendar/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'cal-1', name: 'Personal', color: '#4285f4', isVisible: true, isDefault: true }], notFound: [] }, callId]; +} + +function handleCalendarEventGet(_args: MethodArgs, callId: string): MethodResult { + return ['CalendarEvent/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; +} + +function handleCalendarEventQuery(_args: MethodArgs, callId: string): MethodResult { + return ['CalendarEvent/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: [], total: 0, position: 0, canCalculateChanges: false }, callId]; +} + +function handleSieveScriptGet(_args: MethodArgs, callId: string): MethodResult { + return ['SieveScript/get', { accountId: ACCOUNT_ID, state: nextState(), list: [], notFound: [] }, callId]; +} + +// Catch-all for unknown methods +function handleUnknown(method: string, _args: MethodArgs, callId: string): MethodResult { + return ['error', { type: 'unknownMethod', description: `Mock server does not implement ${method}` }, callId]; +} + +const METHOD_HANDLERS: Record MethodResult> = { + 'Core/echo': handleCoreEcho, + 'Mailbox/get': handleMailboxGet, + 'Mailbox/set': handleMailboxSet, + 'Email/query': handleEmailQuery, + 'Email/get': handleEmailGet, + 'Email/set': handleEmailSet, + 'Email/changes': (_args, callId) => ['Email/changes', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), hasMoreChanges: false, created: [], updated: [], destroyed: [] }, callId], + 'Thread/get': handleThreadGet, + 'Identity/get': handleIdentityGet, + 'Identity/set': handleIdentitySet, + 'EmailSubmission/set': handleEmailSubmissionSet, + 'Quota/get': handleQuotaGet, + 'VacationResponse/get': handleVacationResponseGet, + 'VacationResponse/set': (_args, callId) => ['VacationResponse/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), updated: { 'vacation-1': null } }, callId], + 'ContactCard/get': handleContactCardGet, + 'ContactCard/set': (_args, callId) => ['ContactCard/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'ContactCard/query': (_args, callId) => ['ContactCard/query', { accountId: ACCOUNT_ID, queryState: nextState(), ids: ['contact-001', 'contact-002', 'contact-003'], total: 3, position: 0 }, callId], + 'AddressBook/get': handleAddressBookGet, + 'Calendar/get': handleCalendarGet, + 'CalendarEvent/get': handleCalendarEventGet, + 'CalendarEvent/query': handleCalendarEventQuery, + 'CalendarEvent/set': (_args, callId) => ['CalendarEvent/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], + 'SieveScript/get': handleSieveScriptGet, + 'SieveScript/set': (_args, callId) => ['SieveScript/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created: null, updated: null, destroyed: null }, callId], +}; + +// --------------------------------------------------------------------------- +// Resolve back-references between method calls +// --------------------------------------------------------------------------- + +function resolveBackReferences( + methodCalls: Array<[string, MethodArgs, string]>, + responses: MethodResult[], +): Array<[string, MethodArgs, string]> { + return methodCalls.map((call) => { + const [method, args, callId] = call; + const resolved = { ...args }; + + // Handle #ids back-reference (used by Email/get after Email/query) + if (resolved['#ids']) { + const ref = resolved['#ids'] as { resultOf: string; name: string; path: string }; + const refResponse = responses.find((r) => r[2] === ref.resultOf && r[0] === ref.name); + if (refResponse) { + const path = ref.path.replace(/^\//, ''); + resolved.ids = refResponse[1][path] as string[]; + } + delete resolved['#ids']; + } + + return [method, resolved, callId] as [string, MethodArgs, string]; + }); +} + +// --------------------------------------------------------------------------- +// Route handlers +// --------------------------------------------------------------------------- + +function isDevMockEnabled(): boolean { + return process.env.DEV_MOCK_JMAP === 'true'; +} + +function getBaseUrl(request: NextRequest): string { + const proto = request.headers.get('x-forwarded-proto') || 'http'; + const host = request.headers.get('host') || 'localhost:3000'; + return `${proto}://${host}`; +} + +export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + if (!isDevMockEnabled()) { + return NextResponse.json({ error: 'Mock JMAP server is disabled' }, { status: 404 }); + } + + const { path } = await params; + const joined = path.join('/'); + + // Session endpoint: /.well-known/jmap + if (joined === '.well-known/jmap') { + const base = getBaseUrl(request); + return NextResponse.json({ + capabilities: { + 'urn:ietf:params:jmap:core': { + maxSizeUpload: 50000000, + maxConcurrentUpload: 4, + maxSizeRequest: 10000000, + maxConcurrentRequests: 4, + maxCallsInRequest: 16, + maxObjectsInGet: 500, + maxObjectsInSet: 500, + collationAlgorithms: ['i;ascii-casemap', 'i;ascii-numeric', 'i;unicode-casemap'], + }, + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:quota': {}, + 'urn:ietf:params:jmap:vacationresponse': {}, + 'urn:ietf:params:jmap:contacts': {}, + 'urn:ietf:params:jmap:calendars': {}, + 'urn:ietf:params:jmap:sieve': {}, + }, + accounts: { + [ACCOUNT_ID]: { + name: 'Dev User', + isPersonal: true, + isReadOnly: false, + accountCapabilities: { + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:submission': {}, + 'urn:ietf:params:jmap:quota': {}, + 'urn:ietf:params:jmap:vacationresponse': {}, + 'urn:ietf:params:jmap:contacts': {}, + 'urn:ietf:params:jmap:calendars': {}, + 'urn:ietf:params:jmap:sieve': {}, + }, + }, + }, + primaryAccounts: { + 'urn:ietf:params:jmap:mail': ACCOUNT_ID, + 'urn:ietf:params:jmap:submission': ACCOUNT_ID, + 'urn:ietf:params:jmap:quota': ACCOUNT_ID, + 'urn:ietf:params:jmap:vacationresponse': ACCOUNT_ID, + 'urn:ietf:params:jmap:contacts': ACCOUNT_ID, + 'urn:ietf:params:jmap:calendars': ACCOUNT_ID, + 'urn:ietf:params:jmap:sieve': ACCOUNT_ID, + }, + username: 'dev@localhost', + apiUrl: `${base}/api/dev-jmap/api`, + downloadUrl: `${base}/api/dev-jmap/download/{accountId}/{blobId}/{name}?accept={type}`, + uploadUrl: `${base}/api/dev-jmap/upload/{accountId}/`, + eventSourceUrl: `${base}/api/dev-jmap/eventsource?types={types}&closeafter={closeafter}&ping={ping}`, + state: 'mock-session-state-1', + }); + } + + // Download endpoint: /download/{accountId}/{blobId}/{name} + if (joined.startsWith('download/')) { + const segments = joined.split('/'); + // segments: ['download', accountId, blobId, name] + const blobId = segments[2] || 'unknown'; + const name = decodeURIComponent(segments[3] || 'attachment'); + const accept = new URL(request.url).searchParams.get('accept') || 'application/octet-stream'; + + // Find matching attachment across all emails + let attachmentData: { name: string; type: string; size: number } | undefined; + for (const email of emails) { + const att = email.attachments?.find(a => a.blobId === blobId); + if (att) { + attachmentData = att; + break; + } + } + + // Generate placeholder content for the blob + const contentType = attachmentData?.type || accept; + const fileName = attachmentData?.name || name; + const body = `[Mock file content for blob ${blobId}: ${fileName}]`; + + return new NextResponse(body, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${fileName}"`, + }, + }); + } + + // EventSource endpoint: /eventsource + if (joined === 'eventsource') { + const ping = parseInt(new URL(request.url).searchParams.get('ping') || '0', 10); + const pingInterval = ping > 0 ? ping : 30; + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + // Send initial state event + const stateEvent = JSON.stringify({ + '@type': 'StateChange', + changed: { + [ACCOUNT_ID]: { + 'Email': nextState(), + 'Mailbox': nextState(), + 'Thread': nextState(), + }, + }, + }); + controller.enqueue(encoder.encode(`event: state\ndata: ${stateEvent}\n\n`)); + + // Send periodic pings to keep the connection alive + const interval = setInterval(() => { + try { + controller.enqueue(encoder.encode(`event: ping\ndata: ${JSON.stringify({ interval: pingInterval })}\n\n`)); + } catch { + clearInterval(interval); + } + }, pingInterval * 1000); + + // Close after 5 minutes to prevent indefinite connections in dev + setTimeout(() => { + clearInterval(interval); + try { controller.close(); } catch { /* already closed */ } + }, 5 * 60 * 1000); + }, + }); + + return new NextResponse(stream, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } + + return NextResponse.json({ error: 'Not found' }, { status: 404 }); +} + +export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + if (!isDevMockEnabled()) { + return NextResponse.json({ error: 'Mock JMAP server is disabled' }, { status: 404 }); + } + + const { path } = await params; + const joined = path.join('/'); + + // JMAP API endpoint + if (joined === 'api') { + try { + const body = await request.json(); + const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>; + + if (!methodCalls || !Array.isArray(methodCalls)) { + return NextResponse.json({ error: 'Invalid request: missing methodCalls' }, { status: 400 }); + } + + const responses: MethodResult[] = []; + + // Process method calls sequentially (to support back-references) + const resolved = resolveBackReferences(methodCalls, responses); + for (let i = 0; i < methodCalls.length; i++) { + const [method, , callId] = methodCalls[i]; + // Use resolved args if available, otherwise original + const args = i < resolved.length ? resolved[i][1] : methodCalls[i][1]; + + const handler = METHOD_HANDLERS[method]; + if (handler) { + const result = handler(args, callId); + responses.push(result); + } else { + responses.push(handleUnknown(method, args, callId)); + } + + // Re-resolve remaining calls with new responses + if (i < methodCalls.length - 1) { + const remaining = methodCalls.slice(i + 1); + const reResolved = resolveBackReferences(remaining, responses); + for (let j = 0; j < reResolved.length; j++) { + resolved[i + 1 + j] = reResolved[j]; + } + } + } + + return NextResponse.json({ methodResponses: responses }); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + } + + // Upload endpoint (accept but return a fake blob) + if (joined.startsWith('upload/')) { + return NextResponse.json({ + accountId: ACCOUNT_ID, + blobId: `blob-upload-${Date.now()}`, + type: request.headers.get('content-type') || 'application/octet-stream', + size: Number(request.headers.get('content-length') || 0), + }); + } + + return NextResponse.json({ error: 'Not found' }, { status: 404 }); +} diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index a32fa44d..3dd998b1 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -34,7 +34,7 @@ export function CalendarSidebarPanel({ key={cal.id} onClick={() => onToggleVisibility(cal.id)} 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" )} > diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index eec6e9d1..7f445e2b 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -293,10 +293,10 @@ export function EventDetailPopover({
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index dd7f0073..a0b158d5 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -375,16 +375,16 @@ export function EventModal({ return (
-