Refactor EventModal into section components (#7)

* 🙈(tools) remove OpenSpec skills and commands from Git tracking

Remove .claude/skills/openspec-*/ and .claude/commands/opsx/ from
Git tracking while keeping them locally. Extends the previous
cleanup (d24eed9) that handled the openspec/ directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* (front) add EventModal section components and useEventForm hook

Extract modal sections into dedicated components (DateTimeSection,
RecurrenceSection, LocationSection, VideoConferenceSection,
AttendeesSection, DescriptionSection, InvitationResponseSection,
RemindersSection, StatusSection, AttachmentsSection) with shared
SectionRow and SectionPill layout components.
Add useEventForm hook to centralize form state management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ♻️(front) refactor EventModal to use extracted sections

Simplify EventModal by delegating to section components and
useEventForm hook. Replace alert() with addToast() for error
feedback, convert useCallback to useMemo for buildSummary in
RecurrenceEditor, and add missing organizer dependency in
useEventForm useEffect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* 💄(front) update scheduler styles for new modal design

Rework SCSS for EventModal, AttendeesInput, RecurrenceEditor,
and Scheduler. Move inline styles to CSS classes. Update
globals and frontend dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-02-06 16:30:37 +01:00
committed by GitHub
parent d24eed9cd1
commit 5c86f79192
50 changed files with 2644 additions and 4959 deletions

View File

@@ -17,8 +17,8 @@
},
"dependencies": {
"@event-calendar/core": "^5.2.3",
"@gouvfr-lasuite/cunningham-react": "4.1.0",
"@gouvfr-lasuite/ui-kit": "0.18.7",
"@gouvfr-lasuite/cunningham-react": "4.2.0",
"@gouvfr-lasuite/ui-kit": "0.19.6",
"@tanstack/react-query": "5.90.10",
"@tanstack/react-table": "8.21.3",
"@viselect/react": "3.9.0",

View File

@@ -1,155 +1,83 @@
.attendees-input {
display: flex;
flex-direction: column;
gap: 1rem;
&__field {
display: flex;
align-items: flex-start;
gap: 0.5rem;
width: 100%;
}
&__participants {
&__add-btn {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #6a6a6a;
}
&__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
padding: 0 0.375rem;
background-color: #6a6a6a;
color: white;
width: 2.5rem;
height: 2.5rem;
margin-top: 0.25rem;
padding: 0;
border: 1px solid var(--c--theme--colors--greyscale-200);
border-radius: 4px;
font-size: 0.875rem;
font-weight: 600;
}
&__list {
display: flex;
flex-direction: column;
gap: 0;
background: white;
border: 1px solid #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
&__item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: white;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
background: transparent;
color: var(--c--theme--colors--greyscale-600);
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
&:hover {
background-color: #fafafa;
}
}
&__status-icon {
font-size: 1.5rem;
flex-shrink: 0;
&--accepted {
color: #4caf50;
}
&--pending {
color: #9e9e9e;
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
&__item-name {
font-size: 0.9375rem;
font-weight: 500;
color: #161616;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__item-email {
font-size: 0.875rem;
color: #6a6a6a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__organizer-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
color: #6a6a6a;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
text-transform: capitalize;
}
&__action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
color: #6a6a6a;
border-radius: 4px;
transition: all 0.2s;
flex-shrink: 0;
&:hover:not(:disabled) {
background-color: #f0f0f0;
color: #161616;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
&--remove:hover:not(:disabled) {
background-color: #fef0f0;
color: #d32f2f;
background-color: var(--c--theme--colors--greyscale-100);
color: var(--c--theme--colors--greyscale-900);
border-color: var(--c--theme--colors--greyscale-400);
}
.material-icons {
font-size: 1.25rem;
}
}
&__pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: default;
.material-icons {
font-size: 14px;
}
}
&__organizer-label {
font-size: 0.75rem;
opacity: 0.8;
}
&__pill-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin-left: 0.125rem;
border: none;
background: transparent;
cursor: pointer;
color: inherit;
opacity: 0.6;
border-radius: 50%;
transition: opacity 0.15s;
&:hover {
opacity: 1;
}
.material-icons {
font-size: 14px;
}
}
}

View File

@@ -1,7 +1,8 @@
import { useState, useCallback, type KeyboardEvent } from 'react';
import { Input } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import type { IcsAttendee, IcsOrganizer } from 'ts-ics';
import { useState, useCallback, type KeyboardEvent } from "react";
import { Input } from "@gouvfr-lasuite/cunningham-react";
import { Badge } from "@gouvfr-lasuite/ui-kit";
import { useTranslation } from "react-i18next";
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
interface AttendeesInputProps {
attendees: IcsAttendee[];
@@ -10,48 +11,55 @@ interface AttendeesInputProps {
organizer?: IcsOrganizer;
}
// Validate email format
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
// Get partstat display style
const getPartstatStyle = (partstat?: string): { bgColor: string; textColor: string } => {
type BadgeType =
| "accent"
| "neutral"
| "danger"
| "success"
| "warning"
| "info";
const getBadgeType = (partstat?: string): BadgeType => {
switch (partstat) {
case 'ACCEPTED':
return { bgColor: '#d4edda', textColor: '#155724' };
case 'DECLINED':
return { bgColor: '#f8d7da', textColor: '#721c24' };
case 'TENTATIVE':
return { bgColor: '#fff3cd', textColor: '#856404' };
default: // NEEDS-ACTION or undefined
return { bgColor: '#e9ecef', textColor: '#495057' };
case "ACCEPTED":
return "success";
case "DECLINED":
return "danger";
case "TENTATIVE":
return "warning";
default:
return "neutral";
}
};
// Get partstat icon
const getPartstatIcon = (partstat?: string): string => {
switch (partstat) {
case 'ACCEPTED':
return 'check_circle';
case 'DECLINED':
return 'cancel';
case 'TENTATIVE':
return 'help';
case "ACCEPTED":
return "check_circle";
case "DECLINED":
return "cancel";
case "TENTATIVE":
return "help";
default:
return 'schedule';
return "schedule";
}
};
export function AttendeesInput({ attendees, onChange, organizerEmail, organizer }: AttendeesInputProps) {
export function AttendeesInput({
attendees,
onChange,
organizerEmail,
organizer,
}: AttendeesInputProps) {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [inputValue, setInputValue] = useState("");
const [error, setError] = useState<string | null>(null);
// Calculate total participants (organizer + attendees)
const totalParticipants = (organizer ? 1 : 0) + attendees.length;
const addAttendee = useCallback(() => {
const email = inputValue.trim().toLowerCase();
@@ -60,50 +68,57 @@ export function AttendeesInput({ attendees, onChange, organizerEmail, organizer
}
if (!isValidEmail(email)) {
setError(t('calendar.attendees.invalidEmail'));
setError(t("calendar.attendees.invalidEmail"));
return;
}
// Check if already in list
if (attendees.some(a => a.email.toLowerCase() === email)) {
setError(t('calendar.attendees.alreadyAdded'));
if (attendees.some((a) => a.email.toLowerCase() === email)) {
setError(t("calendar.attendees.alreadyAdded"));
return;
}
// Check if it's the organizer
if (organizerEmail && email === organizerEmail.toLowerCase()) {
setError(t('calendar.attendees.cannotAddOrganizer'));
setError(t("calendar.attendees.cannotAddOrganizer"));
return;
}
const newAttendee: IcsAttendee = {
email,
partstat: 'NEEDS-ACTION',
partstat: "NEEDS-ACTION",
rsvp: true,
role: 'REQ-PARTICIPANT',
role: "REQ-PARTICIPANT",
};
onChange([...attendees, newAttendee]);
setInputValue('');
setInputValue("");
setError(null);
}, [inputValue, attendees, onChange, organizerEmail, t]);
const removeAttendee = useCallback((emailToRemove: string) => {
onChange(attendees.filter(a => a.email !== emailToRemove));
}, [attendees, onChange]);
const removeAttendee = useCallback(
(emailToRemove: string) => {
onChange(attendees.filter((a) => a.email !== emailToRemove));
},
[attendees, onChange],
);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addAttendee();
}
}, [addAttendee]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
addAttendee();
}
},
[addAttendee],
);
return (
<div className="attendees-input">
<div className="attendees-input__field">
<Input
label={t('calendar.attendees.label')}
label={t("calendar.attendees.label")}
hideLabel
placeholder={t("calendar.attendees.placeholder")}
variant="classic"
fullWidth
value={inputValue}
onChange={(e) => {
@@ -111,101 +126,42 @@ export function AttendeesInput({ attendees, onChange, organizerEmail, organizer
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
state={error ? 'error' : 'default'}
state={error ? "error" : "default"}
text={error || undefined}
icon={<span className="material-icons">person_add</span>}
/>
</div>
{totalParticipants > 0 && (
<div className="attendees-input__participants">
<div className="attendees-input__header">
<h3 className="attendees-input__title">
{t('calendar.attendees.participants')}
<span className="attendees-input__badge">{totalParticipants}</span>
</h3>
</div>
<div className="attendees-input__list">
{/* Organizer */}
{organizer && (
<div className="attendees-input__item">
<span className="material-icons attendees-input__status-icon attendees-input__status-icon--accepted">
check_circle
</span>
<div className="attendees-input__item-content">
<span className="attendees-input__item-name">
{organizer.name || organizer.email}
</span>
<span className="attendees-input__organizer-badge">
{t('calendar.attendees.organizer')}
</span>
</div>
<button
type="button"
className="attendees-input__action-btn"
aria-label={t('calendar.attendees.viewProfile')}
>
<span className="material-icons">person</span>
</button>
<button
type="button"
className="attendees-input__action-btn attendees-input__action-btn--remove"
disabled
aria-label={t('calendar.attendees.cannotRemoveOrganizer')}
>
<span className="material-icons">close</span>
</button>
</div>
)}
{/* Attendees */}
{attendees.map((attendee) => {
const icon = getPartstatIcon(attendee.partstat);
const isAccepted = attendee.partstat === 'ACCEPTED';
return (
<div key={attendee.email} className="attendees-input__item">
<span
className={`material-icons attendees-input__status-icon ${
isAccepted
? 'attendees-input__status-icon--accepted'
: 'attendees-input__status-icon--pending'
}`}
>
{icon}
</span>
<div className="attendees-input__item-content">
<span className="attendees-input__item-name">
{attendee.name || attendee.email}
</span>
{attendee.name && (
<span className="attendees-input__item-email">
&lt;{attendee.email}&gt;
</span>
)}
</div>
<button
type="button"
className="attendees-input__action-btn"
aria-label={t('calendar.attendees.viewProfile')}
>
<span className="material-icons">person</span>
</button>
<button
type="button"
className="attendees-input__action-btn attendees-input__action-btn--remove"
onClick={() => removeAttendee(attendee.email)}
aria-label={t('calendar.attendees.remove')}
>
<span className="material-icons">close</span>
</button>
</div>
);
})}
</div>
</div>
)}
<div className="attendees-input__pills">
{organizer && attendees.length > 0 && (
<Badge type={"success"} className="attendees-input__pill">
<span className="material-icons">check_circle</span>
{organizer.email}
<span className="attendees-input__organizer-label">
({t("calendar.attendees.organizer")})
</span>
</Badge>
)}
{attendees.map((attendee) => (
<Badge
key={attendee.email}
type={getBadgeType(attendee.partstat)}
className="attendees-input__pill"
>
<span className="material-icons">
{getPartstatIcon(attendee.partstat)}
</span>
{attendee.email}
<button
type="button"
className="attendees-input__pill-remove"
onClick={() => removeAttendee(attendee.email)}
aria-label={t("calendar.attendees.remove")}
>
<span className="material-icons">close</span>
</button>
</Badge>
))}
</div>
</div>
);
}

View File

@@ -1,90 +1,212 @@
.event-modal {
&__feature-tag {
display: inline-flex;
&__content {
display: flex;
flex-direction: column;
gap: 2px;
}
&__more-options {
border-top: 1px solid #f1f3f4;
padding-top: 0.5rem;
}
&__more-options-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 2rem;
border: none;
background: #f1f3f4;
color: #5f6368;
padding: 0.5rem;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
transition: background-color 0.15s, color 0.15s;
color: #5f6368;
transition: background-color 0.15s;
&:hover {
background: #e8eaeb;
}
&--active {
background: #e8f0fe;
color: #1a73e8;
&:hover {
background: #d2e3fc;
}
background-color: #f8f9fa;
}
.material-icons {
font-size: 1.125rem;
font-size: 20px;
}
}
&__more-options-content {
display: flex;
flex-direction: column;
border: 1px solid #e8eaeb;
border-radius: 8px;
overflow: hidden;
margin-top: 0.5rem;
}
}
// DateTimeSection layout
.datetime-section {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
&__inputs {
display: flex;
gap: 0.5rem;
flex: 1;
}
&__arrow {
align-self: center;
margin: 0 8px;
font-size: 20px;
color: #888;
}
&__allday {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #5f6368;
cursor: pointer;
}
}
// VideoConferenceSection layout
.video-conference-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
&__link {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__remove {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 50%;
cursor: pointer;
color: #5f6368;
flex-shrink: 0;
&:hover {
background-color: #f1f3f4;
}
.material-icons {
font-size: 18px;
}
}
}
// Utility classes for layout
.event-modal-layout {
// RemindersSection layout
.reminders-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
&--row {
flex-direction: row;
}
&--wrap {
flex-wrap: wrap;
}
&--justify-space-between {
justify-content: space-between;
}
&--align-center {
&__item {
display: flex;
align-items: center;
}
&--flex-1 {
flex: 1;
}
&--gap-2rem {
gap: 2rem;
}
&--gap-1rem {
gap: 1rem;
}
&--gap-0-5rem {
gap: 0.5rem;
}
&--margin-bottom-1rem {
margin-bottom: 1rem;
}
&__remove {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
border-radius: 50%;
cursor: pointer;
color: #5f6368;
flex-shrink: 0;
&--margin-top-2rem {
margin-top: 2rem;
}
&:hover {
background-color: #f1f3f4;
}
&--margin-left-auto {
margin-left: auto;
}
&--margin-left-2rem {
margin-left: 2rem;
}
&--margin-top-0-5rem {
margin-top: 0.5rem;
.material-icons {
font-size: 18px;
}
}
}
// AttachmentsSection layout
.attachments-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.8125rem;
}
&__info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
&__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #202124;
}
&__size {
color: #5f6368;
font-size: 0.75rem;
}
&__remove {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
background: transparent;
border-radius: 50%;
cursor: pointer;
color: #5f6368;
flex-shrink: 0;
&:hover {
background-color: #e8eaeb;
}
.material-icons {
font-size: 16px;
}
}
}
// StatusSection layout
.status-section {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}

View File

@@ -1,39 +1,28 @@
/**
* EventModal component.
* Handles creation and editing of calendar events.
*/
import { useEffect, useState } from "react";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
IcsEvent,
IcsAttendee,
IcsOrganizer,
IcsRecurrenceRule,
} from "ts-ics";
import type { IcsEvent, IcsOrganizer } from "ts-ics";
import {
Button,
Input,
Modal,
ModalSize,
Select,
TextArea,
} from "@gouvfr-lasuite/cunningham-react";
import { useAuth } from "@/features/auth/Auth";
import { AttendeesInput } from "./AttendeesInput";
import { RecurrenceEditor } from "./RecurrenceEditor";
import { addToast, ToasterItem } from "@/features/ui/components/toaster/Toaster";
import { DeleteEventModal } from "./DeleteEventModal";
import { useEventForm } from "./hooks/useEventForm";
import { DateTimeSection } from "./event-modal-sections/DateTimeSection";
import { RecurrenceSection } from "./event-modal-sections/RecurrenceSection";
import { LocationSection } from "./event-modal-sections/LocationSection";
import { VideoConferenceSection } from "./event-modal-sections/VideoConferenceSection";
import { AttendeesSection } from "./event-modal-sections/AttendeesSection";
import { DescriptionSection } from "./event-modal-sections/DescriptionSection";
import { InvitationResponseSection } from "./event-modal-sections/InvitationResponseSection";
import { SectionPills } from "./event-modal-sections/SectionPills";
import type { EventModalProps, RecurringDeleteOption } from "./types";
import {
formatDateTimeLocal,
formatDateLocal,
parseDateTimeLocal,
parseDateLocal,
} from "./utils/dateFormatters";
// Get browser timezone
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
import { SectionRow } from "./event-modal-sections/SectionRow";
export const EventModal = ({
isOpen,
@@ -49,36 +38,21 @@ export const EventModal = ({
}: EventModalProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const [title, setTitle] = useState(event?.summary || "");
const [description, setDescription] = useState(event?.description || "");
const [location, setLocation] = useState(event?.location || "");
const [startDateTime, setStartDateTime] = useState("");
const [endDateTime, setEndDateTime] = useState("");
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
const [isLoading, setIsLoading] = useState(false);
const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
const [showAttendees, setShowAttendees] = useState(false);
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>(
event?.recurrenceRule
);
const [showRecurrence, setShowRecurrence] = useState(() => {
return !!event?.recurrenceRule;
});
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isAllDay, setIsAllDay] = useState(() => {
return event?.start?.type === 'DATE';
});
// Calculate organizer
const organizer: IcsOrganizer | undefined =
event?.organizer ||
(user?.email
? { email: user.email, name: user.email.split('@')[0] }
? { email: user.email, name: user.email.split("@")[0] }
: undefined);
// Check if current user is an attendee (invited to this event)
const form = useEventForm({ event, calendarUrl, adapter, organizer, mode });
// Check if current user is invited
const currentUserAttendee = event?.attendees?.find(
(att) => user?.email && att.email.toLowerCase() === user.email.toLowerCase()
(att) =>
user?.email && att.email.toLowerCase() === user.email.toLowerCase(),
);
const isInvited = !!(
event?.organizer &&
@@ -86,304 +60,95 @@ export const EventModal = ({
event.organizer.email !== user?.email
);
const currentParticipationStatus =
currentUserAttendee?.partstat || 'NEEDS-ACTION';
currentUserAttendee?.partstat || "NEEDS-ACTION";
// Reset form when event changes
useEffect(() => {
setTitle(event?.summary || "");
setDescription(event?.description || "");
setLocation(event?.location || "");
setSelectedCalendarUrl(calendarUrl);
// Initialize attendees from event
if (event?.attendees && event.attendees.length > 0) {
setAttendees(event.attendees);
setShowAttendees(true);
} else {
setAttendees([]);
setShowAttendees(false);
}
// Initialize recurrence from event
if (event?.recurrenceRule) {
setRecurrence(event.recurrenceRule);
setShowRecurrence(true);
} else {
setRecurrence(undefined);
setShowRecurrence(false);
}
// Initialize all-day from event
const eventIsAllDay = event?.start?.type === 'DATE';
setIsAllDay(eventIsAllDay);
// Parse start/end dates
// Dates from adapter have timezone info and are "fake UTC" - use getUTC* methods
if (event?.start?.date) {
const startDate =
event.start.date instanceof Date
? event.start.date
: new Date(event.start.date);
// If there's timezone info, the date is "fake UTC"
const isFakeUtc = Boolean(event.start.local?.timezone);
if (eventIsAllDay) {
setStartDateTime(formatDateLocal(startDate, isFakeUtc));
} else {
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc));
}
} else {
if (eventIsAllDay) {
setStartDateTime(formatDateLocal(new Date()));
} else {
setStartDateTime(formatDateTimeLocal(new Date()));
}
}
if (event?.end?.date) {
const endDate =
event.end.date instanceof Date
? event.end.date
: new Date(event.end.date);
const isFakeUtc = Boolean(event.end.local?.timezone);
if (eventIsAllDay) {
// For all-day events, the end date in ICS is exclusive (next day)
// But in the UI we want to show the inclusive end date (same day for 1-day event)
// So subtract 1 day when displaying
const displayEndDate = new Date(endDate);
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc));
} else {
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc));
}
} else {
if (eventIsAllDay) {
// Default: same day
setEndDateTime(formatDateLocal(new Date()));
} else {
// Default: 1 hour after start
const defaultEnd = new Date();
defaultEnd.setHours(defaultEnd.getHours() + 1);
setEndDateTime(formatDateTimeLocal(defaultEnd));
}
}
}, [event, calendarUrl]);
const showError = (message: string) => {
addToast(
<ToasterItem type="error" closeButton>{message}</ToasterItem>,
);
};
const handleSave = async () => {
setIsLoading(true);
try {
// Remove duration to avoid union type conflict
// (IcsEvent is either end or duration, not both)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { duration: _duration, ...eventWithoutDuration } = event ?? {};
let icsEvent: IcsEvent;
if (isAllDay) {
// All-day event
const startDate = parseDateLocal(startDateTime);
const endDate = parseDateLocal(endDateTime);
// Create UTC dates for all-day events
const utcStart = new Date(
Date.UTC(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate()
)
);
// For all-day events, the end date in ICS is exclusive (next day)
// The user enters the inclusive end date, so we add 1 day for ICS
const utcEnd = new Date(
Date.UTC(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate() + 1 // Add 1 day for exclusive end
)
);
icsEvent = {
...eventWithoutDuration,
uid: event?.uid || crypto.randomUUID(),
summary: title,
description: description || undefined,
location: location || undefined,
stamp: event?.stamp || { date: new Date() },
start: {
date: utcStart,
type: "DATE",
},
end: {
date: utcEnd,
type: "DATE",
},
organizer: organizer,
attendees: attendees.length > 0 ? attendees : undefined,
recurrenceRule: recurrence,
};
} else {
// Timed event
const startDate = parseDateTimeLocal(startDateTime);
const endDate = parseDateTimeLocal(endDateTime);
// Create "fake UTC" dates where getUTCHours() = local hours
// This is required because ts-ics uses getUTCHours() to generate ICS
const fakeUtcStart = new Date(
Date.UTC(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds()
)
);
const fakeUtcEnd = new Date(
Date.UTC(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds()
)
);
icsEvent = {
...eventWithoutDuration,
uid: event?.uid || crypto.randomUUID(),
summary: title,
description: description || undefined,
location: location || undefined,
stamp: event?.stamp || { date: new Date() },
start: {
date: fakeUtcStart,
type: "DATE-TIME",
local: {
date: fakeUtcStart,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(startDate, BROWSER_TIMEZONE),
},
},
end: {
date: fakeUtcEnd,
type: "DATE-TIME",
local: {
date: fakeUtcEnd,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(endDate, BROWSER_TIMEZONE),
},
},
organizer: organizer,
attendees: attendees.length > 0 ? attendees : undefined,
recurrenceRule: recurrence,
};
}
await onSave(icsEvent, selectedCalendarUrl);
const icsEvent = form.toIcsEvent();
await onSave(icsEvent, form.selectedCalendarUrl);
onClose();
} catch (error) {
console.error("Failed to save event:", error);
alert(t('api.error.unexpected'));
showError(t("api.error.unexpected"));
} finally {
setIsLoading(false);
}
};
const handleDeleteClick = () => {
setShowDeleteModal(true);
};
const handleDeleteConfirm = async (option?: RecurringDeleteOption) => {
if (!onDelete || !event?.uid) return;
setShowDeleteModal(false);
setIsLoading(true);
try {
await onDelete(event as IcsEvent, selectedCalendarUrl, option);
await onDelete(event as IcsEvent, form.selectedCalendarUrl, option);
onClose();
} catch (error) {
console.error("Failed to delete event:", error);
alert(t('api.error.unexpected'));
showError(t("api.error.unexpected"));
} finally {
setIsLoading(false);
}
};
const handleDeleteCancel = () => {
setShowDeleteModal(false);
};
const handleRespondToInvitation = async (
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED'
status: "ACCEPTED" | "TENTATIVE" | "DECLINED",
) => {
if (!onRespondToInvitation || !event) return;
setIsLoading(true);
try {
await onRespondToInvitation(event as IcsEvent, status);
// Update local state to reflect new status
setAttendees((prev) =>
form.setAttendees((prev) =>
prev.map((att) =>
user?.email && att.email.toLowerCase() === user.email.toLowerCase()
? { ...att, partstat: status }
: att
)
: att,
),
);
} catch (error) {
console.error("Failed to respond to invitation:", error);
alert(t('api.error.unexpected'));
showError(t("api.error.unexpected"));
} finally {
setIsLoading(false);
}
};
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newStartValue = e.target.value;
if (isAllDay) {
const oldStart = parseDateLocal(startDateTime);
const oldEnd = parseDateLocal(endDateTime);
const duration = oldEnd.getTime() - oldStart.getTime();
const newStart = parseDateLocal(newStartValue);
const newEnd = new Date(newStart.getTime() + duration);
setStartDateTime(newStartValue);
setEndDateTime(formatDateLocal(newEnd));
} else {
const oldStart = parseDateTimeLocal(startDateTime);
const oldEnd = parseDateTimeLocal(endDateTime);
const duration = oldEnd.getTime() - oldStart.getTime();
const newStart = parseDateTimeLocal(newStartValue);
const newEnd = new Date(newStart.getTime() + duration);
setStartDateTime(newStartValue);
setEndDateTime(formatDateTimeLocal(newEnd));
}
};
const handleAllDayChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newIsAllDay = e.target.checked;
setIsAllDay(newIsAllDay);
// Convert dates when switching modes
if (newIsAllDay) {
// Convert to date-only format
const start = parseDateTimeLocal(startDateTime);
const end = parseDateTimeLocal(endDateTime);
setStartDateTime(formatDateLocal(start));
setEndDateTime(formatDateLocal(end));
} else {
// Convert to datetime format
const start = parseDateLocal(startDateTime);
const end = parseDateLocal(endDateTime);
setStartDateTime(formatDateTimeLocal(start));
setEndDateTime(formatDateTimeLocal(end));
}
};
const pills = useMemo(
() => [
{
id: "videoConference" as const,
icon: "videocam",
label: t("calendar.event.sections.addVideoConference"),
},
{
id: "location" as const,
icon: "place",
label: t("calendar.event.location"),
},
{
id: "description" as const,
icon: "notes",
label: t("calendar.event.description"),
},
{
id: "recurrence" as const,
icon: "repeat",
label: t("calendar.recurrence.label"),
},
{
id: "attendees" as const,
icon: "group",
label: t("calendar.event.attendees"),
},
],
[t],
);
return (
<>
@@ -393,227 +158,142 @@ export const EventModal = ({
size={ModalSize.MEDIUM}
title={
mode === "create"
? t('calendar.event.createTitle')
: t('calendar.event.editTitle')
? t("calendar.event.createTitle")
: t("calendar.event.editTitle")
}
leftActions={
mode === "edit" && onDelete ? (
<Button
color="error"
onClick={handleDeleteClick}
onClick={() => setShowDeleteModal(true)}
disabled={isLoading}
>
{t('calendar.event.delete')}
{t("calendar.event.delete")}
</Button>
) : undefined
}
rightActions={
<>
<Button color="neutral" onClick={onClose} disabled={isLoading}>
{t('calendar.event.cancel')}
{t("calendar.event.cancel")}
</Button>
<Button
color="brand"
onClick={handleSave}
disabled={isLoading || !title.trim()}
disabled={isLoading || !form.title.trim()}
>
{isLoading ? "..." : t('calendar.event.save')}
{isLoading ? "..." : t("calendar.event.save")}
</Button>
</>
}
>
<div className="event-modal__content">
<Input
label={t('calendar.event.title')}
value={title}
onChange={(e) => setTitle(e.target.value)}
fullWidth
/>
{/* Invitation Response Section */}
{isInvited && mode === 'edit' && onRespondToInvitation && (
<div className="event-modal__invitation">
<div className="event-modal__invitation-header">
<span className="event-modal__invitation-label">
{t('calendar.event.invitation', { defaultValue: 'Invitation' })}
</span>
<span className="event-modal__invitation-organizer">
{t('calendar.event.organizedBy', {
defaultValue: 'Organisé par',
})}{' '}
{event?.organizer?.name || event?.organizer?.email}
</span>
</div>
<div className="event-modal__invitation-actions">
<Button
size="small"
color={
currentParticipationStatus === 'ACCEPTED'
? 'success'
: 'neutral'
}
onClick={() => handleRespondToInvitation('ACCEPTED')}
disabled={
isLoading || currentParticipationStatus === 'ACCEPTED'
}
>
{t('calendar.event.accept', { defaultValue: 'Accepter' })}
</Button>
<Button
size="small"
color={
currentParticipationStatus === 'TENTATIVE'
? 'warning'
: 'neutral'
}
onClick={() => handleRespondToInvitation('TENTATIVE')}
disabled={
isLoading || currentParticipationStatus === 'TENTATIVE'
}
>
? {t('calendar.event.maybe', { defaultValue: 'Peut-être' })}
</Button>
<Button
size="small"
color={
currentParticipationStatus === 'DECLINED'
? 'error'
: 'neutral'
}
onClick={() => handleRespondToInvitation('DECLINED')}
disabled={
isLoading || currentParticipationStatus === 'DECLINED'
}
>
{t('calendar.event.decline', { defaultValue: 'Refuser' })}
</Button>
</div>
{currentParticipationStatus && (
<div className="event-modal__invitation-status">
{t('calendar.event.yourResponse', {
defaultValue: 'Votre réponse',
})}
:{' '}
<strong>
{currentParticipationStatus === 'ACCEPTED' &&
t('calendar.event.accepted', { defaultValue: 'Accepté' })}
{currentParticipationStatus === 'TENTATIVE' &&
t('calendar.event.tentative', {
defaultValue: 'Peut-être',
})}
{currentParticipationStatus === 'DECLINED' &&
t('calendar.event.declined', { defaultValue: 'Refusé' })}
{currentParticipationStatus === 'NEEDS-ACTION' &&
t('calendar.event.needsAction', {
defaultValue: 'En attente',
})}
</strong>
</div>
)}
</div>
)}
<Select
label={t('calendar.event.calendar', { defaultValue: 'Calendrier' })}
value={selectedCalendarUrl}
onChange={(e) => setSelectedCalendarUrl(String(e.target.value))}
options={calendars.map((cal) => ({
value: cal.url,
label: cal.displayName || cal.url,
}))}
fullWidth
/>
<label className="event-modal__checkbox">
<input
type="checkbox"
checked={isAllDay}
onChange={handleAllDayChange}
/>
<span>{t('calendar.event.allDay')}</span>
</label>
<div className="event-modal__datetime-row">
<SectionRow
icon="edit"
label={t("calendar.event.calendar")}
alwaysOpen={true}
>
<Input
type={isAllDay ? "date" : "datetime-local"}
label={t('calendar.event.start')}
value={startDateTime}
onChange={handleStartDateChange}
label={t("calendar.event.title")}
hideLabel
autoFocus={mode === "create"}
value={form.title}
onChange={(e) => form.setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
}}
fullWidth
placeholder={t("calendar.event.titlePlaceholder")}
variant="classic"
/>
</SectionRow>
<SectionRow
icon="event"
label={t("calendar.event.calendar")}
alwaysOpen={true}
>
<Select
label={t("calendar.event.calendar")}
hideLabel
value={form.selectedCalendarUrl}
onChange={(e) =>
form.setSelectedCalendarUrl(String(e.target.value))
}
options={calendars.map((cal) => ({
value: cal.url,
label: cal.displayName || cal.url,
}))}
variant="classic"
fullWidth
/>
<Input
type={isAllDay ? "date" : "datetime-local"}
label={t('calendar.event.end')}
value={endDateTime}
onChange={(e) => setEndDateTime(e.target.value)}
fullWidth
</SectionRow>
<DateTimeSection
startDateTime={form.startDateTime}
endDateTime={form.endDateTime}
isAllDay={form.isAllDay}
onStartChange={form.handleStartDateChange}
onEndChange={form.setEndDateTime}
onAllDayChange={form.handleAllDayChange}
/>
{form.isSectionExpanded("recurrence") && (
<RecurrenceSection
recurrence={form.recurrence}
onChange={form.setRecurrence}
alwaysOpen
/>
)}
{isInvited && mode === "edit" && onRespondToInvitation && (
<InvitationResponseSection
organizer={event?.organizer}
currentStatus={currentParticipationStatus}
isLoading={isLoading}
onRespond={handleRespondToInvitation}
/>
)}
{form.isSectionExpanded("videoConference") && (
<VideoConferenceSection
url={form.videoConferenceUrl}
onChange={form.setVideoConferenceUrl}
alwaysOpen
/>
)}
{form.isSectionExpanded("location") && (
<LocationSection
location={form.location}
onChange={form.setLocation}
alwaysOpen
/>
</div>
<Input
label={t('calendar.event.location')}
value={location}
onChange={(e) => setLocation(e.target.value)}
fullWidth
/>
<TextArea
label={t('calendar.event.description')}
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
fullWidth
/>
<div className="event-modal__features">
<button
type="button"
className={`event-modal__feature-tag ${
showAttendees ? 'event-modal__feature-tag--active' : ''
}`}
onClick={() => setShowAttendees(!showAttendees)}
>
<span className="material-icons">people</span>
{t('calendar.event.attendees')}
{attendees.length > 0 && ` (${attendees.length})`}
</button>
<button
type="button"
className={`event-modal__feature-tag ${
showRecurrence ? 'event-modal__feature-tag--active' : ''
}`}
onClick={() => setShowRecurrence(!showRecurrence)}
>
<span className="material-icons">repeat</span>
{t('calendar.recurrence.label')}
</button>
</div>
{showAttendees && (
<div className="event-modal__attendees-input">
<AttendeesInput
attendees={attendees}
onChange={setAttendees}
organizerEmail={user?.email}
organizer={organizer}
/>
</div>
)}
{showRecurrence && (
<div className="event-modal__recurrence-editor">
<RecurrenceEditor value={recurrence} onChange={setRecurrence} />
</div>
{form.isSectionExpanded("attendees") && (
<AttendeesSection
attendees={form.attendees}
onChange={form.setAttendees}
organizerEmail={user?.email}
organizer={organizer}
alwaysOpen
/>
)}
{form.isSectionExpanded("description") && (
<DescriptionSection
description={form.description}
onChange={form.setDescription}
alwaysOpen
/>
)}
<SectionPills
pills={pills}
isSectionExpanded={form.isSectionExpanded}
onToggle={form.toggleSection}
/>
</div>
</Modal>
<DeleteEventModal
isOpen={showDeleteModal}
isRecurring={!!recurrence}
isRecurring={!!form.recurrence}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
onCancel={() => setShowDeleteModal(false)}
/>
</>
);

View File

@@ -1,8 +1,38 @@
.recurrence-editor {
&__label {
display: flex;
flex-direction: column;
gap: 0.75rem;
&__card {
background: var(--c--theme--colors--greyscale-50, #f8f9fa);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__summary {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
background: var(--c--theme--colors--primary-100, #e8f0fe);
color: var(--c--theme--colors--primary-700, #1a56db);
font-size: 0.8125rem;
font-weight: 500;
color: #333;
font-size: 0.9375rem;
line-height: 1.4;
}
&__interval {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__weekdays {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__weekday-button {
@@ -10,32 +40,94 @@
height: 32px;
border-radius: 50%;
border: none;
background: #f1f3f4;
background: var(--c--theme--colors--greyscale-100, #f1f3f4);
color: inherit;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.15s, color 0.15s;
&:hover {
background: #e8eaeb;
background: var(--c--theme--colors--greyscale-200, #e8eaeb);
}
&--selected {
background: #1a73e8;
background: var(--c--theme--colors--primary-600, #1a73e8);
color: white;
&:hover {
background: #1557b0;
background: var(--c--theme--colors--primary-700, #1557b0);
}
}
}
&__day-select {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__label {
font-weight: 500;
color: #333;
font-size: 0.9375rem;
}
&__section-label {
text-transform: uppercase;
font-size: 0.75rem;
font-weight: 600;
color: var(--c--theme--colors--greyscale-500, #6b7280);
letter-spacing: 0.05em;
}
&__end {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
&__end-options {
display: flex;
gap: 0.5rem;
}
&__end-btn {
border: 1px solid var(--c--theme--colors--greyscale-300, #d1d5db);
border-radius: 20px;
padding: 0.375rem 0.75rem;
background: transparent;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
color: inherit;
&:hover {
border-color: var(--c--theme--colors--greyscale-400, #9ca3af);
}
&--active {
border-color: var(--c--theme--colors--primary-600, #1a73e8);
color: var(--c--theme--colors--primary-600, #1a73e8);
background: var(--c--theme--colors--primary-50, #eff6ff);
&:hover {
border-color: var(--c--theme--colors--primary-700, #1557b0);
color: var(--c--theme--colors--primary-700, #1557b0);
}
}
}
&__end-input {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
@@ -44,37 +136,3 @@
line-height: 1.4;
}
}
// Utility classes for layout
.recurrence-editor-layout {
display: flex;
flex-direction: column;
&--row {
flex-direction: row;
}
&--align-center {
align-items: center;
}
&--flex-wrap {
flex-wrap: wrap;
}
&--gap-1rem {
gap: 1rem;
}
&--gap-0-5rem {
gap: 0.5rem;
}
&--margin-left-2rem {
margin-left: 2rem;
}
&--margin-top-0-5rem {
margin-top: 0.5rem;
}
}

View File

@@ -1,25 +1,26 @@
import { Select, Input } from '@gouvfr-lasuite/cunningham-react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { IcsRecurrenceRule, IcsWeekDay } from 'ts-ics';
import { Select, Input } from "@gouvfr-lasuite/cunningham-react";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { IcsRecurrenceRule, IcsWeekDay } from "ts-ics";
type RecurrenceFrequency = IcsRecurrenceRule['frequency'];
type RecurrenceFrequency = IcsRecurrenceRule["frequency"];
type EndType = "never" | "count" | "date";
const WEEKDAY_KEYS: IcsWeekDay[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
const WEEKDAY_KEYS: IcsWeekDay[] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
const MONTHS = [
{ value: 1, key: 'january' },
{ value: 2, key: 'february' },
{ value: 3, key: 'march' },
{ value: 4, key: 'april' },
{ value: 5, key: 'may' },
{ value: 6, key: 'june' },
{ value: 7, key: 'july' },
{ value: 8, key: 'august' },
{ value: 9, key: 'september' },
{ value: 10, key: 'october' },
{ value: 11, key: 'november' },
{ value: 12, key: 'december' },
{ value: 1, key: "january" },
{ value: 2, key: "february" },
{ value: 3, key: "march" },
{ value: 4, key: "april" },
{ value: 5, key: "may" },
{ value: 6, key: "june" },
{ value: 7, key: "july" },
{ value: 8, key: "august" },
{ value: 9, key: "september" },
{ value: 10, key: "october" },
{ value: 11, key: "november" },
{ value: 12, key: "december" },
];
interface RecurrenceEditorProps {
@@ -27,98 +28,140 @@ interface RecurrenceEditorProps {
onChange: (rule: IcsRecurrenceRule | undefined) => void;
}
/**
* Validate day of month based on month (handles leap years and month lengths)
*/
function isValidDayForMonth(day: number, month?: number): boolean {
if (day < 1 || day > 31) return false;
if (!month) return true; // No month specified, allow any valid day
// Days per month (non-leap year)
const daysInMonth: Record<number, number> = {
1: 31, 2: 29, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31
};
return day <= (daysInMonth[month] || 31);
}
/**
* Get warning message for invalid dates
*/
function getDateWarning(t: (key: string) => string, day: number, month?: number): string | null {
function getDateWarning(
t: (key: string) => string,
day: number,
month?: number,
): string | null {
if (!month) return null;
if (month === 2 && day > 29) {
return t('calendar.recurrence.warnings.februaryMax');
return t("calendar.recurrence.warnings.februaryMax");
}
if (month === 2 && day === 29) {
return t('calendar.recurrence.warnings.leapYear');
return t("calendar.recurrence.warnings.leapYear");
}
if ([4, 6, 9, 11].includes(month) && day > 30) {
return t('calendar.recurrence.warnings.monthMax30');
return t("calendar.recurrence.warnings.monthMax30");
}
if (day > 31) {
return t('calendar.recurrence.warnings.dayMax31');
return t("calendar.recurrence.warnings.dayMax31");
}
return null;
}
function getEndType(value?: IcsRecurrenceRule): EndType {
if (value?.count) return "count";
if (value?.until) return "date";
return "never";
}
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const { t } = useTranslation();
const [isCustom, setIsCustom] = useState(() => {
if (!value) return false;
return value.interval !== 1 || value.byDay?.length || value.byMonthday?.length || value.byMonth?.length || value.count || value.until;
return !!(
value.interval !== 1 ||
value.byDay?.length ||
value.byMonthday?.length ||
value.byMonth?.length ||
value.count ||
value.until
);
});
// Generate options from translations
const recurrenceOptions = useMemo(() => [
{ value: 'NONE', label: t('calendar.recurrence.none') },
{ value: 'DAILY', label: t('calendar.recurrence.daily') },
{ value: 'WEEKLY', label: t('calendar.recurrence.weekly') },
{ value: 'MONTHLY', label: t('calendar.recurrence.monthly') },
{ value: 'YEARLY', label: t('calendar.recurrence.yearly') },
{ value: 'CUSTOM', label: t('calendar.recurrence.custom') },
], [t]);
const recurrenceOptions = useMemo(
() => [
{ value: "NONE", label: t("calendar.recurrence.none") },
{ value: "DAILY", label: t("calendar.recurrence.daily") },
{ value: "WEEKLY", label: t("calendar.recurrence.weekly") },
{ value: "MONTHLY", label: t("calendar.recurrence.monthly") },
{ value: "YEARLY", label: t("calendar.recurrence.yearly") },
{ value: "CUSTOM", label: t("calendar.recurrence.custom") },
],
[t],
);
const frequencyOptions = useMemo(() => [
{ value: 'DAILY', label: t('calendar.recurrence.days') },
{ value: 'WEEKLY', label: t('calendar.recurrence.weeks') },
{ value: 'MONTHLY', label: t('calendar.recurrence.months') },
{ value: 'YEARLY', label: t('calendar.recurrence.years') },
], [t]);
const frequencyOptions = useMemo(
() => [
{ value: "DAILY", label: t("calendar.recurrence.days") },
{ value: "WEEKLY", label: t("calendar.recurrence.weeks") },
{ value: "MONTHLY", label: t("calendar.recurrence.months") },
{ value: "YEARLY", label: t("calendar.recurrence.years") },
],
[t],
);
const weekdays = useMemo(() => WEEKDAY_KEYS.map(key => ({
value: key,
label: t(`calendar.recurrence.weekdays.${key.toLowerCase()}`),
})), [t]);
const weekdays = useMemo(
() =>
WEEKDAY_KEYS.map((key) => ({
value: key,
label: t(`calendar.recurrence.weekdays.${key.toLowerCase()}`),
})),
[t],
);
const monthOptions = useMemo(() => MONTHS.map(month => ({
value: String(month.value),
label: t(`calendar.recurrence.months.${month.key}`),
})), [t]);
const monthOptions = useMemo(
() =>
MONTHS.map((month) => ({
value: String(month.value),
label: t(`calendar.recurrence.months.${month.key}`),
})),
[t],
);
const summary = useMemo((): string => {
if (!isCustom || !value) return "";
const interval = value.interval || 1;
const freq = value.frequency;
const freqLabel = t(`calendar.recurrence.${freq === "DAILY" ? "days" : freq === "WEEKLY" ? "weeks" : freq === "MONTHLY" ? "months" : "years"}`);
let result = `${t("calendar.recurrence.everyLabel")} ${interval > 1 ? `${interval} ` : ""}${freqLabel}`;
if (freq === "WEEKLY" && value.byDay?.length) {
const dayLabels = value.byDay.map((d) => {
const dayKey = typeof d === "string" ? d : d.day;
return t(
`calendar.recurrence.weekdays.${dayKey.toLowerCase()}`,
);
});
result += ` · ${dayLabels.join(", ")}`;
}
if (value.until) {
const dateStr =
value.until.date instanceof Date
? value.until.date.toISOString().split("T")[0]
: "";
result += ` · ${t("calendar.recurrence.on")} ${dateStr}`;
} else if (value.count) {
result += ` · ${value.count} ${t("calendar.recurrence.occurrences")}`;
}
return result;
}, [isCustom, value, t]);
const getSimpleValue = (): string => {
if (!value) return 'NONE';
if (isCustom) return 'CUSTOM';
if (!value) return "NONE";
if (isCustom) return "CUSTOM";
return value.frequency;
};
const handleSimpleChange = (newValue: string) => {
if (newValue === 'NONE') {
if (newValue === "NONE") {
setIsCustom(false);
onChange(undefined);
return;
}
if (newValue === 'CUSTOM') {
if (newValue === "CUSTOM") {
setIsCustom(true);
onChange({
frequency: 'WEEKLY',
interval: 1
frequency: "WEEKLY",
interval: 1,
});
return;
}
@@ -131,23 +174,24 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
byMonthday: undefined,
byMonth: undefined,
count: undefined,
until: undefined
until: undefined,
});
};
const handleChange = (updates: Partial<IcsRecurrenceRule>) => {
onChange({
frequency: 'WEEKLY',
frequency: "WEEKLY",
interval: 1,
...value,
...updates
...updates,
});
};
// Get selected day strings from byDay array
const getSelectedDays = (): IcsWeekDay[] => {
if (!value?.byDay) return [];
return value.byDay.map(d => typeof d === 'string' ? d as IcsWeekDay : d.day);
return value.byDay.map((d) =>
typeof d === "string" ? (d as IcsWeekDay) : d.day,
);
};
const isDaySelected = (day: IcsWeekDay): boolean => {
@@ -157,17 +201,15 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const toggleDay = (day: IcsWeekDay) => {
const currentDays = getSelectedDays();
const newDays = currentDays.includes(day)
? currentDays.filter(d => d !== day)
? currentDays.filter((d) => d !== day)
: [...currentDays, day];
handleChange({ byDay: newDays.map(d => ({ day: d })) });
handleChange({ byDay: newDays.map((d) => ({ day: d })) });
};
// Get selected month day
const getMonthDay = (): number => {
return value?.byMonthday?.[0] ?? 1;
};
// Get selected month for yearly recurrence
const getMonth = (): string => {
return String(value?.byMonth?.[0] ?? 1);
};
@@ -180,78 +222,97 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
handleChange({ byMonth: [month] });
};
// Calculate date warning
const endType = getEndType(value);
const monthDay = getMonthDay();
const selectedMonth = value?.frequency === 'YEARLY' ? parseInt(getMonth()) : undefined;
const dateWarning = isCustom && (value?.frequency === 'MONTHLY' || value?.frequency === 'YEARLY')
? getDateWarning(t, monthDay, selectedMonth)
: null;
const selectedMonth =
value?.frequency === "YEARLY" ? parseInt(getMonth()) : undefined;
const dateWarning =
isCustom &&
(value?.frequency === "MONTHLY" || value?.frequency === "YEARLY")
? getDateWarning(t, monthDay, selectedMonth)
: null;
return (
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem">
<div className="recurrence-editor">
<Select
label={t('calendar.recurrence.label')}
label={t("calendar.recurrence.label")}
hideLabel
value={getSimpleValue()}
options={recurrenceOptions}
onChange={(e) => handleSimpleChange(String(e.target.value ?? ''))}
onChange={(e) => handleSimpleChange(String(e.target.value ?? ""))}
variant="classic"
fullWidth
/>
{isCustom && (
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem recurrence-editor-layout--margin-left-2rem">
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-1rem">
<span>{t('calendar.recurrence.everyLabel')}</span>
<div className="recurrence-editor__card">
{summary && (
<div className="recurrence-editor__summary">{summary}</div>
)}
<div className="recurrence-editor__interval">
<span>{t("calendar.recurrence.everyLabel")}</span>
<Input
label=""
type="number"
variant="classic"
min={1}
value={value?.interval || 1}
onChange={(e) => handleChange({ interval: parseInt(e.target.value) || 1 })}
onChange={(e) =>
handleChange({ interval: parseInt(e.target.value) || 1 })
}
/>
<Select
label=""
value={value?.frequency || 'WEEKLY'}
variant="classic"
value={value?.frequency || "WEEKLY"}
options={frequencyOptions}
onChange={(e) => handleChange({ frequency: String(e.target.value ?? '') as RecurrenceFrequency })}
onChange={(e) =>
handleChange({
frequency: String(
e.target.value ?? "",
) as RecurrenceFrequency,
})
}
/>
</div>
{/* WEEKLY: Day selection */}
{value?.frequency === 'WEEKLY' && (
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOn')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem recurrence-editor-layout--flex-wrap">
{weekdays.map(day => {
const isSelected = isDaySelected(day.value);
return (
<button
key={day.value}
type="button"
className={`recurrence-editor__weekday-button ${isSelected ? 'recurrence-editor__weekday-button--selected' : ''}`}
onClick={(e) => {
e.preventDefault();
toggleDay(day.value);
}}
>
{day.label}
</button>
);
})}
</div>
{value?.frequency === "WEEKLY" && (
<div className="recurrence-editor__weekdays">
{weekdays.map((day) => {
const isSelected = isDaySelected(day.value);
return (
<button
key={day.value}
type="button"
className={`recurrence-editor__weekday-button ${isSelected ? "recurrence-editor__weekday-button--selected" : ""}`}
onClick={(e) => {
e.preventDefault();
toggleDay(day.value);
}}
>
{day.label}
</button>
);
})}
</div>
)}
{/* MONTHLY: Day of month selection */}
{value?.frequency === 'MONTHLY' && (
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDay')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
<span>{t('calendar.recurrence.dayOfMonth')}</span>
{value?.frequency === "MONTHLY" && (
<div className="recurrence-editor__day-select">
<span className="recurrence-editor__label">
{t("calendar.recurrence.repeatOnDay")}
</span>
<div className="recurrence-editor__interval">
<span>{t("calendar.recurrence.dayOfMonth")}</span>
<Input
label=""
type="number"
min={1}
max={31}
variant="classic"
value={getMonthDay()}
onChange={(e) => {
const day = parseInt(e.target.value) || 1;
@@ -259,30 +320,35 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
handleMonthDayChange(day);
}
}}
/>
/>
</div>
{dateWarning && (
<div className="recurrence-editor__warning">
{dateWarning}
{dateWarning}
</div>
)}
</div>
)}
{/* YEARLY: Month + Day selection */}
{value?.frequency === 'YEARLY' && (
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDate')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
{value?.frequency === "YEARLY" && (
<div className="recurrence-editor__day-select">
<span className="recurrence-editor__label">
{t("calendar.recurrence.repeatOnDate")}
</span>
<div className="recurrence-editor__interval">
<Select
label=""
value={getMonth()}
variant="classic"
options={monthOptions}
onChange={(e) => handleMonthChange(parseInt(String(e.target.value)) || 1)}
onChange={(e) =>
handleMonthChange(parseInt(String(e.target.value)) || 1)
}
/>
<Input
label=""
type="number"
variant="classic"
min={1}
max={31}
value={getMonthDay()}
@@ -292,78 +358,91 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
handleMonthDayChange(day);
}
}}
/>
/>
</div>
{dateWarning && (
<div className="recurrence-editor__warning">
{dateWarning}
{dateWarning}
</div>
)}
</div>
)}
{/* End conditions */}
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.endsLabel')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-never"
name="end-type"
checked={!value?.count && !value?.until}
onChange={() => handleChange({ count: undefined, until: undefined })}
/>
<label htmlFor="end-never">{t('calendar.recurrence.never')}</label>
</div>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-date"
name="end-type"
checked={!!value?.until}
onChange={() => handleChange({ until: { type: 'DATE', date: new Date() }, count: undefined })}
/>
<label htmlFor="end-date">{t('calendar.recurrence.on')}</label>
{value?.until && (
<Input
label=""
type="date"
value={value.until.date instanceof Date ? value.until.date.toISOString().split('T')[0] : ''}
onChange={(e) => handleChange({
until: {
type: 'DATE',
date: new Date(e.target.value),
}
})}
/>
)}
</div>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-count"
name="end-type"
checked={!!value?.count}
onChange={() => handleChange({ count: 10, until: undefined })}
/>
<label htmlFor="end-count">{t('calendar.recurrence.after')}</label>
{value?.count !== undefined && (
<>
<Input
label=""
type="number"
min={1}
value={value.count}
onChange={(e) => handleChange({ count: parseInt(e.target.value) || 1 })}
/>
<span>{t('calendar.recurrence.occurrences')}</span>
</>
)}
</div>
<div className="recurrence-editor__end">
<span className="recurrence-editor__section-label">
{t("calendar.recurrence.endsLabel")}
</span>
<div className="recurrence-editor__end-options">
<button
type="button"
className={`recurrence-editor__end-btn ${endType === "never" ? "recurrence-editor__end-btn--active" : ""}`}
onClick={() =>
handleChange({ count: undefined, until: undefined })
}
>
{t("calendar.recurrence.never")}
</button>
<button
type="button"
className={`recurrence-editor__end-btn ${endType === "count" ? "recurrence-editor__end-btn--active" : ""}`}
onClick={() =>
handleChange({ count: 10, until: undefined })
}
>
{t("calendar.recurrence.after")}...
</button>
<button
type="button"
className={`recurrence-editor__end-btn ${endType === "date" ? "recurrence-editor__end-btn--active" : ""}`}
onClick={() =>
handleChange({
until: { type: "DATE", date: new Date() },
count: undefined,
})
}
>
{t("calendar.recurrence.on")}...
</button>
</div>
{endType === "count" && (
<div className="recurrence-editor__end-input">
<Input
label=""
type="number"
variant="classic"
min={1}
value={value?.count ?? 10}
onChange={(e) =>
handleChange({ count: parseInt(e.target.value) || 1 })
}
/>
<span>{t("calendar.recurrence.occurrences")}</span>
</div>
)}
{endType === "date" && (
<div className="recurrence-editor__end-input">
<Input
label=""
type="date"
variant="classic"
value={
value?.until?.date instanceof Date
? value.until.date.toISOString().split("T")[0]
: ""
}
onChange={(e) =>
handleChange({
until: {
type: "DATE",
date: new Date(e.target.value),
},
})
}
/>
</div>
)}
</div>
</div>
)}

View File

@@ -11,145 +11,145 @@
// Event Modal Styles (for use with Cunningham Modal)
// ============================================================================
.event-modal {
&__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
// .event-modal {
// &__content {
// display: flex;
// flex-direction: column;
// gap: 1rem;
// }
// Date/Time row with two columns
&__datetime-row {
display: flex;
gap: 1rem;
// // Date/Time row with two columns
// &__datetime-row {
// display: flex;
// gap: 1rem;
> * {
flex: 1;
}
}
// > * {
// flex: 1;
// }
// }
// Features section (buttons + inputs)
&__features {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
// // Features section (buttons + inputs)
// &__features {
// display: flex;
// flex-wrap: wrap;
// gap: 0.5rem;
// margin-top: 0.5rem;
// }
&__attendees-input {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
margin-top: 0.75rem;
}
// &__attendees-input {
// padding: 0.75rem;
// background-color: #f8f9fa;
// border-radius: 4px;
// margin-top: 0.75rem;
// }
&__recurrence-editor {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
margin-top: 0.75rem;
}
// &__recurrence-editor {
// padding: 0.75rem;
// background-color: #f8f9fa;
// border-radius: 4px;
// margin-top: 0.75rem;
// }
// Checkbox for all-day
&__checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
// // Checkbox for all-day
// &__checkbox {
// display: flex;
// align-items: center;
// gap: 0.5rem;
// cursor: pointer;
// user-select: none;
input[type="checkbox"] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
margin: 0;
}
// input[type="checkbox"] {
// cursor: pointer;
// width: 1.25rem;
// height: 1.25rem;
// margin: 0;
// }
span {
font-size: 0.9375rem;
line-height: 1.5;
color: #212529;
}
}
// span {
// font-size: 0.9375rem;
// line-height: 1.5;
// color: #212529;
// }
// }
// Feature tag button
&__feature-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 1rem;
background-color: #fff;
color: #495057;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s ease-in-out;
// // Feature tag button
// &__feature-tag {
// display: inline-flex;
// align-items: center;
// gap: 0.375rem;
// padding: 0.375rem 0.75rem;
// border: 1px solid #dee2e6;
// border-radius: 1rem;
// background-color: #fff;
// color: #495057;
// font-size: 0.875rem;
// cursor: pointer;
// transition: all 0.15s ease-in-out;
&:hover {
background-color: #f8f9fa;
border-color: #adb5bd;
}
// &:hover {
// background-color: #f8f9fa;
// border-color: #adb5bd;
// }
&--active {
background-color: #e7f5ff;
border-color: #339af0;
color: #1971c2;
}
// &--active {
// background-color: #e7f5ff;
// border-color: #339af0;
// color: #1971c2;
// }
.material-icons {
font-size: 1.125rem;
}
}
// .material-icons {
// font-size: 1.125rem;
// }
// }
// Invitation section
&__invitation {
padding: 1rem;
background-color: #e7f5ff;
border: 1px solid #339af0;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
// // Invitation section
// &__invitation {
// padding: 1rem;
// background-color: #e7f5ff;
// border: 1px solid #339af0;
// border-radius: 0.5rem;
// display: flex;
// flex-direction: column;
// gap: 0.75rem;
// }
&__invitation-header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
// &__invitation-header {
// display: flex;
// flex-direction: column;
// gap: 0.25rem;
// }
&__invitation-label {
font-size: 0.875rem;
font-weight: 600;
color: #1971c2;
text-transform: uppercase;
letter-spacing: 0.05em;
}
// &__invitation-label {
// font-size: 0.875rem;
// font-weight: 600;
// color: #1971c2;
// text-transform: uppercase;
// letter-spacing: 0.05em;
// }
&__invitation-organizer {
font-size: 0.9375rem;
color: #495057;
}
// &__invitation-organizer {
// font-size: 0.9375rem;
// color: #495057;
// }
&__invitation-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
// &__invitation-actions {
// display: flex;
// gap: 0.5rem;
// flex-wrap: wrap;
// }
&__invitation-status {
padding: 0.5rem 0.75rem;
background-color: #fff;
border-radius: 0.375rem;
font-size: 0.875rem;
color: #495057;
// &__invitation-status {
// padding: 0.5rem 0.75rem;
// background-color: #fff;
// border-radius: 0.375rem;
// font-size: 0.875rem;
// color: #495057;
strong {
color: #1971c2;
}
}
}
// strong {
// color: #1971c2;
// }
// }
// }
// ============================================================================
// Delete Event Modal Styles

View File

@@ -0,0 +1,110 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import type { AttachmentMeta } from "../types";
import { SectionRow } from "./SectionRow";
interface AttachmentsSectionProps {
attachments: AttachmentMeta[];
onChange: (attachments: AttachmentMeta[]) => void;
isExpanded?: boolean;
onToggle?: () => void;
}
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export const AttachmentsSection = ({
attachments,
onChange,
isExpanded,
onToggle,
}: AttachmentsSectionProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const summary =
attachments.length > 0
? `${attachments.length} ${t("calendar.event.sections.attachment", { count: attachments.length })}`
: undefined;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const newAttachments: AttachmentMeta[] = Array.from(files).map((file) => ({
id: crypto.randomUUID(),
name: file.name,
size: file.size,
type: file.type,
}));
onChange([...attachments, ...newAttachments]);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleRemove = (id: string) => {
onChange(attachments.filter((a) => a.id !== id));
};
return (
<SectionRow
icon="attach_file"
label={t("calendar.event.sections.addAttachment")}
summary={summary}
isEmpty={attachments.length === 0}
isExpanded={isExpanded}
onToggle={onToggle}
>
<div className="attachments-section">
{attachments.map((attachment) => (
<div key={attachment.id} className="attachments-section__item">
<span className="material-icons" style={{ fontSize: 18 }}>
description
</span>
<div className="attachments-section__info">
<span className="attachments-section__name">
{attachment.name}
</span>
<span className="attachments-section__size">
{formatFileSize(attachment.size)}
</span>
</div>
<button
type="button"
className="attachments-section__remove"
onClick={() => handleRemove(attachment.id)}
aria-label={t("common.cancel")}
>
<span className="material-icons">close</span>
</button>
</div>
))}
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
style={{ display: "none" }}
/>
<Button
size="small"
color="neutral"
onClick={() => fileInputRef.current?.click()}
>
<span className="material-icons" style={{ fontSize: 16 }}>
add
</span>
{t("calendar.event.sections.addAttachment")}
</Button>
</div>
</SectionRow>
);
};

View File

@@ -0,0 +1,45 @@
import { useTranslation } from "react-i18next";
import type { IcsAttendee, IcsOrganizer } from "ts-ics";
import { AttendeesInput } from "../AttendeesInput";
import { SectionRow } from "./SectionRow";
interface AttendeesSectionProps {
attendees: IcsAttendee[];
onChange: (attendees: IcsAttendee[]) => void;
organizerEmail?: string;
organizer?: IcsOrganizer;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
export const AttendeesSection = ({
attendees,
onChange,
organizerEmail,
organizer,
alwaysOpen,
isExpanded,
onToggle,
}: AttendeesSectionProps) => {
const { t } = useTranslation();
return (
<SectionRow
icon="group"
label={t("calendar.event.sections.addAttendees")}
isEmpty={attendees.length === 0}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
iconAlign="flex-start"
>
<AttendeesInput
attendees={attendees}
onChange={onChange}
organizerEmail={organizerEmail}
organizer={organizer}
/>
</SectionRow>
);
};

View File

@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import { Input } from "@gouvfr-lasuite/cunningham-react";
import { SectionRow } from "./SectionRow";
interface DateTimeSectionProps {
startDateTime: string;
endDateTime: string;
isAllDay: boolean;
onStartChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onEndChange: (value: string) => void;
onAllDayChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const DateTimeSection = ({
startDateTime,
endDateTime,
isAllDay,
onStartChange,
onEndChange,
onAllDayChange,
}: DateTimeSectionProps) => {
const { t } = useTranslation();
return (
<div>
<SectionRow
icon="access_time"
label={t("calendar.event.sections.addDateTime")}
isEmpty={!startDateTime || !endDateTime}
alwaysOpen={true}
iconAlign="flex-start"
>
<div className="datetime-section">
<div className="datetime-section__inputs">
<Input
type={isAllDay ? "date" : "datetime-local"}
label={t("calendar.event.start")}
value={startDateTime}
onChange={onStartChange}
fullWidth
hideLabel
variant="classic"
/>
<span
className="material-icons datetime-section__arrow"
aria-hidden="true"
>
arrow_forward
</span>
<Input
type={isAllDay ? "date" : "datetime-local"}
label={t("calendar.event.end")}
value={endDateTime}
onChange={(e) => onEndChange(e.target.value)}
fullWidth
hideLabel
variant="classic"
/>
</div>
<label className="datetime-section__allday">
<input
type="checkbox"
checked={isAllDay}
onChange={onAllDayChange}
/>
<span>{t("calendar.event.allDay")}</span>
</label>
</div>
</SectionRow>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { useTranslation } from "react-i18next";
import { TextArea } from "@gouvfr-lasuite/cunningham-react";
import { SectionRow } from "./SectionRow";
interface DescriptionSectionProps {
description: string;
onChange: (value: string) => void;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
export const DescriptionSection = ({
description,
onChange,
alwaysOpen,
isExpanded,
onToggle,
}: DescriptionSectionProps) => {
const { t } = useTranslation();
return (
<SectionRow
icon="notes"
label={t("calendar.event.sections.addDescription")}
isEmpty={!description}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
iconAlign="flex-start"
>
<TextArea
label={t("calendar.event.description")}
placeholder={t("calendar.event.descriptionPlaceholder")}
value={description}
onChange={(e) => onChange(e.target.value)}
rows={3}
fullWidth
variant="classic"
hideLabel
/>
</SectionRow>
);
};

View File

@@ -0,0 +1,36 @@
.invitation-response {
padding: 0.75rem;
background: #f8f9fa;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.5rem;
&__header {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
&__label {
font-weight: 600;
font-size: 0.875rem;
color: #202124;
}
&__organizer {
font-size: 0.8125rem;
color: #5f6368;
}
&__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
&__status {
font-size: 0.8125rem;
color: #5f6368;
}
}

View File

@@ -0,0 +1,81 @@
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import type { IcsOrganizer } from "ts-ics";
interface InvitationResponseSectionProps {
organizer?: IcsOrganizer;
currentStatus: string;
isLoading: boolean;
onRespond: (status: "ACCEPTED" | "TENTATIVE" | "DECLINED") => void;
}
export const InvitationResponseSection = ({
organizer,
currentStatus,
isLoading,
onRespond,
}: InvitationResponseSectionProps) => {
const { t } = useTranslation();
return (
<div className="invitation-response">
<div className="invitation-response__header">
<span className="invitation-response__label">
{t("calendar.event.invitation", { defaultValue: "Invitation" })}
</span>
<span className="invitation-response__organizer">
{t("calendar.event.organizedBy", {
defaultValue: "Organisé par",
})}{" "}
{organizer?.name || organizer?.email}
</span>
</div>
<div className="invitation-response__actions">
<Button
size="small"
color={currentStatus === "ACCEPTED" ? "success" : "neutral"}
onClick={() => onRespond("ACCEPTED")}
disabled={isLoading || currentStatus === "ACCEPTED"}
>
{t("calendar.event.accept", { defaultValue: "Accepter" })}
</Button>
<Button
size="small"
color={currentStatus === "TENTATIVE" ? "warning" : "neutral"}
onClick={() => onRespond("TENTATIVE")}
disabled={isLoading || currentStatus === "TENTATIVE"}
>
? {t("calendar.event.maybe", { defaultValue: "Peut-être" })}
</Button>
<Button
size="small"
color={currentStatus === "DECLINED" ? "error" : "neutral"}
onClick={() => onRespond("DECLINED")}
disabled={isLoading || currentStatus === "DECLINED"}
>
{t("calendar.event.decline", { defaultValue: "Refuser" })}
</Button>
</div>
{currentStatus && (
<div className="invitation-response__status">
{t("calendar.event.yourResponse", {
defaultValue: "Votre réponse",
})}
:{" "}
<strong>
{currentStatus === "ACCEPTED" &&
t("calendar.event.accepted", { defaultValue: "Accepté" })}
{currentStatus === "TENTATIVE" &&
t("calendar.event.tentative", { defaultValue: "Peut-être" })}
{currentStatus === "DECLINED" &&
t("calendar.event.declined", { defaultValue: "Refusé" })}
{currentStatus === "NEEDS-ACTION" &&
t("calendar.event.needsAction", {
defaultValue: "En attente",
})}
</strong>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { useTranslation } from "react-i18next";
import { Input } from "@gouvfr-lasuite/cunningham-react";
import { SectionRow } from "./SectionRow";
interface LocationSectionProps {
location: string;
onChange: (value: string) => void;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
export const LocationSection = ({
location,
onChange,
alwaysOpen,
isExpanded,
onToggle,
}: LocationSectionProps) => {
const { t } = useTranslation();
return (
<SectionRow
icon="place"
label={t("calendar.event.sections.addLocation")}
isEmpty={!location}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
>
<Input
label={t("calendar.event.location")}
hideLabel
value={location}
placeholder={t("calendar.event.locationPlaceholder")}
onChange={(e) => onChange(e.target.value)}
variant="classic"
fullWidth
/>
</SectionRow>
);
};

View File

@@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import type { IcsRecurrenceRule } from "ts-ics";
import { RecurrenceEditor } from "../RecurrenceEditor";
import { SectionRow } from "./SectionRow";
interface RecurrenceSectionProps {
recurrence: IcsRecurrenceRule | undefined;
onChange: (value: IcsRecurrenceRule | undefined) => void;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
export const RecurrenceSection = ({
recurrence,
onChange,
alwaysOpen,
isExpanded,
onToggle,
}: RecurrenceSectionProps) => {
const { t } = useTranslation();
return (
<SectionRow
icon="repeat"
label={t("calendar.event.sections.addRecurrence")}
isEmpty={!recurrence}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
iconAlign="flex-start"
>
<RecurrenceEditor value={recurrence} onChange={onChange} />
</SectionRow>
);
};

View File

@@ -0,0 +1,118 @@
import { useTranslation } from "react-i18next";
import { Button, Select } from "@gouvfr-lasuite/cunningham-react";
import type { IcsAlarm } from "ts-ics";
import { SectionRow } from "./SectionRow";
interface RemindersSectionProps {
alarms: IcsAlarm[];
onChange: (alarms: IcsAlarm[]) => void;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
const REMINDER_PRESETS = [
{ minutes: 5, key: "5min" },
{ minutes: 15, key: "15min" },
{ minutes: 30, key: "30min" },
{ minutes: 60, key: "1hour" },
{ minutes: 1440, key: "1day" },
{ minutes: 10080, key: "1week" },
];
const alarmToMinutes = (alarm: IcsAlarm): number => {
const trigger = alarm.trigger;
if (trigger.type !== "relative") return 15;
const d = trigger.value;
return (
(d.weeks || 0) * 10080 +
(d.days || 0) * 1440 +
(d.hours || 0) * 60 +
(d.minutes || 0)
);
};
const minutesToAlarm = (minutes: number): IcsAlarm => ({
action: "DISPLAY",
trigger: {
type: "relative",
value: {
before: true,
...(minutes >= 10080 && minutes % 10080 === 0
? { weeks: minutes / 10080 }
: minutes >= 1440 && minutes % 1440 === 0
? { days: minutes / 1440 }
: minutes >= 60 && minutes % 60 === 0
? { hours: minutes / 60 }
: { minutes }),
},
},
});
export const RemindersSection = ({
alarms,
onChange,
alwaysOpen,
isExpanded,
onToggle,
}: RemindersSectionProps) => {
const { t } = useTranslation();
const handleAdd = () => {
onChange([...alarms, minutesToAlarm(15)]);
};
const handleRemove = (index: number) => {
onChange(alarms.filter((_, i) => i !== index));
};
const handleChange = (index: number, minutes: number) => {
const updated = [...alarms];
updated[index] = minutesToAlarm(minutes);
onChange(updated);
};
const presetOptions = REMINDER_PRESETS.map((p) => ({
value: String(p.minutes),
label: t(`calendar.event.reminders.${p.key}`),
}));
return (
<SectionRow
icon="notifications"
label={t("calendar.event.sections.addReminder")}
isEmpty={alarms.length === 0}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
>
<div className="reminders-section">
{alarms.map((alarm, index) => (
<div key={index} className="reminders-section__item">
<Select
label=""
value={String(alarmToMinutes(alarm))}
onChange={(e) => handleChange(index, Number(e.target.value))}
options={presetOptions}
fullWidth
/>
<button
type="button"
className="reminders-section__remove"
onClick={() => handleRemove(index)}
aria-label={t("common.cancel")}
>
<span className="material-icons">close</span>
</button>
</div>
))}
<Button size="small" color="neutral" onClick={handleAdd}>
<span className="material-icons" style={{ fontSize: 16 }}>
add
</span>
{t("calendar.event.sections.addReminder")}
</Button>
</div>
</SectionRow>
);
};

View File

@@ -0,0 +1,46 @@
.section-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
height: 32px;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 0.8125rem;
font-family: inherit;
line-height: 1;
transition:
background-color 0.15s,
color 0.15s;
border: 1px solid var(--c--contextuals--border--surface--primary);
background-color: var(--c--contextuals--background--surface--primary);
color: var(--c--contextuals--content--semantic--neutral--primary);
&--active {
border: 1px solid
var(--c--contextuals--border--semantic--contextual--primary);
background-color: var(
--c--contextuals--background--semantic--neutral--tertiary
);
color: var(--c--contextuals--content--semantic--neutral--tertiary);
}
&__icon {
font-size: 16px;
}
&__label {
white-space: nowrap;
}
}
.section-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 1rem;
padding-bottom: 0.5rem;
}

View File

@@ -0,0 +1,25 @@
interface SectionPillProps {
icon: string;
label: string;
isActive: boolean;
onClick: () => void;
}
export const SectionPill = ({
icon,
label,
isActive,
onClick,
}: SectionPillProps) => {
return (
<button
type="button"
className={`section-pill ${isActive ? "section-pill--active" : ""}`}
onClick={onClick}
aria-pressed={isActive}
>
<span className="material-icons section-pill__icon">{icon}</span>
<span className="section-pill__label">{label}</span>
</button>
);
};

View File

@@ -0,0 +1,34 @@
import { SectionPill } from "./SectionPill";
import type { EventFormSectionId } from "../types";
interface PillConfig {
id: EventFormSectionId;
icon: string;
label: string;
}
interface SectionPillsProps {
pills: PillConfig[];
isSectionExpanded: (id: EventFormSectionId) => boolean;
onToggle: (id: EventFormSectionId) => void;
}
export const SectionPills = ({
pills,
isSectionExpanded,
onToggle,
}: SectionPillsProps) => {
return (
<div className="section-pills">
{pills.map((pill) => (
<SectionPill
key={pill.id}
icon={pill.icon}
label={pill.label}
isActive={isSectionExpanded(pill.id)}
onClick={() => onToggle(pill.id)}
/>
))}
</div>
);
};

View File

@@ -0,0 +1,113 @@
.section-row {
border-bottom: 1px solid #f1f3f4;
&:last-child {
border-bottom: none;
}
&--always-open {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0;
border-bottom: none;
}
&--icon-start {
align-items: flex-start;
.section-row__icon {
padding-top: 10px;
}
}
&--icon-start &__header {
align-items: flex-start;
}
&--always-open &__body {
flex: 1;
min-width: 0;
}
&__header {
display: flex;
align-items: center;
padding: 0.625rem 0.5rem;
min-height: 44px;
gap: 0.25rem;
border-radius: 4px;
transition: background-color 0.15s;
&--clickable {
cursor: pointer;
&:hover {
background-color: #f8f9fa;
}
}
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
flex-shrink: 0;
.material-icons {
font-size: 20px;
color: #5f6368;
}
}
&__label {
flex: 1;
font-size: 0.875rem;
color: #202124;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
&--empty &__label {
color: #5f6368;
}
&__right-action {
flex-shrink: 0;
margin-left: auto;
}
&__chevron {
display: flex;
align-items: center;
flex-shrink: 0;
.material-icons {
font-size: 20px;
color: #5f6368;
}
}
&__content {
padding: 0 0.5rem 0.75rem calc(36px + 0.25rem);
animation: sectionSlideDown 0.2s ease-out;
}
&--expanded &__header--clickable:hover {
background-color: transparent;
}
}
@keyframes sectionSlideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,87 @@
import { type ReactNode } from "react";
interface SectionRowProps {
icon: string;
label: string;
summary?: string;
isEmpty?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
rightAction?: ReactNode;
children?: ReactNode;
alwaysOpen?: boolean;
iconAlign?: "center" | "flex-start";
}
export const SectionRow = ({
icon,
label,
summary,
isEmpty = false,
isExpanded = false,
onToggle,
rightAction,
children,
alwaysOpen = false,
iconAlign = "center",
}: SectionRowProps) => {
const iconAlignClass =
iconAlign === "flex-start" ? "section-row--icon-start" : "";
if (alwaysOpen) {
return (
<div className={`section-row section-row--always-open ${iconAlignClass}`}>
<div className="section-row__icon">
<span className="material-icons">{icon}</span>
</div>
<div className="section-row__body">{children}</div>
</div>
);
}
const isClickable = !!onToggle;
return (
<div
className={`section-row ${isExpanded ? "section-row--expanded" : ""} ${
isEmpty ? "section-row--empty" : ""
} ${iconAlignClass}`}
>
<div
className={`section-row__header ${
isClickable ? "section-row__header--clickable" : ""
}`}
onClick={onToggle}
onKeyDown={(e) => {
if (isClickable && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onToggle?.();
}
}}
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
aria-expanded={isClickable ? isExpanded : undefined}
>
<div className="section-row__icon">
<span className="material-icons">{icon}</span>
</div>
<div className="section-row__label">
{isEmpty ? label : summary || label}
</div>
{rightAction && (
<div className="section-row__right-action">{rightAction}</div>
)}
{isClickable && (
<div className="section-row__chevron">
<span className="material-icons">
{isExpanded ? "expand_less" : "expand_more"}
</span>
</div>
)}
</div>
{isExpanded && children && (
<div className="section-row__content">{children}</div>
)}
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { useTranslation } from "react-i18next";
import { Select } from "@gouvfr-lasuite/cunningham-react";
import type {
IcsClassType,
IcsEventStatusType,
IcsTimeTransparentType,
} from "ts-ics";
import { SectionRow } from "./SectionRow";
interface StatusSectionProps {
status: IcsEventStatusType;
visibility: IcsClassType;
availability: IcsTimeTransparentType;
onStatusChange: (value: IcsEventStatusType) => void;
onVisibilityChange: (value: IcsClassType) => void;
onAvailabilityChange: (value: IcsTimeTransparentType) => void;
isExpanded?: boolean;
onToggle?: () => void;
}
export const StatusSection = ({
status,
visibility,
availability,
onStatusChange,
onVisibilityChange,
onAvailabilityChange,
isExpanded,
onToggle,
}: StatusSectionProps) => {
const { t } = useTranslation();
const statusLabel = t(`calendar.event.status.${status.toLowerCase()}`);
const visibilityLabel = t(
`calendar.event.visibility.${visibility.toLowerCase()}`,
);
const availabilityLabel =
availability === "OPAQUE"
? t("calendar.event.availability.busy")
: t("calendar.event.availability.free");
const summary = `${statusLabel} · ${visibilityLabel} · ${availabilityLabel}`;
return (
<SectionRow
icon="info_outline"
label={t("calendar.event.sections.moreOptions")}
summary={summary}
isExpanded={isExpanded}
onToggle={onToggle}
>
<div className="status-section">
<Select
label={t("calendar.event.status.label")}
value={status}
onChange={(e) =>
onStatusChange(e.target.value as IcsEventStatusType)
}
options={[
{
value: "CONFIRMED",
label: t("calendar.event.status.confirmed"),
},
{
value: "TENTATIVE",
label: t("calendar.event.status.tentative"),
},
{
value: "CANCELLED",
label: t("calendar.event.status.cancelled"),
},
]}
fullWidth
/>
<Select
label={t("calendar.event.visibility.label")}
value={visibility}
onChange={(e) => onVisibilityChange(e.target.value as IcsClassType)}
options={[
{ value: "PUBLIC", label: t("calendar.event.visibility.public") },
{
value: "PRIVATE",
label: t("calendar.event.visibility.private"),
},
{
value: "CONFIDENTIAL",
label: t("calendar.event.visibility.confidential"),
},
]}
fullWidth
/>
<Select
label={t("calendar.event.availability.label")}
value={availability}
onChange={(e) =>
onAvailabilityChange(e.target.value as IcsTimeTransparentType)
}
options={[
{
value: "OPAQUE",
label: t("calendar.event.availability.busy"),
},
{
value: "TRANSPARENT",
label: t("calendar.event.availability.free"),
},
]}
fullWidth
/>
</div>
</SectionRow>
);
};

View File

@@ -0,0 +1,56 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@gouvfr-lasuite/cunningham-react";
import { SectionRow } from "./SectionRow";
interface VideoConferenceSectionProps {
url: string;
onChange: (url: string) => void;
alwaysOpen?: boolean;
isExpanded?: boolean;
onToggle?: () => void;
}
export const VideoConferenceSection = ({
url,
onChange,
alwaysOpen,
isExpanded,
onToggle,
}: VideoConferenceSectionProps) => {
const { t } = useTranslation();
const [isCreating, setIsCreating] = useState(false);
const handleCreateVisio = () => {
// Inert for now - will integrate with La Suite API in the future
setIsCreating(true);
setTimeout(() => {
setIsCreating(false);
}, 500);
};
const handleRemove = () => {
onChange("");
};
return (
<SectionRow
icon="videocam"
label={t("calendar.event.sections.addVideoConference")}
isEmpty={!url}
alwaysOpen={alwaysOpen}
isExpanded={isExpanded}
onToggle={onToggle}
>
<Button
size="small"
color="neutral"
variant="tertiary"
onClick={handleCreateVisio}
disabled={isCreating}
>
{t("calendar.event.sections.createVisio")}
</Button>
</SectionRow>
);
};

View File

@@ -0,0 +1,380 @@
import { useCallback, useEffect, useState } from "react";
import type {
IcsEvent,
IcsAttendee,
IcsAlarm,
IcsRecurrenceRule,
IcsClassType,
IcsEventStatusType,
IcsTimeTransparentType,
IcsOrganizer,
} from "ts-ics";
import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter";
import type { AttachmentMeta, EventFormSectionId } from "../types";
import {
formatDateTimeLocal,
formatDateLocal,
parseDateTimeLocal,
parseDateLocal,
} from "../utils/dateFormatters";
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
interface UseEventFormParams {
event: Partial<IcsEvent> | null;
calendarUrl: string;
adapter: EventCalendarAdapter;
organizer: IcsOrganizer | undefined;
mode: "create" | "edit";
}
export const useEventForm = ({
event,
calendarUrl,
adapter,
organizer,
mode,
}: UseEventFormParams) => {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [startDateTime, setStartDateTime] = useState("");
const [endDateTime, setEndDateTime] = useState("");
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
const [isAllDay, setIsAllDay] = useState(false);
const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
const [recurrence, setRecurrence] = useState<IcsRecurrenceRule | undefined>(
undefined,
);
const [alarms, setAlarms] = useState<IcsAlarm[]>([]);
const [videoConferenceUrl, setVideoConferenceUrl] = useState("");
const [attachments, setAttachments] = useState<AttachmentMeta[]>([]);
const [status, setStatus] = useState<IcsEventStatusType>("CONFIRMED");
const [visibility, setVisibility] = useState<IcsClassType>("PUBLIC");
const [availability, setAvailability] =
useState<IcsTimeTransparentType>("OPAQUE");
const [expandedSections, setExpandedSections] = useState<
Set<EventFormSectionId>
>(new Set());
// Reset form when event changes
useEffect(() => {
setTitle(event?.summary || "");
setDescription(event?.description || "");
setLocation(event?.location || "");
setSelectedCalendarUrl(calendarUrl);
setStatus(event?.status || "CONFIRMED");
setVisibility(event?.class || "PUBLIC");
setAvailability(event?.timeTransparent || "OPAQUE");
setVideoConferenceUrl(event?.url || "");
setAlarms(event?.alarms || []);
setAttachments([]);
// Initialize attendees
if (event?.attendees && event.attendees.length > 0) {
setAttendees(event.attendees);
} else {
setAttendees([]);
}
// Initialize recurrence
if (event?.recurrenceRule) {
setRecurrence(event.recurrenceRule);
} else {
setRecurrence(undefined);
}
// Initialize all-day
const eventIsAllDay = event?.start?.type === "DATE";
setIsAllDay(eventIsAllDay);
const initialExpanded = new Set<EventFormSectionId>();
if (event?.location) initialExpanded.add("location");
if (event?.description) initialExpanded.add("description");
if (event?.recurrenceRule) initialExpanded.add("recurrence");
if (event?.url) initialExpanded.add("videoConference");
if (mode === "create") {
initialExpanded.add("attendees");
} else if (
event?.attendees?.some(
(att) =>
att.email.toLowerCase() !== organizer?.email?.toLowerCase(),
)
) {
initialExpanded.add("attendees");
}
setExpandedSections(initialExpanded);
// Parse start/end dates
if (event?.start?.date) {
const startDate =
event.start.date instanceof Date
? event.start.date
: new Date(event.start.date);
const isFakeUtc = Boolean(event.start.local?.timezone);
if (eventIsAllDay) {
setStartDateTime(formatDateLocal(startDate, isFakeUtc));
} else {
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc));
}
} else {
if (eventIsAllDay) {
setStartDateTime(formatDateLocal(new Date()));
} else {
setStartDateTime(formatDateTimeLocal(new Date()));
}
}
if (event?.end?.date) {
const endDate =
event.end.date instanceof Date
? event.end.date
: new Date(event.end.date);
const isFakeUtc = Boolean(event.end.local?.timezone);
if (eventIsAllDay) {
const displayEndDate = new Date(endDate);
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc));
} else {
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc));
}
} else {
if (eventIsAllDay) {
setEndDateTime(formatDateLocal(new Date()));
} else {
const defaultEnd = new Date();
defaultEnd.setHours(defaultEnd.getHours() + 1);
setEndDateTime(formatDateTimeLocal(defaultEnd));
}
}
}, [event, calendarUrl, mode, organizer?.email]);
const toggleSection = useCallback((sectionId: EventFormSectionId) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
next.add(sectionId);
}
return next;
});
}, []);
const isSectionExpanded = useCallback(
(sectionId: EventFormSectionId) => expandedSections.has(sectionId),
[expandedSections],
);
const handleStartDateChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newStartValue = e.target.value;
if (isAllDay) {
const oldStart = parseDateLocal(startDateTime);
const oldEnd = parseDateLocal(endDateTime);
const duration = oldEnd.getTime() - oldStart.getTime();
const newStart = parseDateLocal(newStartValue);
const newEnd = new Date(newStart.getTime() + duration);
setStartDateTime(newStartValue);
setEndDateTime(formatDateLocal(newEnd));
} else {
const oldStart = parseDateTimeLocal(startDateTime);
const oldEnd = parseDateTimeLocal(endDateTime);
const duration = oldEnd.getTime() - oldStart.getTime();
const newStart = parseDateTimeLocal(newStartValue);
const newEnd = new Date(newStart.getTime() + duration);
setStartDateTime(newStartValue);
setEndDateTime(formatDateTimeLocal(newEnd));
}
},
[isAllDay, startDateTime, endDateTime],
);
const handleAllDayChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newIsAllDay = e.target.checked;
setIsAllDay(newIsAllDay);
if (newIsAllDay) {
const start = parseDateTimeLocal(startDateTime);
const end = parseDateTimeLocal(endDateTime);
setStartDateTime(formatDateLocal(start));
setEndDateTime(formatDateLocal(end));
} else {
const start = parseDateLocal(startDateTime);
const end = parseDateLocal(endDateTime);
setStartDateTime(formatDateTimeLocal(start));
setEndDateTime(formatDateTimeLocal(end));
}
},
[startDateTime, endDateTime],
);
const toIcsEvent = useCallback((): IcsEvent => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { duration: _duration, ...eventWithoutDuration } = event ?? {};
if (isAllDay) {
const startDate = parseDateLocal(startDateTime);
const endDate = parseDateLocal(endDateTime);
const utcStart = new Date(
Date.UTC(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
),
);
const utcEnd = new Date(
Date.UTC(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate() + 1,
),
);
return {
...eventWithoutDuration,
uid: event?.uid || crypto.randomUUID(),
summary: title,
description: description || undefined,
location: location || undefined,
stamp: event?.stamp || { date: new Date() },
start: { date: utcStart, type: "DATE" },
end: { date: utcEnd, type: "DATE" },
organizer,
attendees: attendees.length > 0 ? attendees : undefined,
recurrenceRule: recurrence,
alarms: alarms.length > 0 ? alarms : undefined,
url: videoConferenceUrl || undefined,
status,
class: visibility,
timeTransparent: availability,
} as IcsEvent;
}
const startDate = parseDateTimeLocal(startDateTime);
const endDate = parseDateTimeLocal(endDateTime);
const fakeUtcStart = new Date(
Date.UTC(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate(),
startDate.getHours(),
startDate.getMinutes(),
startDate.getSeconds(),
),
);
const fakeUtcEnd = new Date(
Date.UTC(
endDate.getFullYear(),
endDate.getMonth(),
endDate.getDate(),
endDate.getHours(),
endDate.getMinutes(),
endDate.getSeconds(),
),
);
return {
...eventWithoutDuration,
uid: event?.uid || crypto.randomUUID(),
summary: title,
description: description || undefined,
location: location || undefined,
stamp: event?.stamp || { date: new Date() },
start: {
date: fakeUtcStart,
type: "DATE-TIME",
local: {
date: fakeUtcStart,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(startDate, BROWSER_TIMEZONE),
},
},
end: {
date: fakeUtcEnd,
type: "DATE-TIME",
local: {
date: fakeUtcEnd,
timezone: BROWSER_TIMEZONE,
tzoffset: adapter.getTimezoneOffset(endDate, BROWSER_TIMEZONE),
},
},
organizer,
attendees: attendees.length > 0 ? attendees : undefined,
recurrenceRule: recurrence,
alarms: alarms.length > 0 ? alarms : undefined,
url: videoConferenceUrl || undefined,
status,
class: visibility,
timeTransparent: availability,
} as IcsEvent;
}, [
event,
isAllDay,
startDateTime,
endDateTime,
title,
description,
location,
organizer,
attendees,
recurrence,
alarms,
videoConferenceUrl,
status,
visibility,
availability,
adapter,
]);
return {
// Basic fields
title,
setTitle,
description,
setDescription,
location,
setLocation,
startDateTime,
endDateTime,
setEndDateTime,
selectedCalendarUrl,
setSelectedCalendarUrl,
isAllDay,
// Complex fields
attendees,
setAttendees,
recurrence,
setRecurrence,
alarms,
setAlarms,
videoConferenceUrl,
setVideoConferenceUrl,
attachments,
setAttachments,
status,
setStatus,
visibility,
setVisibility,
availability,
setAvailability,
// Section management
toggleSection,
isSectionExpanded,
// Handlers
handleStartDateChange,
handleAllDayChange,
// Conversion
toIcsEvent,
};
};

View File

@@ -2,7 +2,15 @@
* Type definitions for Scheduler components.
*/
import type { IcsEvent, IcsRecurrenceRule } from "ts-ics";
import type {
IcsEvent,
IcsRecurrenceRule,
IcsAlarm,
IcsAttendee,
IcsClassType,
IcsEventStatusType,
IcsTimeTransparentType,
} from "ts-ics";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { EventCalendarAdapter } from "../../services/dav/EventCalendarAdapter";
@@ -63,6 +71,26 @@ export interface EventModalState {
etag?: string;
}
/**
* Sections toggled via pills in the event modal.
*/
export type EventFormSectionId =
| "location"
| "description"
| "recurrence"
| "attendees"
| "videoConference";
/**
* Attachment metadata (UI only, no actual file upload).
*/
export interface AttachmentMeta {
id: string;
name: string;
size: number;
type: string;
}
/**
* Form state for the event modal.
*/
@@ -74,9 +102,15 @@ export interface EventFormState {
endDateTime: string;
selectedCalendarUrl: string;
isAllDay: boolean;
attendees: IcsAttendee[];
recurrence: IcsRecurrenceRule | undefined;
showRecurrence: boolean;
showAttendees: boolean;
alarms: IcsAlarm[];
videoConferenceUrl: string;
attachments: AttachmentMeta[];
status: IcsEventStatusType;
visibility: IcsClassType;
availability: IcsTimeTransparentType;
expandedSections: Set<EventFormSectionId>;
}
/**

View File

@@ -131,7 +131,48 @@
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"create": "Create"
"create": "Create",
"sections": {
"addRecurrence": "Add recurrence",
"addLocation": "Add location",
"addVideoConference": "Visio",
"createVisio": "Add video conference",
"videoLink": "Video conference link",
"addAttendees": "Add participants",
"addDescription": "Add description",
"addReminder": "Add reminder",
"addAttachment": "Add attachment",
"moreOptions": "More options",
"reminder_one": "reminder",
"reminder_other": "reminders",
"attachment_one": "attachment",
"attachment_other": "attachments"
},
"reminders": {
"5min": "5 minutes before",
"15min": "15 minutes before",
"30min": "30 minutes before",
"1hour": "1 hour before",
"1day": "1 day before",
"1week": "1 week before"
},
"status": {
"label": "Status",
"confirmed": "Confirmed",
"tentative": "Tentative",
"cancelled": "Cancelled"
},
"visibility": {
"label": "Visibility",
"public": "Public",
"private": "Private",
"confidential": "Confidential"
},
"availability": {
"label": "Availability",
"busy": "Busy",
"free": "Free"
}
},
"list": {
"myCalendars": "My calendars",
@@ -170,7 +211,7 @@
},
"recurrence": {
"label": "Repeat",
"none": "No",
"none": "Does not repeat",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
@@ -700,7 +741,48 @@
"delete": "Supprimer",
"cancel": "Annuler",
"save": "Enregistrer",
"create": "Créer"
"create": "Créer",
"sections": {
"addRecurrence": "Ajouter une récurrence",
"addLocation": "Ajouter un lieu",
"addVideoConference": "Visio",
"createVisio": "Ajouter une visioconférence",
"videoLink": "Lien de visioconférence",
"addAttendees": "Ajouter des participants",
"addDescription": "Ajouter une description",
"addReminder": "Ajouter un rappel",
"addAttachment": "Ajouter une pièce jointe",
"moreOptions": "Plus d'options",
"reminder_one": "rappel",
"reminder_other": "rappels",
"attachment_one": "pièce jointe",
"attachment_other": "pièces jointes"
},
"reminders": {
"5min": "5 minutes avant",
"15min": "15 minutes avant",
"30min": "30 minutes avant",
"1hour": "1 heure avant",
"1day": "1 jour avant",
"1week": "1 semaine avant"
},
"status": {
"label": "Statut",
"confirmed": "Confirmé",
"tentative": "Provisoire",
"cancelled": "Annulé"
},
"visibility": {
"label": "Visibilité",
"public": "Public",
"private": "Privé",
"confidential": "Confidentiel"
},
"availability": {
"label": "Disponibilité",
"busy": "Occupé",
"free": "Libre"
}
},
"list": {
"myCalendars": "Mes agendas",
@@ -739,7 +821,7 @@
},
"recurrence": {
"label": "Répéter",
"none": "Non",
"none": "Ne se répète pas",
"daily": "Tous les jours",
"weekly": "Toutes les semaines",
"monthly": "Tous les mois",
@@ -1016,7 +1098,48 @@
"delete": "Verwijderen",
"cancel": "Annuleren",
"save": "Opslaan",
"create": "Aanmaken"
"create": "Aanmaken",
"sections": {
"addRecurrence": "Herhaling toevoegen",
"addLocation": "Locatie toevoegen",
"addVideoConference": "Visio",
"createVisio": "Add video conference",
"videoLink": "Videoconferentie link",
"addAttendees": "Deelnemers toevoegen",
"addDescription": "Beschrijving toevoegen",
"addReminder": "Herinnering toevoegen",
"addAttachment": "Bijlage toevoegen",
"moreOptions": "Meer opties",
"reminder_one": "herinnering",
"reminder_other": "herinneringen",
"attachment_one": "bijlage",
"attachment_other": "bijlagen"
},
"reminders": {
"5min": "5 minuten voor",
"15min": "15 minuten voor",
"30min": "30 minuten voor",
"1hour": "1 uur voor",
"1day": "1 dag voor",
"1week": "1 week voor"
},
"status": {
"label": "Status",
"confirmed": "Bevestigd",
"tentative": "Voorlopig",
"cancelled": "Geannuleerd"
},
"visibility": {
"label": "Zichtbaarheid",
"public": "Openbaar",
"private": "Privé",
"confidential": "Vertrouwelijk"
},
"availability": {
"label": "Beschikbaarheid",
"busy": "Bezet",
"free": "Vrij"
}
},
"list": {
"myCalendars": "Mijn agenda's",
@@ -1055,7 +1178,7 @@
},
"recurrence": {
"label": "Herhalen",
"none": "Nee",
"none": "Niet herhalen",
"daily": "Dagelijks",
"weekly": "Wekelijks",
"monthly": "Maandelijks",

View File

@@ -18,6 +18,9 @@
@use "./../features/calendar/components/scheduler/Scheduler.scss";
@use "./../features/calendar/components/scheduler/scheduler-theme.scss";
@use "./../features/calendar/components/scheduler/SchedulerToolbar.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionRow.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection.scss";
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill.scss";
@use "./../pages/index.scss" as *;
@use "./../pages/calendar.scss" as *;
html,
@@ -77,5 +80,26 @@ body {
.c__datagrid--empty,
.c__datagrid thead {
transition: background-color 0.3s ease, box-shadow 0.3s ease;
transition:
background-color 0.3s ease,
box-shadow 0.3s ease;
}
.c__modal__title {
text-align: left;
padding: 0;
font-size: 1.125rem;
margin-bottom: 1rem;
}
.c__modal__close button {
padding: 0;
font-size: 88px;
top: -5px !important;
width: 28px !important;
height: 28px;
}
.c__modal__scroller {
padding-top: 1rem;
}

View File

@@ -489,10 +489,10 @@
dependencies:
tslib "^2.8.0"
"@gouvfr-lasuite/cunningham-react@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.1.0.tgz#3a1347a7e05f9add18462d5b1ded3e26635bdd37"
integrity sha512-r6QKi4qcPEG4Vxe7rKt89d7I2KYD9gvOKxkzlDAVBjo9DYnQ4ZM9T5tG/W0/oBbcURTFqxINfFhaF8CyviiOjw==
"@gouvfr-lasuite/cunningham-react@4.2.0":
version "4.2.0"
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/cunningham-react/-/cunningham-react-4.2.0.tgz#19bc8a658cc069d7b9397cba735b194e578b9b1f"
integrity sha512-97eTA+v/ySsl6d5NCn6D2mVoNqrJnHWG+PWCY1L/Fz8MEx18sPwqUsqdaIuSj0C+iug0SCJ6Ufx57MURHCf0oA==
dependencies:
"@fontsource-variable/roboto-flex" "5.2.5"
"@fontsource/material-icons-outlined" "5.2.5"
@@ -529,16 +529,16 @@
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
"@gouvfr-lasuite/ui-kit@0.18.7":
version "0.18.7"
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.18.7.tgz#1c3912f2b366ece09616937c2a5901300fe25a35"
integrity sha512-1Bw1FSol9+mxztiaPEyfwVf9/1Vns7OJVEzvwLTlChbo6cS8FjuP/ZYG57RMvschsN4kfMzC03YmxcVdS0L9Nw==
"@gouvfr-lasuite/ui-kit@0.19.6":
version "0.19.6"
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.19.6.tgz#bc9e32f5b575147bd92e19172aaac794522c4772"
integrity sha512-PY0Jh2Wno/+dBVy89OdAook3h9GfrJXXVyOeNLA5mJ9NlNQBq0XKxSB87iqzDn/CBe1aUSLJY47QWzRWQQ0wXQ==
dependencies:
"@dnd-kit/core" "6.3.1"
"@dnd-kit/modifiers" "9.0.0"
"@dnd-kit/sortable" "10.0.0"
"@fontsource/material-icons" "5.2.5"
"@gouvfr-lasuite/cunningham-react" "4.1.0"
"@gouvfr-lasuite/cunningham-react" "4.2.0"
"@gouvfr-lasuite/cunningham-tokens" "3.1.0"
"@gouvfr-lasuite/integration" "1.0.2"
"@types/node" "22.10.7"