diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.scss b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.scss new file mode 100644 index 0000000..b128fd6 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.scss @@ -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; + } + } +} diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx new file mode 100644 index 0000000..9ebdf55 --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/Scheduler.tsx @@ -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(null); + const calendarRef = contextCalendarRef as React.MutableRefObject; + const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || ""); + + // Modal state + const [modalState, setModalState] = useState({ + 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(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 ( + <> +
+ + + + ); +}; + +export default Scheduler; diff --git a/src/frontend/apps/calendars/src/features/calendar/components/scheduler/index.ts b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/index.ts new file mode 100644 index 0000000..dfbcb9a --- /dev/null +++ b/src/frontend/apps/calendars/src/features/calendar/components/scheduler/index.ts @@ -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";