Files
SRCmail/components/calendar/event-card.tsx
T
Matthieu MALVACHEandMatthieu MALVACHE e6f67c24cb feat: add JMAP Calendar integration with month/week/day/agenda views
Full calendar support via JMAP Calendars (RFC 8984):
- Event create/edit/delete with recurrence rules and reminders
- Multi-day event spanning, column-based overlap layout
- Locale-aware date formatting, first day of week and time format settings
- Real-time updates via push notifications
- ARIA accessibility, input validation, color sanitization
- Keyboard shortcuts, mobile touch targets, focus trap
- ICU pluralization for all 8 supported languages
2026-02-16 20:34:29 +01:00

95 lines
3.3 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 { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import { format, parseISO } from "date-fns";
interface EventCardProps {
event: CalendarEvent;
calendar?: Calendar;
variant: "chip" | "block";
onClick?: () => void;
isSelected?: 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;
}
export function EventCard({ event, calendar, variant, onClick, isSelected }: EventCardProps) {
const t = useTranslations("calendar");
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}` : ""}`;
if (variant === "chip") {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick?.(); }}
aria-label={ariaLabel}
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"
)}
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}
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"
)}
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>
)}
</button>
);
}
export { parseDuration, getEventColor, sanitizeColor };