feat: add pending event preview functionality to calendar views and event modal
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user