feat: enhance mobile responsiveness across calendar and contact components

- Added `isMobile` prop to `CalendarWeekView`, `EventDetailPopover`, `ContactDetail`, `ContactGroupDetail`, and `EmailComposer` components for improved mobile layout.
- Adjusted layout and styles in `CalendarWeekView` to display a 3-day view on mobile.
- Updated `EventDetailPopover` to adapt its size and layout for mobile devices.
- Modified `ContactDetail` and `ContactGroupDetail` to adjust padding and font sizes based on mobile view.
- Enhanced `EmailComposer` with mobile-friendly header and field layouts, including auto-saving draft functionality.
- Added confirmation dialog for unsaved changes when closing the email composer.
- Updated localization files to include new strings for draft management.
This commit is contained in:
Linus Rath
2026-03-11 18:07:44 +01:00
parent 723aff38f2
commit d23e9a642c
12 changed files with 848 additions and 353 deletions
+11 -6
View File
@@ -17,6 +17,7 @@ interface CalendarDayViewProps {
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const HOUR_HEIGHT = 64;
@@ -29,6 +30,7 @@ export function CalendarDayView({
onSelectEvent,
onCreateAtTime,
timeFormat = "24h",
isMobile,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -110,9 +112,12 @@ export function CalendarDayView({
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
<div className="px-4 py-3 border-b border-border">
<h3 className={cn("text-lg font-semibold", today && "text-primary")}>
{intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}
<div className={cn("px-4 py-3 border-b border-border", isMobile && "px-3 py-2")}>
<h3 className={cn("font-semibold", isMobile ? "text-base" : "text-lg", today && "text-primary")}>
{isMobile
? intlFormatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })
}
</h3>
</div>
@@ -138,15 +143,15 @@ export function CalendarDayView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-16 flex-shrink-0">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-16")}>
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-3"
className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[10px]" : "text-xs")}>
{formatHour(h)}
</span>
)}
+57 -25
View File
@@ -21,6 +21,7 @@ interface CalendarMonthViewProps {
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
}
export function CalendarMonthView({
@@ -30,6 +31,7 @@ export function CalendarMonthView({
onSelectDate,
onSelectEvent,
firstDayOfWeek = 1,
isMobile,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -125,22 +127,28 @@ export function CalendarMonthView({
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => (
<div key={d} role="columnheader" className="text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0">
{t(`days.${d}`)}
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
</div>
))}
</div>
<div className="flex-1 flex flex-col overflow-y-auto">
{weeks.map((week, wi) => (
<div key={wi} className="grid grid-cols-7 flex-1 min-h-[100px] border-b border-border last:border-b-0" role="row">
<div key={wi} className={cn(
"grid grid-cols-7 flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row">
{week.map((day) => {
const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const maxVisible = 3;
const maxVisible = isMobile ? 0 : 3;
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return (
@@ -154,16 +162,18 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors",
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
!inMonth && "bg-muted/30",
"hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
dropDayKey === key && "ring-2 ring-inset ring-primary bg-primary/10"
)}
>
<div className="flex items-center justify-center mb-0.5">
<span
className={cn(
"inline-flex items-center justify-center w-6 h-6 text-xs rounded-full",
"inline-flex items-center justify-center rounded-full",
isMobile ? "w-7 h-7 text-xs" : "w-6 h-6 text-xs",
today && !selected && "bg-primary text-primary-foreground font-bold",
selected && "bg-primary text-primary-foreground font-bold",
!inMonth && !selected && !today && "text-muted-foreground/50",
@@ -173,26 +183,48 @@ export function CalendarMonthView({
{format(day, "d")}
</span>
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, maxVisible).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
return (
<EventCard
key={ev.id}
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
draggable
/>
);
})}
{dayEvents.length > maxVisible && (
<div className="text-[10px] text-muted-foreground px-1">
{t("events.more", { count: dayEvents.length - maxVisible })}
{isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
const cal = calendarMap.get(calId);
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</div>
)}
</div>
)
) : (
<div className="space-y-0.5">
{dayEvents.slice(0, maxVisible).map((ev) => {
const calId = Object.keys(ev.calendarIds)[0];
return (
<EventCard
key={ev.id}
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={(rect) => onSelectEvent(ev, rect)}
draggable
/>
);
})}
{dayEvents.length > maxVisible && (
<div className="text-[10px] text-muted-foreground px-1">
{t("events.more", { count: dayEvents.length - maxVisible })}
</div>
)}
</div>
)}
</div>
);
})}
+140 -16
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -19,6 +21,9 @@ interface CalendarToolbarProps {
isMobile?: boolean;
firstDayOfWeek?: number;
onNavigateBack?: () => void;
calendars?: Calendar[];
selectedCalendarIds?: string[];
onToggleVisibility?: (id: string) => void;
}
export function CalendarToolbar({
@@ -32,18 +37,39 @@ export function CalendarToolbar({
onImport,
isMobile,
firstDayOfWeek = 1,
calendars,
selectedCalendarIds,
onToggleVisibility,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
const views: CalendarViewMode[] = ["month", "week", "day", "agenda"];
const [showCalendarDropdown, setShowCalendarDropdown] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showCalendarDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowCalendarDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showCalendarDropdown]);
const getDateLabel = (): string => {
switch (viewMode) {
case "month":
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6);
if (isMobile) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`;
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
@@ -51,30 +77,126 @@ export function CalendarToolbar({
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
}
case "day":
return formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
case "agenda":
return formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
}
};
const [showViewDropdown, setShowViewDropdown] = useState(false);
const viewDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showViewDropdown) return;
function handleClickOutside(e: MouseEvent) {
if (viewDropdownRef.current && !viewDropdownRef.current.contains(e.target as Node)) {
setShowViewDropdown(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showViewDropdown]);
return (
<div className="flex items-center gap-2 px-4 py-3 border-b border-border flex-wrap">
<div className="flex items-center gap-1">
<button onClick={onPrev} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_prev")}>
<div className={cn("flex items-center gap-1.5 px-2 py-2 border-b border-border flex-wrap", !isMobile && "px-4 py-3 gap-2")}>
<div className="flex items-center gap-0.5">
<button onClick={onPrev} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_prev")}>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-medium min-w-[140px] text-center">
<span className={cn("text-sm font-medium text-center", isMobile ? "min-w-[80px]" : "min-w-[140px]")}>
{getDateLabel()}
</span>
<button onClick={onNext} className="p-1.5 rounded hover:bg-muted transition-colors" aria-label={t("nav_next")}>
<button onClick={onNext} className="p-2 rounded hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
</div>
<Button variant="outline" size="sm" onClick={onToday}>
<Button variant="outline" size="sm" onClick={onToday} className="touch-manipulation">
{t("views.today")}
</Button>
{isMobile && calendars && selectedCalendarIds && onToggleVisibility && (
<div className="relative" ref={dropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowCalendarDropdown((v) => !v)}
aria-label={t("my_calendars")}
className="touch-manipulation"
>
<CalendarDays className="w-4 h-4" />
</Button>
{showCalendarDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-2 min-w-[180px]">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 px-1">
{t("my_calendars")}
</h3>
<div className="space-y-0.5">
{calendars.map((cal) => {
const isVisible = selectedCalendarIds.includes(cal.id);
const color = cal.color || "#3b82f6";
return (
<button
key={cal.id}
onClick={() => onToggleVisibility(cal.id)}
className={cn(
"flex items-center gap-2 w-full px-2 py-2 rounded-md text-sm transition-colors duration-150 touch-manipulation",
"hover:bg-muted"
)}
>
<span
className={cn(
"w-3.5 h-3.5 rounded-sm border-2 flex-shrink-0 transition-colors",
isVisible ? "border-transparent" : "border-muted-foreground/40 bg-transparent"
)}
style={isVisible ? { backgroundColor: color, borderColor: color } : undefined}
/>
<span className={cn("truncate", !isVisible && "text-muted-foreground")}>
{cal.name}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
)}
{isMobile && (
<div className="relative" ref={viewDropdownRef}>
<Button
variant="outline"
size="sm"
onClick={() => setShowViewDropdown((v) => !v)}
className="touch-manipulation capitalize text-xs"
>
{t(`views.${viewMode}`)}
<ChevronLeft className="w-3 h-3 ml-1 rotate-[-90deg]" />
</Button>
{showViewDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-popover border border-border rounded-lg shadow-lg p-1 min-w-[120px]">
{views.map((v) => (
<button
key={v}
onClick={() => { onViewModeChange(v); setShowViewDropdown(false); }}
className={cn(
"flex items-center w-full px-3 py-2 rounded-md text-sm transition-colors touch-manipulation",
v === viewMode ? "bg-primary text-primary-foreground" : "hover:bg-muted text-foreground"
)}
>
{t(`views.${v}`)}
</button>
))}
</div>
)}
</div>
)}
<div className="flex-1" />
{!isMobile && (
@@ -97,17 +219,19 @@ export function CalendarToolbar({
</div>
)}
{onImport && (
{onImport && !isMobile && (
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="w-4 h-4 mr-1" />
{!isMobile && t("import.title")}
{t("import.title")}
</Button>
)}
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{!isMobile && t("events.create")}
</Button>
{!isMobile && (
<Button size="sm" onClick={onCreateEvent}>
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
)}
</div>
);
}
+17 -9
View File
@@ -21,6 +21,7 @@ interface CalendarWeekViewProps {
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const HOUR_HEIGHT = 60;
@@ -35,6 +36,7 @@ export function CalendarWeekView({
onCreateAtTime,
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -42,9 +44,13 @@ export function CalendarWeekView({
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekDays = useMemo(() => {
if (isMobile) {
// Show 3-day window centered on selected date
return Array.from({ length: 3 }, (_, i) => addDays(selectedDate, i - 1));
}
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
}, [selectedDate, weekStart]);
}, [selectedDate, weekStart, isMobile]);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -132,14 +138,16 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const colCount = isMobile ? 3 : 7;
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
<div className="flex border-b border-border">
<div className="w-14 flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right">
<div className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10" : "w-14")}>
{t("events.all_day")}
</div>
<div className="flex-1 grid grid-cols-7 gap-px bg-border">
<div className={cn("flex-1 grid gap-px bg-border", isMobile ? "grid-cols-3" : "grid-cols-7")}>
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayAllDay = allDayEvents.get(key) || [];
@@ -165,8 +173,8 @@ export function CalendarWeekView({
)}
<div className="flex border-b border-border" role="row">
<div className="w-14 flex-shrink-0" />
<div className="flex-1 grid grid-cols-7 border-l border-border">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")} />
<div className={cn("flex-1 border-l border-border", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -178,7 +186,7 @@ export function CalendarWeekView({
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors",
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
@@ -201,7 +209,7 @@ export function CalendarWeekView({
<div ref={scrollRef} className="flex-1 overflow-y-auto">
<div className="flex relative" style={{ height: 24 * HOUR_HEIGHT }}>
<div className="w-14 flex-shrink-0">
<div className={cn("flex-shrink-0", isMobile ? "w-10" : "w-14")}>
{HOURS.map((h) => (
<div
key={h}
@@ -209,7 +217,7 @@ export function CalendarWeekView({
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none">
<span className={cn("absolute top-0 right-2 -translate-y-1/2 leading-none", isMobile ? "text-[9px]" : "text-[10px]")}>
{formatHour(h)}
</span>
)}
@@ -217,7 +225,7 @@ export function CalendarWeekView({
))}
</div>
<div className="flex-1 grid grid-cols-7 border-l border-border relative">
<div className={cn("flex-1 border-l border-border relative", isMobile ? "grid grid-cols-3" : "grid grid-cols-7")}>
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
+17 -3
View File
@@ -9,6 +9,7 @@ import {
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import {
@@ -30,6 +31,7 @@ interface EventDetailPopoverProps {
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
currentUserEmails?: string[];
timeFormat?: "12h" | "24h";
isMobile?: boolean;
}
const POPOVER_WIDTH = 360;
@@ -119,6 +121,7 @@ export function EventDetailPopover({
onRsvp,
currentUserEmails = [],
timeFormat = "24h",
isMobile,
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const popoverRef = useRef<HTMLDivElement>(null);
@@ -249,8 +252,16 @@ export function EventDetailPopover({
ref={popoverRef}
role="dialog"
aria-label={event.title || t("events.no_title")}
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out"
style={{
className={cn(
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
isMobile
? "inset-0 rounded-none flex flex-col"
: "rounded-lg"
)}
style={isMobile ? {
opacity: 1,
transform: "none",
} : {
width: POPOVER_WIDTH,
maxHeight: MAX_HEIGHT,
top: position?.top ?? -9999,
@@ -301,7 +312,10 @@ export function EventDetailPopover({
</div>
{/* Content */}
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}>
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
+20 -13
View File
@@ -13,10 +13,11 @@ interface ContactDetailProps {
contact: ContactCard | null;
onEdit: () => void;
onDelete: () => void;
isMobile?: boolean;
className?: string;
}
export function ContactDetail({ contact, onEdit, onDelete, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
if (!contact) {
@@ -38,23 +39,23 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<Avatar name={name} email={email} size="lg" />
<div>
<h2 className="text-xl font-semibold">{name || "—"}</h2>
<Avatar name={name} email={email} size={isMobile ? "md" : "lg"} />
<div className="min-w-0 flex-1">
<h2 className={cn("font-semibold truncate", isMobile ? "text-lg" : "text-xl")}>{name || "—"}</h2>
{orgs.length > 0 && orgs[0].name && (
<p className="text-sm text-muted-foreground">{orgs[0].name}</p>
<p className="text-sm text-muted-foreground truncate">{orgs[0].name}</p>
)}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950">
<Button variant="outline" size="sm" onClick={onDelete} className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation">
<Trash2 className="w-4 h-4" />
</Button>
</div>
@@ -72,10 +73,13 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
{e.contexts && (
<ContextBadge contexts={e.contexts} />
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<div className={cn(
"flex items-center gap-0.5 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<a
href={`mailto:${e.address}`}
className="p-1 rounded hover:bg-muted transition-colors"
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.compose_email")}
aria-label={t("detail.compose_email")}
>
@@ -90,7 +94,7 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors"
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
title={t("detail.copy_email")}
aria-label={t("detail.copy_email")}
>
@@ -121,7 +125,10 @@ export function ContactDetail({ contact, onEdit, onDelete, className }: ContactD
toast.error(t("detail.copy_failed"));
}
}}
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100"
className={cn(
"p-1.5 rounded hover:bg-muted transition-colors touch-manipulation",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
title={t("detail.copy_phone")}
aria-label={t("detail.copy_phone")}
>
+13 -8
View File
@@ -15,6 +15,7 @@ interface ContactGroupDetailProps {
onDelete: () => void;
onRemoveMember: (memberId: string) => void;
onSelectMember: (id: string) => void;
isMobile?: boolean;
className?: string;
}
@@ -25,6 +26,7 @@ export function ContactGroupDetail({
onDelete,
onRemoveMember,
onSelectMember,
isMobile,
className,
}: ContactGroupDetailProps) {
const t = useTranslations("contacts");
@@ -32,21 +34,21 @@ export function ContactGroupDetail({
return (
<div className={cn("flex flex-col h-full overflow-y-auto", className)}>
<div className="px-6 py-6 border-b border-border">
<div className="flex items-start justify-between">
<div className={cn("border-b border-border", isMobile ? "px-4 py-4" : "px-6 py-6")}>
<div className={cn("flex gap-4", isMobile ? "flex-col" : "items-start justify-between")}>
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
<Users className="w-7 h-7 text-primary" />
<div className={cn("rounded-full bg-primary/10 flex items-center justify-center", isMobile ? "w-12 h-12" : "w-14 h-14")}>
<Users className={cn("text-primary", isMobile ? "w-6 h-6" : "w-7 h-7")} />
</div>
<div>
<h2 className="text-xl font-semibold">{groupName}</h2>
<h2 className={cn("font-semibold", isMobile ? "text-lg" : "text-xl")}>{groupName}</h2>
<p className="text-sm text-muted-foreground">
{t("groups.member_count", { count: members.length })}
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit}>
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
{t("form.edit_title")}
</Button>
@@ -54,7 +56,7 @@ export function ContactGroupDetail({
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950"
className="text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 touch-manipulation"
>
<Trash2 className="w-4 h-4" />
</Button>
@@ -95,7 +97,10 @@ export function ContactGroupDetail({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
className={cn(
"h-8 w-8 transition-opacity",
isMobile ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
onClick={() => onRemoveMember(member.id)}
>
<UserMinus className="w-4 h-4 text-muted-foreground" />
+258 -119
View File
@@ -2,11 +2,9 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus } from "lucide-react";
import { cn, formatFileSize } from "@/lib/utils";
import { debug } from "@/lib/debug";
@@ -21,6 +19,21 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
export interface ComposerDraftData {
to: string;
cc: string;
bcc: string;
subject: string;
body: string;
showCc: boolean;
showBcc: boolean;
selectedIdentityId: string | null;
subAddressTag: string;
mode: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: EmailComposerProps['replyTo'];
draftId: string | null;
}
interface EmailComposerProps {
onSend?: (data: {
to: string[];
@@ -35,8 +48,10 @@ interface EmailComposerProps {
}) => void | Promise<void>;
onClose?: () => void;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
replyTo?: {
from?: { email?: string; name?: string }[];
@@ -52,8 +67,10 @@ export function EmailComposer({
onSend,
onClose,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
replyTo
}: EmailComposerProps) {
@@ -106,14 +123,14 @@ export function EmailComposer({
return prefix;
};
const [to, setTo] = useState(getInitialTo());
const [cc, setCc] = useState(getInitialCc());
const [bcc, setBcc] = useState("");
const [subject, setSubject] = useState(getInitialSubject());
const [body, setBody] = useState(getInitialBody());
const [showCc, setShowCc] = useState(!!getInitialCc());
const [showBcc, setShowBcc] = useState(false);
const [draftId, setDraftId] = useState<string | null>(null);
const [to, setTo] = useState(initialData?.to ?? getInitialTo());
const [cc, setCc] = useState(initialData?.cc ?? getInitialCc());
const [bcc, setBcc] = useState(initialData?.bcc ?? "");
const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
const [body, setBody] = useState(initialData?.body ?? getInitialBody());
const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc());
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
const [draftId, setDraftId] = useState<string | null>(initialData?.draftId ?? null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedDataRef = useRef<string>("");
@@ -121,11 +138,11 @@ export function EmailComposer({
const fileInputRef = useRef<HTMLInputElement>(null);
const [validationErrors, setValidationErrors] = useState<{ to?: boolean; subject?: boolean; body?: boolean }>({});
const [shakeField, setShakeField] = useState<string | null>(null);
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(null);
const [subAddressTag, setSubAddressTag] = useState<string>('');
const [selectedIdentityId, setSelectedIdentityId] = useState<string | null>(initialData?.selectedIdentityId ?? null);
const [subAddressTag, setSubAddressTag] = useState<string>(initialData?.subAddressTag ?? '');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog();
const [showCloseDialog, setShowCloseDialog] = useState(false);
const saveTemplateModalRef = useFocusTrap({
isActive: showSaveAsTemplate,
@@ -133,9 +150,56 @@ export function EmailComposer({
restoreFocus: true,
});
const closeDialogRef = useFocusTrap({
isActive: showCloseDialog,
onEscape: () => setShowCloseDialog(false),
restoreFocus: true,
});
const { client, identities, primaryIdentity } = useAuthStore();
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const addTemplate = useTemplateStore((s) => s.addTemplate);
// Keep a ref to current state for the unmount save
const stateRef = useRef({ to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId });
stateRef.current = { to, cc, bcc, subject, body, showCc, showBcc, selectedIdentityId, subAddressTag, draftId };
// Track initial values for dirty detection (captured once on first render)
const initialValuesRef = useRef({ to, cc, bcc, subject, body, attachmentCount: 0 });
const isDirtyRef = useRef(false);
isDirtyRef.current = to !== initialValuesRef.current.to || cc !== initialValuesRef.current.cc ||
bcc !== initialValuesRef.current.bcc || subject !== initialValuesRef.current.subject ||
body !== initialValuesRef.current.body || attachments.length > initialValuesRef.current.attachmentCount;
// Ref to latest saveDraft for use in event handlers with stale closures
const saveDraftRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
if (onSaveState && isDirtyRef.current) {
const s = stateRef.current;
onSaveState({
...s,
mode,
replyTo,
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-save draft to server on page close (best-effort)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current) {
saveDraftRef.current();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
@@ -389,15 +453,18 @@ export function EmailComposer({
}
};
// Trigger auto-save when content changes
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
// Trigger auto-save when content changes (only if user modified something)
useEffect(() => {
// Clear existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Don't auto-save if there's no content
if (!to && !subject && !body) {
// Don't auto-save if nothing has changed from initial state
if (!isDirtyRef.current) {
return;
}
@@ -498,77 +565,107 @@ export function EmailComposer({
setDraftId(null);
setSubAddressTag("");
setValidationErrors({});
// Clear ref so unmount effect doesn't re-save
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
} catch (err) {
debug.error('Failed to send email:', err);
toast.error(t('send_failed'));
}
};
const handleClose = async () => {
if (draftId && (to || subject || body)) {
const confirmed = await confirm({
title: t('discard_draft_title'),
message: t('discard_draft_confirm'),
confirmText: t('discard'),
variant: "destructive",
});
const cleanClose = () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
if (confirmed) {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const handleSaveDraftAndClose = async () => {
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
await saveDraft();
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
if (onDiscardDraft) {
onDiscardDraft(draftId);
}
const handleDiscardAndClose = () => {
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
if (draftId && onDiscardDraft) {
onDiscardDraft(draftId);
}
stateRef.current = { to: '', cc: '', bcc: '', subject: '', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', draftId: null };
onClose?.();
};
onClose?.();
}
const handleClose = () => {
if (isDirtyRef.current) {
setShowCloseDialog(true);
} else {
onClose?.();
cleanClose();
}
};
return (
<div className={cn("flex flex-col h-full bg-background border rounded-lg", className)}>
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span>{t('saving')}</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span>{t('draft_saved')}</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span>{t('save_failed')}</span>
</div>
)}
<div className={cn("flex flex-col h-full bg-background", className)}>
{/* Header - mobile: clean bar with close/send, desktop: title bar */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-background">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<X className="w-5 h-5 md:w-4 md:h-4" />
</Button>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-base">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Save className="w-3 h-3 animate-pulse" />
<span className="hidden md:inline">{t('saving')}</span>
</div>
)}
{saveStatus === 'saved' && (
<div className="flex items-center gap-1 text-xs text-green-600">
<Check className="w-3 h-3" />
<span className="hidden md:inline">{t('draft_saved')}</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1 text-xs text-red-600">
<X className="w-3 h-3" />
<span className="hidden md:inline">{t('save_failed')}</span>
</div>
)}
</div>
</div>
<Button variant="ghost" size="icon" onClick={handleClose}>
<X className="w-4 h-4" />
{/* Mobile: send button in header */}
<Button
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
size="sm"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 mr-1.5" />
{t('send')}
</Button>
</div>
<div className="flex-1 flex flex-col">
<div className="space-y-2 px-4 py-3 border-b">
{/* From field - show dropdown if multiple identities, otherwise display email */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1">
<div className="flex-1 flex flex-col min-h-0">
{/* Fields section */}
<div className="space-y-0 border-b">
{/* From field */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('from')}:</span>
<div className="flex-1 flex items-center gap-1 min-w-0">
{identities.length > 1 ? (
<select
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => setSelectedIdentityId(e.target.value)}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors"
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identities.map((identity) => (
<option key={identity.id} value={identity.id}>
@@ -577,7 +674,7 @@ export function EmailComposer({
))}
</select>
) : (
<span className="text-sm text-foreground flex-1">
<span className="text-sm text-foreground flex-1 truncate">
{subAddressTag ? (
<span className="font-mono">
{generateSubAddress(primaryIdentity?.email || '', subAddressTag)}
@@ -615,9 +712,10 @@ export function EmailComposer({
</div>
</div>
<div className={cn("flex items-center gap-2 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-16">{t('to')}:</span>
<div className="flex-1 relative">
{/* To field */}
<div className={cn("flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative", shakeField === 'to' && "animate-shake")}>
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('to')}:</span>
<div className="flex-1 relative min-w-0">
<Input
ref={toInputRef}
type="email"
@@ -631,7 +729,7 @@ export function EmailComposer({
onKeyDown={(e) => handleAutoKeyDown(e, 'to')}
onBlur={(e) => handleAutoBlur(e, 'to')}
className={cn(
"border-0 focus-visible:ring-0",
"border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.to && "ring-2 ring-red-500 dark:ring-red-400"
)}
role="combobox"
@@ -642,18 +740,18 @@ export function EmailComposer({
aria-invalid={validationErrors.to || undefined}
/>
{validationErrors.to && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5 px-1">{t('validation.recipient_required')}</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{t('validation.recipient_required')}</p>
)}
{activeAutoField === 'to' && autocompleteResults.length > 0 && (
<AutocompleteDropdown ref={toDropdownRef} id="autocomplete-to" results={autocompleteResults} selectedIndex={autoSelectedIndex} onSelect={(email) => insertAutocomplete(email, 'to')} />
)}
</div>
<div className="flex gap-1">
<div className="flex gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowCc(!showCc)}
className="text-xs"
className="text-xs h-7 px-2"
>
Cc
</Button>
@@ -661,17 +759,18 @@ export function EmailComposer({
variant="ghost"
size="sm"
onClick={() => setShowBcc(!showBcc)}
className="text-xs"
className="text-xs h-7 px-2"
>
Bcc
</Button>
</div>
</div>
{/* Cc field */}
{showCc && (
<div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('cc_label')}</span>
<div className="flex-1 relative">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('cc_label')}</span>
<div className="flex-1 relative min-w-0">
<Input
ref={ccInputRef}
type="email"
@@ -683,7 +782,7 @@ export function EmailComposer({
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'cc')}
onBlur={(e) => handleAutoBlur(e, 'cc')}
className="border-0 focus-visible:ring-0"
className="border-0 focus-visible:ring-0 h-8 px-0 text-sm"
role="combobox"
aria-expanded={activeAutoField === 'cc' && autocompleteResults.length > 0}
aria-autocomplete="list"
@@ -697,10 +796,11 @@ export function EmailComposer({
</div>
)}
{/* Bcc field */}
{showBcc && (
<div className="flex items-center gap-2 relative">
<span className="text-sm text-muted-foreground w-16">{t('bcc_label')}</span>
<div className="flex-1 relative">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border/50 relative">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('bcc_label')}</span>
<div className="flex-1 relative min-w-0">
<Input
ref={bccInputRef}
type="email"
@@ -712,7 +812,7 @@ export function EmailComposer({
}}
onKeyDown={(e) => handleAutoKeyDown(e, 'bcc')}
onBlur={(e) => handleAutoBlur(e, 'bcc')}
className="border-0 focus-visible:ring-0"
className="border-0 focus-visible:ring-0 h-8 px-0 text-sm"
role="combobox"
aria-expanded={activeAutoField === 'bcc' && autocompleteResults.length > 0}
aria-autocomplete="list"
@@ -726,8 +826,9 @@ export function EmailComposer({
</div>
)}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground w-16">{t('subject_label')}</span>
{/* Subject field */}
<div className="flex items-center gap-2 px-4 py-2.5">
<span className="text-sm text-muted-foreground w-12 md:w-16 shrink-0">{t('subject_label')}</span>
<Input
type="text"
placeholder={t('subject_placeholder')}
@@ -737,7 +838,7 @@ export function EmailComposer({
if (validationErrors.subject) setValidationErrors(prev => ({ ...prev, subject: false }));
}}
className={cn(
"flex-1 border-0 focus-visible:ring-0",
"flex-1 border-0 focus-visible:ring-0 h-8 px-0 text-sm",
validationErrors.subject && "ring-2 ring-red-500 dark:ring-red-400"
)}
aria-invalid={validationErrors.subject || undefined}
@@ -745,6 +846,7 @@ export function EmailComposer({
</div>
</div>
{/* Body */}
<div className="flex-1 px-4 py-3 min-h-0">
<textarea
className={cn(
@@ -761,6 +863,7 @@ export function EmailComposer({
/>
</div>
{/* Attachments */}
{attachments.length > 0 && (
<div className="px-4 py-2 border-t">
<div className="flex flex-wrap gap-2">
@@ -786,7 +889,7 @@ export function EmailComposer({
) : (
<Paperclip className="w-3 h-3 flex-shrink-0" />
)}
<span className="max-w-[200px] truncate">{att.file.name}</span>
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.file.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
({formatFileSize(att.file.size)})
</span>
@@ -804,35 +907,10 @@ export function EmailComposer({
</div>
)}
<div className="flex items-center justify-between px-4 py-3 border-t">
{/* Left side - Discard button */}
<button
type="button"
onClick={handleClose}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors"
>
{t('discard')}
</button>
{/* Right side - Template, Save as Template, Attach and Send */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
>
<FileText className="w-4 h-4 mr-2" />
{t('use_template')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
>
<BookmarkPlus className="w-4 h-4" />
</Button>
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t bg-background">
{/* Left side actions */}
<div className="flex items-center gap-1">
<input
ref={fileInputRef}
type="file"
@@ -843,16 +921,47 @@ export function EmailComposer({
/>
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => fileInputRef.current?.click()}
className="h-9 w-9"
title={t('attach')}
>
<Paperclip className="w-4 h-4 mr-2" />
{t('attach')}
<Paperclip className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowTemplatePicker(true)}
title={t('use_template')}
className="h-9 w-9"
>
<FileText className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowSaveAsTemplate(true)}
title={t('save_as_template')}
className="h-9 w-9"
>
<BookmarkPlus className="w-4 h-4" />
</Button>
</div>
{/* Right side - Discard + Send (desktop) */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleClose}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors px-2 py-1"
>
{t('discard')}
</button>
<Button
onClick={handleSend}
disabled={!canSend}
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
{t('send')}
@@ -896,7 +1005,37 @@ export function EmailComposer({
</div>
)}
<ConfirmDialog {...confirmDialogProps} />
{showCloseDialog && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150"
onClick={() => setShowCloseDialog(false)}
>
<div
ref={closeDialogRef}
role="alertdialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-md animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<h2 className="text-lg font-semibold text-foreground">{t('close_draft_title')}</h2>
<p className="mt-2 text-sm text-muted-foreground">{t('close_draft_message')}</p>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={() => setShowCloseDialog(false)}>
{t('cancel')}
</Button>
<Button variant="destructive" onClick={handleDiscardAndClose}>
{t('discard')}
</Button>
<Button onClick={handleSaveDraftAndClose}>
<Save className="w-4 h-4 mr-2" />
{t('save_draft')}
</Button>
</div>
</div>
</div>
)}
</div>
);
}