diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e56d14eb..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebFetch(domain:github.com)" - ] - } -} diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index ab1f5d06..00b26dbc 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -30,6 +30,9 @@ import { MiniCalendar } from "@/components/calendar/mini-calendar"; import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel"; 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 { useContextMenu } from "@/hooks/use-context-menu"; +import { downloadEventICS } from "@/lib/calendar-ics-export"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; @@ -357,6 +360,18 @@ export default function CalendarPage() { openEditModal(event); }, [closeDetail, openEditModal]); + const { + contextMenu: eventContextMenu, + openContextMenu: openEventContextMenu, + closeContextMenu: closeEventContextMenu, + menuRef: eventContextMenuRef, + } = useContextMenu(); + + const handleContextMenuEvent = useCallback((e: React.MouseEvent, event: CalendarEvent) => { + closeDetail(); + openEventContextMenu(e, event); + }, [closeDetail, openEventContextMenu]); + const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; if (calendarHoverPreview === 'off') return; @@ -732,6 +747,73 @@ export default function CalendarPage() { } }, [detailEvent, client, updateEvent, t]); + const handleDuplicateContextMenu = useCallback(async (event: CalendarEvent) => { + if (!client) { toast.error(t("notifications.event_error")); return; } + const start = parseISO(event.start); + const newStart = addDays(start, 1); + const data = sanitizeOutgoingCalendarEventData>({ + title: event.title, + description: event.description, + start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"), + duration: event.duration, + timeZone: event.timeZone, + showWithoutTime: event.showWithoutTime, + calendarIds: { ...event.calendarIds }, + status: "confirmed", + freeBusyStatus: event.freeBusyStatus, + privacy: event.privacy, + }); + if (event.locations) data.locations = structuredClone(event.locations); + if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules); + if (event.alerts) data.alerts = structuredClone(event.alerts); + if (event.participants) data.participants = structuredClone(event.participants); + try { + const created = await createEvent(client, data); + if (created) { + toast.success(t("notifications.event_duplicated")); + openEditModal(created); + } + } catch { + toast.error(t("notifications.event_error")); + } + }, [client, createEvent, openEditModal, t]); + + const handleExportICS = useCallback((event: CalendarEvent) => { + try { + downloadEventICS(event); + toast.success(t("notifications.event_exported")); + } catch { + toast.error(t("notifications.event_error")); + } + }, [t]); + + const handleCopyTitle = useCallback(async (event: CalendarEvent) => { + try { + await navigator.clipboard.writeText(event.title || ""); + toast.success(t("notifications.title_copied")); + } catch { + toast.error(t("notifications.event_error")); + } + }, [t]); + + const handleCopyMeetingLink = useCallback(async (event: CalendarEvent) => { + const uri = event.virtualLocations + ? Object.values(event.virtualLocations).find((v) => v.uri)?.uri + : undefined; + if (!uri) return; + try { + await navigator.clipboard.writeText(uri); + toast.success(t("notifications.link_copied")); + } catch { + toast.error(t("notifications.event_error")); + } + }, [t]); + + const handleDeleteContextMenu = useCallback((event: CalendarEvent) => { + const hasParticipants = event.participants && Object.keys(event.participants).length > 0; + handleDeleteEvent(event.id, hasParticipants || undefined); + }, [handleDeleteEvent]); + const handleRsvpFromDetail = useCallback(async (status: CalendarParticipant['participationStatus']) => { if (!detailEvent || !client) return; const participantId = getUserParticipantId(detailEvent, currentUserEmails); @@ -841,6 +923,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} + onContextMenuEvent={handleContextMenuEvent} onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} isMobile={isMobile} @@ -857,6 +940,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} + onContextMenuEvent={handleContextMenuEvent} onCreateAtTime={openCreateModal} firstDayOfWeek={firstDayOfWeek} timeFormat={timeFormat} @@ -875,6 +959,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} + onContextMenuEvent={handleContextMenuEvent} onCreateAtTime={openCreateModal} timeFormat={timeFormat} isMobile={isMobile} @@ -892,6 +977,7 @@ export default function CalendarPage() { onSelectEvent={handleSelectEvent} onHoverEvent={handleHoverEvent} onHoverLeave={handleHoverLeave} + onContextMenuEvent={handleContextMenuEvent} timeFormat={timeFormat} /> ); @@ -1106,6 +1192,22 @@ export default function CalendarPage() { )} + {eventContextMenu.data && ( + openEditModal(eventContextMenu.data!)} + onDuplicate={() => handleDuplicateContextMenu(eventContextMenu.data!)} + onExportICS={() => handleExportICS(eventContextMenu.data!)} + onCopyTitle={() => handleCopyTitle(eventContextMenu.data!)} + onCopyMeetingLink={() => handleCopyMeetingLink(eventContextMenu.data!)} + onDelete={() => handleDeleteContextMenu(eventContextMenu.data!)} + /> + )} + {detailEvent && detailAnchorRect && ( void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; timeFormat?: "12h" | "24h"; } @@ -33,6 +34,7 @@ export function CalendarAgendaView({ onSelectEvent, onHoverEvent, onHoverLeave, + onContextMenuEvent, timeFormat = "24h", }: CalendarAgendaViewProps) { const t = useTranslations("calendar"); @@ -157,6 +159,7 @@ export function CalendarAgendaView({ onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())} onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())} onMouseLeave={() => onHoverLeave?.()} + onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined} className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }} > diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index 630292c9..62e0d554 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -19,6 +19,7 @@ interface CalendarDayViewProps { onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; timeFormat?: "12h" | "24h"; isMobile?: boolean; @@ -37,6 +38,7 @@ export function CalendarDayView({ onSelectEvent, onHoverEvent, onHoverLeave, + onContextMenuEvent, onCreateAtTime, timeFormat = "24h", isMobile, @@ -157,6 +159,7 @@ export function CalendarDayView({ onClick={(rect) => onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} /> ); })} @@ -264,6 +267,7 @@ export function CalendarDayView({ onClick={(rect) => onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} draggable />
void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onCreateAtTime?: (date: Date) => void; firstDayOfWeek?: number; isMobile?: boolean; @@ -37,6 +38,7 @@ export function CalendarMonthView({ onSelectEvent, onHoverEvent, onHoverLeave, + onContextMenuEvent, onCreateAtTime, firstDayOfWeek = 1, isMobile, @@ -279,6 +281,7 @@ export function CalendarMonthView({ onClick={(rect) => onSelectEvent(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} draggable />
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index 102e3b23..19aa3d8e 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -22,6 +22,7 @@ interface CalendarWeekViewProps { onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverEvent?: (event: CalendarEvent, anchorRect: DOMRect) => void; onHoverLeave?: () => void; + onContextMenuEvent?: (e: React.MouseEvent, event: CalendarEvent) => void; onCreateAtTime: (date: Date, endDate?: Date) => void; firstDayOfWeek?: number; timeFormat?: "12h" | "24h"; @@ -42,6 +43,7 @@ export function CalendarWeekView({ onSelectEvent, onHoverEvent, onHoverLeave, + onContextMenuEvent, onCreateAtTime, firstDayOfWeek = 1, timeFormat = "24h", @@ -242,6 +244,7 @@ export function CalendarWeekView({ onClick={(rect) => onSelectEvent(segment.event, rect)} onMouseEnter={(rect) => onHoverEvent?.(segment.event, rect)} onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} /> ); @@ -402,6 +405,7 @@ export function CalendarWeekView({ onClick={(rect) => onSelectEvent(ev, rect)} onMouseEnter={(rect) => onHoverEvent?.(ev, rect)} onMouseLeave={onHoverLeave} + onContextMenu={onContextMenuEvent} draggable />
void; onMouseEnter?: (anchorRect: DOMRect) => void; onMouseLeave?: () => void; + onContextMenu?: (e: React.MouseEvent, event: CalendarEvent) => void; isSelected?: boolean; draggable?: boolean; continuesBefore?: boolean; @@ -65,7 +66,7 @@ function createEventDragPreview(title: string, timeRange: string, color: string) return el; } -export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) { +export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onMouseLeave, onContextMenu, isSelected, draggable: isDraggable, continuesBefore = false, continuesAfter = false, className, style }: EventCardProps) { const t = useTranslations("calendar"); const [isBeingDragged, setIsBeingDragged] = useState(false); const color = getEventColor(event, calendar); @@ -109,12 +110,15 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM "aria-roledescription": "draggable event", } : {}; + const handleContextMenu = onContextMenu ? (e: React.MouseEvent) => onContextMenu(e, event) : undefined; + if (variant === "chip") { return (