feat: add pending event preview functionality to calendar views and event modal

This commit is contained in:
Linus Rath
2026-03-20 17:21:44 +01:00
parent bd686c092c
commit 65fc489b9c
5 changed files with 146 additions and 22 deletions
+7 -2
View File
@@ -25,7 +25,7 @@ import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
import { EventModal } from "@/components/calendar/event-modal";
import { EventModal, type PendingEventPreview } from "@/components/calendar/event-modal";
import { EventDetailPopover } from "@/components/calendar/event-detail-popover";
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal";
@@ -82,6 +82,7 @@ export default function CalendarPage() {
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
const hasFetched = useRef(false);
// Sidebar resize state
@@ -648,6 +649,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal}
firstDayOfWeek={firstDayOfWeek}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "week":
@@ -664,6 +666,7 @@ export default function CalendarPage() {
firstDayOfWeek={firstDayOfWeek}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "day":
@@ -678,6 +681,7 @@ export default function CalendarPage() {
onCreateAtTime={openCreateModal}
timeFormat={timeFormat}
isMobile={isMobile}
pendingPreview={pendingPreview}
/>
);
case "agenda":
@@ -808,7 +812,8 @@ export default function CalendarPage() {
onDelete={handleDeleteEvent}
onDuplicate={handleDuplicateEvent}
onRsvp={handleRsvp}
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
onPreviewChange={setPendingPreview}
currentUserEmails={currentUserEmails}
isMobile={false}
/>
+32 -1
View File
@@ -2,13 +2,14 @@
import { useMemo, useEffect, useRef, useState } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { format, isToday, parseISO } from "date-fns";
import { format, isSameDay, isToday, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard, parseDuration } from "./event-card";
import { QuickEventInput } from "./quick-event-input";
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarDayViewProps {
selectedDate: Date;
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
onCreateAtTime: (date: Date, endDate?: Date) => void;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
const HOUR_HEIGHT = 64;
@@ -35,6 +37,7 @@ export function CalendarDayView({
onCreateAtTime,
timeFormat = "24h",
isMobile,
pendingPreview,
}: CalendarDayViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -274,6 +277,34 @@ export function CalendarDayView({
</div>
</div>
)}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, selectedDate) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-2 right-2 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(24, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div>
</div>
</div>
+54 -19
View File
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
interface CalendarMonthViewProps {
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
onCreateAtTime?: (date: Date) => void;
firstDayOfWeek?: number;
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
export function CalendarMonthView({
@@ -38,6 +40,7 @@ export function CalendarMonthView({
onCreateAtTime,
firstDayOfWeek = 1,
isMobile,
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -194,31 +197,63 @@ export function CalendarMonthView({
</span>
</div>
{isMobile ? (
dayEvents.length > 0 && (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
</div>
)
<div className="flex items-center justify-center gap-0.5 flex-wrap">
{dayEvents.slice(0, 3).map((ev) => {
const calId = getPrimaryCalendarId(ev);
const cal = calId ? calendarMap.get(calId) : undefined;
const evColor = ev.color || cal?.color || "#3b82f6";
return (
<span
key={ev.id}
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: evColor }}
/>
);
})}
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
/>
)}
</div>
) : null}
</div>
);
})}
</div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
const color = cal?.color || "#3b82f6";
return (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
<div
className="absolute px-0.5"
style={{
left: `calc(${(previewDayIdx / 7) * 100}% + 1px)`,
width: `calc(${(1 / 7) * 100}% - 2px)`,
top: previewRow * 22,
height: 20,
}}
>
<div
className="h-full rounded text-[10px] leading-[20px] font-medium px-1.5 truncate border-2 border-dashed"
style={{ borderColor: color, color, backgroundColor: `${color}10` }}
>
{pendingPreview.title}
</div>
</div>
</div>
);
})()}
{!isMobile && segments.length > 0 && (
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
{segments.map((segment) => {
@@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input";
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
import type { PendingEventPreview } from "./event-modal";
interface CalendarWeekViewProps {
selectedDate: Date;
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
isMobile?: boolean;
pendingPreview?: PendingEventPreview | null;
}
const HOUR_HEIGHT = 60;
@@ -41,6 +43,7 @@ export function CalendarWeekView({
firstDayOfWeek = 1,
timeFormat = "24h",
isMobile,
pendingPreview,
}: CalendarWeekViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
@@ -366,6 +369,34 @@ export function CalendarWeekView({
</div>
</div>
)}
{pendingPreview && !pendingPreview.allDay && isSameDay(pendingPreview.start, day) && (
(() => {
const startMin = pendingPreview.start.getHours() * 60 + pendingPreview.start.getMinutes();
const endMin = pendingPreview.end.getHours() * 60 + pendingPreview.end.getMinutes();
const durationMin = Math.max(15, endMin - startMin);
const cal = calendars.find(c => c.id === pendingPreview.calendarId);
const color = cal?.color || "hsl(var(--primary))";
return (
<div
className="absolute left-1 right-1 z-10 rounded-md pointer-events-none border-2 border-dashed overflow-hidden"
style={{
top: (startMin / 60) * HOUR_HEIGHT,
height: Math.max(20, (durationMin / 60) * HOUR_HEIGHT),
borderColor: color,
backgroundColor: `${color}10`,
}}
>
<div className="text-[10px] font-medium px-1.5 py-0.5 truncate" style={{ color }}>
{pendingPreview.title}
</div>
<div className="text-[9px] px-1.5 opacity-70" style={{ color }}>
{formatSnapTime(startMin, timeFormat)} {formatSnapTime(startMin + durationMin, timeFormat)}
</div>
</div>
);
})()
)}
</div>
);
})}
+22
View File
@@ -20,6 +20,14 @@ import {
} from "@/lib/calendar-participants";
import { useSettingsStore } from "@/stores/settings-store";
export interface PendingEventPreview {
start: Date;
end: Date;
title: string;
allDay: boolean;
calendarId: string;
}
interface EventModalProps {
event?: CalendarEvent | null;
calendars: Calendar[];
@@ -30,6 +38,7 @@ interface EventModalProps {
onDuplicate?: (data: Partial<CalendarEvent>) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
onPreviewChange?: (preview: PendingEventPreview | null) => void;
currentUserEmails?: string[];
isMobile?: boolean;
}
@@ -105,6 +114,7 @@ export function EventModal({
onDuplicate,
onRsvp,
onClose,
onPreviewChange,
currentUserEmails = [],
isMobile = false,
}: EventModalProps) {
@@ -219,6 +229,18 @@ export function EventModal({
});
const [sendInvitations, setSendInvitations] = useState(true);
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`;
const endStr = allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`;
const s = new Date(startStr);
const e = new Date(endStr);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return;
onPreviewChange({ start: s, end: e, title: title || "(No title)", allDay, calendarId });
return () => onPreviewChange(null);
}, [startDate, startTime, endDate, endTime, allDay, title, calendarId, isEdit, onPreviewChange]);
const statusCounts = useMemo(() => {
if (!event?.participants) return null;
return getStatusCounts(event);