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:
Matthieu MALVACHE
2026-02-22 17:32:10 +01:00
committed by Matthieu MALVACHE
parent fb7cfe1634
commit ccbab654f3
22 changed files with 2011 additions and 322 deletions
+3 -13
View File
@@ -6,6 +6,7 @@ import { format, parseISO, isToday, isTomorrow } from "date-fns";
import { Calendar as CalendarIcon, MapPin, Users } from "lucide-react";
import { cn } from "@/lib/utils";
import { parseDuration, getEventColor } from "./event-card";
import { getEventEndDate } from "@/lib/calendar-utils";
import { getParticipantCount } from "@/lib/calendar-participants";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
@@ -13,7 +14,7 @@ interface CalendarAgendaViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectEvent: (event: CalendarEvent) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
timeFormat?: "12h" | "24h";
}
@@ -23,17 +24,6 @@ interface DayGroup {
events: CalendarEvent[];
}
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);
}
export function CalendarAgendaView({
events,
calendars,
@@ -149,7 +139,7 @@ export function CalendarAgendaView({
return (
<button
key={ev.id}
onClick={() => onSelectEvent(ev)}
onClick={(e) => onSelectEvent(ev, e.currentTarget.getBoundingClientRect())}
className="w-full flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
+80 -122
View File
@@ -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>
)}
+3 -13
View File
@@ -8,6 +8,7 @@ import {
} from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { getEventEndDate } from "@/lib/calendar-utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
@@ -18,21 +19,10 @@ interface CalendarMonthViewProps {
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
firstDayOfWeek?: number;
}
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);
}
export function CalendarMonthView({
selectedDate,
events,
@@ -192,7 +182,7 @@ export function CalendarMonthView({
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={() => onSelectEvent(ev)}
onClick={(rect) => onSelectEvent(ev, rect)}
draggable
/>
);
+73 -121
View File
@@ -1,24 +1,24 @@
"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 {
startOfWeek, addDays, 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 { 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 CalendarWeekViewProps {
selectedDate: Date;
events: CalendarEvent[];
calendars: Calendar[];
onSelectDate: (date: Date) => void;
onSelectEvent: (event: CalendarEvent) => void;
onCreateAtTime: (date: Date) => void;
onSelectEvent: (event: CalendarEvent, anchorRect: DOMRect) => void;
onCreateAtTime: (date: Date, endDate?: Date) => void;
firstDayOfWeek?: number;
timeFormat?: "12h" | "24h";
}
@@ -26,51 +26,6 @@ interface CalendarWeekViewProps {
const HOUR_HEIGHT = 60;
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 CalendarWeekView({
selectedDate,
events,
@@ -137,8 +92,7 @@ export function CalendarWeekView({
useEffect(() => {
if (scrollRef.current) {
const now = new Date();
const scrollTo = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
scrollRef.current.scrollTop = scrollTo;
scrollRef.current.scrollTop = Math.max(0, (now.getHours() - 1) * HOUR_HEIGHT);
}
}, []);
@@ -153,11 +107,22 @@ export function CalendarWeekView({
return () => clearInterval(interval);
}, []);
const handleSlotClick = (day: Date, hour: number) => {
const d = new Date(day);
d.setHours(hour, 0, 0, 0);
onCreateAtTime(d);
};
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") {
@@ -167,61 +132,6 @@ export function CalendarWeekView({
return format(new Date(2000, 0, 1, h), "HH:mm");
};
const [dropTarget, setDropTarget] = useState<{ dayKey: string; minutes: 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 handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const minutes = snapMinutes(e);
setDropTarget((prev) =>
prev?.dayKey === dayKey && prev?.minutes === minutes ? prev : { dayKey, minutes }
);
}, [snapMinutes]);
const handleColumnDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
const related = e.relatedTarget as Node | null;
if (!e.currentTarget.contains(related)) setDropTarget(null);
}, []);
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
e.preventDefault();
setDropTarget(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(day);
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, t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={t("views.week")}>
{hasAllDay && (
@@ -243,7 +153,7 @@ export function CalendarWeekView({
event={ev}
calendar={calendarMap.get(calId)}
variant="chip"
onClick={() => onSelectEvent(ev)}
onClick={(rect) => onSelectEvent(ev, rect)}
/>
);
})}
@@ -295,10 +205,14 @@ export function CalendarWeekView({
{HOURS.map((h) => (
<div
key={h}
className="text-[10px] text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT, lineHeight: `${HOUR_HEIGHT}px` }}
className="relative text-muted-foreground text-right pr-2"
style={{ height: HOUR_HEIGHT }}
>
{formatHour(h)}
{h > 0 && (
<span className="absolute top-0 right-2 -translate-y-1/2 text-[10px] leading-none">
{formatHour(h)}
</span>
)}
</div>
))}
</div>
@@ -316,6 +230,9 @@ export function CalendarWeekView({
className="relative border-r border-border last:border-r-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
onPointerMove={handleGridPointerMove}
onPointerUp={handleGridPointerUp}
onDragOver={(e) => handleColumnDragOver(e, key)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, day)}
@@ -326,6 +243,7 @@ export function CalendarWeekView({
role="gridcell"
aria-label={`${intlFormatter.dateTime(day, { weekday: "short" })} ${formatHour(h)}`}
onClick={() => handleSlotClick(day, h)}
onDoubleClick={() => handleSlotDoubleClick(day, h)}
className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors"
style={{ height: HOUR_HEIGHT }}
/>
@@ -336,7 +254,8 @@ export function CalendarWeekView({
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(20, (durMin / 60) * HOUR_HEIGHT);
const baseHeight = Math.max(20, (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;
@@ -344,16 +263,27 @@ export function CalendarWeekView({
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: 1, paddingRight: 1 }}
>
<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>
);
})}
@@ -370,6 +300,28 @@ export function CalendarWeekView({
</div>
)}
{quickCreate?.dayKey === key && (
<QuickEventInput
top={quickCreate.top}
onSubmit={handleQuickCreateSubmit}
onCancel={handleQuickCreateCancel}
/>
)}
{dragCreate?.dayKey === key && (
<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 === key && (
<div
className="absolute left-0 right-0 z-30 pointer-events-none"
@@ -380,7 +332,7 @@ export function CalendarWeekView({
<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(dropTarget.minutes)}
{formatSnapTime(dropTarget.minutes, timeFormat)}
</div>
</div>
)}
+5 -4
View File
@@ -12,7 +12,7 @@ interface EventCardProps {
event: CalendarEvent;
calendar?: Calendar;
variant: "chip" | "block";
onClick?: () => void;
onClick?: (anchorRect: DOMRect) => void;
isSelected?: boolean;
draggable?: boolean;
}
@@ -100,7 +100,7 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
aria-label={ariaLabel}
{...dragProps}
className={cn(
@@ -123,11 +123,12 @@ export function EventCard({ event, calendar, variant, onClick, isSelected, dragg
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
onClick={(e) => { e.stopPropagation(); onClick?.(e.currentTarget.getBoundingClientRect()); }}
aria-label={ariaLabel}
{...dragProps}
data-calendar-event
className={cn(
"w-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-left rounded px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
@@ -0,0 +1,609 @@
"use client";
import { useState, useEffect, useRef, useMemo, useCallback, useLayoutEffect } from "react";
import { useTranslations } from "next-intl";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import {
isOrganizer,
getUserParticipantId,
getUserStatus,
getParticipantList,
} from "@/lib/calendar-participants";
interface EventDetailPopoverProps {
event: CalendarEvent;
calendar?: Calendar;
anchorRect: DOMRect;
onEdit: () => void;
onDelete: () => void;
onDuplicate: () => void;
onClose: () => void;
onSaveNote: (note: string) => void;
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
currentUserEmails?: string[];
timeFormat?: "12h" | "24h";
}
const POPOVER_WIDTH = 360;
const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
function computePosition(
anchorRect: DOMRect,
popoverHeight: number
): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
const clampTop = (top: number) =>
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
const clampLeft = (left: number) =>
Math.min(Math.max(VIEWPORT_MARGIN, left), vw - POPOVER_WIDTH - VIEWPORT_MARGIN);
const rightLeft = anchorRect.right + POPOVER_GAP;
if (rightLeft + POPOVER_WIDTH + VIEWPORT_MARGIN <= vw) {
return { top: clampTop(anchorRect.top), left: rightLeft };
}
const leftLeft = anchorRect.left - POPOVER_GAP - POPOVER_WIDTH;
if (leftLeft >= VIEWPORT_MARGIN) {
return { top: clampTop(anchorRect.top), left: leftLeft };
}
const belowTop = anchorRect.bottom + POPOVER_GAP;
if (belowTop + clampedHeight + VIEWPORT_MARGIN <= vh) {
return { top: belowTop, left: clampLeft(anchorRect.left) };
}
const aboveTop = anchorRect.top - POPOVER_GAP - clampedHeight;
return {
top: Math.max(VIEWPORT_MARGIN, aboveTop),
left: clampLeft(anchorRect.left),
};
}
function formatDurationDisplay(minutes: number): string {
if (minutes < 60) return `${minutes}min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (m === 0) return `${h}h`;
return `${h}h${m}min`;
}
function getAlertLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.alerts) return null;
const first = Object.values(event.alerts)[0];
if (!first || first.trigger["@type"] !== "OffsetTrigger") return null;
const offset = first.trigger.offset;
if (offset === "PT0S") return t("alerts.at_time");
const minMatch = offset.match(/-?PT?(\d+)M$/);
if (minMatch) return t("alerts.minutes_before", { count: parseInt(minMatch[1]) });
const hourMatch = offset.match(/-?PT?(\d+)H$/);
if (hourMatch) return t("alerts.hours_before", { count: parseInt(hourMatch[1]) });
const dayMatch = offset.match(/-?P(\d+)D/);
if (dayMatch) return t("alerts.days_before", { count: parseInt(dayMatch[1]) });
return null;
}
function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTranslations>): string | null {
if (!event.recurrenceRules?.length) return null;
const freq = event.recurrenceRules[0].frequency;
const labels: Record<string, string> = {
daily: t("recurrence.daily"),
weekly: t("recurrence.weekly"),
monthly: t("recurrence.monthly"),
yearly: t("recurrence.yearly"),
};
return labels[freq] || null;
}
export function EventDetailPopover({
event,
calendar,
anchorRect,
onEdit,
onDelete,
onDuplicate,
onClose,
onSaveNote,
onRsvp,
currentUserEmails = [],
timeFormat = "24h",
}: EventDetailPopoverProps) {
const t = useTranslations("calendar");
const popoverRef = useRef<HTMLDivElement>(null);
const noteInputRef = useRef<HTMLTextAreaElement>(null);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const [ready, setReady] = useState(false);
const [noteText, setNoteText] = useState("");
const [noteExpanded, setNoteExpanded] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isSavingNote, setIsSavingNote] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const durationMinutes = parseDuration(event.duration);
const endDate = new Date(startDate.getTime() + durationMinutes * 60000);
const locationName = useMemo(() => {
if (!event.locations) return null;
return Object.values(event.locations)[0]?.name || null;
}, [event.locations]);
const virtualLocation = useMemo(() => {
if (!event.virtualLocations) return null;
const first = Object.values(event.virtualLocations)[0];
return first?.uri || null;
}, [event.virtualLocations]);
const participants = useMemo(() => getParticipantList(event), [event]);
const recurrenceLabel = useMemo(() => getRecurrenceLabel(event, t), [event, t]);
const alertLabel = useMemo(() => getAlertLabel(event, t), [event, t]);
const userIsOrganizer = useMemo(() => {
if (!event.participants) return true;
return isOrganizer(event, currentUserEmails);
}, [event, currentUserEmails]);
const isAttendeeMode = useMemo(() => {
if (!event.participants) return false;
return !event.isOrigin && !userIsOrganizer;
}, [event, userIsOrganizer]);
const userParticipantId = useMemo(
() => getUserParticipantId(event, currentUserEmails),
[event, currentUserEmails]
);
const userCurrentStatus = useMemo(
() => getUserStatus(event, currentUserEmails),
[event, currentUserEmails]
);
const formatTime = useCallback(
(d: Date) => format(d, timeFormat === "12h" ? "h:mm a" : "HH:mm"),
[timeFormat]
);
useLayoutEffect(() => {
if (!popoverRef.current) return;
const height = popoverRef.current.offsetHeight;
setPosition(computePosition(anchorRect, height));
if (!ready) requestAnimationFrame(() => setReady(true));
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "e" && !noteExpanded) {
e.preventDefault();
onEdit();
}
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose, onEdit, noteExpanded]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleScroll = () => onClose();
const timer = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("scroll", handleScroll, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
const handleSaveNote = useCallback(async () => {
const trimmed = noteText.trim();
if (!trimmed) return;
setIsSavingNote(true);
try {
onSaveNote(trimmed);
setNoteText("");
setNoteExpanded(false);
} finally {
setIsSavingNote(false);
}
}, [noteText, onSaveNote]);
const handleNoteKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSaveNote();
}
if (e.key === "Escape") {
e.stopPropagation();
setNoteText("");
setNoteExpanded(false);
}
},
[handleSaveNote]
);
const hasParticipants = participants.length > 0;
const popover = (
<div
ref={popoverRef}
role="dialog"
aria-label={event.title || t("events.no_title")}
className="fixed z-[60] bg-background border border-border rounded-lg shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out"
style={{
width: POPOVER_WIDTH,
maxHeight: MAX_HEIGHT,
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: ready ? 1 : 0,
transform: ready ? "scale(1)" : "scale(0.95)",
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="text-base font-semibold truncate text-foreground">
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ml-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>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors flex-shrink-0 mt-0.5"
aria-label={t("form.cancel")}
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
</div>
{/* Content */}
<div className="px-4 py-2 space-y-2.5 overflow-y-auto" style={{ maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{format(startDate, "EEE, MMM d, yyyy")}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</div>
</div>
{/* Location */}
{locationName && (
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/^https?:\/\//i.test(locationName) ? (
<a
href={locationName}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline truncate"
title={locationName}
>
{(() => {
try { return new URL(locationName).hostname; } catch { return locationName; }
})()}
</a>
) : (
<span className="text-sm text-foreground">{locationName}</span>
)}
</div>
)}
{/* Virtual Location / Meeting Link */}
{virtualLocation && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a
href={virtualLocation}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline truncate"
title={virtualLocation}
>
{(() => {
try {
return new URL(virtualLocation).hostname;
} catch {
return virtualLocation;
}
})()}
</a>
</div>
)}
{/* Participants */}
{hasParticipants && (
<div className="flex items-start gap-2.5">
<Users className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm min-w-0">
<span className="text-muted-foreground">
{t("participants.count", { count: participants.length })}
</span>
<div className="mt-1 space-y-0.5">
{participants.slice(0, 5).map((p) => (
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">
({t("participants.organizer").toLowerCase()})
</span>
)}
</span>
<ParticipantStatusBadge status={p.status} t={t} />
</div>
))}
{participants.length > 5 && (
<span className="text-xs text-muted-foreground">
+{participants.length - 5}
</span>
)}
</div>
</div>
</div>
)}
{/* Recurrence */}
{recurrenceLabel && (
<div className="flex items-start gap-2.5">
<Repeat className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground">{recurrenceLabel}</span>
</div>
)}
{/* Reminder */}
{alertLabel && (
<div className="flex items-start gap-2.5">
<Bell className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground">{alertLabel}</span>
</div>
)}
{/* Description */}
{event.description && (
<div className="flex items-start gap-2.5">
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
{event.description}
</p>
</div>
)}
</div>
{/* Quick Note */}
{!isAttendeeMode && (
<div className="px-4 py-2 border-t border-border">
{noteExpanded ? (
<div className="space-y-2">
<textarea
ref={noteInputRef}
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
onKeyDown={handleNoteKeyDown}
placeholder={t("detail.add_note")}
rows={2}
autoFocus
className="w-full rounded-md border border-input bg-muted/30 px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring"
/>
<div className="flex justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => {
setNoteText("");
setNoteExpanded(false);
}}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
<Button
size="sm"
onClick={handleSaveNote}
disabled={!noteText.trim() || isSavingNote}
className="h-7 text-xs"
>
<Send className="w-3 h-3 mr-1" />
{t("detail.save_note")}
</Button>
</div>
</div>
) : (
<button
onClick={() => setNoteExpanded(true)}
className="flex items-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
>
<AlignLeft className="w-4 h-4" />
{t("detail.add_note")}
</button>
)}
</div>
)}
{/* RSVP Bar (for attendees) */}
{isAttendeeMode && onRsvp && userParticipantId && (
<div className="px-4 py-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground mb-2">
{t("participants.rsvp_label")}
</p>
<div className="flex gap-2">
<Button
size="sm"
variant={userCurrentStatus === "accepted" ? "default" : "outline"}
onClick={() => onRsvp("accepted")}
className={
userCurrentStatus === "accepted"
? "bg-green-600 hover:bg-green-700 text-white dark:bg-green-500 dark:hover:bg-green-600"
: "text-green-600 dark:text-green-400 border-green-300 dark:border-green-700 hover:bg-green-50 dark:hover:bg-green-950"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
{t("participants.accepted")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "tentative" ? "default" : "outline"}
onClick={() => onRsvp("tentative")}
className={
userCurrentStatus === "tentative"
? "bg-amber-600 hover:bg-amber-700 text-white dark:bg-amber-500 dark:hover:bg-amber-600"
: "border border-amber-500 text-amber-600 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
{t("participants.tentative")}
</Button>
<Button
size="sm"
variant={userCurrentStatus === "declined" ? "default" : "ghost"}
onClick={() => onRsvp("declined")}
className={
userCurrentStatus === "declined"
? "bg-red-600 hover:bg-red-700 text-white dark:bg-red-500 dark:hover:bg-red-600"
: "text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
{t("participants.declined")}
</Button>
</div>
</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-red-600 dark:text-red-400 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>
) : (
<>
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 mr-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 mr-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
<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>
);
return createPortal(popover, document.body);
}
function ParticipantStatusBadge({
status,
t,
}: {
status: CalendarParticipant["participationStatus"];
t: ReturnType<typeof useTranslations>;
}) {
const colors: Record<string, string> = {
accepted: "text-green-600 dark:text-green-400",
declined: "text-red-600 dark:text-red-400",
tentative: "text-amber-600 dark:text-amber-400",
"needs-action": "text-muted-foreground",
};
const labels: Record<string, string> = {
accepted: "participants.accepted",
declined: "participants.declined",
tentative: "participants.tentative",
"needs-action": "participants.needs_action",
};
return (
<span className={`text-[10px] flex-shrink-0 ${colors[status] || ""}`}>
{t(labels[status] || labels["needs-action"])}
</span>
);
}
+70 -30
View File
@@ -4,8 +4,8 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays } from "lucide-react";
import { format, parseISO, addHours } from "date-fns";
import { X, Trash2, Check, Users, CalendarDays, Copy } from "lucide-react";
import { format, parseISO, addHours, addDays } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration } from "./event-card";
import { ParticipantInput } from "./participant-input";
@@ -22,8 +22,10 @@ interface EventModalProps {
event?: CalendarEvent | null;
calendars: Calendar[];
defaultDate?: Date;
defaultEndDate?: Date;
onSave: (data: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => void;
onDelete?: (id: string, sendSchedulingMessages?: boolean) => void;
onDuplicate?: (data: Partial<CalendarEvent>) => void;
onRsvp?: (eventId: string, participantId: string, status: CalendarParticipant['participationStatus']) => void;
onClose: () => void;
currentUserEmails?: string[];
@@ -59,8 +61,10 @@ export function EventModal({
event,
calendars,
defaultDate,
defaultEndDate,
onSave,
onDelete,
onDuplicate,
onRsvp,
onClose,
currentUserEmails = [],
@@ -104,6 +108,7 @@ export function EventModal({
if (event?.start) return parseISO(event.start);
if (defaultDate) {
const d = new Date(defaultDate);
if (defaultEndDate) return d;
const now = new Date();
d.setHours(now.getHours() + 1, 0, 0, 0);
return d;
@@ -119,6 +124,7 @@ export function EventModal({
const dur = parseDuration(event.duration);
return new Date(s.getTime() + dur * 60000);
}
if (defaultEndDate) return new Date(defaultEndDate);
return addHours(getInitialStart(), 1);
};
@@ -289,6 +295,29 @@ export function EventModal({
onClose();
}, [event, userParticipantId, onRsvp, onClose]);
const handleDuplicate = useCallback(() => {
if (!event || !onDuplicate) return;
const start = parseISO(event.start);
const newStart = addDays(start, 1);
const data: Partial<CalendarEvent> = {
title: event.title,
description: event.description,
start: format(newStart, "yyyy-MM-dd'T'HH:mm:ss"),
duration: event.duration,
timeZone: event.timeZone,
showWithoutTime: event.showWithoutTime,
calendarIds: { ...event.calendarIds },
status: "confirmed",
freeBusyStatus: event.freeBusyStatus,
privacy: event.privacy,
};
if (event.locations) data.locations = structuredClone(event.locations);
if (event.recurrenceRules) data.recurrenceRules = structuredClone(event.recurrenceRules);
if (event.alerts) data.alerts = structuredClone(event.alerts);
if (event.participants) data.participants = structuredClone(event.participants);
onDuplicate(data);
}, [event, onDuplicate]);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -630,45 +659,56 @@ export function EventModal({
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-border">
{isEdit && onDelete ? (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
<div className="flex items-center gap-1">
{isEdit && onDelete && (
showDeleteConfirm ? (
<div className="flex items-center gap-2">
<div>
<span className="text-sm text-red-600 dark:text-red-400">
{t("form.delete_confirm")}
</span>
{hasParticipants && (
<p className="text-xs text-muted-foreground mt-0.5">
{t("participants.cancel_notification")}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
>
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
<Button
variant="outline"
variant="ghost"
size="sm"
onClick={() => { onDelete(event!.id, hasParticipants || undefined); onClose(); }}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
</Button>
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(false)}>
{t("form.cancel")}
</Button>
</div>
) : (
)
)}
{isEdit && onDuplicate && !showDeleteConfirm && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Trash2 className="w-4 h-4 mr-1" />
{t("events.delete")}
<Copy className="w-4 h-4 mr-1" />
{t("events.duplicate")}
</Button>
)
) : (
<div />
)}
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onClose}>
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
interface QuickEventInputProps {
top: number;
onSubmit: (title: string) => void;
onCancel: () => void;
}
export function QuickEventInput({ top, onSubmit, onCancel }: QuickEventInputProps) {
const t = useTranslations("calendar");
const [title, setTitle] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const submittedRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
const trimmed = title.trim();
if (trimmed) {
submittedRef.current = true;
onSubmit(trimmed);
} else {
onCancel();
}
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}, [title, onSubmit, onCancel]);
const handleBlur = useCallback(() => {
if (!submittedRef.current) onCancel();
}, [onCancel]);
return (
<div
className="absolute left-1 right-1 z-40"
style={{ top }}
>
<input
ref={inputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={t("quick_create.placeholder")}
aria-label={t("quick_create.aria_label")}
maxLength={500}
className="w-full px-2 py-1 text-xs rounded border border-primary bg-primary/10 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useState, useId } from "react";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
import { Repeat, Trash2 } from "lucide-react";
export type RecurrenceEditScope = "this" | "this_and_future" | "all";
interface RecurrenceScopeDialogProps {
isOpen: boolean;
actionType: "edit" | "delete";
onSelect: (scope: RecurrenceEditScope) => void;
onClose: () => void;
}
export function RecurrenceScopeDialog({
isOpen,
actionType,
onSelect,
onClose,
}: RecurrenceScopeDialogProps) {
const t = useTranslations("calendar.recurrence_scope");
const id = useId();
const [selected, setSelected] = useState<RecurrenceEditScope>("this");
const dialogRef = useFocusTrap({
isActive: isOpen,
onEscape: onClose,
restoreFocus: true,
});
if (!isOpen) return null;
const isDelete = actionType === "delete";
const options: { value: RecurrenceEditScope; label: string }[] = [
{ value: "this", label: t("this_event") },
{ value: "this_and_future", label: t("this_and_future") },
{ value: "all", label: t("all_events") },
];
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-[2px] flex items-center justify-center z-[60] p-4 animate-in fade-in duration-150">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-desc`}
className="bg-background border border-border rounded-lg shadow-xl w-full max-w-sm animate-in zoom-in-95 duration-200"
>
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div className={`flex-shrink-0 w-10 h-10 rounded-full flex items-center justify-center ${
isDelete ? "bg-destructive/10" : "bg-primary/10"
}`}>
{isDelete ? (
<Trash2 className="w-5 h-5 text-destructive" />
) : (
<Repeat className="w-5 h-5 text-primary" />
)}
</div>
<div>
<h2 id={`${id}-title`} className="text-lg font-semibold">
{isDelete ? t("delete_title") : t("edit_title")}
</h2>
<p id={`${id}-desc`} className="text-sm text-muted-foreground mt-1">
{t("description")}
</p>
</div>
</div>
<div className="space-y-2" role="radiogroup" aria-labelledby={`${id}-title`}>
{options.map((option) => (
<label
key={option.value}
className={`flex items-center gap-3 px-3 py-2.5 rounded-md cursor-pointer transition-colors ${
selected === option.value
? "bg-primary/10 border border-primary/30"
: "hover:bg-muted border border-transparent"
}`}
>
<input
type="radio"
name={`${id}-scope`}
value={option.value}
checked={selected === option.value}
onChange={() => setSelected(option.value)}
className="accent-primary"
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-6">
<Button variant="outline" onClick={onClose}>
{t("cancel")}
</Button>
<Button
variant={isDelete ? "destructive" : "default"}
onClick={() => onSelect(selected)}
>
{isDelete ? t("delete") : t("save")}
</Button>
</div>
</div>
</div>
);
}