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.
This commit is contained in:
Linus Rath
2026-03-11 18:07:44 +01:00
parent 723aff38f2
commit d23e9a642c
12 changed files with 848 additions and 353 deletions
+55 -3
View File
@@ -1,8 +1,9 @@
"use client"; "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 { useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addWeeks, subWeeks, addDays, subDays, addMonths, subMonths, addWeeks, subWeeks, addDays, subDays,
@@ -14,6 +15,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store"; import { useIdentityStore } from "@/stores/identity-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useIsMobile } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-media-query";
import { Button } from "@/components/ui/button";
import { CalendarToolbar } from "@/components/calendar/calendar-toolbar"; import { CalendarToolbar } from "@/components/calendar/calendar-toolbar";
import { CalendarMonthView } from "@/components/calendar/calendar-month-view"; import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view"; import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
@@ -68,6 +70,9 @@ export default function CalendarPage() {
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null); const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const hasFetched = useRef(false); const hasFetched = useRef(false);
// Swipe navigation ref (handlers defined after navigatePrev/navigateNext)
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
useEffect(() => { useEffect(() => {
if (!isAuthenticated) { if (!isAuthenticated) {
router.push("/login"); router.push("/login");
@@ -155,10 +160,35 @@ export default function CalendarPage() {
setMiniMonth(new Date()); setMiniMonth(new Date());
}, [setSelectedDate]); }, [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) => { const handleSelectDate = useCallback((date: Date) => {
setSelectedDate(date); setSelectedDate(date);
setMiniMonth(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) => { const handleMiniMonthChange = useCallback((date: Date) => {
setMiniMonth(date); setMiniMonth(date);
@@ -549,6 +579,7 @@ export default function CalendarPage() {
onSelectDate={handleSelectDate} onSelectDate={handleSelectDate}
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
/> />
); );
case "week": case "week":
@@ -562,6 +593,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek} firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile}
/> />
); );
case "day": case "day":
@@ -573,6 +605,7 @@ export default function CalendarPage() {
onSelectEvent={handleSelectEvent} onSelectEvent={handleSelectEvent}
onCreateAtTime={openCreateModal} onCreateAtTime={openCreateModal}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile}
/> />
); );
case "agenda": case "agenda":
@@ -620,9 +653,16 @@ export default function CalendarPage() {
onCreateEvent={() => openCreateModal()} onCreateEvent={() => openCreateModal()}
onImport={() => setShowImportModal(true)} onImport={() => setShowImportModal(true)}
isMobile={isMobile} isMobile={isMobile}
calendars={calendars}
selectedCalendarIds={selectedCalendarIds}
onToggleVisibility={toggleCalendarVisibility}
/> />
<div className="flex flex-1 overflow-hidden"> <div
className="flex flex-1 overflow-hidden relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{!isMobile && ( {!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0"> <div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar <MiniCalendar
@@ -642,6 +682,17 @@ export default function CalendarPage() {
)} )}
{renderView()} {renderView()}
{/* Floating Create Event Button (mobile) */}
{isMobile && (
<Button
onClick={() => openCreateModal()}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t("events.create")}
>
<Plus className="h-6 w-6" />
</Button>
)}
</div> </div>
{/* Mobile Bottom Navigation */} {/* Mobile Bottom Navigation */}
@@ -663,6 +714,7 @@ export default function CalendarPage() {
onRsvp={handleRsvpFromDetail} onRsvp={handleRsvpFromDetail}
currentUserEmails={currentUserEmails} currentUserEmails={currentUserEmails}
timeFormat={timeFormat} timeFormat={timeFormat}
isMobile={isMobile}
/> />
)} )}
+136 -104
View File
@@ -336,6 +336,7 @@ export default function ContactsPage() {
onEdit={handleEditGroup} onEdit={handleEditGroup}
onDelete={handleDeleteGroup} onDelete={handleDeleteGroup}
onRemoveMember={handleRemoveGroupMember} onRemoveMember={handleRemoveGroupMember}
isMobile={isMobile}
onSelectMember={(id) => { onSelectMember={(id) => {
setSelectedContact(id); setSelectedContact(id);
setActiveTab("all"); setActiveTab("all");
@@ -422,11 +423,20 @@ export default function ContactsPage() {
contact={selectedContact} contact={selectedContact}
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
isMobile={isMobile}
/> />
); );
} }
}; };
const showListPanel = !isMobile || view === "list";
const showRightPanel = !isMobile || view !== "list";
const mobileBackToList = () => {
setView("list");
clearSelection();
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
{!isMobile && ( {!isMobile && (
@@ -437,112 +447,134 @@ export default function ContactsPage() {
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div className="w-80 border-r border-border flex flex-col flex-shrink-0"> {showListPanel && (
<div className="p-4 border-b border-border"> <div className={cn(
<div className="flex items-center justify-between"> "border-r border-border flex flex-col flex-shrink-0",
<Button isMobile ? "w-full" : "w-80"
variant="ghost" )}>
size="sm" <div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
onClick={() => router.push("/")} <div className="flex items-center justify-between">
className="justify-start" <Button
> variant="ghost"
<ArrowLeft className="w-4 h-4 mr-2" /> size="sm"
{t("back_to_mail")} onClick={() => router.push("/")}
</Button> className="justify-start"
<div className="flex gap-1"> >
<Button <ArrowLeft className="w-4 h-4 mr-2" />
variant="ghost" {t("back_to_mail")}
size="icon" </Button>
className="h-8 w-8" <div className="flex gap-1">
onClick={() => setView("import")} <Button
title={t("import.title")} variant="ghost"
> size="icon"
<Upload className="w-4 h-4" /> className="h-8 w-8"
</Button> onClick={() => setView("import")}
<Button title={t("import.title")}
variant="ghost" >
size="icon" <Upload className="w-4 h-4" />
className="h-8 w-8" </Button>
onClick={() => { <Button
if (contacts.length > 0) { variant="ghost"
exportContacts(contacts.filter(c => c.kind !== "group")); size="icon"
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length })); className="h-8 w-8"
} onClick={() => {
}} if (contacts.length > 0) {
title={t("export.title")} exportContacts(contacts.filter(c => c.kind !== "group"));
> toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
<Download className="w-4 h-4" /> }
</Button> }}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab("all")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors touch-manipulation",
activeTab === "all"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<BookUser className="w-4 h-4" />
{t("tabs.all")}
</button>
<button
onClick={() => setActiveTab("groups")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors touch-manipulation",
activeTab === "groups"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
<Users className="w-4 h-4" />
{t("tabs.groups")}
{groups.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
{groups.length}
</span>
)}
</button>
</div>
{activeTab === "all" ? (
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
) : (
<ContactGroupList
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={handleSelectGroup}
onCreateGroup={handleCreateGroup}
searchQuery={searchQuery}
className="flex-1"
/>
)}
</div> </div>
</div> )}
</div>
<div className="flex border-b border-border"> {showRightPanel && (
<button <div className="flex-1 min-w-0 flex flex-col">
onClick={() => setActiveTab("all")} {isMobile && (
className={cn( <div className="px-3 py-2 border-b border-border">
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors", <Button
activeTab === "all" variant="ghost"
? "border-b-2 border-primary text-primary" size="sm"
: "text-muted-foreground hover:text-foreground" onClick={mobileBackToList}
)} className="touch-manipulation"
> >
<BookUser className="w-4 h-4" /> <ArrowLeft className="w-4 h-4 mr-2" />
{t("tabs.all")} {t("back_to_mail")}
</button> </Button>
<button </div>
onClick={() => setActiveTab("groups")} )}
className={cn( <div className="flex-1 min-h-0">
"flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-sm font-medium transition-colors", {renderRightPanel()}
activeTab === "groups" </div>
? "border-b-2 border-primary text-primary" </div>
: "text-muted-foreground hover:text-foreground" )}
)}
>
<Users className="w-4 h-4" />
{t("tabs.groups")}
{groups.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-muted">
{groups.length}
</span>
)}
</button>
</div>
{activeTab === "all" ? (
<ContactList
contacts={contacts}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectContact={handleSelectContact}
onCreateNew={handleCreateNew}
onImport={() => setView("import")}
supportsSync={supportsSync}
className="flex-1"
selectedContactIds={selectedContactIds}
onToggleSelection={toggleContactSelection}
onSelectAll={selectAllContacts}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkAddToGroup={handleBulkAddToGroup}
onBulkExport={handleBulkExport}
/>
) : (
<ContactGroupList
groups={groups}
selectedGroupId={selectedGroupId}
onSelectGroup={handleSelectGroup}
onCreateGroup={handleCreateGroup}
searchQuery={searchQuery}
className="flex-1"
/>
)}
</div>
<div className="flex-1 min-w-0">
{renderRightPanel()}
</div>
</div> </div>
{isMobile && ( {isMobile && (
+119 -46
View File
@@ -7,6 +7,7 @@ import { Sidebar } from "@/components/layout/sidebar";
import { EmailList } from "@/components/email/email-list"; import { EmailList } from "@/components/email/email-list";
import { EmailViewer } from "@/components/email/email-viewer"; import { EmailViewer } from "@/components/email/email-viewer";
import { EmailComposer } from "@/components/email/email-composer"; import { EmailComposer } from "@/components/email/email-composer";
import type { ComposerDraftData } from "@/components/email/email-composer";
import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header"; import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email } from "@/lib/jmap/types"; import { ThreadGroup, Email } from "@/lib/jmap/types";
@@ -18,6 +19,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
import { playNotificationSound } from "@/lib/notification-sound"; import { playNotificationSound } from "@/lib/notification-sound";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -28,12 +30,13 @@ import {
EmailViewerErrorFallback, EmailViewerErrorFallback,
ComposerErrorFallback, ComposerErrorFallback,
} from "@/components/error"; } from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context"; import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils"; import { isFilterEmpty, activeFilterCount } 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 { Input } from "@/components/ui/input"; 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 { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -44,6 +47,8 @@ export default function Home() {
const [showComposer, setShowComposer] = useState(false); const [showComposer, setShowComposer] = useState(false);
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
const [composerDraftText, setComposerDraftText] = useState(""); const [composerDraftText, setComposerDraftText] = useState("");
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [initialCheckDone, setInitialCheckDone] = useState(false); const [initialCheckDone, setInitialCheckDone] = useState(false);
const [showShortcutsModal, setShowShortcutsModal] = useState(false); const [showShortcutsModal, setShowShortcutsModal] = useState(false);
const [showAdvancedFields, setShowAdvancedFields] = useState(false); const [showAdvancedFields, setShowAdvancedFields] = useState(false);
@@ -170,6 +175,7 @@ export default function Home() {
onCompose: () => { onCompose: () => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}, },
onFocusSearch: () => { onFocusSearch: () => {
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement; const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
@@ -422,16 +428,19 @@ export default function Home() {
setComposerDraftText(draftText || ""); setComposerDraftText(draftText || "");
setComposerMode('reply'); setComposerMode('reply');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const handleReplyAll = () => { const handleReplyAll = () => {
setComposerMode('replyAll'); setComposerMode('replyAll');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const handleForward = () => { const handleForward = () => {
setComposerMode('forward'); setComposerMode('forward');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const handleDelete = async () => { const handleDelete = async () => {
@@ -682,6 +691,11 @@ export default function Home() {
const handleEmailSelect = async (email: { id: string }) => { const handleEmailSelect = async (email: { id: string }) => {
if (!client || !email) return; 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) // Set loading state immediately (keep current email visible)
setLoadingEmail(true); setLoadingEmail(true);
@@ -751,18 +765,21 @@ export default function Home() {
selectEmail(email); selectEmail(email);
setComposerMode('reply'); setComposerMode('reply');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const handleConversationReplyAll = (email: Email) => { const handleConversationReplyAll = (email: Email) => {
selectEmail(email); selectEmail(email);
setComposerMode('replyAll'); setComposerMode('replyAll');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const handleConversationForward = (email: Email) => { const handleConversationForward = (email: Email) => {
selectEmail(email); selectEmail(email);
setComposerMode('forward'); setComposerMode('forward');
setShowComposer(true); setShowComposer(true);
if (isMobile) setActiveView('viewer');
}; };
const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => ( 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={() => { onCompose={() => {
setComposerMode('compose'); setComposerMode('compose');
setShowComposer(true); setShowComposer(true);
if (isMobile) setSidebarOpen(false); if (isMobile) {
setSidebarOpen(false);
setActiveView('viewer');
}
}} }}
onSidebarClose={() => setSidebarOpen(false)} onSidebarClose={() => setSidebarOpen(false)}
/> />
@@ -847,7 +867,7 @@ export default function Home() {
{/* Email List - full width on mobile, fixed width on tablet/desktop */} {/* Email List - full width on mobile, fixed width on tablet/desktop */}
<div <div
className={cn( className={cn(
"flex flex-col h-full bg-background border-r border-border", "relative flex flex-col h-full bg-background border-r border-border",
// Mobile: full width, hidden when viewing email // Mobile: full width, hidden when viewing email
"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",
@@ -862,10 +882,6 @@ export default function Home() {
{/* Mobile Header for List View */} {/* Mobile Header for List View */}
<MobileHeader <MobileHeader
title={currentMailboxName} title={currentMailboxName}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
/> />
{/* Search Bar + Inline Advanced Filters */} {/* Search Bar + Inline Advanced Filters */}
@@ -1105,6 +1121,21 @@ export default function Home() {
/> />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
{/* Floating Compose Button (mobile) */}
{isMobile && (
<Button
onClick={() => {
setComposerMode('compose');
setShowComposer(true);
setActiveView('viewer');
}}
className="absolute bottom-4 right-4 z-40 h-14 w-14 rounded-full shadow-lg"
aria-label={t('sidebar.compose')}
>
<PenSquare className="h-6 w-6" />
</Button>
)}
</div> </div>
{/* Email list resize handle (desktop only) */} {/* 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 */}
<div <div
className={cn( className={cn(
"flex flex-col h-full bg-background", "flex flex-col h-full bg-background",
@@ -1127,6 +1158,82 @@ export default function Home() {
"md:flex-1 md:min-w-0 md:relative" "md:flex-1 md:min-w-0 md:relative"
)} )}
> >
{/* Inline Composer - shown in viewer pane */}
{showComposer ? (
<ErrorBoundary
fallback={ComposerErrorFallback}
onReset={() => {
setShowComposer(false);
setComposerMode('compose');
}}
>
<EmailComposer
mode={pendingDraft?.mode ?? composerMode}
replyTo={pendingDraft?.replyTo ?? (selectedEmail ? {
from: selectedEmail.from,
to: selectedEmail.to,
cc: selectedEmail.cc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined)}
initialDraftText={composerDraftText}
initialData={pendingDraft}
onSaveState={(data) => 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);
}}
/>
</ErrorBoundary>
) : (
<>
{/* Pending draft banner */}
{pendingDraft && (
<button
onClick={() => {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left"
>
<PenLine className="w-4 h-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
{pendingDraft.subject && (
<span className="text-xs text-muted-foreground ml-2 truncate">{pendingDraft.subject}</span>
)}
</div>
<X
className="w-4 h-4 text-muted-foreground hover:text-foreground shrink-0"
onClick={async (e) => {
e.stopPropagation();
const confirmed = await confirmDialog({
title: t('email_composer.discard_draft_title'),
message: t('email_composer.discard_draft_confirm'),
confirmText: t('email_composer.discard'),
variant: "destructive",
});
if (confirmed) {
setPendingDraft(null);
}
}}
/>
</button>
)}
{/* Mobile Conversation View - shown when thread is selected on mobile */} {/* Mobile Conversation View - shown when thread is selected on mobile */}
{isMobile && conversationThread ? ( {isMobile && conversationThread ? (
<ThreadConversationView <ThreadConversationView
@@ -1187,6 +1294,8 @@ export default function Home() {
</ErrorBoundary> </ErrorBoundary>
</> </>
)} )}
</>
)}
</div> </div>
</div> </div>
@@ -1196,44 +1305,6 @@ export default function Home() {
)} )}
</div> </div>
{/* Email Composer Modal */}
{showComposer && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0">
<div className={cn(
"w-full h-full lg:h-[600px] lg:max-w-3xl",
"max-lg:flex max-lg:flex-col"
)}>
<ErrorBoundary
fallback={ComposerErrorFallback}
onReset={() => {
setShowComposer(false);
setComposerMode('compose');
}}
>
<EmailComposer
mode={composerMode}
replyTo={selectedEmail ? {
from: selectedEmail.from,
to: selectedEmail.to,
cc: selectedEmail.cc,
subject: selectedEmail.subject,
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
receivedAt: selectedEmail.receivedAt
} : undefined}
initialDraftText={composerDraftText}
onSend={handleEmailSend}
onClose={() => {
setShowComposer(false);
setComposerMode('compose');
setComposerDraftText("");
}}
onDiscardDraft={handleDiscardDraft}
/>
</ErrorBoundary>
</div>
</div>
)}
{/* Keyboard Shortcuts Modal */} {/* Keyboard Shortcuts Modal */}
<KeyboardShortcutsModal <KeyboardShortcutsModal
isOpen={showShortcutsModal} isOpen={showShortcutsModal}
@@ -1242,6 +1313,8 @@ export default function Home() {
{/* Screen reader live region for dynamic status announcements */} {/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" /> <div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<ConfirmDialog {...confirmDialogProps} />
</div> </div>
</DragDropProvider> </DragDropProvider>
); );
+11 -6
View File
@@ -17,6 +17,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean;
} }
const HOUR_HEIGHT = 64; const HOUR_HEIGHT = 64;
@@ -29,6 +30,7 @@ export function CalendarDayView({
onSelectEvent, onSelectEvent,
onCreateAtTime, onCreateAtTime,
timeFormat = "24h", timeFormat = "24h",
isMobile,
}: CalendarDayViewProps) { }: CalendarDayViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -110,9 +112,12 @@ export function CalendarDayView({
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}> <div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="px-4 py-3 border-b border-border"> <div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("text-lg font-semibold", today && "text-primary")}> <h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{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" })
}
</h3> </h3>
</div> </div>
@@ -138,15 +143,15 @@ export function CalendarDayView({
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-16 flex-shrink-0"> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div <div
key={h} key={h}
className="relative text-muted-foreground text-right pr-3" className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
> >
{h > 0 && ( {h > 0 && (
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none"> <span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
{formatHour(h)} {formatHour(h)}
</span> </span>
)} )}
+57 -25
View File
@@ -21,6 +21,7 @@ interface CalendarMonthViewProps {
onSelectDate: (date: Date) => void; onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
isMobile?: boolean;
} }
export function CalendarMonthView({ export function CalendarMonthView({
@@ -30,6 +31,7 @@ export function CalendarMonthView({
onSelectDate, onSelectDate,
onSelectEvent, onSelectEvent,
firstDayOfWeek = 1, firstDayOfWeek = 1,
isMobile,
}: CalendarMonthViewProps) { }: CalendarMonthViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -125,22 +127,28 @@ export function CalendarMonthView({
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}> <div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="grid grid-cols-7 border-b border-border" role="row"> <div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => ( {dayHeaders.map((d) => (
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0"> <div key={d} role="columnheader" className={cn(
{t(`days.${d}`)} "text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
</div> </div>
))} ))}
</div> </div>
<div className="flex-1 flex flex-col overflow-y-auto"> <div className="flex-1 flex flex-col overflow-y-auto">
{weeks.map((week, wi) => ( {weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row"> <div key={wi} className={cn(
"grid grid-cols-7 flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row">
{week.map((day) => { {week.map((day) => {
const inMonth = isSameMonth(day, selectedDate); const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
const today = isToday(day); const today = isToday(day);
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || []; 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" }); const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return ( return (
@@ -154,16 +162,18 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave} onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)} onDrop={(e) => handleCellDrop(e, day)}
className={cn( 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", !inMonth && "bg-muted/30",
"hover:bg-muted/50", "hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10" dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
)} )}
> >
<div className="flex items-center justify-center mb-0.5"> <div className="flex items-center justify-center mb-0.5">
<span <span
className={cn( className={cn(
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full", "inline-flex items-center justify-center rounded-full",
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
today && !selected && "bg-primary text-primary-foreground font-bold", today && !selected && "bg-primary text-primary-foreground font-bold",
selected && "bg-primary text-primary-foreground font-bold", selected && "bg-primary text-primary-foreground font-bold",
!inMonth && !selected && !today && "text-muted-foreground/50", !inMonth && !selected && !today && "text-muted-foreground/50",
@@ -173,26 +183,48 @@ export function CalendarMonthView({
{format(day, "d")} {format(day, "d")}
</span> </span>
</div> </div>
<div className="space-y-0.5"> {isMobile ? (
{dayEvents.slice(0, maxVisible).map((ev) => { dayEvents.length > 0 && (
const calId = Object.keys(ev.calendarIds)[0]; <div className="flex items-center justify-center gap-0.5 flex-wrap">
return ( {dayEvents.slice(0, 3).map((ev) => {
<EventCard const calId = Object.keys(ev.calendarIds)[0];
key={ev.id} const cal = calendarMap.get(calId);
event={ev} const evColor = ev.color || cal?.color || "#3b82f6";
calendar={calendarMap.get(calId)} return (
variant="chip" <span
onClick={(rect) => onSelectEvent(ev, rect)} key={ev.id}
draggable className="w-1.5 h-1.5 rounded-full"
/> style={{ backgroundColor: evColor }}
); />
})} );
{dayEvents.length > maxVisible && ( })}
<div className="text-[10px] text-muted-foreground px-1"> {dayEvents.length > 3 && (
{t("events.more", { count: dayEvents.length - maxVisible })} <span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</div> </div>
)} )
</div> ) : (
<div className="space-y-0.5">
{dayEvents.slice(0, maxVisible).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
return (
<EventCard
key={ev.id}
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
draggable
/>
);
})}
{dayEvents.length > maxVisible && (
<div className="text-[10px] text-muted-foreground px-1">
{t("events.more", { count: dayEvents.length - maxVisible })}
</div>
)}
</div>
)}
</div> </div>
); );
})} })}
+140 -16
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl"; import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button"; 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 { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store"; import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
interface CalendarToolbarProps { interface CalendarToolbarProps {
selectedDate: Date; selectedDate: Date;
@@ -19,6 +21,9 @@ interface CalendarToolbarProps {
isMobile?: boolean; isMobile?: boolean;
firstDayOfWeek?: number; firstDayOfWeek?: number;
onNavigateBack?: () => void; onNavigateBack?: () => void;
calendars?: Calendar[];
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
} }
export function CalendarToolbar({ export function CalendarToolbar({
@@ -32,18 +37,39 @@ export function CalendarToolbar({
onImport, onImport,
isMobile, isMobile,
firstDayOfWeek = 1, firstDayOfWeek = 1,
calendars,
selectedCalendarIds,
onToggleVisibility,
}: CalendarToolbarProps) { }: CalendarToolbarProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const formatter = useFormatter(); const formatter = useFormatter();
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"]; const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 => { const getDateLabel = (): string => {
switch (viewMode) { switch (viewMode) {
case "month": 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": { case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 }); const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6); 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(); const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) { if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`; 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()}`; return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
} }
case "day": 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": 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<HTMLDivElement>(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 ( return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap"> <div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}> <button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<span className="text-sm font-medium min-w-[140px] text-center"> <span className={cn("text-sm font-medium text-center", isMobile ? "min-w-[80px]" : "min-w-[140px]")}>
{getDateLabel()} {getDateLabel()}
</span> </span>
<button onClick={onNext} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_next")}> <button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
<Button variant="outline" size="sm" onClick={onToday}> <Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
{t("views.today")} {t("views.today")}
</Button> </Button>
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowCalendarDropdown((v) => !v)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
</Button>
{showViewDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1" /> <div className="flex-1" />
{!isMobile && ( {!isMobile && (
@@ -97,17 +219,19 @@ export function CalendarToolbar({
</div> </div>
)} )}
{onImport && ( {onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}> <Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" /> <Upload className="w-4 h-4 mr-1" />
{!isMobile && t("import.title")} {t("import.title")}
</Button> </Button>
)} )}
<Button size="sm" onClick={onCreateEvent}> {!isMobile && (
<Plus className="w-4 h-4 mr-1" /> <Button size="sm" onClick={onCreateEvent}>
{!isMobile && t("events.create")} <Plus className="w-4 h-4 mr-1" />
</Button> {t("events.create")}
</Button>
)}
</div> </div>
); );
} }
+17 -9
View File
@@ -21,6 +21,7 @@ interface CalendarWeekViewProps {
onCreateAtTime: (date: Date, endDate?: Date) => void; onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number; firstDayOfWeek?: number;
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean;
} }
const HOUR_HEIGHT = 60; const HOUR_HEIGHT = 60;
@@ -35,6 +36,7 @@ export function CalendarWeekView({
onCreateAtTime, onCreateAtTime,
firstDayOfWeek = 1, firstDayOfWeek = 1,
timeFormat = "24h", timeFormat = "24h",
isMobile,
}: CalendarWeekViewProps) { }: CalendarWeekViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const intlFormatter = useFormatter(); const intlFormatter = useFormatter();
@@ -42,9 +44,13 @@ export function CalendarWeekView({
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1; const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => { 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 }); const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i)); return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart]); }, [selectedDate, weekStart, isMobile]);
const calendarMap = useMemo(() => { const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>(); const map = new Map<string, Calendar>();
@@ -132,14 +138,16 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm"); return format(new Date(2000, 0, 1, h), "HH:mm");
}; };
const colCount = isMobile ? 3 : 7;
return ( return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}> <div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && ( {hasAllDay && (
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<div className="w-14 flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right"> <div className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}>
{t("events.all_day")} {t("events.all_day")}
</div> </div>
<div className="flex-1 grid grid-cols-7 gap-px bg-border"> <div className={cn("flex-1 grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}>
{weekDays.map((day) => { {weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayAllDay = allDayEvents.get(key) || []; const dayAllDay = allDayEvents.get(key) || [];
@@ -165,8 +173,8 @@ export function CalendarWeekView({
)} )}
<div className="flex border-b border-border" role="row"> <div className="flex border-b border-border" role="row">
<div className="w-14 flex-shrink-0" /> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className="flex-1 grid grid-cols-7 border-l border-border"> <div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => { {weekDays.map((day) => {
const todayCol = isToday(day); const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
@@ -178,7 +186,7 @@ export function CalendarWeekView({
role="columnheader" role="columnheader"
aria-label={fullLabel} aria-label={fullLabel}
className={cn( 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", "hover:bg-muted/50",
todayCol && "font-bold", todayCol && "font-bold",
)} )}
@@ -201,7 +209,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto"> <div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}> <div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-14 flex-shrink-0"> <div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
{HOURS.map((h) => ( {HOURS.map((h) => (
<div <div
key={h} key={h}
@@ -209,7 +217,7 @@ export function CalendarWeekView({
style={{ height: HOUR_HEIGHT }} style={{ height: HOUR_HEIGHT }}
> >
{h > 0 && ( {h > 0 && (
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none"> <span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{formatHour(h)} {formatHour(h)}
</span> </span>
)} )}
@@ -217,7 +225,7 @@ export function CalendarWeekView({
))} ))}
</div> </div>
<div className="flex-1 grid grid-cols-7 border-l border-border relative"> <div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => { {weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd"); const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || []; const dayEvents = timedEvents.get(key) || [];
+17 -3
View File
@@ -9,6 +9,7 @@ import {
Pencil, Trash2, Copy, Send, Check, Pencil, Trash2, Copy, Send, Check,
} from "lucide-react"; } from "lucide-react";
import { format, parseISO } from "date-fns"; import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types"; import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card"; import { parseDuration, getEventColor } from "./event-card";
import { import {
@@ -30,6 +31,7 @@ interface EventDetailPopoverProps {
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void; onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
currentUserEmails?: string[]; currentUserEmails?: string[];
timeFormat?: "12h" | "24h"; timeFormat?: "12h" | "24h";
isMobile?: boolean;
} }
const POPOVER_WIDTH = 360; const POPOVER_WIDTH = 360;
@@ -119,6 +121,7 @@ export function EventDetailPopover({
onRsvp, onRsvp,
currentUserEmails = [], currentUserEmails = [],
timeFormat = "24h", timeFormat = "24h",
isMobile,
}: EventDetailPopoverProps) { }: EventDetailPopoverProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@@ -249,8 +252,16 @@ export function EventDetailPopover({
ref={popoverRef} ref={popoverRef}
role="dialog" role="dialog"
aria-label={event.title || t("events.no_title")} 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" className={cn(
style={{ "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, width: POPOVER_WIDTH,
maxHeight: MAX_HEIGHT, maxHeight: MAX_HEIGHT,
top: position?.top ?? -9999, top: position?.top ?? -9999,
@@ -301,7 +312,10 @@ export function EventDetailPopover({
</div> </div>
{/* Content */} {/* Content */}
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}> <div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */} {/* Date & Time */}
<div className="flex items-start gap-2.5"> <div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" /> <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
+20 -13
View File
@@ -13,10 +13,11 @@ interface ContactDetailProps {
contact: ContactCard | null; contact: ContactCard | null;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
isMobile?: boolean;
className?: string; className?: string;
} }
export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) { export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
if (!contact) { if (!contact) {
@@ -38,23 +39,23 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
return ( return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}> <div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border"> <div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className="flex items-start justify-between"> <div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Avatar name={name} email={email} size="lg" /> <Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div> <div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold">{name || "—"}</h2> <h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
{orgs.length > 0 && orgs[0].name && ( {orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground">{orgs[0].name}</p> <p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)} )}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}> <Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" /> <Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")} {t("form.edit_title")}
</Button> </Button>
<Button 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"> <Button 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 touch-manipulation">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
</div> </div>
@@ -72,10 +73,13 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{e.contexts && ( {e.contexts && (
<ContextBadge contexts={e.contexts} /> <ContextBadge contexts={e.contexts} />
)} )}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a <a
href={`mailto:${e.address}`} href={`mailto:${e.address}`}
className="p-1 rounded hover:bg-muted transition-colors" className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")} title={t("detail.compose_email")}
aria-label={t("detail.compose_email")} aria-label={t("detail.compose_email")}
> >
@@ -90,7 +94,7 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
toast.error(t("detail.copy_failed")); 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")} title={t("detail.copy_email")}
aria-label={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")); 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")} title={t("detail.copy_phone")}
aria-label={t("detail.copy_phone")} aria-label={t("detail.copy_phone")}
> >
+13 -8
View File
@@ -15,6 +15,7 @@ interface ContactGroupDetailProps {
onDelete: () => void; onDelete: () => void;
onRemoveMember: (memberId: string) => void; onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void; onSelectMember: (id: string) => void;
isMobile?: boolean;
className?: string; className?: string;
} }
@@ -25,6 +26,7 @@ export function ContactGroupDetail({
onDelete, onDelete,
onRemoveMember, onRemoveMember,
onSelectMember, onSelectMember,
isMobile,
className, className,
}: ContactGroupDetailProps) { }: ContactGroupDetailProps) {
const t = useTranslations("contacts"); const t = useTranslations("contacts");
@@ -32,21 +34,21 @@ export function ContactGroupDetail({
return ( return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}> <div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border"> <div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className="flex items-start justify-between"> <div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center"> <div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
<Users className="w-7 h-7 text-primary" /> <Users className={cn("text-primary", isMobile ? "w-6 h-6" : "w-7 h-7")} />
</div> </div>
<div> <div>
<h2 className="text-xl font-semibold">{groupName}</h2> <h2 className={cn("font-semibold", isMobile ? "text-lg" : "text-xl")}>{groupName}</h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("groups.member_count", { count: members.length })} {t("groups.member_count", { count: members.length })}
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}> <Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" /> <Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")} {t("form.edit_title")}
</Button> </Button>
@@ -54,7 +56,7 @@ export function ContactGroupDetail({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onDelete} 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"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</Button> </Button>
@@ -95,7 +97,10 @@ export function ContactGroupDetail({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity" className={cn(
"h-8 w-8 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={() => onRemoveMember(member.id)} onClick={() => onRemoveMember(member.id)}
> >
<UserMinus className="w-4 h-4 text-muted-foreground" /> <UserMinus className="w-4 h-4 text-muted-foreground" />
+258 -119
View File
@@ -2,11 +2,9 @@
import React, { useState, useEffect, useRef, useCallback } from "react"; import React, { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react"; import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react";
import { cn, formatFileSize } from "@/lib/utils"; import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
@@ -21,6 +19,21 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form"; import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types"; import type { EmailTemplate } from "@/lib/template-types";
export interface ComposerDraftData {
to: string;
cc: string;
bcc: string;
subject: string;
body: string;
showCc: boolean;
showBcc: boolean;
selectedIdentityId: string | null;
subAddressTag: string;
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
}
interface EmailComposerProps { interface EmailComposerProps {
onSend?: (data: { onSend?: (data: {
to: string[]; to: string[];
@@ -35,8 +48,10 @@ interface EmailComposerProps {
}) => void | Promise<void>; }) => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string; className?: string;
initialDraftText?: string; initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward'; mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: { replyTo?: {
from?: { email?: string; name?: string }[]; from?: { email?: string; name?: string }[];
@@ -52,8 +67,10 @@ export function EmailComposer({
onSend, onSend,
onClose, onClose,
onDiscardDraft, onDiscardDraft,
onSaveState,
className, className,
initialDraftText, initialDraftText,
initialData,
mode = 'compose', mode = 'compose',
replyTo replyTo
}: EmailComposerProps) { }: EmailComposerProps) {
@@ -106,14 +123,14 @@ export function EmailComposer({
return prefix; return prefix;
}; };
const [to, setTo] = useState(getInitialTo()); const [to, setTo] = useState(initialData?.to ?? getInitialTo());
const [cc, setCc] = useState(getInitialCc()); const [cc, setCc] = useState(initialData?.cc ?? getInitialCc());
const [bcc, setBcc] = useState(""); const [bcc, setBcc] = useState(initialData?.bcc ?? "");
const [subject, setSubject] = useState(getInitialSubject()); const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
const [body, setBody] = useState(getInitialBody()); const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(!!getInitialCc()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(false); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(null); const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>(""); const lastSavedDataRef = useRef<string>("");
@@ -121,11 +138,11 @@ export function EmailComposer({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({}); const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null); const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null); const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(''); const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false); const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); const [showCloseDialog, setShowCloseDialog] = useState(false);
const saveTemplateModalRef = useFocusTrap({ const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate, isActive: showSaveAsTemplate,
@@ -133,9 +150,56 @@ export function EmailComposer({
restoreFocus: true, restoreFocus: true,
}); });
const closeDialogRef = useFocusTrap({
isActive: showCloseDialog,
onEscape: () => setShowCloseDialog(false),
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore(); const { client, identities, primaryIdentity } = useAuthStore();
const getAutocomplete = useContactStore((s) => s.getAutocomplete); const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate); const addTemplate = useTemplateStore((s) => s.addTemplate);
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount;
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (onSaveState && isDirtyRef.current) {
const s = stateRef.current;
onSaveState({
...s,
mode,
replyTo,
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-save draft to server on page close (best-effort)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current) {
saveDraftRef.current();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]); const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -389,15 +453,18 @@ export function EmailComposer({
} }
}; };
// Trigger auto-save when content changes // Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
// Trigger auto-save when content changes (only if user modified something)
useEffect(() => { useEffect(() => {
// Clear existing timeout // Clear existing timeout
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current); clearTimeout(saveTimeoutRef.current);
} }
// Don't auto-save if there's no content // Don't auto-save if nothing has changed from initial state
if (!to && !subject && !body) { if (!isDirtyRef.current) {
return; return;
} }
@@ -498,77 +565,107 @@ export function EmailComposer({
setDraftId(null); setDraftId(null);
setSubAddressTag(""); setSubAddressTag("");
setValidationErrors({}); setValidationErrors({});
// Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
} catch (err) { } catch (err) {
debug.error('Failed to send email:', err); debug.error('Failed to send email:', err);
toast.error(t('send_failed')); toast.error(t('send_failed'));
} }
}; };
const handleClose = async () => { const cleanClose = () => {
if (draftId && (to || subject || body)) { if (saveTimeoutRef.current) {
const confirmed = await confirm({ clearTimeout(saveTimeoutRef.current);
title: t('discard_draft_title'), }
message: t('discard_draft_confirm'), stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
confirmText: t('discard'), onClose?.();
variant: "destructive", };
});
if (confirmed) { const handleSaveDraftAndClose = async () => {
if (saveTimeoutRef.current) { setShowCloseDialog(false);
clearTimeout(saveTimeoutRef.current); if (saveTimeoutRef.current) {
} clearTimeout(saveTimeoutRef.current);
}
await saveDraft();
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
if (onDiscardDraft) { const handleDiscardAndClose = () => {
onDiscardDraft(draftId); setShowCloseDialog(false);
} if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
if (draftId && onDiscardDraft) {
onDiscardDraft(draftId);
}
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
onClose?.(); const handleClose = () => {
} if (isDirtyRef.current) {
setShowCloseDialog(true);
} else { } else {
onClose?.(); cleanClose();
} }
}; };
return ( return (
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}> <div className={cn("flex flex-col h-full bg-background", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b"> {/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<h3 className="font-semibold">{t('new_message')}</h3> <div className="flex items-center gap-3">
{saveStatus === 'saving' && ( <Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<div className="flex items-center gap-1 text-xs text-muted-foreground"> <X className="w-5 h-5 md:w-4 md:h-4" />
<Save className="w-3 h-3 animate-pulse" /> </Button>
<span>{t('saving')}</span> <div className="flex items-center gap-2">
</div> <h3 className="font-semibold text-base">{t('new_message')}</h3>
)} {saveStatus === 'saving' && (
{saveStatus === 'saved' && ( <div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1 text-xs text-green-600"> <Save className="w-3 h-3 animate-pulse" />
<Check className="w-3 h-3" /> <span className="hidden md:inline">{t('saving')}</span>
<span>{t('draft_saved')}</span> </div>
</div> )}
)} {saveStatus === 'saved' && (
{saveStatus === 'error' && ( <div className="flex items-center gap-1 text-xs text-green-600">
<div className="flex items-center gap-1 text-xs text-red-600"> <Check className="w-3 h-3" />
<X className="w-3 h-3" /> <span className="hidden md:inline">{t('draft_saved')}</span>
<span>{t('save_failed')}</span> </div>
</div> )}
)} {saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span className="hidden md:inline">{t('save_failed')}</span>
</div>
)}
</div>
</div> </div>
<Button variant="ghost" size="icon" onClick={handleClose}> {/* Mobile: send button in header */}
<X className="w-4 h-4" /> <Button
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 mr-1.5" />
{t('send')}
</Button> </Button>
</div> </div>
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col min-h-0">
<div className="space-y-2 px-4 py-3 border-b"> {/* Fields section */}
{/* From field - show dropdown if multiple identities, otherwise display email */} <div className="space-y-0 border-b">
<div className="flex items-center gap-2"> {/* From field */}
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<div className="flex-1 flex items-center gap-1"> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1 min-w-0">
{identities.length > 1 ? ( {identities.length > 1 ? (
<select <select
value={selectedIdentityId || primaryIdentity?.id || ''} value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)} onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors" className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
> >
{identities.map((identity) => ( {identities.map((identity) => (
<option key={identity.id} value={identity.id}> <option key={identity.id} value={identity.id}>
@@ -577,7 +674,7 @@ export function EmailComposer({
))} ))}
</select> </select>
) : ( ) : (
<span className="text-sm text-foreground flex-1"> <span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? ( {subAddressTag ? (
<span className="font-mono"> <span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)} {generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
@@ -615,9 +712,10 @@ export function EmailComposer({
</div> </div>
</div> </div>
<div className={cn("flex items-center gap-2 relative", shakeField === 'to' && "animate-shake")}> {/* To field */}
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span> <div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<div className="flex-1 relative"> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<div className="flex-1 relative min-w-0">
<Input <Input
ref={toInputRef} ref={toInputRef}
type="email" type="email"
@@ -631,7 +729,7 @@ export function EmailComposer({
onKeyDown={(e) => handleAutoKeyDown(e, 'to')} onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
onBlur={(e) => handleAutoBlur(e, 'to')} onBlur={(e) => handleAutoBlur(e, 'to')}
className={cn( 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" validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400"
)} )}
role="combobox" role="combobox"
@@ -642,18 +740,18 @@ export function EmailComposer({
aria-invalid={validationErrors.to || undefined} aria-invalid={validationErrors.to || undefined}
/> />
{validationErrors.to && ( {validationErrors.to && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5 px-1">{t('validation.recipient_required')}</p> <p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{t('validation.recipient_required')}</p>
)} )}
{activeAutoField === 'to' && autocompleteResults.length > 0 && ( {activeAutoField === 'to' && autocompleteResults.length > 0 && (
<AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} /> <AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)} )}
</div> </div>
<div className="flex gap-1"> <div className="flex gap-0.5 shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowCc(!showCc)} onClick={() => setShowCc(!showCc)}
className="text-xs" className="text-xs h-7 px-2"
> >
Cc Cc
</Button> </Button>
@@ -661,17 +759,18 @@ export function EmailComposer({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowBcc(!showBcc)} onClick={() => setShowBcc(!showBcc)}
className="text-xs" className="text-xs h-7 px-2"
> >
Bcc Bcc
</Button> </Button>
</div> </div>
</div> </div>
{/* Cc field */}
{showCc && ( {showCc && (
<div className="flex items-center gap-2 relative"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<div className="flex-1 relative"> <div className="flex-1 relative min-w-0">
<Input <Input
ref={ccInputRef} ref={ccInputRef}
type="email" type="email"
@@ -683,7 +782,7 @@ export function EmailComposer({
}} }}
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')} onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
onBlur={(e) => handleAutoBlur(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" role="combobox"
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0} aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
aria-autocomplete="list" aria-autocomplete="list"
@@ -697,10 +796,11 @@ export function EmailComposer({
</div> </div>
)} )}
{/* Bcc field */}
{showBcc && ( {showBcc && (
<div className="flex items-center gap-2 relative"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span> <span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<div className="flex-1 relative"> <div className="flex-1 relative min-w-0">
<Input <Input
ref={bccInputRef} ref={bccInputRef}
type="email" type="email"
@@ -712,7 +812,7 @@ export function EmailComposer({
}} }}
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')} onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
onBlur={(e) => handleAutoBlur(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" role="combobox"
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0} aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
aria-autocomplete="list" aria-autocomplete="list"
@@ -726,8 +826,9 @@ export function EmailComposer({
</div> </div>
)} )}
<div className="flex items-center gap-2"> {/* Subject field */}
<span className="text-sm text-muted-foreground w-16">{t('subject_label')}</span> <div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input <Input
type="text" type="text"
placeholder={t('subject_placeholder')} placeholder={t('subject_placeholder')}
@@ -737,7 +838,7 @@ export function EmailComposer({
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false })); if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}} }}
className={cn( 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" validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
)} )}
aria-invalid={validationErrors.subject || undefined} aria-invalid={validationErrors.subject || undefined}
@@ -745,6 +846,7 @@ export function EmailComposer({
</div> </div>
</div> </div>
{/* Body */}
<div className="flex-1 px-4 py-3 min-h-0"> <div className="flex-1 px-4 py-3 min-h-0">
<textarea <textarea
className={cn( className={cn(
@@ -761,6 +863,7 @@ export function EmailComposer({
/> />
</div> </div>
{/* Attachments */}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div className="px-4 py-2 border-t"> <div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -786,7 +889,7 @@ export function EmailComposer({
) : ( ) : (
<Paperclip className="w-3 h-3 flex-shrink-0" /> <Paperclip className="w-3 h-3 flex-shrink-0" />
)} )}
<span className="max-w-[200px] truncate">{att.file.name}</span> <span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)}) ({formatFileSize(att.file.size)})
</span> </span>
@@ -804,35 +907,10 @@ export function EmailComposer({
</div> </div>
)} )}
<div className="flex items-center justify-between px-4 py-3 border-t"> {/* Bottom toolbar */}
{/* Left side - Discard button */} <div className="flex items-center justify-between px-4 py-2.5 border-t bg-background">
<button {/* Left side actions */}
type="button" <div className="flex items-center gap-1">
onClick={handleClose}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors"
>
{t('discard')}
</button>
{/* Right side - Template, Save as Template, Attach and Send */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
>
<FileText className="w-4 h-4 mr-2" />
{t('use_template')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
>
<BookmarkPlus className="w-4 h-4" />
</Button>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -843,16 +921,47 @@ export function EmailComposer({
/> />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="icon"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="h-9 w-9"
title={t('attach')}
> >
<Paperclip className="w-4 h-4 mr-2" /> <Paperclip className="w-4 h-4" />
{t('attach')}
</Button> </Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
className="h-9 w-9"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
className="h-9 w-9"
>
<BookmarkPlus className="w-4 h-4" />
</Button>
</div>
{/* Right side - Discard + Send (desktop) */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors px-2 py-1"
>
{t('discard')}
</button>
<Button <Button
onClick={handleSend} onClick={handleSend}
disabled={!canSend} disabled={!canSend}
title={getSendTooltip()} title={getSendTooltip()}
className="hidden md:inline-flex"
> >
<Send className="w-4 h-4 mr-2" /> <Send className="w-4 h-4 mr-2" />
{t('send')} {t('send')}
@@ -896,7 +1005,37 @@ export function EmailComposer({
</div> </div>
)} )}
<ConfirmDialog {...confirmDialogProps} /> {showCloseDialog && (
<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"
onClick={() => setShowCloseDialog(false)}
>
<div
ref={closeDialogRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('close_draft_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('close_draft_message')}</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowCloseDialog(false)}>
{t('cancel')}
</Button>
<Button variant="destructive" onClick={handleDiscardAndClose}>
{t('discard')}
</Button>
<Button onClick={handleSaveDraftAndClose}>
<Save className="w-4 h-4 mr-2" />
{t('save_draft')}
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+5 -1
View File
@@ -331,7 +331,11 @@
"upload_progress": "Uploading {uploaded} / {total}", "upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload", "upload_cancel": "Cancel upload",
"upload_failed": "Failed to upload {filename}", "upload_failed": "Failed to upload {filename}",
"send_failed": "Failed to send email" "send_failed": "Failed to send email",
"continue_draft": "Continue draft",
"close_draft_title": "Save or discard draft?",
"close_draft_message": "You have unsaved changes. Would you like to save this as a draft or discard it?",
"save_draft": "Save Draft"
}, },
"confirm_dialog": { "confirm_dialog": {
"confirm": "Confirm", "confirm": "Confirm",