✨(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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
Reference in New Issue
Block a user