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 { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
|
||||||
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
import { MiniCalendar } from "@/components/calendar/mini-calendar";
|
||||||
import { CalendarSidebarPanel } from "@/components/calendar/calendar-sidebar-panel";
|
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 { EventDetailPopover } from "@/components/calendar/event-detail-popover";
|
||||||
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
import { ICalImportModal } from "@/components/calendar/ical-import-modal";
|
||||||
import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-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 [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
|
||||||
const hasFetched = useRef(false);
|
const hasFetched = useRef(false);
|
||||||
|
|
||||||
// Sidebar resize state
|
// Sidebar resize state
|
||||||
@@ -648,6 +649,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "week":
|
case "week":
|
||||||
@@ -664,6 +666,7 @@ export default function CalendarPage() {
|
|||||||
firstDayOfWeek={firstDayOfWeek}
|
firstDayOfWeek={firstDayOfWeek}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "day":
|
case "day":
|
||||||
@@ -678,6 +681,7 @@ export default function CalendarPage() {
|
|||||||
onCreateAtTime={openCreateModal}
|
onCreateAtTime={openCreateModal}
|
||||||
timeFormat={timeFormat}
|
timeFormat={timeFormat}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
|
pendingPreview={pendingPreview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agenda":
|
case "agenda":
|
||||||
@@ -808,7 +812,8 @@ export default function CalendarPage() {
|
|||||||
onDelete={handleDeleteEvent}
|
onDelete={handleDeleteEvent}
|
||||||
onDuplicate={handleDuplicateEvent}
|
onDuplicate={handleDuplicateEvent}
|
||||||
onRsvp={handleRsvp}
|
onRsvp={handleRsvp}
|
||||||
onClose={() => { setShowEventModal(false); setEditEvent(null); }}
|
onClose={() => { setShowEventModal(false); setEditEvent(null); setPendingPreview(null); }}
|
||||||
|
onPreviewChange={setPendingPreview}
|
||||||
currentUserEmails={currentUserEmails}
|
currentUserEmails={currentUserEmails}
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useMemo, useEffect, useRef, useState } from "react";
|
import { useMemo, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations, useFormatter } from "next-intl";
|
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 { cn } from "@/lib/utils";
|
||||||
import { EventCard, parseDuration } from "./event-card";
|
import { EventCard, parseDuration } from "./event-card";
|
||||||
import { QuickEventInput } from "./quick-event-input";
|
import { QuickEventInput } from "./quick-event-input";
|
||||||
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarDayViewProps {
|
interface CalendarDayViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -20,6 +21,7 @@ interface CalendarDayViewProps {
|
|||||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 64;
|
const HOUR_HEIGHT = 64;
|
||||||
@@ -35,6 +37,7 @@ export function CalendarDayView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarDayViewProps) {
|
}: CalendarDayViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -274,6 +277,34 @@ export function CalendarDayView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/li
|
|||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
|
|
||||||
interface CalendarMonthViewProps {
|
interface CalendarMonthViewProps {
|
||||||
@@ -25,6 +26,7 @@ interface CalendarMonthViewProps {
|
|||||||
onCreateAtTime?: (date: Date) => void;
|
onCreateAtTime?: (date: Date) => void;
|
||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarMonthView({
|
export function CalendarMonthView({
|
||||||
@@ -38,6 +40,7 @@ export function CalendarMonthView({
|
|||||||
onCreateAtTime,
|
onCreateAtTime,
|
||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarMonthViewProps) {
|
}: CalendarMonthViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -194,31 +197,63 @@ export function CalendarMonthView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
dayEvents.length > 0 && (
|
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
||||||
<div className="flex items-center justify-center gap-0.5 flex-wrap">
|
{dayEvents.slice(0, 3).map((ev) => {
|
||||||
{dayEvents.slice(0, 3).map((ev) => {
|
const calId = getPrimaryCalendarId(ev);
|
||||||
const calId = getPrimaryCalendarId(ev);
|
const cal = calId ? calendarMap.get(calId) : undefined;
|
||||||
const cal = calId ? calendarMap.get(calId) : undefined;
|
const evColor = ev.color || cal?.color || "#3b82f6";
|
||||||
const evColor = ev.color || cal?.color || "#3b82f6";
|
return (
|
||||||
return (
|
<span
|
||||||
<span
|
key={ev.id}
|
||||||
key={ev.id}
|
className="w-1.5 h-1.5 rounded-full"
|
||||||
className="w-1.5 h-1.5 rounded-full"
|
style={{ backgroundColor: evColor }}
|
||||||
style={{ backgroundColor: evColor }}
|
/>
|
||||||
/>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
{dayEvents.length > 3 && (
|
||||||
{dayEvents.length > 3 && (
|
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
|
)}
|
||||||
)}
|
{pendingPreview && isSameDay(pendingPreview.start, day) && (
|
||||||
</div>
|
<span
|
||||||
)
|
className="w-1.5 h-1.5 rounded-full border border-dashed"
|
||||||
|
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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 && (
|
{!isMobile && segments.length > 0 && (
|
||||||
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
<div className="absolute inset-x-0 pointer-events-none" style={{ top: 30 }}>
|
||||||
{segments.map((segment) => {
|
{segments.map((segment) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { QuickEventInput } from "./quick-event-input";
|
|||||||
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
import { buildWeekSegments, formatSnapTime, getEventDayBounds, getPrimaryCalendarId, layoutOverlappingEvents } from "@/lib/calendar-utils";
|
||||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||||
|
import type { PendingEventPreview } from "./event-modal";
|
||||||
|
|
||||||
interface CalendarWeekViewProps {
|
interface CalendarWeekViewProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -24,6 +25,7 @@ interface CalendarWeekViewProps {
|
|||||||
firstDayOfWeek?: number;
|
firstDayOfWeek?: number;
|
||||||
timeFormat?: "12h" | "24h";
|
timeFormat?: "12h" | "24h";
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
|
pendingPreview?: PendingEventPreview | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HOUR_HEIGHT = 60;
|
const HOUR_HEIGHT = 60;
|
||||||
@@ -41,6 +43,7 @@ export function CalendarWeekView({
|
|||||||
firstDayOfWeek = 1,
|
firstDayOfWeek = 1,
|
||||||
timeFormat = "24h",
|
timeFormat = "24h",
|
||||||
isMobile,
|
isMobile,
|
||||||
|
pendingPreview,
|
||||||
}: CalendarWeekViewProps) {
|
}: CalendarWeekViewProps) {
|
||||||
const t = useTranslations("calendar");
|
const t = useTranslations("calendar");
|
||||||
const intlFormatter = useFormatter();
|
const intlFormatter = useFormatter();
|
||||||
@@ -366,6 +369,34 @@ export function CalendarWeekView({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ import {
|
|||||||
} from "@/lib/calendar-participants";
|
} from "@/lib/calendar-participants";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
|
export interface PendingEventPreview {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
title: string;
|
||||||
|
allDay: boolean;
|
||||||
|
calendarId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EventModalProps {
|
interface EventModalProps {
|
||||||
event?: CalendarEvent | null;
|
event?: CalendarEvent | null;
|
||||||
calendars: Calendar[];
|
calendars: Calendar[];
|
||||||
@@ -30,6 +38,7 @@ interface EventModalProps {
|
|||||||
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
onDuplicate?: (data: Partial<CalendarEvent>) => void;
|
||||||
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onPreviewChange?: (preview: PendingEventPreview | null) => void;
|
||||||
currentUserEmails?: string[];
|
currentUserEmails?: string[];
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,7 @@ export function EventModal({
|
|||||||
onDuplicate,
|
onDuplicate,
|
||||||
onRsvp,
|
onRsvp,
|
||||||
onClose,
|
onClose,
|
||||||
|
onPreviewChange,
|
||||||
currentUserEmails = [],
|
currentUserEmails = [],
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
}: EventModalProps) {
|
}: EventModalProps) {
|
||||||
@@ -219,6 +229,18 @@ export function EventModal({
|
|||||||
});
|
});
|
||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
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(() => {
|
const statusCounts = useMemo(() => {
|
||||||
if (!event?.participants) return null;
|
if (!event?.participants) return null;
|
||||||
return getStatusCounts(event);
|
return getStatusCounts(event);
|
||||||
|
|||||||
Reference in New Issue
Block a user