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";
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<DOMRect | null>(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}
/>
<div className="flex flex-1 overflow-hidden">
<div
className="flex flex-1 overflow-hidden relative"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{!isMobile && (
<div className="w-60 border-r border-border p-3 overflow-y-auto flex-shrink-0">
<MiniCalendar
@@ -642,6 +682,17 @@ export default function CalendarPage() {
)}
{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>
{/* Mobile Bottom Navigation */}
@@ -663,6 +714,7 @@ export default function CalendarPage() {
onRsvp={handleRsvpFromDetail}
currentUserEmails={currentUserEmails}
timeFormat={timeFormat}
isMobile={isMobile}
/>
)}
+136 -104
View File
@@ -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 (
<div className="flex h-screen bg-background">
{!isMobile && (
@@ -437,112 +447,134 @@ export default function ContactsPage() {
<div className="flex flex-col flex-1 min-w-0">
<div className="flex flex-1 min-h-0">
<div className="w-80 border-r border-border flex flex-col flex-shrink-0">
<div className="p-4 border-b border-border">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
title={t("export.title")}
>
<Download className="w-4 h-4" />
</Button>
{showListPanel && (
<div className={cn(
"border-r border-border flex flex-col flex-shrink-0",
isMobile ? "w-full" : "w-80"
)}>
<div className={cn("p-4 border-b border-border", isMobile && "px-3 py-3")}>
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setView("import")}
title={t("import.title")}
>
<Upload className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (contacts.length > 0) {
exportContacts(contacts.filter(c => c.kind !== "group"));
toast.success(t("export.success", { count: contacts.filter(c => c.kind !== "group").length }));
}
}}
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 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",
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",
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 className="flex-1 min-w-0">
{renderRightPanel()}
</div>
{showRightPanel && (
<div className="flex-1 min-w-0 flex flex-col">
{isMobile && (
<div className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={mobileBackToList}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
{t("back_to_mail")}
</Button>
</div>
)}
<div className="flex-1 min-h-0">
{renderRightPanel()}
</div>
</div>
)}
</div>
{isMobile && (
+119 -46
View File
@@ -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<ComposerDraftData | null>(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 */}
<div
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
"max-md:flex-1 max-md:border-r-0",
isMobile && activeView !== "list" && "max-md:hidden",
@@ -862,10 +882,6 @@ export default function Home() {
{/* Mobile Header for List View */}
<MobileHeader
title={currentMailboxName}
onCompose={() => {
setComposerMode('compose');
setShowComposer(true);
}}
/>
{/* Search Bar + Inline Advanced Filters */}
@@ -1105,6 +1121,21 @@ export default function Home() {
/>
</ErrorBoundary>
</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>
{/* 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
className={cn(
"flex flex-col h-full bg-background",
@@ -1127,6 +1158,82 @@ export default function Home() {
"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 */}
{isMobile && conversationThread ? (
<ThreadConversationView
@@ -1187,6 +1294,8 @@ export default function Home() {
</ErrorBoundary>
</>
)}
</>
)}
</div>
</div>
@@ -1196,44 +1305,6 @@ export default function Home() {
)}
</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 */}
<KeyboardShortcutsModal
isOpen={showShortcutsModal}
@@ -1242,6 +1313,8 @@ export default function Home() {
{/* Screen reader live region for dynamic status announcements */}
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
<ConfirmDialog {...confirmDialogProps} />
</div>
</DragDropProvider>
);
+11 -6
View File
@@ -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 (
<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">
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
}
</h3>
</div>
@@ -138,15 +143,15 @@ export function CalendarDayView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<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) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-3"
className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }}
>
{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)}
</span>
)}
+57 -25
View File
@@ -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({
<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">
{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">
{t(`days.${d}`)}
<div key={d} role="columnheader" className={cn(
"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 className="flex-1 flex flex-col overflow-y-auto">
{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) => {
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"
)}
>
<div className="flex items-center justify-center mb-0.5">
<span
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",
selected && "bg-primary text-primary-foreground font-bold",
!inMonth && !selected && !today && "text-muted-foreground/50",
@@ -173,26 +183,48 @@ export function CalendarMonthView({
{format(day, "d")}
</span>
</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 })}
{isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{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 (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</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>
);
})}
+140 -16
View File
@@ -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<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 => {
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<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 (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
<div className="flex items-center gap-1">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<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-0.5">
<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" />
</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()}
</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" />
</button>
</div>
<Button variant="outline" size="sm" onClick={onToday}>
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
{t("views.today")}
</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" />
{!isMobile && (
@@ -97,17 +219,19 @@ export function CalendarToolbar({
</div>
)}
{onImport && (
{onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{!isMobile && t("import.title")}
{t("import.title")}
</Button>
)}
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{!isMobile && t("events.create")}
</Button>
{!isMobile && (
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
)}
</div>
);
}
+17 -9
View File
@@ -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<string, Calendar>();
@@ -132,14 +138,16 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const colCount = isMobile ? 3 : 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<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")}
</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) => {
const key = format(day, "yyyy-MM-dd");
const dayAllDay = allDayEvents.get(key) || [];
@@ -165,8 +173,8 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className="w-14 flex-shrink-0" />
<div className="flex-1 grid grid-cols-7 border-l border-border">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{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({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<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) => (
<div
key={h}
@@ -209,7 +217,7 @@ export function CalendarWeekView({
style={{ height: HOUR_HEIGHT }}
>
{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)}
</span>
)}
@@ -217,7 +225,7 @@ export function CalendarWeekView({
))}
</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) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
+17 -3
View File
@@ -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<HTMLDivElement>(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({
</div>
{/* 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 */}
<div className="flex items-start gap-2.5">
<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;
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 (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size="lg" />
<div>
<h2 className="text-xl font-semibold">{name || "—"}</h2>
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
{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 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" />
{t("form.edit_title")}
</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" />
</Button>
</div>
@@ -72,10 +73,13 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{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
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")}
aria-label={t("detail.compose_email")}
>
@@ -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")}
>
+13 -8
View File
@@ -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 (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-7 h-7 text-primary" />
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
<Users className={cn("text-primary", isMobile ? "w-6 h-6" : "w-7 h-7")} />
</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">
{t("groups.member_count", { count: members.length })}
</p>
</div>
</div>
<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" />
{t("form.edit_title")}
</Button>
@@ -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"
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -95,7 +97,10 @@ export function ContactGroupDetail({
<Button
variant="ghost"
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)}
>
<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 { useFocusTrap } from "@/hooks/use-focus-trap";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
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 { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
@@ -21,6 +19,21 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
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 {
onSend?: (data: {
to: string[];
@@ -35,8 +48,10 @@ interface EmailComposerProps {
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
@@ -52,8 +67,10 @@ export function EmailComposer({
onSend,
onClose,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
replyTo
}: EmailComposerProps) {
@@ -106,14 +123,14 @@ export function EmailComposer({
return prefix;
};
const [to, setTo] = useState(getInitialTo());
const [cc, setCc] = useState(getInitialCc());
const [bcc, setBcc] = useState("");
const [subject, setSubject] = useState(getInitialSubject());
const [body, setBody] = useState(getInitialBody());
const [showCc, setShowCc] = useState(!!getInitialCc());
const [showBcc, setShowBcc] = useState(false);
const [draftId, setDraftId] = useState<string | null>(null);
const [to, setTo] = useState(initialData?.to ?? getInitialTo());
const [cc, setCc] = useState(initialData?.cc ?? getInitialCc());
const [bcc, setBcc] = useState(initialData?.bcc ?? "");
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
@@ -121,11 +138,11 @@ export function EmailComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const [showCloseDialog, setShowCloseDialog] = useState(false);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -133,9 +150,56 @@ export function EmailComposer({
restoreFocus: true,
});
const closeDialogRef = useFocusTrap({
isActive: showCloseDialog,
onEscape: () => setShowCloseDialog(false),
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore();
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
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 [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
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(() => {
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Don't auto-save if there's no content
if (!to && !subject && !body) {
// Don't auto-save if nothing has changed from initial state
if (!isDirtyRef.current) {
return;
}
@@ -498,77 +565,107 @@ export function EmailComposer({
setDraftId(null);
setSubAddressTag("");
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) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
}
};
const handleClose = async () => {
if (draftId && (to || subject || body)) {
const confirmed = await confirm({
title: t('discard_draft_title'),
message: t('discard_draft_confirm'),
confirmText: t('discard'),
variant: "destructive",
});
const cleanClose = () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
if (confirmed) {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const handleSaveDraftAndClose = async () => {
setShowCloseDialog(false);
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) {
onDiscardDraft(draftId);
}
const handleDiscardAndClose = () => {
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 {
onClose?.();
cleanClose();
}
};
return (
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span>{t('saving')}</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span>{t('draft_saved')}</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span>{t('save_failed')}</span>
</div>
)}
<div className={cn("flex flex-col h-full bg-background", className)}>
{/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<X className="w-5 h-5 md:w-4 md:h-4" />
</Button>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span className="hidden md:inline">{t('saving')}</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span className="hidden md:inline">{t('draft_saved')}</span>
</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>
<Button variant="ghost" size="icon" onClick={handleClose}>
<X className="w-4 h-4" />
{/* Mobile: send button in header */}
<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>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-2 px-4 py-3 border-b">
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1">
<div className="flex-1 flex flex-col min-h-0">
{/* Fields section */}
<div className="space-y-0 border-b">
{/* From field */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<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 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
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) => (
<option key={identity.id} value={identity.id}>
@@ -577,7 +674,7 @@ export function EmailComposer({
))}
</select>
) : (
<span className="text-sm text-foreground flex-1">
<span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
@@ -615,9 +712,10 @@ export function EmailComposer({
</div>
</div>
<div className={cn("flex items-center gap-2 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
<div className="flex-1 relative">
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<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
ref={toInputRef}
type="email"
@@ -631,7 +729,7 @@ export function EmailComposer({
onKeyDown={(e) => 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 && (
<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 && (
<AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)}
</div>
<div className="flex gap-1">
<div className="flex gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs"
className="text-xs h-7 px-2"
>
Cc
</Button>
@@ -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
</Button>
</div>
</div>
{/* Cc field */}
{showCc && (
<div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
<div className="flex-1 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-12 md:w-16 shrink-0">{t('cc_label')}</span>
<div className="flex-1 relative min-w-0">
<Input
ref={ccInputRef}
type="email"
@@ -683,7 +782,7 @@ export function EmailComposer({
}}
onKeyDown={(e) => 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({
</div>
)}
{/* Bcc field */}
{showBcc && (
<div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
<div className="flex-1 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-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<div className="flex-1 relative min-w-0">
<Input
ref={bccInputRef}
type="email"
@@ -712,7 +812,7 @@ export function EmailComposer({
}}
onKeyDown={(e) => 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({
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('subject_label')}</span>
{/* Subject field */}
<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
type="text"
placeholder={t('subject_placeholder')}
@@ -737,7 +838,7 @@ export function EmailComposer({
if (validationErrors.subject) setValidationErrors(prev => ({ ...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({
</div>
</div>
{/* Body */}
<div className="flex-1 px-4 py-3 min-h-0">
<textarea
className={cn(
@@ -761,6 +863,7 @@ export function EmailComposer({
/>
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2">
@@ -786,7 +889,7 @@ export function EmailComposer({
) : (
<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">
({formatFileSize(att.file.size)})
</span>
@@ -804,35 +907,10 @@ export function EmailComposer({
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t">
{/* Left side - Discard button */}
<button
type="button"
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>
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
ref={fileInputRef}
type="file"
@@ -843,16 +921,47 @@ export function EmailComposer({
/>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => fileInputRef.current?.click()}
className="h-9 w-9"
title={t('attach')}
>
<Paperclip className="w-4 h-4 mr-2" />
{t('attach')}
<Paperclip className="w-4 h-4" />
</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
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
@@ -896,7 +1005,37 @@ export function EmailComposer({
</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>
);
}
+5 -1
View File
@@ -331,7 +331,11 @@
"upload_progress": "Uploading {uploaded} / {total}",
"upload_cancel": "Cancel upload",
"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": "Confirm",