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