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:
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) || [];
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user