feat(calendar): add drag-create, resize, recurring scope, quick-create, and duplication
- Click-drag on empty time slots to create events with pre-filled time range - Resize events by dragging bottom edge (15-min snap, optimistic JMAP update) - Recurring event edit/delete scope dialog (this/following/all occurrences) - Double-click quick event creation with inline title input - Event duplication button in modal (+1 day offset) - Shared interaction hook for pointer-based calendar interactions Fixes #13
This commit is contained in:
@@ -1,72 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useRef, useState, useCallback, type DragEvent } from "react";
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslations, useFormatter } from "next-intl";
|
||||
import { format, isToday, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventCard, parseDuration } from "./event-card";
|
||||
import { QuickEventInput } from "./quick-event-input";
|
||||
import { getEventEndDate, layoutOverlappingEvents, formatSnapTime } from "@/lib/calendar-utils";
|
||||
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useTimeGridInteractions } from "@/hooks/use-time-grid-interactions";
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
selectedDate: Date;
|
||||
events: CalendarEvent[];
|
||||
calendars: Calendar[];
|
||||
onSelectEvent: (event: CalendarEvent) => void;
|
||||
onCreateAtTime: (date: Date) => void;
|
||||
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
|
||||
onCreateAtTime: (date: Date, endDate?: Date) => void;
|
||||
timeFormat?: "12h" | "24h";
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 64;
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
function getEventEndDate(event: CalendarEvent): Date {
|
||||
const start = new Date(event.start);
|
||||
if (!event.duration) return start;
|
||||
const days = parseInt(event.duration.match(/(\d+)D/)?.[1] || "0");
|
||||
const hours = parseInt(event.duration.match(/(\d+)H/)?.[1] || "0");
|
||||
const minutes = parseInt(event.duration.match(/(\d+)M/)?.[1] || "0");
|
||||
const weeks = parseInt(event.duration.match(/(\d+)W/)?.[1] || "0");
|
||||
const totalMs = ((weeks * 7 + days) * 24 * 60 + hours * 60 + minutes) * 60000;
|
||||
return new Date(start.getTime() + totalMs);
|
||||
}
|
||||
|
||||
function layoutOverlappingEvents(events: CalendarEvent[]): { event: CalendarEvent; column: number; totalColumns: number }[] {
|
||||
const sorted = [...events].sort((a, b) => {
|
||||
const diff = new Date(a.start).getTime() - new Date(b.start).getTime();
|
||||
if (diff !== 0) return diff;
|
||||
return parseDuration(b.duration) - parseDuration(a.duration);
|
||||
});
|
||||
|
||||
const columns: { event: CalendarEvent; end: number }[][] = [];
|
||||
const result: { event: CalendarEvent; column: number; totalColumns: number }[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
const start = parseISO(event.start);
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const endMin = startMin + Math.max(15, parseDuration(event.duration));
|
||||
let placed = false;
|
||||
for (let col = 0; col < columns.length; col++) {
|
||||
if (columns[col].every(e => e.end <= startMin)) {
|
||||
columns[col].push({ event, end: endMin });
|
||||
result.push({ event, column: col, totalColumns: 0 });
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
columns.push([{ event, end: endMin }]);
|
||||
result.push({ event, column: columns.length - 1, totalColumns: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
const total = columns.length;
|
||||
result.forEach(r => r.totalColumns = total);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function CalendarDayView({
|
||||
selectedDate,
|
||||
events,
|
||||
@@ -78,6 +33,7 @@ export function CalendarDayView({
|
||||
const t = useTranslations("calendar");
|
||||
const intlFormatter = useFormatter();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const dayKey = format(selectedDate, "yyyy-MM-dd");
|
||||
|
||||
const calendarMap = useMemo(() => {
|
||||
const map = new Map<string, Calendar>();
|
||||
@@ -125,6 +81,23 @@ export function CalendarDayView({
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
dragCreate, handleGridPointerDown, handleGridPointerMove, handleGridPointerUp,
|
||||
resizeVisual, handleResizePointerDown, handleResizePointerMove, handleResizePointerUp,
|
||||
quickCreate, handleSlotClick, handleSlotDoubleClick, handleQuickCreateSubmit, handleQuickCreateCancel,
|
||||
dropTarget, handleColumnDragOver, handleColumnDragLeave, handleColumnDrop,
|
||||
} = useTimeGridInteractions({
|
||||
hourHeight: HOUR_HEIGHT,
|
||||
calendars,
|
||||
onCreateRange: onCreateAtTime,
|
||||
errorMessages: {
|
||||
resize: t("notifications.event_resize_error"),
|
||||
move: t("notifications.event_move_error"),
|
||||
created: t("notifications.event_created"),
|
||||
error: t("notifications.event_error"),
|
||||
},
|
||||
});
|
||||
|
||||
const formatHour = (h: number): string => {
|
||||
if (timeFormat === "12h") {
|
||||
const d = new Date(2000, 0, 1, h);
|
||||
@@ -135,59 +108,6 @@ export function CalendarDayView({
|
||||
|
||||
const layouted = useMemo(() => layoutOverlappingEvents(timedEvents), [timedEvents]);
|
||||
|
||||
const [dropMinutes, setDropMinutes] = useState<number | null>(null);
|
||||
|
||||
const snapMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const y = e.clientY - rect.top;
|
||||
const raw = (y / HOUR_HEIGHT) * 60;
|
||||
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
|
||||
}, []);
|
||||
|
||||
const formatSnapTime = useCallback((minutes: number): string => {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (timeFormat === "12h") {
|
||||
return `${h % 12 || 12}:${String(m).padStart(2, "0")} ${h < 12 ? "AM" : "PM"}`;
|
||||
}
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}, [timeFormat]);
|
||||
|
||||
const handleDayDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const minutes = snapMinutes(e);
|
||||
setDropMinutes((prev) => prev === minutes ? prev : minutes);
|
||||
}, [snapMinutes]);
|
||||
|
||||
const handleDayDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
if (!e.currentTarget.contains(related)) setDropMinutes(null);
|
||||
}, []);
|
||||
|
||||
const handleDayDrop = useCallback(async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDropMinutes(null);
|
||||
const json = e.dataTransfer.getData("application/x-calendar-event");
|
||||
if (!json) return;
|
||||
try {
|
||||
const data = JSON.parse(json);
|
||||
const minutes = snapMinutes(e);
|
||||
const newStart = new Date(selectedDate);
|
||||
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
||||
const newStartISO = format(newStart, "yyyy-MM-dd'T'HH:mm:ss");
|
||||
if (newStartISO === data.originalStart) return;
|
||||
const client = useAuthStore.getState().client;
|
||||
if (!client) return;
|
||||
const event = useCalendarStore.getState().events.find(e => e.id === data.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
||||
} catch {
|
||||
toast.error(t("notifications.event_move_error"));
|
||||
}
|
||||
}, [snapMinutes, selectedDate, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" })}>
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
@@ -208,7 +128,7 @@ export function CalendarDayView({
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="chip"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -222,10 +142,14 @@ export function CalendarDayView({
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
className="text-xs text-muted-foreground text-right pr-3"
|
||||
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
|
||||
className="relative text-muted-foreground text-right pr-3"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{formatHour(h)}
|
||||
{h > 0 && (
|
||||
<span className="absolute top-0 right-3 -translate-y-1/2 text-xs leading-none">
|
||||
{formatHour(h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -234,20 +158,20 @@ export function CalendarDayView({
|
||||
className="flex-1 relative border-l border-border"
|
||||
role="row"
|
||||
aria-label={t("views.day")}
|
||||
onDragOver={handleDayDragOver}
|
||||
onDragLeave={handleDayDragLeave}
|
||||
onDrop={handleDayDrop}
|
||||
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
|
||||
onPointerMove={handleGridPointerMove}
|
||||
onPointerUp={handleGridPointerUp}
|
||||
onDragOver={(e) => handleColumnDragOver(e, dayKey)}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
onDrop={(e) => handleColumnDrop(e, selectedDate)}
|
||||
>
|
||||
{HOURS.map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
role="gridcell"
|
||||
aria-label={formatHour(h)}
|
||||
onClick={() => {
|
||||
const d = new Date(selectedDate);
|
||||
d.setHours(h, 0, 0, 0);
|
||||
onCreateAtTime(d);
|
||||
}}
|
||||
onClick={() => handleSlotClick(selectedDate, h)}
|
||||
onDoubleClick={() => handleSlotDoubleClick(selectedDate, h)}
|
||||
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
/>
|
||||
@@ -258,7 +182,8 @@ export function CalendarDayView({
|
||||
const startMin = start.getHours() * 60 + start.getMinutes();
|
||||
const durMin = Math.max(15, parseDuration(ev.duration));
|
||||
const top = (startMin / 60) * HOUR_HEIGHT;
|
||||
const height = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const calId = Object.keys(ev.calendarIds)[0];
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -266,16 +191,27 @@ export function CalendarDayView({
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10"
|
||||
className="absolute z-10 group/event"
|
||||
data-calendar-event
|
||||
style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%`, paddingLeft: 2, paddingRight: 2 }}
|
||||
>
|
||||
<EventCard
|
||||
event={ev}
|
||||
calendar={calendarMap.get(calId)}
|
||||
variant="block"
|
||||
onClick={() => onSelectEvent(ev)}
|
||||
onClick={(rect) => onSelectEvent(ev, rect)}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mb-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -292,17 +228,39 @@ export function CalendarDayView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropMinutes !== null && (
|
||||
{quickCreate?.dayKey === dayKey && (
|
||||
<QuickEventInput
|
||||
top={quickCreate.top}
|
||||
onSubmit={handleQuickCreateSubmit}
|
||||
onCancel={handleQuickCreateCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dragCreate && (
|
||||
<div
|
||||
className="absolute left-1 right-1 z-30 rounded-md pointer-events-none bg-primary/15 border-2 border-primary/30 border-dashed"
|
||||
style={{
|
||||
top: (dragCreate.startMinutes / 60) * HOUR_HEIGHT,
|
||||
height: ((dragCreate.endMinutes - dragCreate.startMinutes) / 60) * HOUR_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<div className="text-[10px] font-medium text-primary px-1.5 py-0.5">
|
||||
{formatSnapTime(dragCreate.startMinutes, timeFormat)} – {formatSnapTime(dragCreate.endMinutes, timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dropTarget?.dayKey === dayKey && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-30 pointer-events-none"
|
||||
style={{ top: (dropMinutes / 60) * HOUR_HEIGHT }}
|
||||
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
|
||||
<div className="flex-1 h-0.5 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
|
||||
{formatSnapTime(dropMinutes)}
|
||||
{formatSnapTime(dropTarget.minutes, timeFormat)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user