diff --git a/src/components/generator/Calendar/CalendarComponent.jsx b/src/components/generator/Calendar/CalendarComponent.jsx index 9aac6a0..d3e5b53 100644 --- a/src/components/generator/Calendar/CalendarComponent.jsx +++ b/src/components/generator/Calendar/CalendarComponent.jsx @@ -39,6 +39,7 @@ import { getCalendarViewNotificationMessage, getVisibleTimetables, } from "./utils/calendarViewUtils.js"; +import { buildSelectionPreviewEvents } from "./utils/selectionUtils.js"; import { getFullCalendarConfig } from "./utils/calendarConfigUtils.js"; import MultiLineSnackbar from "@/components/sitewide/MultiLineSnackbar"; import { useIsMobile } from "@/lib/utils/screenSizeUtils"; @@ -71,11 +72,19 @@ export default function CalendarComponent({ const [renameAnchorEl, setRenameAnchorEl] = useState(null); const [renameAnchorPosition, setRenameAnchorPosition] = useState(null); const [coursesForTimeline, setCoursesForTimeline] = useState([]); + const [selectionPreviewEvents, setSelectionPreviewEvents] = useState([]); + const selectionPreviewKeyRef = React.useRef(""); const visibleTimetables = useMemo( () => getVisibleTimetables(timetables, viewRange), [timetables, viewRange], ); + const calendarEvents = useMemo(() => { + if (selectionPreviewEvents.length === 0) { + return events; + } + return [...events, ...selectionPreviewEvents]; + }, [events, selectionPreviewEvents]); // Screen size detection const isMobile = useIsMobile(); @@ -417,6 +426,7 @@ export default function CalendarComponent({ }; const handleSelect = (selectInfo) => { + clearSelectionPreview(); handleCalendarSelection( selectInfo, setCurrentTimetableIndex, @@ -429,9 +439,51 @@ export default function CalendarComponent({ ); }; - const handleSelectAllow = (selectionInfo) => { - return true; - }; + const clearSelectionPreview = useCallback(() => { + if ( + selectionPreviewKeyRef.current !== "" || + selectionPreviewEvents.length + ) { + selectionPreviewKeyRef.current = ""; + setSelectionPreviewEvents([]); + } + }, [selectionPreviewEvents.length]); + + const handleSelectAllow = useCallback( + (selectionInfo) => { + const start = selectionInfo?.start; + const end = selectionInfo?.end; + + if (!start || !end) { + clearSelectionPreview(); + return true; + } + + const previewEvents = buildSelectionPreviewEvents(start, end); + if (previewEvents.length === 0) { + clearSelectionPreview(); + return true; + } + + const previewKey = previewEvents + .map( + (event) => `${event.start.toISOString()}-${event.end.toISOString()}`, + ) + .join("|"); + + if (previewKey !== selectionPreviewKeyRef.current) { + selectionPreviewKeyRef.current = previewKey; + setSelectionPreviewEvents(previewEvents); + } + + return true; + }, + [clearSelectionPreview], + ); + + const handleUnselect = useCallback(() => { + clearSelectionPreview(); + }, [clearSelectionPreview]); // Function to navigate the calendar to a specific date const navigateToDate = useCallback( @@ -492,11 +544,12 @@ export default function CalendarComponent({ {...getFullCalendarConfig({ calendarRef, showWeekends, - events, + events: calendarEvents, handleDatesSet, handleEventClick, handleSelect, handleSelectAllow, + handleUnselect, handleEventMouseEnter, handleEventMouseLeave, isMobile, diff --git a/src/components/generator/Calendar/utils/calendarConfigUtils.js b/src/components/generator/Calendar/utils/calendarConfigUtils.js index 3c595b5..b951e94 100644 --- a/src/components/generator/Calendar/utils/calendarConfigUtils.js +++ b/src/components/generator/Calendar/utils/calendarConfigUtils.js @@ -10,6 +10,7 @@ export const getFullCalendarConfig = ({ handleEventClick, handleSelect, handleSelectAllow, + handleUnselect, handleEventMouseEnter, handleEventMouseLeave, isMobile = false, @@ -21,6 +22,7 @@ export const getFullCalendarConfig = ({ headerToolbar: false, height: 835, dayHeaderFormat: { weekday: "short" }, + dayHeaderContent: (arg) => arg.text.toUpperCase(), dayCellClassNames: (arg) => arg.date.getDay() === new Date().getDay() ? "fc-day-today" : "", slotMinTime: "08:00:00", @@ -29,6 +31,15 @@ export const getFullCalendarConfig = ({ allDaySlot: true, allDayText: "ONLINE", eventContent: (eventInfo) => renderEventContent(eventInfo, isMobile), + eventClassNames: (arg) => + arg.event.extendedProps?.isPinned ? ["fc-event-pinned"] : [], + eventDidMount: (arg) => { + if (arg.event.extendedProps?.isPinned) { + arg.el.style.borderColor = "transparent"; + arg.el.style.borderWidth = "1px"; + arg.el.style.borderStyle = "solid"; + } + }, eventClick: handleEventClick, eventMouseEnter: handleEventMouseEnter, eventMouseLeave: handleEventMouseLeave, @@ -37,6 +48,7 @@ export const getFullCalendarConfig = ({ selectMinDistance: 25, select: handleSelect, selectAllow: handleSelectAllow, + unselect: handleUnselect, longPressDelay: 0, selectLongPressDelay: 500, firstDay: 1, diff --git a/src/components/generator/Calendar/utils/calendarUtils.jsx b/src/components/generator/Calendar/utils/calendarUtils.jsx index 8056698..77225cd 100644 --- a/src/components/generator/Calendar/utils/calendarUtils.jsx +++ b/src/components/generator/Calendar/utils/calendarUtils.jsx @@ -19,7 +19,12 @@ export const renderEventContent = (eventInfo, isMobile = false) => { return (
{eventDuration >= minDurationToShowTime && (
{ ); } else { const isPinned = isEventPinned(eventInfo.event); + const instructorText = eventInfo.event.extendedProps.description?.trim(); + const shouldShowInstructor = + !!instructorText && instructorText.toLowerCase() !== "no instructor"; return (
{ position: "relative", backgroundColor: isPinned ? "rgba(0, 0, 0, 0.5)" : "transparent", height: "100%", - padding: "2px", - borderRadius: "4px", + padding: "0.25rem 0.375rem", + borderRadius: "inherit", }} > - {eventDuration >= minDurationToShowTime && ( -
- {eventInfo.timeText} -
- )} {isPinned && (
{ {eventInfo.timeText}
{eventInfo.event.title} - {(isMobile || eventDuration >= minDurationToShowTime) && ( - <> -
- - {eventInfo.event.extendedProps.description || "No instructor"} - - - )} + {(isMobile || eventDuration >= minDurationToShowTime) && + shouldShowInstructor && ( + <> +
+ + {instructorText} + + + )}
); diff --git a/src/components/generator/Calendar/utils/eventHandlerUtils.js b/src/components/generator/Calendar/utils/eventHandlerUtils.js index 5543a24..0905704 100644 --- a/src/components/generator/Calendar/utils/eventHandlerUtils.js +++ b/src/components/generator/Calendar/utils/eventHandlerUtils.js @@ -17,6 +17,11 @@ import { generateTimetables, getValidTimetables, } from "@/lib/generator/timetableGeneration/timetableGeneration"; +import { + getNormalizedSelectionWindow, + getSelectionDayCodes, + toSlotRange, +} from "./selectionUtils.js"; // Extract the complex logic for handling course component clicks export const handleCourseComponentClick = ( @@ -118,35 +123,19 @@ export const handleCalendarSelection = ( ) => { const startDateTime = new Date(selectInfo.startStr); const endDateTime = new Date(selectInfo.endStr); - const startTime = - startDateTime.getHours() + ":" + (startDateTime.getMinutes() || "00"); - const endTime = - endDateTime.getHours() + ":" + (endDateTime.getMinutes() || "00"); - const slotStart = - (startDateTime.getHours() - 8) * 2 + startDateTime.getMinutes() / 30; - const slotEnd = - (endDateTime.getHours() - 8) * 2 + endDateTime.getMinutes() / 30; - - const dayMapping = { - Mon: "M", - Tue: "T", - Wed: "W", - Thu: "R", - Fri: "F", - Sat: "S", - Sun: "U", - }; - - // Get all days between start and end date - const days = []; - let currentDate = new Date(startDateTime); - while (currentDate <= endDateTime) { - const dayName = currentDate.toLocaleString("en-US", { weekday: "short" }); - if (dayMapping[dayName]) { - days.push(dayMapping[dayName]); - } - currentDate.setDate(currentDate.getDate() + 1); - } + const normalizedWindow = getNormalizedSelectionWindow( + startDateTime, + endDateTime, + ); + if (!normalizedWindow) return; + + const { slotStart, slotEnd } = toSlotRange( + normalizedWindow.selectionStartMinutes, + normalizedWindow.selectionEndMinutes, + ); + if (slotEnd <= slotStart) return; + + const days = getSelectionDayCodes(startDateTime, endDateTime); if (days.length > 0) { const slotsToBlock = []; diff --git a/src/components/generator/Calendar/utils/selectionUtils.js b/src/components/generator/Calendar/utils/selectionUtils.js new file mode 100644 index 0000000..483ee13 --- /dev/null +++ b/src/components/generator/Calendar/utils/selectionUtils.js @@ -0,0 +1,109 @@ +const MINUTES_PER_DAY = 24 * 60; +const SLOT_MINUTES = 30; +const DAY_CODES = ["U", "M", "T", "W", "R", "F", "S"]; + +const clamp = (value, min, max) => Math.max(min, Math.min(value, max)); + +export const getMinutesOfDay = (date) => + date.getHours() * 60 + date.getMinutes(); + +export const getNormalizedSelectionWindow = (start, end) => { + if (!start || !end) return null; + + const chronologicalStart = start <= end ? start : end; + const chronologicalEnd = start <= end ? end : start; + + const startMinutes = getMinutesOfDay(start); + const endMinutes = getMinutesOfDay(end); + const isReverseVerticalDrag = startMinutes > endMinutes; + const spansMultipleDays = + chronologicalStart.toDateString() !== chronologicalEnd.toDateString(); + + let selectionStartMinutes = Math.min(startMinutes, endMinutes); + let selectionEndMinutes = Math.max(startMinutes, endMinutes); + + if (isReverseVerticalDrag) { + selectionEndMinutes = clamp( + selectionEndMinutes + SLOT_MINUTES, + 0, + MINUTES_PER_DAY, + ); + if (spansMultipleDays) { + selectionStartMinutes = clamp( + selectionStartMinutes - SLOT_MINUTES, + 0, + MINUTES_PER_DAY, + ); + } + } + + return { + chronologicalStart, + chronologicalEnd, + selectionStartMinutes, + selectionEndMinutes, + }; +}; + +export const getDateRangeInclusive = (start, end) => { + const window = getNormalizedSelectionWindow(start, end); + if (!window) return []; + + const currentDate = new Date(window.chronologicalStart); + currentDate.setHours(0, 0, 0, 0); + const lastDate = new Date(window.chronologicalEnd); + lastDate.setHours(0, 0, 0, 0); + + const dates = []; + while (currentDate <= lastDate) { + dates.push(new Date(currentDate)); + currentDate.setDate(currentDate.getDate() + 1); + } + return dates; +}; + +export const getSelectionDayCodes = (start, end) => + getDateRangeInclusive(start, end).map((date) => DAY_CODES[date.getDay()]); + +export const toSlotRange = ( + selectionStartMinutes, + selectionEndMinutes, + dayStartMinutes = 8 * 60, +) => ({ + slotStart: (selectionStartMinutes - dayStartMinutes) / SLOT_MINUTES, + slotEnd: (selectionEndMinutes - dayStartMinutes) / SLOT_MINUTES, +}); + +export const buildSelectionPreviewEvents = (start, end) => { + const window = getNormalizedSelectionWindow(start, end); + if (!window || window.selectionEndMinutes <= window.selectionStartMinutes) { + return []; + } + + const dates = getDateRangeInclusive(start, end); + return dates.map((date) => { + const eventStart = new Date(date); + eventStart.setHours( + Math.floor(window.selectionStartMinutes / 60), + window.selectionStartMinutes % 60, + 0, + 0, + ); + + const eventEnd = new Date(date); + eventEnd.setHours( + Math.floor(window.selectionEndMinutes / 60), + window.selectionEndMinutes % 60, + 0, + 0, + ); + + return { + id: `selection-preview-${eventStart.toISOString()}`, + start: eventStart, + end: eventEnd, + display: "background", + classNames: ["fc-block-selection-preview"], + }; + }); +}; diff --git a/src/styles/generator/Calendar.css b/src/styles/generator/Calendar.css index 2cd2efc..1fcb828 100644 --- a/src/styles/generator/Calendar.css +++ b/src/styles/generator/Calendar.css @@ -1,5 +1,5 @@ -.fc .fc-day-today { - background-color: transparent !important; +#Calendar .fc .fc-day-today { + background-color: hsl(var(--accent) / 0.28) !important; } #infoButtonBox { @@ -43,7 +43,7 @@ @media (max-width: 1325px) { .fc-event-main { - font-size: 10px; + font-size: 11px; text-wrap: wrap; } @@ -96,7 +96,7 @@ @media (max-width: 599px) { .fc-event-main { - font-size: 8px; + font-size: 9px; text-wrap: wrap; } diff --git a/src/styles/generator/CustomCalendar.css b/src/styles/generator/CustomCalendar.css index 9ff4524..9e8e17c 100644 --- a/src/styles/generator/CustomCalendar.css +++ b/src/styles/generator/CustomCalendar.css @@ -1,78 +1,180 @@ -:root { - --calendar-grid-color-light: var(--theme-divider-color); - --calendar-grid-color-dark: var(--theme-outline-color); -} - -.fc .fc-timegrid-slot, -.fc .fc-col-header-cell, -.fc .fc-daygrid-day, -.fc .fc-daygrid-day-frame, -.fc .fc-timegrid-axis, -.fc .fc-timegrid-divider, -.fc .fc-timegrid-lines, -.fc .fc-timegrid-bg, -.fc .fc-timegrid-slots, -.fc .fc-scrollgrid, -.fc .fc-scrollgrid-sync-inner, -.fc .fc-scrollgrid-liquid, -.fc .fc-scrollgrid-section, -.fc .fc-scrollgrid-section table, -.fc .fc-scrollgrid-section td, -.fc .fc-scrollgrid-section th { - border-color: var(--calendar-grid-color-light); -} - -.dark .fc .fc-timegrid-slot, -.dark .fc .fc-col-header-cell, -.dark .fc .fc-daygrid-day, -.dark .fc .fc-daygrid-day-frame, -.dark .fc .fc-timegrid-axis, -.dark .fc .fc-timegrid-divider, -.dark .fc .fc-timegrid-lines, -.dark .fc .fc-timegrid-bg, -.dark .fc .fc-timegrid-slots, -.dark .fc .fc-scrollgrid, -.dark .fc .fc-scrollgrid-sync-inner, -.dark .fc .fc-scrollgrid-liquid, -.dark .fc .fc-scrollgrid-section, -.dark .fc .fc-scrollgrid-section table, -.dark .fc .fc-scrollgrid-section td, -.dark .fc .fc-scrollgrid-section th { - border-color: var(--calendar-grid-color-dark); -} - -.fc-event { - border: none; - border-radius: 3px; - box-shadow: 0 0 1px var(--calendar-grid-color-light); -} - -.dark .fc-event { - border: none; - border-radius: 3px; - box-shadow: 0 0 1px var(--calendar-grid-color-dark); -} - -.fc-event, -.fc-event-main, -.fc-timegrid-event { +#Calendar .fc { + --fc-border-color: hsl(var(--border) / 0.65); + --fc-page-bg-color: hsl(var(--background)); + --fc-neutral-bg-color: hsl(var(--muted) / 0.55); + --fc-neutral-text-color: hsl(var(--muted-foreground)); + --fc-today-bg-color: hsl(var(--accent) / 0.38); + --fc-now-indicator-color: hsl(var(--primary)); +} + +#Calendar .fc .fc-scrollgrid { + border: 1px solid hsl(var(--border) / 0.72); + border-radius: calc(var(--radius) + 2px); + overflow: hidden; + background: hsl(var(--background)); + box-shadow: + inset 0 1px 0 hsl(var(--background) / 0.6), + 0 1px 3px hsl(var(--foreground) / 0.04); +} + +#Calendar .fc .fc-timegrid-slot, +#Calendar .fc .fc-col-header-cell, +#Calendar .fc .fc-daygrid-day, +#Calendar .fc .fc-daygrid-day-frame, +#Calendar .fc .fc-timegrid-axis, +#Calendar .fc .fc-timegrid-divider, +#Calendar .fc .fc-timegrid-lines, +#Calendar .fc .fc-timegrid-bg, +#Calendar .fc .fc-timegrid-slots, +#Calendar .fc .fc-scrollgrid-sync-inner, +#Calendar .fc .fc-scrollgrid-liquid, +#Calendar .fc .fc-scrollgrid-section, +#Calendar .fc .fc-scrollgrid-section table, +#Calendar .fc .fc-scrollgrid-section td, +#Calendar .fc .fc-scrollgrid-section th { + border-color: hsl(var(--border) / 0.58); +} + +#Calendar .fc .fc-col-header-cell { + background: hsl(var(--muted) / 0.45); +} + +#Calendar .fc .fc-timegrid-col-bg { + background: hsl(var(--background)); +} + +#Calendar .fc .fc-timegrid-axis { + width: 74px !important; + background: hsl(var(--muted) / 0.45); +} + +#Calendar .fc .fc-col-header-cell-cushion { + text-decoration: none; + color: hsl(var(--foreground) / 0.84); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.75rem; + font-weight: 600; + padding: 0.5rem 0; +} + +#Calendar .fc .fc-timegrid-axis-cushion, +#Calendar .fc .fc-timegrid-slot-label-cushion { + text-decoration: none; + color: hsl(var(--foreground) / 0.84); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.75rem; + font-weight: 600; + padding: 0 0.25rem !important; + line-height: 1.2; + display: block; + text-align: center; + width: auto; + overflow: visible; +} + +#Calendar .fc .fc-timegrid-axis-cushion { + padding: 0 0.25rem !important; +} + +#Calendar .fc .fc-timegrid-slot-label { + background: hsl(var(--muted) / 0.45); +} + +#Calendar .fc .fc-day-today { + background-color: hsl(var(--accent) / 0.28) !important; +} + +#Calendar .fc .fc-timegrid-col.fc-day-today { + box-shadow: inset 0 0 0 1px hsl(var(--ring) / 0.32); +} + +#Calendar .fc .fc-timegrid-event, +#Calendar .fc .fc-event { + border: 1px solid hsl(var(--background) / 0.45); + border-radius: calc(var(--radius) - 1px); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.1); + cursor: pointer; +} + +#Calendar .fc .fc-event-main { cursor: pointer; } -.fc-timegrid-event.rename-mode, -.fc-timegrid-event.rename-mode .fc-event-main, -.fc-timegrid-event.rename-mode * { +#Calendar .fc .fc-timegrid-event.rename-mode, +#Calendar .fc .fc-timegrid-event.rename-mode .fc-event-main, +#Calendar .fc .fc-timegrid-event.rename-mode * { cursor: text !important; } -.fc-timegrid-event .fc-event-main { +#Calendar .fc .fc-timegrid-event .fc-event-main { padding: 0; + line-height: 1.25; +} + +#Calendar .fc .fc-event-pinned { + border: 1px solid transparent !important; + box-shadow: none !important; + overflow: hidden; + outline: 0; +} + +#Calendar .fc .fc-highlight { + background: transparent !important; +} + +#Calendar .fc .fc-timegrid-now-indicator-line { + border-color: hsl(var(--primary) / 0.55); +} + +#Calendar .fc .fc-bg-event.fc-block-selection-preview { + background: hsl(var(--primary) / 0.18); + border: 1px solid hsl(var(--primary) / 0.5); + border-radius: calc(var(--radius) - 2px); + opacity: 1; +} + +.dark #Calendar .fc { + --fc-border-color: hsl(var(--border) / 0.8); + --fc-page-bg-color: hsl(var(--card)); + --fc-neutral-bg-color: hsl(var(--muted) / 0.6); + --fc-neutral-text-color: hsl(var(--muted-foreground)); + --fc-today-bg-color: hsl(var(--accent) / 0.28); +} + +.dark #Calendar .fc .fc-col-header-cell-cushion, +.dark #Calendar .fc .fc-timegrid-axis-cushion, +.dark #Calendar .fc .fc-timegrid-slot-label-cushion { + color: hsl(var(--muted-foreground)); +} + +.dark #Calendar .fc .fc-scrollgrid { + box-shadow: + inset 0 1px 0 hsl(var(--background) / 0.3), + 0 1px 2px hsl(var(--foreground) / 0.12); } -.fc .fc-timegrid-axis-cushion { - font-size: 0.875rem !important; +.dark #Calendar .fc .fc-timegrid-slot, +.dark #Calendar .fc .fc-col-header-cell, +.dark #Calendar .fc .fc-daygrid-day, +.dark #Calendar .fc .fc-daygrid-day-frame, +.dark #Calendar .fc .fc-timegrid-axis, +.dark #Calendar .fc .fc-timegrid-divider, +.dark #Calendar .fc .fc-timegrid-lines, +.dark #Calendar .fc .fc-timegrid-bg, +.dark #Calendar .fc .fc-timegrid-slots, +.dark #Calendar .fc .fc-scrollgrid-sync-inner, +.dark #Calendar .fc .fc-scrollgrid-liquid, +.dark #Calendar .fc .fc-scrollgrid-section, +.dark #Calendar .fc .fc-scrollgrid-section table, +.dark #Calendar .fc .fc-scrollgrid-section td, +.dark #Calendar .fc .fc-scrollgrid-section th { + border-color: hsl(var(--border) / 0.78); } -.fc .fc-timegrid-axis { - width: 70px !important; +.dark #Calendar .fc .fc-timegrid-event, +.dark #Calendar .fc .fc-event { + border-color: hsl(var(--background) / 0.2); + box-shadow: 0 1px 2px hsl(var(--foreground) / 0.16); }