From 2c419cc4fecd59bddf3e0c144665736464ec671d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:56:15 +0200 Subject: [PATCH] feat: add right-click context menu on empty calendar space --- app/[locale]/calendar/page.tsx | 61 ++++++++++++++++- components/calendar/calendar-day-view.tsx | 11 ++- components/calendar/calendar-month-view.tsx | 3 + components/calendar/calendar-week-view.tsx | 9 ++- .../calendar/empty-space-context-menu.tsx | 67 +++++++++++++++++++ components/calendar/event-modal.tsx | 4 +- locales/en/common.json | 6 +- 7 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 components/calendar/empty-space-context-menu.tsx diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 0bcad9a8..c64b6424 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -31,6 +31,7 @@ import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-pan import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal"; import { EventDetailPopover } from "@/components/calendar/event-detail-popover"; import { EventContextMenu } from "@/components/calendar/event-context-menu"; +import { EmptySpaceContextMenu } from "@/components/calendar/empty-space-context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { downloadEventICS } from "@/lib/calendar-ics-export"; @@ -104,6 +105,7 @@ export default function CalendarPage() { const [editEvent, setEditEvent] = useState(null); const [defaultModalDate, setDefaultModalDate] = useState(); const [defaultModalEndDate, setDefaultModalEndDate] = useState(); + const [defaultModalAllDay, setDefaultModalAllDay] = useState(false); const [miniMonth, setMiniMonth] = useState(new Date()); const [pendingScopeAction, setPendingScopeAction] = useState(null); const [detailEvent, setDetailEvent] = useState(null); @@ -326,11 +328,12 @@ export default function CalendarPage() { setSelectedDate(date); }, [setSelectedDate]); - const openCreateModal = useCallback((date?: Date, endDate?: Date) => { + const openCreateModal = useCallback((date?: Date, endDate?: Date, allDay?: boolean) => { setEditEvent(null); const d = date || selectedDate; setDefaultModalDate(d); setDefaultModalEndDate(endDate); + setDefaultModalAllDay(allDay ?? false); setSelectedDate(d); setShowEventModal(true); }, [selectedDate, setSelectedDate]); @@ -395,6 +398,21 @@ export default function CalendarPage() { openEventContextMenu(e, event); }, [closeDetail, openEventContextMenu]); + const { + contextMenu: emptyContextMenu, + openContextMenu: openEmptyContextMenu, + closeContextMenu: closeEmptyContextMenu, + menuRef: emptyContextMenuRef, + } = useContextMenu<{ date: Date; hour?: number; allDayArea?: boolean }>(); + + const handleContextMenuEmpty = useCallback( + (e: React.MouseEvent, date: Date, hour?: number, allDayArea?: boolean) => { + closeDetail(); + openEmptyContextMenu(e, { date, hour, allDayArea }); + }, + [closeDetail, openEmptyContextMenu], + ); + const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; if (calendarHoverPreview === 'off') return; @@ -961,6 +979,7 @@ export default function CalendarPage() { onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} + onContextMenuEmpty={handleContextMenuEmpty} onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} @@ -978,6 +997,7 @@ export default function CalendarPage() { onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} + onContextMenuEmpty={handleContextMenuEmpty} onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} @@ -997,6 +1017,7 @@ export default function CalendarPage() { onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} onContextMenuEvent={handleContextMenuEvent} + onContextMenuEmpty={handleContextMenuEmpty} onCreateAtTime={openCreateModal} timeFormat={timeFormat} isMobile={isMobile} @@ -1211,12 +1232,13 @@ export default function CalendarPage() { calendars={calendars} defaultDate={defaultModalDate} defaultEndDate={defaultModalEndDate} + defaultAllDay={defaultModalAllDay} defaultCalendarId={defaultCalendarIdForCreate} onSave={handleSaveEvent} onDelete={handleDeleteEvent} onDuplicate={handleDuplicateEvent} onRsvp={handleRsvp} - onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} onPreviewChange={setPendingPreview} currentUserEmails={currentUserEmails} isMobile={false} @@ -1282,6 +1304,38 @@ export default function CalendarPage() { /> )} + {emptyContextMenu.data && (() => { + const { date, hour } = emptyContextMenu.data; + return ( + { + const d = new Date(date); + if (typeof hour === "number") { + d.setHours(hour, 0, 0, 0); + } else { + const now = new Date(); + d.setHours(now.getHours() + 1, 0, 0, 0); + } + openCreateModal(d); + }} + onNewAllDayEvent={() => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + openCreateModal(d, undefined, true); + }} + onNewTask={enableCalendarTasks ? () => { + setEditTask(null); + setShowTaskModal(true); + } : undefined} + onGoToToday={goToToday} + /> + ); + })()} + {detailEvent && detailAnchorRect && ( { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); }} + onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }} currentUserEmails={currentUserEmails} isMobile={true} /> diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index cec29b8d..b06528fc 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -20,6 +20,7 @@ interface CalendarDayViewProps { 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, endDate?: Date) => void; timeFormat?: "12h" | "24h"; isMobile?: boolean; @@ -39,6 +40,7 @@ export function CalendarDayView({ onHoverEvent, onHoverLeave, onContextMenuEvent, + onContextMenuEmpty, onCreateAtTime, timeFormat = "24h", isMobile, @@ -144,7 +146,13 @@ export function CalendarDayView({ {(allDayEvents.length > 0 || dayTasks.length > 0) && ( -
+
{ + if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return; + onContextMenuEmpty(e, selectedDate, undefined, true); + } : undefined} + > {allDayEvents.length > 0 && ( <>
{t("events.all_day")}
@@ -240,6 +248,7 @@ export function CalendarDayView({ aria-label={formatHour(h)} onClick={() => handleSlotClick(selectedDate, h)} onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)} + onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, selectedDate, h, false) : undefined} className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" style={{ height: HOUR_HEIGHT }} /> diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index 6137ed60..c6b26e49 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -24,6 +24,7 @@ interface CalendarMonthViewProps { 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; firstDayOfWeek?: number; isMobile?: boolean; @@ -39,6 +40,7 @@ export function CalendarMonthView({ onHoverEvent, onHoverLeave, onContextMenuEvent, + onContextMenuEmpty, onCreateAtTime, firstDayOfWeek = 1, isMobile, @@ -174,6 +176,7 @@ export function CalendarMonthView({ 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)} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 6dc8ac61..16e822aa 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -23,6 +23,7 @@ interface CalendarWeekViewProps { 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, endDate?: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; @@ -44,6 +45,7 @@ export function CalendarWeekView({ onHoverEvent, onHoverLeave, onContextMenuEvent, + onContextMenuEmpty, onCreateAtTime, firstDayOfWeek = 1, timeFormat = "24h", @@ -219,7 +221,11 @@ export function CalendarWeekView({ style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }} > {weekDays.map((day) => ( -
+
onContextMenuEmpty(e, day, undefined, true) : undefined} + /> ))}
@@ -378,6 +384,7 @@ export function CalendarWeekView({ aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`} onClick={() => handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} + onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" style={{ height: HOUR_HEIGHT }} /> diff --git a/components/calendar/empty-space-context-menu.tsx b/components/calendar/empty-space-context-menu.tsx new file mode 100644 index 00000000..c45b1ce3 --- /dev/null +++ b/components/calendar/empty-space-context-menu.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + ContextMenu, + ContextMenuItem, + ContextMenuSeparator, +} from "@/components/ui/context-menu"; +import { Plus, CalendarDays, CheckSquare, Clock } from "lucide-react"; + +interface Position { + x: number; + y: number; +} + +interface EmptySpaceContextMenuProps { + position: Position; + isOpen: boolean; + onClose: () => void; + menuRef: React.RefObject; + onNewEvent: () => void; + onNewAllDayEvent: () => void; + onNewTask?: () => void; + onGoToToday: () => void; + showAllDayOption?: boolean; +} + +export function EmptySpaceContextMenu({ + position, + isOpen, + onClose, + menuRef, + onNewEvent, + onNewAllDayEvent, + onNewTask, + onGoToToday, + showAllDayOption = true, +}: EmptySpaceContextMenuProps) { + const t = useTranslations("calendar"); + + const handle = (fn: () => void) => () => { + fn(); + onClose(); + }; + + return ( + + + {showAllDayOption && ( + + )} + {onNewTask && ( + + )} + + + + ); +} diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index fe65a867..346338a3 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -35,6 +35,7 @@ interface EventModalProps { calendars: Calendar[]; defaultDate?: Date; defaultEndDate?: Date; + defaultAllDay?: boolean; defaultCalendarId?: string; onSave: (data: Partial, sendSchedulingMessages?: boolean) => void | Promise; onDelete?: (id: string, sendSchedulingMessages?: boolean) => void; @@ -114,6 +115,7 @@ export function EventModal({ calendars, defaultDate, defaultEndDate, + defaultAllDay, defaultCalendarId, onSave, onDelete, @@ -199,7 +201,7 @@ export function EventModal({ const [startTime, setStartTime] = useState(formatTimeInput(getInitialStart())); const [endDate, setEndDate] = useState(formatDateInput(getInitialEnd())); const [endTime, setEndTime] = useState(formatTimeInput(getInitialEnd())); - const [allDay, setAllDay] = useState(event?.showWithoutTime || false); + const [allDay, setAllDay] = useState(event?.showWithoutTime || defaultAllDay || false); const [calendarId, setCalendarId] = useState(() => { if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || ""; if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId; diff --git a/locales/en/common.json b/locales/en/common.json index 026d45fc..a884fe2e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2136,7 +2136,11 @@ "tomorrow_header": "Tomorrow", "export_ics": "Export as .ics", "copy_title": "Copy title", - "copy_link": "Copy meeting link" + "copy_link": "Copy meeting link", + "new_event": "New event", + "new_all_day_event": "New all-day event", + "new_task": "New task", + "go_to_today": "Go to today" }, "detail": { "add_note": "Add a note...",