mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
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.
This commit is contained in:
@@ -189,11 +189,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(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<PendingEventPreview | null>(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<QuickCreateDraft | null>(null);
|
||||
const [defaultModalTitle, setDefaultModalTitle] = useState<string | undefined>();
|
||||
const [modalStartsInEdit, setModalStartsInEdit] = useState(false);
|
||||
const [showTaskModal, setShowTaskModal] = useState(false);
|
||||
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(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 && (
|
||||
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
event={editEvent}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultTitle={defaultModalTitle}
|
||||
startInEditMode={modalStartsInEdit}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
@@ -2018,6 +2041,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
onEdit={handleEditFromDetail}
|
||||
onDelete={handleDeleteFromDetail}
|
||||
onDuplicate={handleDuplicateFromDetail}
|
||||
onMore={(e) => handleContextMenuEvent(e, detailEvent)}
|
||||
onClose={closeDetail}
|
||||
onSaveNote={handleSaveNoteFromDetail}
|
||||
onRsvp={handleRsvpFromDetail}
|
||||
@@ -2032,13 +2056,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
|
||||
{showEventModal && isMobile && (
|
||||
<EventModal
|
||||
key={editEvent?.id ?? 'new'}
|
||||
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
|
||||
event={editEvent}
|
||||
calendars={displayCalendars}
|
||||
defaultDate={defaultModalDate}
|
||||
defaultEndDate={defaultModalEndDate}
|
||||
defaultAllDay={defaultModalAllDay}
|
||||
defaultTitle={defaultModalTitle}
|
||||
startInEditMode={modalStartsInEdit}
|
||||
defaultCalendarId={defaultCalendarIdForCreate}
|
||||
onSave={handleSaveEvent}
|
||||
onDelete={handleDeleteEvent}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { LinkifiedText } from "@/components/ui/linkified-text";
|
||||
import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
Pencil, Trash2, Copy, Send, Check, MoreVertical, Calendar as CalendarIcon,
|
||||
} from "@/components/icons";
|
||||
import { format, isSameDay } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -32,6 +32,8 @@ interface EventDetailPopoverProps {
|
||||
onEdit: () => 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 */}
|
||||
<div className="h-1 w-full" style={{ backgroundColor: color }} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 px-4 pt-3 pb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<h3 className={cn(
|
||||
"text-base font-semibold truncate text-foreground",
|
||||
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{event.title || t("events.no_title")}
|
||||
</h3>
|
||||
</div>
|
||||
{calendar && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
|
||||
{calendar.name}
|
||||
{event.status === "tentative" && (
|
||||
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||
{t("detail.tentative")}
|
||||
</span>
|
||||
)}
|
||||
{event.status === "cancelled" && (
|
||||
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||
{t("detail.cancelled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Actions sit at the top, the event's colour square beside its title. */}
|
||||
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
|
||||
{canEditBody && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
className={HEADER_BUTTON}
|
||||
aria-label={t("events.edit")}
|
||||
title={t("events.edit")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{canEditBody && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className={HEADER_BUTTON}
|
||||
aria-label={t("events.delete")}
|
||||
title={t("events.delete")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMore ?? onDuplicate}
|
||||
className={HEADER_BUTTON}
|
||||
aria-label={onMore ? t("toolbar.more") : t("events.duplicate")}
|
||||
title={onMore ? t("toolbar.more") : t("events.duplicate")}
|
||||
>
|
||||
{onMore ? <MoreVertical className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground"
|
||||
className={HEADER_BUTTON}
|
||||
aria-label={t("form.cancel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDeleteConfirm && (
|
||||
<div className="mx-4 mt-1 mb-2 flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2">
|
||||
<span className="text-sm text-destructive flex-1">
|
||||
{t("detail.delete_confirm")}
|
||||
</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-2.5 px-4 pb-1">
|
||||
<span
|
||||
className="w-4 h-4 rounded-sm flex-shrink-0 mt-1"
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className={cn(
|
||||
"text-lg font-semibold leading-snug break-words text-foreground",
|
||||
event.status === "cancelled" && "line-through text-muted-foreground"
|
||||
)}>
|
||||
{event.title || t("events.no_title")}
|
||||
</h3>
|
||||
{event.status === "tentative" && (
|
||||
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
|
||||
{t("detail.tentative")}
|
||||
</span>
|
||||
)}
|
||||
{event.status === "cancelled" && (
|
||||
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
|
||||
{t("detail.cancelled")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn(
|
||||
"px-4 py-2 space-y-2.5 overflow-y-auto",
|
||||
@@ -523,6 +577,14 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calendar */}
|
||||
{calendar && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm text-foreground truncate">{calendar.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{event.description && (
|
||||
<div className="flex items-start gap-2.5">
|
||||
@@ -634,63 +696,6 @@ export function EventDetailPopover({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Bar */}
|
||||
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<span className="text-sm text-destructive flex-1">
|
||||
{t("form.delete_confirm")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 h-7 text-xs"
|
||||
>
|
||||
{t("events.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{canEditBody && (
|
||||
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
|
||||
<Pencil className="w-3.5 h-3.5 me-1" />
|
||||
{t("events.edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDuplicate}
|
||||
className="h-7 text-xs"
|
||||
title={t("events.duplicate")}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 me-1" />
|
||||
{t("events.duplicate")}
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{canEditBody && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="h-7 text-xs text-red-600 dark:text-red-400"
|
||||
title={t("events.delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user