✨(ui) improve event display and add some niceties to the modal
This commit is contained in:
@@ -229,6 +229,7 @@ export const EventModal = ({
|
||||
value: cal.url,
|
||||
label: cal.displayName || cal.url,
|
||||
}))}
|
||||
clearable={false}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const DescriptionSection = ({
|
||||
placeholder={t("calendar.event.descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={3}
|
||||
rows={5}
|
||||
fullWidth
|
||||
variant="classic"
|
||||
hideLabel
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { Button, Input } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { SectionRow } from "./SectionRow";
|
||||
import { extractUrl } from "../utils/eventDisplayRules";
|
||||
|
||||
interface LocationSectionProps {
|
||||
location: string;
|
||||
@@ -18,6 +20,7 @@ export const LocationSection = ({
|
||||
onToggle,
|
||||
}: LocationSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const detectedUrl = useMemo(() => extractUrl(location), [location]);
|
||||
|
||||
return (
|
||||
<SectionRow
|
||||
@@ -28,15 +31,30 @@ export const LocationSection = ({
|
||||
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
|
||||
/>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<Input
|
||||
label={t("calendar.event.location")}
|
||||
hideLabel
|
||||
value={location}
|
||||
placeholder={t("calendar.event.locationPlaceholder")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
variant="classic"
|
||||
fullWidth
|
||||
/>
|
||||
{detectedUrl && (
|
||||
<Button
|
||||
size="small"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
icon={<span className="material-icons">open_in_new</span>}
|
||||
href={detectedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("calendar.event.openLocation", "Open")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SectionRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
IcsEvent,
|
||||
IcsAttendee,
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "ts-ics";
|
||||
import type { EventCalendarAdapter } from "../../../services/dav/EventCalendarAdapter";
|
||||
import type { AttachmentMeta, EventFormSectionId } from "../types";
|
||||
import { cleanEventForDisplay } from "../utils/eventDisplayRules";
|
||||
import {
|
||||
formatDateTimeLocal,
|
||||
formatDateLocal,
|
||||
@@ -40,6 +41,8 @@ export const useEventForm = ({
|
||||
const [location, setLocation] = useState("");
|
||||
const [startDateTime, setStartDateTime] = useState("");
|
||||
const [endDateTime, setEndDateTime] = useState("");
|
||||
// Stash full datetime strings so toggling all-day off restores the original times
|
||||
const savedDateTimesRef = useRef({ start: "", end: "" });
|
||||
const [selectedCalendarUrl, setSelectedCalendarUrl] = useState(calendarUrl);
|
||||
const [isAllDay, setIsAllDay] = useState(false);
|
||||
const [attendees, setAttendees] = useState<IcsAttendee[]>([]);
|
||||
@@ -60,13 +63,20 @@ export const useEventForm = ({
|
||||
// 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 || "");
|
||||
|
||||
// Clean and deduplicate description / location / url for display
|
||||
const cleaned = cleanEventForDisplay({
|
||||
description: event?.description || "",
|
||||
location: event?.location || "",
|
||||
url: event?.url || "",
|
||||
});
|
||||
setDescription(cleaned.description);
|
||||
setLocation(cleaned.location);
|
||||
setVideoConferenceUrl(cleaned.url);
|
||||
setAlarms(event?.alarms || []);
|
||||
setAttachments([]);
|
||||
|
||||
@@ -89,10 +99,10 @@ export const useEventForm = ({
|
||||
setIsAllDay(eventIsAllDay);
|
||||
|
||||
const initialExpanded = new Set<EventFormSectionId>();
|
||||
if (event?.location) initialExpanded.add("location");
|
||||
if (event?.description) initialExpanded.add("description");
|
||||
if (cleaned.location) initialExpanded.add("location");
|
||||
if (cleaned.description) initialExpanded.add("description");
|
||||
if (event?.recurrenceRule) initialExpanded.add("recurrence");
|
||||
if (event?.url) initialExpanded.add("videoConference");
|
||||
if (cleaned.url) initialExpanded.add("videoConference");
|
||||
|
||||
if (mode === "create") {
|
||||
initialExpanded.add("attendees");
|
||||
@@ -107,7 +117,10 @@ export const useEventForm = ({
|
||||
|
||||
setExpandedSections(initialExpanded);
|
||||
|
||||
// Parse start/end dates
|
||||
// Parse start/end dates and stash full datetime strings for all-day toggle
|
||||
let initStart = "";
|
||||
let initEnd = "";
|
||||
|
||||
if (event?.start?.date) {
|
||||
const startDate =
|
||||
event.start.date instanceof Date
|
||||
@@ -116,15 +129,15 @@ export const useEventForm = ({
|
||||
const isFakeUtc = Boolean(event.start.local?.timezone);
|
||||
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(startDate, isFakeUtc));
|
||||
initStart = formatDateLocal(startDate, isFakeUtc);
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(startDate, isFakeUtc));
|
||||
initStart = formatDateTimeLocal(startDate, isFakeUtc);
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
setStartDateTime(formatDateLocal(new Date()));
|
||||
initStart = formatDateLocal(new Date());
|
||||
} else {
|
||||
setStartDateTime(formatDateTimeLocal(new Date()));
|
||||
initStart = formatDateTimeLocal(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,19 +151,28 @@ export const useEventForm = ({
|
||||
if (eventIsAllDay) {
|
||||
const displayEndDate = new Date(endDate);
|
||||
displayEndDate.setUTCDate(displayEndDate.getUTCDate() - 1);
|
||||
setEndDateTime(formatDateLocal(displayEndDate, isFakeUtc));
|
||||
initEnd = formatDateLocal(displayEndDate, isFakeUtc);
|
||||
} else {
|
||||
setEndDateTime(formatDateTimeLocal(endDate, isFakeUtc));
|
||||
initEnd = formatDateTimeLocal(endDate, isFakeUtc);
|
||||
}
|
||||
} else {
|
||||
if (eventIsAllDay) {
|
||||
setEndDateTime(formatDateLocal(new Date()));
|
||||
initEnd = formatDateLocal(new Date());
|
||||
} else {
|
||||
const defaultEnd = new Date();
|
||||
defaultEnd.setHours(defaultEnd.getHours() + 1);
|
||||
setEndDateTime(formatDateTimeLocal(defaultEnd));
|
||||
initEnd = formatDateTimeLocal(defaultEnd);
|
||||
}
|
||||
}
|
||||
|
||||
setStartDateTime(initStart);
|
||||
setEndDateTime(initEnd);
|
||||
|
||||
// For timed events, stash the full datetime so toggling all-day off
|
||||
// can restore the original times instead of defaulting to midnight.
|
||||
if (!eventIsAllDay) {
|
||||
savedDateTimesRef.current = { start: initStart, end: initEnd };
|
||||
}
|
||||
}, [event, calendarUrl, mode, organizer?.email]);
|
||||
|
||||
const toggleSection = useCallback((sectionId: EventFormSectionId) => {
|
||||
@@ -201,15 +223,23 @@ export const useEventForm = ({
|
||||
setIsAllDay(newIsAllDay);
|
||||
|
||||
if (newIsAllDay) {
|
||||
// Stash current datetimes before stripping the time portion
|
||||
savedDateTimesRef.current = { start: startDateTime, end: endDateTime };
|
||||
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));
|
||||
// Restore stashed datetimes to preserve the original time of day
|
||||
if (savedDateTimesRef.current.start) {
|
||||
setStartDateTime(savedDateTimesRef.current.start);
|
||||
setEndDateTime(savedDateTimesRef.current.end);
|
||||
} else {
|
||||
const start = parseDateLocal(startDateTime);
|
||||
const end = parseDateLocal(endDateTime);
|
||||
setStartDateTime(formatDateTimeLocal(start));
|
||||
setEndDateTime(formatDateTimeLocal(end));
|
||||
}
|
||||
}
|
||||
},
|
||||
[startDateTime, endDateTime],
|
||||
@@ -219,6 +249,7 @@ export const useEventForm = ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { duration: _duration, ...eventWithoutDuration } = event ?? {};
|
||||
|
||||
|
||||
if (isAllDay) {
|
||||
const startDate = parseDateLocal(startDateTime);
|
||||
const endDate = parseDateLocal(endDateTime);
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { CalDavService } from "../../../services/dav/CalDavService";
|
||||
import type { CalDavCalendar } from "../../../services/dav/types/caldav-service";
|
||||
import type { EventCalendarEvent, EventCalendarFetchInfo } from "../../../services/dav/types/event-calendar";
|
||||
import type { CalendarApi } from "../types";
|
||||
import { createEventContent, type EventContentInfo } from "../utils/eventContent";
|
||||
|
||||
type ECEvent = EventCalendarEvent;
|
||||
|
||||
@@ -132,6 +133,9 @@ export const useSchedulerInit = ({
|
||||
handlersRef.current.setCurrentDate(info);
|
||||
},
|
||||
|
||||
// Custom event content — adapts layout to event duration
|
||||
eventContent: (info: EventContentInfo) => createEventContent(info),
|
||||
|
||||
// Event display
|
||||
dayMaxEvents: true,
|
||||
nowIndicator: true,
|
||||
|
||||
@@ -122,21 +122,95 @@
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 4. Events styling - Rounded corners, no shadow, bold title
|
||||
// 4. Events styling - Custom duration-aware content (see utils/eventContent.ts)
|
||||
// -----------------------------------------------------------------------------
|
||||
.ec-event {
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
padding: 8px 10px;
|
||||
overflow: hidden;
|
||||
|
||||
padding-left: 4px !important;
|
||||
|
||||
// Compact (≤ 30 min): vertically center the single line
|
||||
&:has(.ec-custom--compact) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Medium / large: small top padding
|
||||
&:has(.ec-custom--medium),
|
||||
&:has(.ec-custom--large) {
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ec-event-title {
|
||||
.ec-event-body {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// --- Shared custom-content styles ---
|
||||
.ec-custom__title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ec-event-time {
|
||||
.ec-custom__details,
|
||||
.ec-custom__time {
|
||||
font-weight: 400;
|
||||
opacity: 0.95;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
// --- Compact: ≤ 30 min — single line ---
|
||||
.ec-custom--compact {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
// --- Medium: 45–60 min — two truncated lines ---
|
||||
.ec-custom--medium {
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.30;
|
||||
|
||||
.ec-custom__title,
|
||||
.ec-custom__details {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Large: 75 min+ — title wraps up to 2 lines, then details ---
|
||||
.ec-custom--large {
|
||||
overflow: hidden;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
|
||||
.ec-custom__title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ec-custom__details {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- All-day events ---
|
||||
.ec-custom--allday {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Custom event content renderer for the scheduler.
|
||||
*
|
||||
* Adapts event display based on duration:
|
||||
* - 15–30 min → single line: "Title, HH:MM"
|
||||
* - 45–60 min → two lines: title / time + location
|
||||
* - 75 min+ → title (up to 2 lines) / time + location
|
||||
*/
|
||||
|
||||
import type { CalDavExtendedProps } from '../../../services/dav/EventCalendarAdapter';
|
||||
import type {
|
||||
EventCalendarEvent,
|
||||
EventCalendarContent,
|
||||
} from '../../../services/dav/types/event-calendar';
|
||||
import { cleanEventForDisplay } from './eventDisplayRules';
|
||||
|
||||
export interface EventContentInfo {
|
||||
event: EventCalendarEvent;
|
||||
timeText: string;
|
||||
view: unknown;
|
||||
}
|
||||
|
||||
const esc = (s: string): string =>
|
||||
s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const fmtTime = (d: Date): string =>
|
||||
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
export const createEventContent = (
|
||||
info: EventContentInfo,
|
||||
): EventCalendarContent => {
|
||||
const { event } = info;
|
||||
const title = typeof event.title === 'string' ? event.title : '';
|
||||
|
||||
// All-day events: just show the title
|
||||
if (event.allDay) {
|
||||
return {
|
||||
html: `<div class="ec-custom ec-custom--allday"><span class="ec-custom__title">${esc(title)}</span></div>`,
|
||||
};
|
||||
}
|
||||
|
||||
const start =
|
||||
event.start instanceof Date ? event.start : new Date(event.start);
|
||||
const end = event.end
|
||||
? event.end instanceof Date
|
||||
? event.end
|
||||
: new Date(event.end)
|
||||
: start;
|
||||
const durationMin = Math.round(
|
||||
(end.getTime() - start.getTime()) / 60_000,
|
||||
);
|
||||
|
||||
const extProps = event.extendedProps as CalDavExtendedProps | undefined;
|
||||
const cleaned = cleanEventForDisplay({
|
||||
description: extProps?.description ?? '',
|
||||
location: extProps?.location ?? '',
|
||||
url: extProps?.url ?? '',
|
||||
});
|
||||
const location = cleaned.location;
|
||||
const time = fmtTime(start);
|
||||
|
||||
// ≤ 30 min — single line: "Title, HH:MM"
|
||||
if (durationMin <= 30) {
|
||||
return {
|
||||
html:
|
||||
`<div class="ec-custom ec-custom--compact">` +
|
||||
`<span class="ec-custom__title">${esc(title)}</span>` +
|
||||
`<span class="ec-custom__sep">, </span>` +
|
||||
`<span class="ec-custom__time">${esc(time)}</span>` +
|
||||
`</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
const details = location ? `${time}, ${location}` : time;
|
||||
|
||||
// 45–60 min — two single lines
|
||||
if (durationMin <= 60) {
|
||||
return {
|
||||
html:
|
||||
`<div class="ec-custom ec-custom--medium">` +
|
||||
`<div class="ec-custom__title">${esc(title)}</div>` +
|
||||
`<div class="ec-custom__details">${esc(details)}</div>` +
|
||||
`</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
// 75 min+ — title wraps up to 2 lines, then details
|
||||
return {
|
||||
html:
|
||||
`<div class="ec-custom ec-custom--large">` +
|
||||
`<div class="ec-custom__title">${esc(title)}</div>` +
|
||||
`<div class="ec-custom__details">${esc(details)}</div>` +
|
||||
`</div>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Event display rules — clean and deduplicate fields before rendering.
|
||||
*
|
||||
* Single entry point: cleanEventForDisplay()
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prefixes stripped from the Location field (case-insensitive). */
|
||||
const LOCATION_PREFIXES_TO_STRIP = [
|
||||
'Pour participer à la visioconférence, cliquez sur ce lien : ',
|
||||
];
|
||||
|
||||
/**
|
||||
* Embedded conference block delimited by ~:~ markers.
|
||||
* Contains a video-conference URL. Providers inject this in descriptions.
|
||||
*/
|
||||
const CONFERENCE_BLOCK_RE =
|
||||
/-::~:~::~:~[:~]*::~:~::-\s*\n.*?(https:\/\/\S+).*?\n.*?-::~:~::~:~[:~]*::~:~::-/s;
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s]+/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EventDisplayFields = {
|
||||
description: string;
|
||||
location: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Clean and deduplicate event fields for display.
|
||||
*
|
||||
* Applies in order:
|
||||
* 1. Trim whitespace on all fields
|
||||
* 2. Strip known prefixes from location
|
||||
* 3. Extract embedded conference URL from description → url (if url empty)
|
||||
* 4. Deduplicate: desc==location → empty desc,
|
||||
* location==url → empty location, desc==url → empty desc
|
||||
*/
|
||||
export const cleanEventForDisplay = (
|
||||
raw: EventDisplayFields,
|
||||
): EventDisplayFields => {
|
||||
let description = raw.description.trim();
|
||||
let location = stripLocationPrefixes(raw.location.trim());
|
||||
let url = raw.url.trim();
|
||||
|
||||
// Extract embedded conference block from description
|
||||
if (!url) {
|
||||
const extracted = extractConferenceBlock(description);
|
||||
if (extracted.url) {
|
||||
description = extracted.description;
|
||||
url = extracted.url;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate across fields
|
||||
if (description && description === location) description = '';
|
||||
if (location && location === url) location = '';
|
||||
if (description && description === url) description = '';
|
||||
|
||||
return { description, location, url };
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the first URL found anywhere in a string.
|
||||
*/
|
||||
export const extractUrl = (text: string): string | null => {
|
||||
const match = text.match(URL_RE);
|
||||
return match ? match[0] : null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const stripLocationPrefixes = (value: string): string => {
|
||||
for (const prefix of LOCATION_PREFIXES_TO_STRIP) {
|
||||
if (value.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
return value.slice(prefix.length).trim();
|
||||
}
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const extractConferenceBlock = (
|
||||
text: string,
|
||||
): { description: string; url: string | null } => {
|
||||
const match = text.match(CONFERENCE_BLOCK_RE);
|
||||
if (!match) return { description: text, url: null };
|
||||
return {
|
||||
description: text.replace(match[0], '').trim(),
|
||||
url: match[1] ?? null,
|
||||
};
|
||||
};
|
||||
@@ -113,6 +113,7 @@
|
||||
"titlePlaceholder": "Event title",
|
||||
"location": "Location",
|
||||
"locationPlaceholder": "Location",
|
||||
"openLocation": "Open",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description",
|
||||
"start": "Start",
|
||||
@@ -738,6 +739,7 @@
|
||||
"titlePlaceholder": "Titre de l'événement",
|
||||
"location": "Lieu",
|
||||
"locationPlaceholder": "Lieu",
|
||||
"openLocation": "Ouvrir",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Description",
|
||||
"start": "Début",
|
||||
@@ -1110,6 +1112,7 @@
|
||||
"titlePlaceholder": "Evenement titel",
|
||||
"location": "Locatie",
|
||||
"locationPlaceholder": "Locatie",
|
||||
"openLocation": "Openen",
|
||||
"description": "Beschrijving",
|
||||
"descriptionPlaceholder": "Beschrijving",
|
||||
"start": "Start",
|
||||
|
||||
Reference in New Issue
Block a user