(front) add main Scheduler component

Add Scheduler component integrating FullCalendar for event
display with day/week/month views, drag-drop support and
CalDAV synchronization via CalendarContext.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:34:30 +01:00
parent 884062658a
commit 034e8f5c79
3 changed files with 380 additions and 0 deletions

View File

@@ -0,0 +1,212 @@
.day-of-month {
font-size: 2rem;
font-weight: 700;
}
.my-event {
// opacity: 0.5;
}
// ============================================================================
// Event Modal Styles (for use with Cunningham Modal)
// ============================================================================
.event-modal {
&__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
// Date/Time row with two columns
&__datetime-row {
display: flex;
gap: 1rem;
> * {
flex: 1;
}
}
// 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;
}
&__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;
input[type="checkbox"] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
margin: 0;
}
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;
&:hover {
background-color: #f8f9fa;
border-color: #adb5bd;
}
&--active {
background-color: #e7f5ff;
border-color: #339af0;
color: #1971c2;
}
.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-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-organizer {
font-size: 0.9375rem;
color: #495057;
}
&__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;
strong {
color: #1971c2;
}
}
}
// ============================================================================
// Delete Event Modal Styles
// ============================================================================
.delete-modal {
&__content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
&__message {
margin: 0;
font-size: 1rem;
line-height: 1.5;
color: #495057;
}
&__options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease-in-out;
&:hover {
background-color: #f8f9fa;
border-color: #adb5bd;
}
input[type="radio"] {
cursor: pointer;
width: 1.25rem;
height: 1.25rem;
margin: 0;
accent-color: #dc3545; // Error color for delete action
}
span {
flex: 1;
font-size: 0.9375rem;
line-height: 1.5;
color: #212529;
}
input[type="radio"]:checked + span {
font-weight: 500;
}
}
}

View File

@@ -0,0 +1,157 @@
/**
* Scheduler component using EventCalendar (vkurko/calendar).
* Renders a CalDAV-connected calendar view with full interactivity.
*
* Features:
* - Drag & drop events (eventDrop)
* - Resize events (eventResize)
* - Click to edit (eventClick)
* - Click to create (dateClick)
* - Select range to create (select)
*
* Next.js consideration: This component must be client-side only
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
*/
import "@event-calendar/core/index.css";
import { useEffect, useRef, useState } from "react";
import { useCalendarContext } from "../../contexts/CalendarContext";
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
import { EventModal } from "./EventModal";
import type { SchedulerProps, EventModalState } from "./types";
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
import {
useSchedulerInit,
useSchedulingCapabilitiesCheck,
} from "./hooks/useSchedulerInit";
type ECEvent = EventCalendarEvent;
// Calendar API interface
interface CalendarApi {
updateEvent: (event: ECEvent) => void;
addEvent: (event: ECEvent) => void;
unselect: () => void;
refetchEvents: () => void;
$destroy?: () => void;
}
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
const {
caldavService,
adapter,
davCalendars,
visibleCalendarUrls,
isConnected,
calendarRef: contextCalendarRef,
setCurrentDate,
} = useCalendarContext();
const containerRef = useRef<HTMLDivElement>(null);
const calendarRef = contextCalendarRef as React.MutableRefObject<CalendarApi | null>;
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
// Modal state
const [modalState, setModalState] = useState<EventModalState>({
isOpen: false,
mode: "create",
event: null,
calendarUrl: "",
});
// Keep refs to visibleCalendarUrls and davCalendars for use in eventFilter/eventSources
const visibleCalendarUrlsRef = useRef(visibleCalendarUrls);
visibleCalendarUrlsRef.current = visibleCalendarUrls;
const davCalendarsRef = useRef<CalDavCalendar[]>(davCalendars);
davCalendarsRef.current = davCalendars;
// Initialize calendar URL from context
useEffect(() => {
if (davCalendars.length > 0 && !calendarUrl) {
const firstCalendar = davCalendars[0];
setCalendarUrl(defaultCalendarUrl || firstCalendar.url);
}
}, [davCalendars, defaultCalendarUrl, calendarUrl]);
// Check scheduling capabilities on mount
useSchedulingCapabilitiesCheck(isConnected, caldavService);
// Initialize event handlers
const {
handleEventDrop,
handleEventResize,
handleEventClick,
handleDateClick,
handleSelect,
handleModalSave,
handleModalDelete,
handleModalClose,
handleRespondToInvitation,
} = useSchedulerHandlers({
adapter,
caldavService,
davCalendarsRef,
calendarRef,
calendarUrl,
modalState,
setModalState,
});
// Initialize calendar
// Cast handlers to bypass library type differences between specific event types and unknown
useSchedulerInit({
containerRef,
calendarRef,
isConnected,
calendarUrl,
caldavService,
adapter,
visibleCalendarUrlsRef,
davCalendarsRef,
setCurrentDate,
handleEventClick: handleEventClick as (info: unknown) => void,
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
handleDateClick: handleDateClick as (info: unknown) => void,
handleSelect: handleSelect as (info: unknown) => void,
});
// Update eventFilter when visible calendars change
useEffect(() => {
if (calendarRef.current) {
// The refs are already updated (synchronously during render)
// Now trigger a re-evaluation of eventFilter by calling refetchEvents
calendarRef.current.refetchEvents();
}
}, [visibleCalendarUrls, davCalendars, calendarRef]);
return (
<>
<div
ref={containerRef}
id="event-calendar"
style={{ height: "calc(100vh - 100px)" }}
/>
<EventModal
isOpen={modalState.isOpen}
mode={modalState.mode}
event={modalState.event}
calendarUrl={modalState.calendarUrl}
calendars={davCalendars}
adapter={adapter}
onSave={handleModalSave}
onDelete={modalState.mode === "edit" ? handleModalDelete : undefined}
onRespondToInvitation={handleRespondToInvitation}
onClose={handleModalClose}
/>
</>
);
};
export default Scheduler;

View File

@@ -0,0 +1,11 @@
/**
* Scheduler components exports.
*/
export { Scheduler } from "./Scheduler";
export { EventModal } from "./EventModal";
export { DeleteEventModal } from "./DeleteEventModal";
export { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
export { useSchedulerInit, useSchedulingCapabilitiesCheck } from "./hooks/useSchedulerInit";
export * from "./types";
export * from "./utils/dateFormatters";