Files
SRCmail/components/calendar/event-card.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE b65b2f26df feat: add participant scheduling with iTIP invitations and inline calendar invitation banner
Add organizer/attendee UI with RSVP, contact autocomplete for participants,
scheduling messages, and inline calendar invitation banner in email viewer
with auto-detect .ics attachments, RSVP/import to calendar, and cancellation display.
2026-02-17 01:09:57 +01:00

154 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useState, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
import { Users } from "lucide-react";
import { getParticipantCount } from "@/lib/calendar-participants";
interface EventCardProps {
event: CalendarEvent;
calendar?: Calendar;
variant: "chip" | "block";
onClick?: () => void;
isSelected?: boolean;
draggable?: boolean;
}
function sanitizeColor(color: string | null | undefined, fallback = "#3b82f6"): string {
if (!color) return fallback;
if (/^#[0-9a-fA-F]{3,8}$/.test(color)) return color;
if (/^(rgb|hsl)a?\([\d\s,.%/]+\)$/.test(color)) return color;
return fallback;
}
function getEventColor(event: CalendarEvent, calendar?: Calendar): string {
return sanitizeColor(event.color, sanitizeColor(calendar?.color));
}
function parseDuration(duration: string): number {
let totalMinutes = 0;
const weekMatch = duration.match(/(\d+)W/);
const hourMatch = duration.match(/(\d+)H/);
const minMatch = duration.match(/(\d+)M/);
const dayMatch = duration.match(/(\d+)D/);
if (weekMatch) totalMinutes += parseInt(weekMatch[1]) * 7 * 24 * 60;
if (dayMatch) totalMinutes += parseInt(dayMatch[1]) * 24 * 60;
if (hourMatch) totalMinutes += parseInt(hourMatch[1]) * 60;
if (minMatch) totalMinutes += parseInt(minMatch[1]);
return totalMinutes;
}
function createEventDragPreview(title: string, color: string): HTMLElement {
const el = document.createElement("div");
el.style.cssText = `
position: fixed; top: -9999px; left: 0;
padding: 6px 12px; border-radius: 6px;
background: ${color}40; border-left: 3px solid ${color};
color: ${color}; font-size: 12px; font-weight: 500;
max-width: 200px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; pointer-events: none; z-index: 9999;
`;
el.textContent = title;
document.body.appendChild(el);
return el;
}
export function EventCard({ event, calendar, variant, onClick, isSelected, draggable: isDraggable }: EventCardProps) {
const t = useTranslations("calendar");
const [isBeingDragged, setIsBeingDragged] = useState(false);
const color = getEventColor(event, calendar);
const startDate = parseISO(event.start);
const calendarName = calendar?.name || "";
const durationMinutes = parseDuration(event.duration);
const endTime = new Date(startDate.getTime() + durationMinutes * 60000);
const timeString = `${format(startDate, "HH:mm")} ${format(endTime, "HH:mm")}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("application/x-calendar-event", JSON.stringify({
type: "calendar-event",
eventId: event.id,
originalStart: event.start,
duration: event.duration,
durationMinutes,
}));
const displayTitle = event.title || t("events.no_title");
e.dataTransfer.setData("text/plain", displayTitle);
const preview = createEventDragPreview(displayTitle, color);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
setIsBeingDragged(true);
}, [event, color, t, durationMinutes]);
const handleDragEnd = useCallback(() => {
setIsBeingDragged(false);
}, []);
const dragProps = isDraggable ? {
draggable: true as const,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
"aria-roledescription": "draggable event",
} : {};
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50"
)}
style={{ backgroundColor: `${color}20`, color }}
>
<span
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="truncate">{event.title || t("events.no_title")}</span>
</button>
);
}
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-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"
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
{durationMinutes > 30 && (
<div className="opacity-80 text-[10px]">
{timeString}
</div>
)}
{durationMinutes > 30 && getParticipantCount(event) > 0 && (
<div className="flex items-center gap-0.5 opacity-70 text-[10px]">
<Users className="w-3 h-3" />
<span>{getParticipantCount(event)}</span>
</div>
)}
</button>
);
}
export { parseDuration, getEventColor, sanitizeColor };