Files
SRCmail/components/calendar/calendar-month-view.tsx
Bernd Rodler 83e29b3ef1 feat: P2.2 Create Appointment from Email + P2.4 Calendar Dashlet + P2.12 Action Wheel + P2.14 Share Files
- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal
  with subject, body, participants, date. calendar-store newEventPrefill state.
- P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots,
  day click navigates to calendar. Collapsible, respect firstDayOfWeek.
- P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG
  menu with keyboard nav, animations. Wired into email-list, contact-list,
  file-browser, calendar-month-view right-click handlers.
- P2.14: 'Send as Attachment' button in file browser — opens compose tab
  with selected files pre-attached via Pro tab store.
2026-08-07 13:15:04 +02:00

375 lines
15 KiB
TypeScript

"use client";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
import { Pencil, Trash2, Copy } from "lucide-react";
interface CalendarMonthViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void;
onHoverLeave?: () => void;
onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void;
onContextMenuEmpty?: (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => void;
onCreateAtTime?: (date: Date) => void;
onEditEvent?: (event: CalendarEvent) => void;
onDeleteEvent?: (event: CalendarEvent) => void;
onDuplicateEvent?: (event: CalendarEvent) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
export function CalendarMonthView({
selectedDate,
events,
calendars,
onSelectDate,
onSelectEvent,
onHoverEvent,
onHoverLeave,
onContextMenuEvent,
onContextMenuEmpty,
onCreateAtTime,
onEditEvent,
onDeleteEvent,
onDuplicateEvent,
firstDayOfWeek = 1,
isMobile,
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const showTimeInMonthView = useSettingsStore((state) => state.showTimeInMonthView);
// On mobile the month view collapses events to dots unless the user opted
// into full entries via "Show time in month view" (#666).
const showChips = !isMobile || showTimeInMonthView;
const overlayTop = isMobile ? 34 : 30;
const rowHeight = isMobile ? 18 : 22;
const chipHeight = rowHeight - 2;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatFullDate,
} = useCalendarLocale();
const days = useMemo(
() => getMonthGridDays(selectedDate),
[selectedDate, getMonthGridDays],
);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
calendars.forEach((c) => map.set(c.id, c));
return map;
}, [calendars]);
const eventsByDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
events.forEach((e) => {
try {
const { startDay, endDay } = getEventDayBounds(e);
const cursor = new Date(startDay);
while (cursor <= endDay) {
const key = format(cursor, "yyyy-MM-dd");
const arr = map.get(key) || [];
arr.push(e);
map.set(key, arr);
cursor.setDate(cursor.getDate() + 1);
}
} catch { /* skip invalid dates */ }
});
return map;
}, [events]);
const weeks = useMemo(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
result.push(days.slice(i, i + 7));
}
return result;
}, [days]);
const weekSegments = useMemo(() => {
return weeks.map((week) => {
const segments = buildWeekSegments(events, week);
const rowCount = segments.reduce((maxRows, segment) => Math.max(maxRows, segment.row + 1), 0);
return { week, segments, rowCount };
});
}, [events, weeks]);
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
// Radial menu state
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
const [radialMenuEvent, setRadialMenuEvent] = useState<CalendarEvent | null>(null);
const closeRadialMenu = useCallback(() => {
setRadialMenuOpen(false);
}, []);
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
if (!radialMenuEvent) return [];
const ev = radialMenuEvent;
const items: RadialMenuItem[] = [];
if (onEditEvent) {
items.push({
id: "edit",
icon: <Pencil className="w-5 h-5" />,
label: t("edit"),
onClick: () => { onEditEvent(ev); },
});
}
if (onDeleteEvent) {
items.push({
id: "delete",
icon: <Trash2 className="w-5 h-5" />,
label: t("delete"),
onClick: () => { onDeleteEvent(ev); },
destructive: true,
});
}
if (onDuplicateEvent) {
items.push({
id: "duplicate",
icon: <Copy className="w-5 h-5" />,
label: t("duplicate"),
onClick: () => { onDuplicateEvent(ev); },
});
}
return items;
}, [radialMenuEvent, t, onEditEvent, onDeleteEvent, onDuplicateEvent]);
const handleRadialMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => {
setRadialMenuPos({ x: e.clientX, y: e.clientY });
setRadialMenuEvent(event);
setRadialMenuOpen(true);
onContextMenuEvent?.(e, event);
}, [onContextMenuEvent]);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDropDayKey((prev) => prev === dayKey ? prev : dayKey);
}, []);
const handleCellDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(related)) setDropDayKey(null);
}, []);
const handleCellDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
e.preventDefault();
setDropDayKey(null);
const json = e.dataTransfer.getData("application/x-calendar-event");
if (!json) return;
try {
const data = JSON.parse(json);
const originalStart = parseISO(data.originalStart);
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
const isAllDay = event?.showWithoutTime;
const newStart = new Date(day);
newStart.setHours(originalStart.getHours(), originalStart.getMinutes(), originalStart.getSeconds(), 0);
const newStartISO = isAllDay ? format(newStart, "yyyy-MM-dd") : format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
if (newStartISO === data.originalStart) return;
const client = useAuthStore.getState().client;
if (!client) return;
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
} catch {
toast.error(t("notifications.event_move_error"));
}
}, [t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-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">
{weekSegments.map(({ week, segments, rowCount }, wi) => (
<div key={wi} className={cn(
"relative flex-1 border-b border-border last:border-b-0",
isMobile ? "min-h-[52px]" : "min-h-[100px]"
)} role="row" style={showChips ? { minHeight: Math.max(isMobile ? 52 : 100, overlayTop + 4 + rowCount * rowHeight + 8) } : undefined}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = checkIsSameMonth(day, selectedDate);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const fullDateLabel = formatFullDate(day);
return (
<div
key={key}
role="gridcell"
aria-selected={selected}
aria-label={fullDateLabel}
onClick={() => onSelectDate(day)}
onDoubleClick={() => onCreateAtTime?.(day)}
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
onDragOver={(e) => handleCellDragOver(e, key)}
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-e border-border last:border-e-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 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",
inMonth && !selected && !today && "font-medium"
)}
>
{formatDayNumber(day)}
</span>
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
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" />
)}
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
/>
)}
</div>
) : null}
</div>
);
})}
</div>
{showChips && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * rowHeight,
height: chipHeight,
}}
>
<div
className={cn(
"h-full rounded text-[10px] font-medium truncate border-2 border-dashed",
isMobile ? "leading-[16px] px-1" : "leading-[20px] px-1.5"
)}
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
</div>
);
})()}
{showChips && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: overlayTop }}>
{segments.map((segment) => {
const calId = getPrimaryCalendarId(segment.event);
return (
<div
key={`${segment.event.id}-${segment.startIndex}-${segment.row}`}
className="absolute px-0.5 pointer-events-auto"
style={{
left: `calc(${(segment.startIndex / 7) * 100}% + 1px)`,
width: `calc(${(segment.span / 7) * 100}% - 2px)`,
top: segment.row * rowHeight,
height: chipHeight,
}}
>
<EventCard
event={segment.event}
calendar={calId ? calendarMap.get(calId) : undefined}
variant="span"
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onClick={(rect) => onSelectEvent(segment.event, rect)}
onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)}
onMouseLeave={onHoverLeave}
onContextMenu={handleRadialMenuEvent}
draggable
className={isMobile ? "text-[10px] px-1" : undefined}
/>
</div>
);
})}
</div>
)}
</div>
))}
</div>
<RadialMenu
items={radialMenuItems}
isOpen={radialMenuOpen}
position={radialMenuPos}
onClose={closeRadialMenu}
/>
</div>
);
}