"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(); calendars.forEach((c) => map.set(c.id, c)); return map; }, [calendars]); const eventsByDate = useMemo(() => { const map = new Map(); 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(null); // Radial menu state const [radialMenuOpen, setRadialMenuOpen] = useState(false); const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 }); const [radialMenuEvent, setRadialMenuEvent] = useState(null); const closeRadialMenu = useCallback(() => { setRadialMenuOpen(false); }, []); const radialMenuItems = useMemo(() => { if (!radialMenuEvent) return []; const ev = radialMenuEvent; const items: RadialMenuItem[] = []; if (onEditEvent) { items.push({ id: "edit", icon: , label: t("edit"), onClick: () => { onEditEvent(ev); }, }); } if (onDeleteEvent) { items.push({ id: "delete", icon: , label: t("delete"), onClick: () => { onDeleteEvent(ev); }, destructive: true, }); } if (onDuplicateEvent) { items.push({ id: "duplicate", icon: , 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, 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) => { const related = e.relatedTarget as Node | null; if (!e.currentTarget.contains(related)) setDropDayKey(null); }, []); const handleCellDrop = useCallback(async (e: DragEvent, 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 (
{dayHeaderKeys.map((d) => (
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
))}
{weekSegments.map(({ week, segments, rowCount }, wi) => (
{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 (
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" )} >
{formatDayNumber(day)}
{isMobile && !showChips ? (
{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 ( ); })} {dayEvents.length > 3 && ( )} {pendingPreview && checkIsSameDay(pendingPreview.start, day) && ( )}
) : null}
); })}
{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 (
{pendingPreview.title}
); })()} {showChips && segments.length > 0 && (
{segments.map((segment) => { const calId = getPrimaryCalendarId(segment.event); return (
onSelectEvent(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} onContextMenu={handleRadialMenuEvent} draggable className={isMobile ? "text-[10px] px-1" : undefined} />
); })}
)}
))}
); }