From 2dd90a7b5c5c705cfa4c342b4043126dca0b41ec Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:03:08 +0200 Subject: [PATCH] feat(calendar): open a clicked event as a card with its actions on top On wide screens a click on an event now opens its card next to it instead of the side panel. The card stays until it is closed and has edit, delete, more and close buttons at the top, a square in the event's colour beside the title, and a line naming the calendar. More opens the same menu as a right-click (duplicate, export, copy). Delete asks for confirmation inside the card, and edit goes straight to the form. Hover previews still work as before while no card is open. Phones keep opening the full editor. --- components/calendar/calendar-app.tsx | 49 +++-- components/calendar/event-detail-popover.tsx | 191 ++++++++++--------- components/calendar/event-modal.tsx | 5 +- 3 files changed, 139 insertions(+), 106 deletions(-) diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 84380754d..d2e4f57ad 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -189,11 +189,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const [pendingScopeAction, setPendingScopeAction] = useState(null); const [detailEvent, setDetailEvent] = useState(null); const [detailAnchorRect, setDetailAnchorRect] = useState(null); + // A clicked event's card stays until closed; a hover preview goes when + // the pointer leaves. Held in a ref too for the hover timers. + const [detailPinned, setDetailPinned] = useState(false); + const detailPinnedRef = useRef(false); + useEffect(() => { detailPinnedRef.current = detailPinned; }, [detailPinned]); const [pendingPreview, setPendingPreview] = useState(null); // Quick create (#calendar redesign): an empty slot clicked in the week or // day view opens a small editor beside a placeholder block. const [quickDraft, setQuickDraft] = useState(null); const [defaultModalTitle, setDefaultModalTitle] = useState(); + const [modalStartsInEdit, setModalStartsInEdit] = useState(false); const [showTaskModal, setShowTaskModal] = useState(false); const [editTask, setEditTask] = useState(null); const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false); @@ -618,7 +624,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setShowEventModal(true); }, [selectedDate, setSelectedDate]); - const openEditModal = useCallback((event: CalendarEvent) => { + const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => { + setModalStartsInEdit(startInForm); setEditEvent(event); setDefaultModalDate(undefined); setShowTaskModal(false); @@ -675,15 +682,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const closeDetail = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } + detailPinnedRef.current = false; + setDetailPinned(false); setDetailEvent(null); setDetailAnchorRect(null); }, []); - const handleSelectEvent = useCallback((event: CalendarEvent, _anchorRect: DOMRect) => { - // Click opens the sidebar for viewing/editing - closeDetail(); - openEditModal(event); - }, [closeDetail, openEditModal]); + const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { + // Phones open the full editor; wide screens show the event's card next + // to it, with edit and delete at the top (as in Google Calendar). + if (isMobile) { + closeDetail(); + openEditModal(event); + return; + } + if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } + setQuickDraft(null); + detailPinnedRef.current = true; + setDetailPinned(true); + setDetailEvent(event); + setDetailAnchorRect(anchorRect); + }, [isMobile, closeDetail, openEditModal]); const { contextMenu: eventContextMenu, @@ -714,6 +733,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => { if (isMobile) return; + if (detailPinnedRef.current) return; if (calendarHoverPreview === 'off') return; if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } // Don't show hover popover if the sidebar is already open for this event @@ -732,7 +752,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const handleHoverLeave = useCallback(() => { if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } + if (detailPinnedRef.current) return; hoverTimerRef.current = setTimeout(() => { + if (detailPinnedRef.current) return; setDetailEvent(null); setDetailAnchorRect(null); }, 300); @@ -822,7 +844,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = if (detailEvent) { const ev = detailEvent; closeDetail(); - openEditModal(ev); + // The pencil on the event card goes straight to the form. + openEditModal(ev, true); } }, [detailEvent, closeDetail, openEditModal]); @@ -983,8 +1006,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = setEditEvent(null); setShowTaskModal(false); setEditTask(null); - setDetailEvent(null); - setDetailAnchorRect(null); + closeDetail(); setQuickDraft((prev) => ({ start, end: finalEnd, @@ -993,7 +1015,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = ? prev.calendarId : preferred?.id ?? "", })); - }, [isMobile, openCreateModal, writableCalendars]); + }, [isMobile, openCreateModal, writableCalendars, closeDetail]); const closeQuickCreate = useCallback(() => setQuickDraft(null), []); @@ -1883,13 +1905,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {!isMobile && showEventModal && (
handleContextMenuEvent(e, detailEvent)} onClose={closeDetail} onSaveNote={handleSaveNoteFromDetail} onRsvp={handleRsvpFromDetail} @@ -2032,13 +2056,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = {showEventModal && isMobile && ( void; onDelete: () => void; onDuplicate: () => void; + /** Opens the event's full action menu (export, copy, ...) at the click. */ + onMore?: (e: React.MouseEvent) => void; onClose: () => void; onSaveNote: (note: string) => void; onRsvp?: (status: CalendarParticipant["participationStatus"]) => void; @@ -44,6 +46,7 @@ interface EventDetailPopoverProps { } const POPOVER_WIDTH = 360; +const HEADER_BUTTON = "p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"; const POPOVER_GAP = 8; const VIEWPORT_MARGIN = 12; const MAX_HEIGHT = 480; @@ -118,6 +121,7 @@ export function EventDetailPopover({ onEdit, onDelete, onDuplicate, + onMore, onClose, onSaveNote, onRsvp, @@ -324,49 +328,99 @@ export function EventDetailPopover({ visibility: position ? "visible" : "hidden", }} > - {/* Color accent bar */} -
- - {/* Header */} -
-
-
- -

- {event.title || t("events.no_title")} -

-
- {calendar && ( -

- {calendar.name} - {event.status === "tentative" && ( - - {t("detail.tentative")} - - )} - {event.status === "cancelled" && ( - - {t("detail.cancelled")} - - )} -

- )} -
+ {/* Actions sit at the top, the event's colour square beside its title. */} +
+ {canEditBody && ( + + )} + {canEditBody && ( + + )} +
+ {showDeleteConfirm && ( +
+ + {t("detail.delete_confirm")} + + + +
+ )} + +
+
+ {/* Content */}
)} + {/* Calendar */} + {calendar && ( +
+ + {calendar.name} +
+ )} + {/* Description */} {event.description && (
@@ -634,63 +696,6 @@ export function EventDetailPopover({
)} - {/* Action Bar */} -
- {showDeleteConfirm ? ( -
- - {t("form.delete_confirm")} - - - -
- ) : ( - <> - {canEditBody && ( - - )} - -
- {canEditBody && ( - - )} - - )} -
); diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index f15e2fae8..f4a2bd85e 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -49,6 +49,8 @@ interface EventModalProps { defaultDate?: Date; defaultEndDate?: Date; defaultAllDay?: boolean; + /** Open an existing event straight in the form instead of its summary. */ + startInEditMode?: boolean; /** Title to start a new event with (from the quick editor's "More options"). */ defaultTitle?: string; defaultCalendarId?: string; @@ -195,6 +197,7 @@ export function EventModal({ defaultEndDate, defaultAllDay, defaultTitle, + startInEditMode, defaultCalendarId, onSave, onDelete, @@ -220,7 +223,7 @@ export function EventModal({ : "fixed inset-0 z-50 flex flex-col bg-background"; const isEdit = !!event; const formatEventDate = useFormatEventDate(); - const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit"); + const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit"); const userIsOrganizer = useMemo(() => { if (!event) return true;