feat: add right-click context menu on empty calendar space
This commit is contained in:
@@ -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<CalendarEvent | null>(null);
|
||||
const [defaultModalDate, setDefaultModalDate] = useState<Date | undefined>();
|
||||
const [defaultModalEndDate, setDefaultModalEndDate] = useState<Date | undefined>();
|
||||
const [defaultModalAllDay, setDefaultModalAllDay] = useState(false);
|
||||
const [miniMonth, setMiniMonth] = useState(new Date());
|
||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(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 (
|
||||
<EmptySpaceContextMenu
|
||||
position={emptyContextMenu.position}
|
||||
isOpen={emptyContextMenu.isOpen}
|
||||
onClose={closeEmptyContextMenu}
|
||||
menuRef={emptyContextMenuRef}
|
||||
onNewEvent={() => {
|
||||
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 && (
|
||||
<EventDetailPopover
|
||||
event={detailEvent}
|
||||
@@ -1308,12 +1362,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); setDefaultCalendarIdForCreate(undefined); }}
|
||||
onClose={() => { setShowEventModal(false); setEditEvent(null); setDefaultCalendarIdForCreate(undefined); setDefaultModalAllDay(false); }}
|
||||
currentUserEmails={currentUserEmails}
|
||||
isMobile={true}
|
||||
/>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
{(allDayEvents.length > 0 || dayTasks.length > 0) && (
|
||||
<div className="px-4 py-2 border-b border-border">
|
||||
<div
|
||||
className="px-4 py-2 border-b border-border"
|
||||
onContextMenu={onContextMenuEmpty ? (e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-calendar-event],button")) return;
|
||||
onContextMenuEmpty(e, selectedDate, undefined, true);
|
||||
} : undefined}
|
||||
>
|
||||
{allDayEvents.length > 0 && (
|
||||
<>
|
||||
<div className="text-[10px] text-muted-foreground mb-1">{t("events.all_day")}</div>
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => (
|
||||
<div key={format(day, "yyyy-MM-dd")} className="bg-background min-h-[28px]" />
|
||||
<div
|
||||
key={format(day, "yyyy-MM-dd")}
|
||||
className="bg-background min-h-[28px]"
|
||||
onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, undefined, true) : undefined}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
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 (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Plus} label={t("events.new_event")} onClick={handle(onNewEvent)} />
|
||||
{showAllDayOption && (
|
||||
<ContextMenuItem
|
||||
icon={CalendarDays}
|
||||
label={t("events.new_all_day_event")}
|
||||
onClick={handle(onNewAllDayEvent)}
|
||||
/>
|
||||
)}
|
||||
{onNewTask && (
|
||||
<ContextMenuItem
|
||||
icon={CheckSquare}
|
||||
label={t("events.new_task")}
|
||||
onClick={handle(onNewTask)}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Clock} label={t("events.go_to_today")} onClick={handle(onGoToToday)} />
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ interface EventModalProps {
|
||||
calendars: Calendar[];
|
||||
defaultDate?: Date;
|
||||
defaultEndDate?: Date;
|
||||
defaultAllDay?: boolean;
|
||||
defaultCalendarId?: string;
|
||||
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void | Promise<void>;
|
||||
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<string>(() => {
|
||||
if (event?.calendarIds) return getPrimaryCalendarId(event) || calendars[0]?.id || "";
|
||||
if (defaultCalendarId && calendars.some(c => c.id === defaultCalendarId)) return defaultCalendarId;
|
||||
|
||||
@@ -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...",
|
||||
|
||||
Reference in New Issue
Block a user