From d23e9a642c7a3034c6a7741b78e0138f87b0ced9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:07:44 +0100 Subject: [PATCH] feat: enhance mobile responsiveness across calendar and contact components - Added `isMobile` prop to `CalendarWeekView`, `EventDetailPopover`, `ContactDetail`, `ContactGroupDetail`, and `EmailComposer` components for improved mobile layout. - Adjusted layout and styles in `CalendarWeekView` to display a 3-day view on mobile. - Updated `EventDetailPopover` to adapt its size and layout for mobile devices. - Modified `ContactDetail` and `ContactGroupDetail` to adjust padding and font sizes based on mobile view. - Enhanced `EmailComposer` with mobile-friendly header and field layouts, including auto-saving draft functionality. - Added confirmation dialog for unsaved changes when closing the email composer. - Updated localization files to include new strings for draft management. --- app/[locale]/calendar/page.tsx | 58 ++- app/[locale]/contacts/page.tsx | 240 +++++++----- app/[locale]/page.tsx | 165 +++++--- components/calendar/calendar-day-view.tsx | 17 +- components/calendar/calendar-month-view.tsx | 82 ++-- components/calendar/calendar-toolbar.tsx | 156 +++++++- components/calendar/calendar-week-view.tsx | 26 +- components/calendar/event-detail-popover.tsx | 20 +- components/contacts/contact-detail.tsx | 33 +- components/contacts/contact-group-detail.tsx | 21 +- components/email/email-composer.tsx | 377 +++++++++++++------ locales/en/common.json | 6 +- 12 files changed, 848 insertions(+), 353 deletions(-) diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 1567ac8b..ac34ae1a 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo, type TouchEvent as ReactTouchEvent } from "react"; import { useRouter } from "@/i18n/navigation"; import { useTranslations } from "next-intl"; +import { Plus } from "lucide-react"; import { startOfMonth, endOfMonth, startOfWeek, endOfWeek, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, @@ -14,6 +15,7 @@ import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; import { toast } from "@/stores/toast-store"; import { useIsMobile } from "@/hooks/use-media-query"; +import { Button } from "@/components/ui/button"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; @@ -68,6 +70,9 @@ export default function CalendarPage() { const [detailAnchorRect, setDetailAnchorRect] = useState(null); const hasFetched = useRef(false); + // Swipe navigation ref (handlers defined after navigatePrev/navigateNext) + const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); + useEffect(() => { if (!isAuthenticated) { router.push("/login"); @@ -155,10 +160,35 @@ export default function CalendarPage() { setMiniMonth(new Date()); }, [setSelectedDate]); + // Swipe navigation handlers for mobile + const handleTouchStart = useCallback((e: ReactTouchEvent) => { + const touch = e.touches[0]; + touchStartRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; + }, []); + + const handleTouchEnd = useCallback((e: ReactTouchEvent) => { + if (!touchStartRef.current || !isMobile) return; + const touch = e.changedTouches[0]; + const dx = touch.clientX - touchStartRef.current.x; + const dy = touch.clientY - touchStartRef.current.y; + const elapsed = Date.now() - touchStartRef.current.time; + touchStartRef.current = null; + + // Only trigger swipe if horizontal movement is dominant and fast enough + if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5 && elapsed < 400) { + if (dx > 0) navigatePrev(); + else navigateNext(); + } + }, [isMobile, navigatePrev, navigateNext]); + const handleSelectDate = useCallback((date: Date) => { setSelectedDate(date); setMiniMonth(date); - }, [setSelectedDate]); + // On mobile month view, tapping a date switches to day view + if (isMobile && viewMode === "month") { + setViewMode("day"); + } + }, [setSelectedDate, isMobile, viewMode, setViewMode]); const handleMiniMonthChange = useCallback((date: Date) => { setMiniMonth(date); @@ -549,6 +579,7 @@ export default function CalendarPage() { onSelectDate={handleSelectDate} onSelectEvent={handleSelectEvent} firstDayOfWeek={firstDayOfWeek} + isMobile={isMobile} /> ); case "week": @@ -562,6 +593,7 @@ export default function CalendarPage() { onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} + isMobile={isMobile} /> ); case "day": @@ -573,6 +605,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onCreateAtTime={openCreateModal} timeFormat={timeFormat} + isMobile={isMobile} /> ); case "agenda": @@ -620,9 +653,16 @@ export default function CalendarPage() { onCreateEvent={() => openCreateModal()} onImport={() => setShowImportModal(true)} isMobile={isMobile} + calendars={calendars} + selectedCalendarIds={selectedCalendarIds} + onToggleVisibility={toggleCalendarVisibility} /> -
+
{!isMobile && (
openCreateModal()} + className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg" + aria-label={t("events.create")} + > + + + )}
{/* Mobile Bottom Navigation */} @@ -663,6 +714,7 @@ export default function CalendarPage() { onRsvp={handleRsvpFromDetail} currentUserEmails={currentUserEmails} timeFormat={timeFormat} + isMobile={isMobile} /> )} diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index 356a6775..0bc2cc50 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -336,6 +336,7 @@ export default function ContactsPage() { onEdit={handleEditGroup} onDelete={handleDeleteGroup} onRemoveMember={handleRemoveGroupMember} + isMobile={isMobile} onSelectMember={(id) => { setSelectedContact(id); setActiveTab("all"); @@ -422,11 +423,20 @@ export default function ContactsPage() { contact={selectedContact} onEdit={handleEdit} onDelete={handleDelete} + isMobile={isMobile} /> ); } }; + const showListPanel = !isMobile || view === "list"; + const showRightPanel = !isMobile || view !== "list"; + + const mobileBackToList = () => { + setView("list"); + clearSelection(); + }; + return (
{!isMobile && ( @@ -437,112 +447,134 @@ export default function ContactsPage() {
-
-
-
- -
- - + {showListPanel && ( +
+
+
+ +
+ + +
+
+
+ +
+ + +
+ + {activeTab === "all" ? ( + setView("import")} + supportsSync={supportsSync} + className="flex-1" + selectedContactIds={selectedContactIds} + onToggleSelection={toggleContactSelection} + onSelectAll={selectAllContacts} + onClearSelection={clearSelection} + onBulkDelete={handleBulkDelete} + onBulkAddToGroup={handleBulkAddToGroup} + onBulkExport={handleBulkExport} + /> + ) : ( + + )}
-
-
+ )} -
- - -
- - {activeTab === "all" ? ( - setView("import")} - supportsSync={supportsSync} - className="flex-1" - selectedContactIds={selectedContactIds} - onToggleSelection={toggleContactSelection} - onSelectAll={selectAllContacts} - onClearSelection={clearSelection} - onBulkDelete={handleBulkDelete} - onBulkAddToGroup={handleBulkAddToGroup} - onBulkExport={handleBulkExport} - /> - ) : ( - - )} -
- -
- {renderRightPanel()} -
+ {showRightPanel && ( +
+ {isMobile && ( +
+ +
+ )} +
+ {renderRightPanel()} +
+
+ )}
{isMobile && ( diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 58d80e77..bec93162 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -7,6 +7,7 @@ import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; +import type { ComposerDraftData } from "@/components/email/email-composer"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email } from "@/lib/jmap/types"; @@ -18,6 +19,7 @@ import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore } from "@/stores/ui-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; import { cn } from "@/lib/utils"; @@ -28,12 +30,13 @@ import { EmailViewerErrorFallback, ComposerErrorFallback, } from "@/components/error"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { DragDropProvider } from "@/contexts/drag-drop-context"; import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { WelcomeBanner } from "@/components/ui/welcome-banner"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { Input } from "@/components/ui/input"; -import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw } from "lucide-react"; +import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine } from "lucide-react"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -44,6 +47,8 @@ export default function Home() { const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); + const [pendingDraft, setPendingDraft] = useState(null); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [initialCheckDone, setInitialCheckDone] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false); @@ -170,6 +175,7 @@ export default function Home() { onCompose: () => { setComposerMode('compose'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }, onFocusSearch: () => { const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; @@ -422,16 +428,19 @@ export default function Home() { setComposerDraftText(draftText || ""); setComposerMode('reply'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const handleReplyAll = () => { setComposerMode('replyAll'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const handleForward = () => { setComposerMode('forward'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const handleDelete = async () => { @@ -682,6 +691,11 @@ export default function Home() { const handleEmailSelect = async (email: { id: string }) => { if (!client || !email) return; + // If composing, suspend the composer (unmount will trigger onSaveState) + if (showComposer) { + setShowComposer(false); + } + // Set loading state immediately (keep current email visible) setLoadingEmail(true); @@ -751,18 +765,21 @@ export default function Home() { selectEmail(email); setComposerMode('reply'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const handleConversationReplyAll = (email: Email) => { selectEmail(email); setComposerMode('replyAll'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const handleConversationForward = (email: Email) => { selectEmail(email); setComposerMode('forward'); setShowComposer(true); + if (isMobile) setActiveView('viewer'); }; const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => ( @@ -825,7 +842,10 @@ export default function Home() { onCompose={() => { setComposerMode('compose'); setShowComposer(true); - if (isMobile) setSidebarOpen(false); + if (isMobile) { + setSidebarOpen(false); + setActiveView('viewer'); + } }} onSidebarClose={() => setSidebarOpen(false)} /> @@ -847,7 +867,7 @@ export default function Home() { {/* Email List - full width on mobile, fixed width on tablet/desktop */} + + {/* Floating Compose Button (mobile) */} + {isMobile && ( + + )}
{/* Email list resize handle (desktop only) */} @@ -1116,7 +1147,7 @@ export default function Home() { /> )} - {/* Email Viewer - full screen on mobile, flex on tablet/desktop */} + {/* Email Viewer / Composer - full screen on mobile, flex on tablet/desktop */}
+ {/* Inline Composer - shown in viewer pane */} + {showComposer ? ( + { + setShowComposer(false); + setComposerMode('compose'); + }} + > + setPendingDraft(data)} + onSend={async (data) => { + await handleEmailSend(data); + setPendingDraft(null); + }} + onClose={() => { + setShowComposer(false); + setComposerMode('compose'); + setComposerDraftText(""); + setPendingDraft(null); + if (isMobile) { + setActiveView('list'); + } + }} + onDiscardDraft={(draftId) => { + handleDiscardDraft(draftId); + setPendingDraft(null); + }} + /> + + ) : ( + <> + {/* Pending draft banner */} + {pendingDraft && ( + + )} {/* Mobile Conversation View - shown when thread is selected on mobile */} {isMobile && conversationThread ? ( )} + + )}
@@ -1196,44 +1305,6 @@ export default function Home() { )}
- {/* Email Composer Modal */} - {showComposer && ( -
-
- { - setShowComposer(false); - setComposerMode('compose'); - }} - > - { - setShowComposer(false); - setComposerMode('compose'); - setComposerDraftText(""); - }} - onDiscardDraft={handleDiscardDraft} - /> - -
-
- )} - {/* Keyboard Shortcuts Modal */} + +
); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 6749d09f..fc9821a3 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -17,6 +17,7 @@ interface CalendarDayViewProps { onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; timeFormat?: "12h" | "24h"; + isMobile?: boolean; } const HOUR_HEIGHT = 64; @@ -29,6 +30,7 @@ export function CalendarDayView({ onSelectEvent, onCreateAtTime, timeFormat = "24h", + isMobile, }: CalendarDayViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -110,9 +112,12 @@ export function CalendarDayView({ return (
-
-

- {intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })} +
+

+ {isMobile + ? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) + : intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }) + }

@@ -138,15 +143,15 @@ export function CalendarDayView({
-
+
{HOURS.map((h) => (
{h > 0 && ( - + {formatHour(h)} )} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index d6d81f9b..3b0cfbfb 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -21,6 +21,7 @@ interface CalendarMonthViewProps { onSelectDate: (date: Date) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; firstDayOfWeek?: number; + isMobile?: boolean; } export function CalendarMonthView({ @@ -30,6 +31,7 @@ export function CalendarMonthView({ onSelectDate, onSelectEvent, firstDayOfWeek = 1, + isMobile, }: CalendarMonthViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -125,22 +127,28 @@ export function CalendarMonthView({
{dayHeaders.map((d) => ( -
- {t(`days.${d}`)} +
+ {isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
))}
{weeks.map((week, wi) => ( -
+
{week.map((day) => { const inMonth = isSameMonth(day, selectedDate); const selected = isSameDay(day, selectedDate); const today = isToday(day); const key = format(day, "yyyy-MM-dd"); const dayEvents = eventsByDate.get(key) || []; - const maxVisible = 3; + const maxVisible = isMobile ? 0 : 3; const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return ( @@ -154,16 +162,18 @@ export function CalendarMonthView({ onDragLeave={handleCellDragLeave} onDrop={(e) => handleCellDrop(e, day)} className={cn( - "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors", + "border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation", !inMonth && "bg-muted/30", "hover:bg-muted/50", + selected && isMobile && "bg-primary/10", dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10" )} >
-
- {dayEvents.slice(0, maxVisible).map((ev) => { - const calId = Object.keys(ev.calendarIds)[0]; - return ( - onSelectEvent(ev, rect)} - draggable - /> - ); - })} - {dayEvents.length > maxVisible && ( -
- {t("events.more", { count: dayEvents.length - maxVisible })} + {isMobile ? ( + dayEvents.length > 0 && ( +
+ {dayEvents.slice(0, 3).map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + const cal = calendarMap.get(calId); + const evColor = ev.color || cal?.color || "#3b82f6"; + return ( + + ); + })} + {dayEvents.length > 3 && ( + + )}
- )} -
+ ) + ) : ( +
+ {dayEvents.slice(0, maxVisible).map((ev) => { + const calId = Object.keys(ev.calendarIds)[0]; + return ( + onSelectEvent(ev, rect)} + draggable + /> + ); + })} + {dayEvents.length > maxVisible && ( +
+ {t("events.more", { count: dayEvents.length - maxVisible })} +
+ )} +
+ )}
); })} diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 65b0f39a..8872b34f 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -1,11 +1,13 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import { useTranslations, useFormatter } from "next-intl"; import { Button } from "@/components/ui/button"; -import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react"; import { addDays, startOfWeek } from "date-fns"; import { cn } from "@/lib/utils"; import type { CalendarViewMode } from "@/stores/calendar-store"; +import type { Calendar } from "@/lib/jmap/types"; interface CalendarToolbarProps { selectedDate: Date; @@ -19,6 +21,9 @@ interface CalendarToolbarProps { isMobile?: boolean; firstDayOfWeek?: number; onNavigateBack?: () => void; + calendars?: Calendar[]; + selectedCalendarIds?: string[]; + onToggleVisibility?: (id: string) => void; } export function CalendarToolbar({ @@ -32,18 +37,39 @@ export function CalendarToolbar({ onImport, isMobile, firstDayOfWeek = 1, + calendars, + selectedCalendarIds, + onToggleVisibility, }: CalendarToolbarProps) { const t = useTranslations("calendar"); const formatter = useFormatter(); const views: CalendarViewMode[] = ["month", "week", "day", "agenda"]; + const [showCalendarDropdown, setShowCalendarDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!showCalendarDropdown) return; + function handleClickOutside(e: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setShowCalendarDropdown(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showCalendarDropdown]); const getDateLabel = (): string => { switch (viewMode) { case "month": - return formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + return isMobile + ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) + : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); case "week": { const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); const we = addDays(ws, 6); + if (isMobile) { + return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}`; + } const sameMonth = ws.getMonth() === we.getMonth(); if (sameMonth) { return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`; @@ -51,30 +77,126 @@ export function CalendarToolbar({ return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} – ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`; } case "day": - return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); + return isMobile + ? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" }) + : formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); case "agenda": - return formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); + return isMobile + ? formatter.dateTime(selectedDate, { month: "short", year: "numeric" }) + : formatter.dateTime(selectedDate, { month: "long", year: "numeric" }); } }; + const [showViewDropdown, setShowViewDropdown] = useState(false); + const viewDropdownRef = useRef(null); + + useEffect(() => { + if (!showViewDropdown) return; + function handleClickOutside(e: MouseEvent) { + if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) { + setShowViewDropdown(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showViewDropdown]); + return ( -
-
- - + {getDateLabel()} -
- + {isMobile && calendars && selectedCalendarIds && onToggleVisibility && ( +
+ + {showCalendarDropdown && ( +
+

+ {t("my_calendars")} +

+
+ {calendars.map((cal) => { + const isVisible = selectedCalendarIds.includes(cal.id); + const color = cal.color || "#3b82f6"; + return ( + + ); + })} +
+
+ )} +
+ )} + + {isMobile && ( +
+ + {showViewDropdown && ( +
+ {views.map((v) => ( + + ))} +
+ )} +
+ )} +
{!isMobile && ( @@ -97,17 +219,19 @@ export function CalendarToolbar({
)} - {onImport && ( + {onImport && !isMobile && ( )} - + {!isMobile && ( + + )}
); } diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index a0f82a1e..2f526dc1 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -21,6 +21,7 @@ interface CalendarWeekViewProps { onCreateAtTime: (date: Date, endDate?: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; + isMobile?: boolean; } const HOUR_HEIGHT = 60; @@ -35,6 +36,7 @@ export function CalendarWeekView({ onCreateAtTime, firstDayOfWeek = 1, timeFormat = "24h", + isMobile, }: CalendarWeekViewProps) { const t = useTranslations("calendar"); const intlFormatter = useFormatter(); @@ -42,9 +44,13 @@ export function CalendarWeekView({ const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekDays = useMemo(() => { + if (isMobile) { + // Show 3-day window centered on selected date + return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1)); + } const start = startOfWeek(selectedDate, { weekStartsOn: weekStart }); return Array.from({ length: 7 }, (_, i) => addDays(start, i)); - }, [selectedDate, weekStart]); + }, [selectedDate, weekStart, isMobile]); const calendarMap = useMemo(() => { const map = new Map(); @@ -132,14 +138,16 @@ export function CalendarWeekView({ return format(new Date(2000, 0, 1, h), "HH:mm"); }; + const colCount = isMobile ? 3 : 7; + return (
{hasAllDay && (
-
+
{t("events.all_day")}
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayAllDay = allDayEvents.get(key) || []; @@ -165,8 +173,8 @@ export function CalendarWeekView({ )}
-
-
+
+
{weekDays.map((day) => { const todayCol = isToday(day); const selected = isSameDay(day, selectedDate); @@ -178,7 +186,7 @@ export function CalendarWeekView({ role="columnheader" aria-label={fullLabel} className={cn( - "text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors", + "text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation", "hover:bg-muted/50", todayCol && "font-bold", )} @@ -201,7 +209,7 @@ export function CalendarWeekView({
-
+
{HOURS.map((h) => (
{h > 0 && ( - + {formatHour(h)} )} @@ -217,7 +225,7 @@ export function CalendarWeekView({ ))}
-
+
{weekDays.map((day) => { const key = format(day, "yyyy-MM-dd"); const dayEvents = timedEvents.get(key) || []; diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 7f445e2b..970ce313 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -9,6 +9,7 @@ import { Pencil, Trash2, Copy, Send, Check, } from "lucide-react"; import { format, parseISO } from "date-fns"; +import { cn } from "@/lib/utils"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import { parseDuration, getEventColor } from "./event-card"; import { @@ -30,6 +31,7 @@ interface EventDetailPopoverProps { onRsvp?: (status: CalendarParticipant["participationStatus"]) => void; currentUserEmails?: string[]; timeFormat?: "12h" | "24h"; + isMobile?: boolean; } const POPOVER_WIDTH = 360; @@ -119,6 +121,7 @@ export function EventDetailPopover({ onRsvp, currentUserEmails = [], timeFormat = "24h", + isMobile, }: EventDetailPopoverProps) { const t = useTranslations("calendar"); const popoverRef = useRef(null); @@ -249,8 +252,16 @@ export function EventDetailPopover({ ref={popoverRef} role="dialog" aria-label={event.title || t("events.no_title")} - className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out" - style={{ + className={cn( + "fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out", + isMobile + ? "inset-0 rounded-none flex flex-col" + : "rounded-lg" + )} + style={isMobile ? { + opacity: 1, + transform: "none", + } : { width: POPOVER_WIDTH, maxHeight: MAX_HEIGHT, top: position?.top ?? -9999, @@ -301,7 +312,10 @@ export function EventDetailPopover({
{/* Content */} -
+
{/* Date & Time */}
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index e3c93269..550c1294 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -13,10 +13,11 @@ interface ContactDetailProps { contact: ContactCard | null; onEdit: () => void; onDelete: () => void; + isMobile?: boolean; className?: string; } -export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) { const t = useTranslations("contacts"); if (!contact) { @@ -38,23 +39,23 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD return (
-
-
+
+
- -
-

{name || "—"}

+ +
+

{name || "—"}

{orgs.length > 0 && orgs[0].name && ( -

{orgs[0].name}

+

{orgs[0].name}

)}
- -
@@ -72,10 +73,13 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD {e.contexts && ( )} -
+
@@ -90,7 +94,7 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD toast.error(t("detail.copy_failed")); } }} - className="p-1 rounded hover:bg-muted transition-colors" + className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation" title={t("detail.copy_email")} aria-label={t("detail.copy_email")} > @@ -121,7 +125,10 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD toast.error(t("detail.copy_failed")); } }} - className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100" + className={cn( + "p-1.5 rounded hover:bg-muted transition-colors touch-manipulation", + isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100" + )} title={t("detail.copy_phone")} aria-label={t("detail.copy_phone")} > diff --git a/components/contacts/contact-group-detail.tsx b/components/contacts/contact-group-detail.tsx index 76ad0c3e..9c3e46ac 100644 --- a/components/contacts/contact-group-detail.tsx +++ b/components/contacts/contact-group-detail.tsx @@ -15,6 +15,7 @@ interface ContactGroupDetailProps { onDelete: () => void; onRemoveMember: (memberId: string) => void; onSelectMember: (id: string) => void; + isMobile?: boolean; className?: string; } @@ -25,6 +26,7 @@ export function ContactGroupDetail({ onDelete, onRemoveMember, onSelectMember, + isMobile, className, }: ContactGroupDetailProps) { const t = useTranslations("contacts"); @@ -32,21 +34,21 @@ export function ContactGroupDetail({ return (
-
-
+
+
-
- +
+
-

{groupName}

+

{groupName}

{t("groups.member_count", { count: members.length })}

- @@ -54,7 +56,7 @@ export function ContactGroupDetail({ variant="outline" size="sm" onClick={onDelete} - className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950" + className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation" > @@ -95,7 +97,10 @@ export function ContactGroupDetail({ +
+

{t('new_message')}

+ {saveStatus === 'saving' && ( +
+ + {t('saving')} +
+ )} + {saveStatus === 'saved' && ( +
+ + {t('draft_saved')} +
+ )} + {saveStatus === 'error' && ( +
+ + {t('save_failed')} +
+ )} +
-
-
-
- {/* From field - show dropdown if multiple identities, otherwise display email */} -
- {t('from')}: -
+
+ {/* Fields section */} +
+ {/* From field */} +
+ {t('from')}: +
{identities.length > 1 ? ( ) : ( - + {subAddressTag ? ( {generateSubAddress(primaryIdentity?.email || '', subAddressTag)} @@ -615,9 +712,10 @@ export function EmailComposer({
-
- {t('to')}: -
+ {/* To field */} +
+ {t('to')}: +
handleAutoKeyDown(e, 'to')} onBlur={(e) => handleAutoBlur(e, 'to')} className={cn( - "border-0 focus-visible:ring-0", + "border-0 focus-visible:ring-0 h-8 px-0 text-sm", validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400" )} role="combobox" @@ -642,18 +740,18 @@ export function EmailComposer({ aria-invalid={validationErrors.to || undefined} /> {validationErrors.to && ( -

{t('validation.recipient_required')}

+

{t('validation.recipient_required')}

)} {activeAutoField === 'to' && autocompleteResults.length > 0 && ( insertAutocomplete(email, 'to')} /> )}
-
+
@@ -661,17 +759,18 @@ export function EmailComposer({ variant="ghost" size="sm" onClick={() => setShowBcc(!showBcc)} - className="text-xs" + className="text-xs h-7 px-2" > Bcc
+ {/* Cc field */} {showCc && ( -
- {t('cc_label')} -
+
+ {t('cc_label')} +
handleAutoKeyDown(e, 'cc')} onBlur={(e) => handleAutoBlur(e, 'cc')} - className="border-0 focus-visible:ring-0" + className="border-0 focus-visible:ring-0 h-8 px-0 text-sm" role="combobox" aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} aria-autocomplete="list" @@ -697,10 +796,11 @@ export function EmailComposer({
)} + {/* Bcc field */} {showBcc && ( -
- {t('bcc_label')} -
+
+ {t('bcc_label')} +
handleAutoKeyDown(e, 'bcc')} onBlur={(e) => handleAutoBlur(e, 'bcc')} - className="border-0 focus-visible:ring-0" + className="border-0 focus-visible:ring-0 h-8 px-0 text-sm" role="combobox" aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} aria-autocomplete="list" @@ -726,8 +826,9 @@ export function EmailComposer({
)} -
- {t('subject_label')} + {/* Subject field */} +
+ {t('subject_label')} ({ ...prev, subject: false })); }} className={cn( - "flex-1 border-0 focus-visible:ring-0", + "flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm", validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400" )} aria-invalid={validationErrors.subject || undefined} @@ -745,6 +846,7 @@ export function EmailComposer({
+ {/* Body */}