667 lines
19 KiB
TypeScript
667 lines
19 KiB
TypeScript
import {
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
type DragEvent as ReactDragEvent,
|
|
} from "react";
|
|
|
|
import { i18nMessage } from "@govoplan/core-webui";
|
|
|
|
import type { CalendarEvent } from "../../api/calendar";
|
|
import {
|
|
HOUR_ROW_HEIGHT,
|
|
INITIAL_SCROLL_HOUR,
|
|
calendarEventColorStyle,
|
|
chunk,
|
|
continuousVirtualWindow,
|
|
dayKey,
|
|
dayMonthLabel,
|
|
dropSlotMinuteOfDay,
|
|
eventTimeLabel,
|
|
isWeekend,
|
|
layoutTimedEventsForDay,
|
|
minuteOfDayLabel,
|
|
monthYearLabel,
|
|
sameDay,
|
|
sameMonth,
|
|
timedEventStyle,
|
|
timedOverflowStyle,
|
|
timeGridStyle,
|
|
weekdayLong,
|
|
type CalendarDropTarget,
|
|
type CalendarMode,
|
|
type CalendarResizeEdge,
|
|
type CalendarTimeDropTarget,
|
|
type CalendarViewPreferences,
|
|
type ContinuousViewport,
|
|
} from "./calendarViewModel";
|
|
|
|
type CalendarViewCallbacks = {
|
|
onEventSelect: (event: CalendarEvent) => void;
|
|
onEventHover: (eventId: string) => void;
|
|
onEventDragStart: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
event: CalendarEvent,
|
|
) => void;
|
|
onEventDragEnd: () => void;
|
|
onEventDragOverDay: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
) => void;
|
|
onDropTargetLeave: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
) => void;
|
|
onEventDropOnDay: (
|
|
dropEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
) => void;
|
|
};
|
|
|
|
type CalendarWeekRowsProps = CalendarViewCallbacks & {
|
|
days: Date[];
|
|
eventsByDay: Map<string, CalendarEvent[]>;
|
|
calendarColorById: Map<string, string>;
|
|
focusDate: Date;
|
|
variant: "month" | "continuous";
|
|
preferences: CalendarViewPreferences;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarDropTarget;
|
|
viewport?: ContinuousViewport;
|
|
};
|
|
|
|
export function CalendarWeekRows({
|
|
days,
|
|
eventsByDay,
|
|
calendarColorById,
|
|
focusDate,
|
|
variant,
|
|
preferences,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
viewport,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverDay,
|
|
onDropTargetLeave,
|
|
onEventDropOnDay,
|
|
}: CalendarWeekRowsProps) {
|
|
const weeks = chunk(days, 7);
|
|
const virtualWindow =
|
|
variant === "continuous" && preferences.continuousVirtualization
|
|
? continuousVirtualWindow(
|
|
weeks.length,
|
|
viewport ?? { scrollTop: 0, height: 0 },
|
|
preferences.continuousOverscanWeeks,
|
|
)
|
|
: {
|
|
start: 0,
|
|
end: weeks.length,
|
|
topSpacerHeight: 0,
|
|
bottomSpacerHeight: 0,
|
|
};
|
|
const visibleWeeks = weeks.slice(
|
|
virtualWindow.start,
|
|
virtualWindow.end,
|
|
);
|
|
const eventLimit = variant === "month" ? 5 : 3;
|
|
|
|
return (
|
|
<div className={`calendar-week-rows is-${variant}`}>
|
|
<div className="calendar-weekday-header">
|
|
{[
|
|
"i18n:govoplan-calendar.mon.24b2a099",
|
|
"i18n:govoplan-calendar.tue.529541bb",
|
|
"i18n:govoplan-calendar.wed.23408b19",
|
|
"i18n:govoplan-calendar.thu.3593ccd9",
|
|
"i18n:govoplan-calendar.fri.bbd6e32e",
|
|
"i18n:govoplan-calendar.sat.6b782d41",
|
|
"i18n:govoplan-calendar.sun.48c98cab",
|
|
].map((label) => (
|
|
<span key={label}>{label}</span>
|
|
))}
|
|
</div>
|
|
{virtualWindow.topSpacerHeight > 0 && (
|
|
<div
|
|
className="calendar-week-spacer"
|
|
style={{ height: virtualWindow.topSpacerHeight }}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{visibleWeeks.map((week) => (
|
|
<div className="calendar-week-row" key={dayKey(week[0])}>
|
|
{week.map((day) => {
|
|
const key = dayKey(day);
|
|
const dayEvents = eventsByDay.get(key) ?? [];
|
|
const outsideFocusMonth =
|
|
variant === "month" && !sameMonth(day, focusDate);
|
|
return (
|
|
<section
|
|
key={key}
|
|
className={[
|
|
"calendar-day-cell",
|
|
outsideFocusMonth ? "is-muted" : "",
|
|
sameDay(day, new Date()) ? "is-today" : "",
|
|
dropTarget?.kind === "day" &&
|
|
dropTarget.key === key
|
|
? "is-drop-target"
|
|
: "",
|
|
variant === "continuous" &&
|
|
preferences.alternateContinuousMonths
|
|
? day.getMonth() % 2 === 0
|
|
? "is-even-month"
|
|
: "is-odd-month"
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
onDragOver={
|
|
canWrite
|
|
? (event) => onEventDragOverDay(event, day)
|
|
: undefined
|
|
}
|
|
onDragLeave={
|
|
canWrite ? onDropTargetLeave : undefined
|
|
}
|
|
onDrop={
|
|
canWrite
|
|
? (event) => onEventDropOnDay(event, day)
|
|
: undefined
|
|
}
|
|
>
|
|
<header>
|
|
<span className="calendar-day-number">
|
|
{day.getDate()}
|
|
</span>
|
|
{day.getDate() === 1 && (
|
|
<small>{monthYearLabel(day)}</small>
|
|
)}
|
|
</header>
|
|
<div className="calendar-event-stack">
|
|
{dayEvents.slice(0, eventLimit).map((event) => (
|
|
<CalendarEventChip
|
|
key={event.id}
|
|
event={event}
|
|
color={calendarColorById.get(event.calendar_id)}
|
|
canDrag={canWrite}
|
|
dragging={draggingEventId === event.id}
|
|
linkedHover={hoveredEventId === event.id}
|
|
onSelect={onEventSelect}
|
|
onHover={onEventHover}
|
|
onDragStart={onEventDragStart}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
))}
|
|
{dayEvents.length > eventLimit && (
|
|
<span className="calendar-more-events">
|
|
+{dayEvents.length - eventLimit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
{virtualWindow.bottomSpacerHeight > 0 && (
|
|
<div
|
|
className="calendar-week-spacer"
|
|
style={{ height: virtualWindow.bottomSpacerHeight }}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CalendarTimeGridProps = CalendarViewCallbacks & {
|
|
days: Date[];
|
|
eventsByDay: Map<string, CalendarEvent[]>;
|
|
calendarColorById: Map<string, string>;
|
|
mode: CalendarMode;
|
|
preferences: CalendarViewPreferences;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarDropTarget;
|
|
onEventResizeDragStart: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
event: CalendarEvent,
|
|
edge: CalendarResizeEdge,
|
|
) => void;
|
|
onEventDragOverTime: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
) => void;
|
|
onEventDropOnTime: (
|
|
dropEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
minuteOfDay: number,
|
|
) => void;
|
|
};
|
|
|
|
export function CalendarTimeGrid({
|
|
days,
|
|
eventsByDay,
|
|
calendarColorById,
|
|
mode,
|
|
preferences,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventResizeDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverDay,
|
|
onEventDragOverTime,
|
|
onDropTargetLeave,
|
|
onEventDropOnDay,
|
|
onEventDropOnTime,
|
|
}: CalendarTimeGridProps) {
|
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
const hours = Array.from({ length: 24 }, (_item, index) => index);
|
|
const columns = `72px repeat(${days.length}, minmax(132px, 1fr))`;
|
|
const dimWeekends = preferences.dimWeekends && mode === "week";
|
|
const gridStyle = timeGridStyle(columns, preferences);
|
|
const allDayEventsByDay = days.map((day) => ({
|
|
key: dayKey(day),
|
|
date: day,
|
|
events: (eventsByDay.get(dayKey(day)) ?? []).filter(
|
|
(event) => event.all_day,
|
|
),
|
|
}));
|
|
const hasAllDayEvents = allDayEventsByDay.some(
|
|
(day) => day.events.length > 0,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop =
|
|
INITIAL_SCROLL_HOUR * HOUR_ROW_HEIGHT;
|
|
}
|
|
}, [days.length, days[0]?.getTime()]);
|
|
|
|
return (
|
|
<div className="calendar-time-view">
|
|
<div
|
|
className="calendar-time-header"
|
|
style={{ gridTemplateColumns: columns }}
|
|
>
|
|
<div className="calendar-time-corner" />
|
|
{days.map((day) => (
|
|
<header
|
|
key={dayKey(day)}
|
|
className={[
|
|
sameDay(day, new Date()) ? "is-today" : "",
|
|
dimWeekends && isWeekend(day) ? "is-weekend" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
>
|
|
<strong>{weekdayLong(day)}</strong>
|
|
<span>{dayMonthLabel(day)}</span>
|
|
</header>
|
|
))}
|
|
</div>
|
|
{hasAllDayEvents && (
|
|
<div
|
|
className="calendar-all-day-strip"
|
|
style={{ gridTemplateColumns: columns }}
|
|
>
|
|
<div className="calendar-all-day-label">
|
|
i18n:govoplan-calendar.all_day.11457433
|
|
</div>
|
|
{allDayEventsByDay.map((day) => (
|
|
<div
|
|
key={day.key}
|
|
className={[
|
|
"calendar-all-day-cell",
|
|
dimWeekends && isWeekend(day.date)
|
|
? "is-weekend"
|
|
: "",
|
|
dropTarget?.kind === "day" &&
|
|
dropTarget.key === day.key
|
|
? "is-drop-target"
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
onDragOver={
|
|
canWrite
|
|
? (event) => onEventDragOverDay(event, day.date)
|
|
: undefined
|
|
}
|
|
onDragLeave={
|
|
canWrite ? onDropTargetLeave : undefined
|
|
}
|
|
onDrop={
|
|
canWrite
|
|
? (event) => onEventDropOnDay(event, day.date)
|
|
: undefined
|
|
}
|
|
>
|
|
{day.events.map((event) => (
|
|
<CalendarEventChip
|
|
key={event.id}
|
|
event={event}
|
|
color={calendarColorById.get(event.calendar_id)}
|
|
compact
|
|
canDrag={canWrite}
|
|
dragging={draggingEventId === event.id}
|
|
linkedHover={hoveredEventId === event.id}
|
|
onSelect={onEventSelect}
|
|
onHover={onEventHover}
|
|
onDragStart={onEventDragStart}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="calendar-time-scroll" ref={scrollRef}>
|
|
<div className="calendar-time-grid" style={gridStyle}>
|
|
<div className="calendar-hour-label-column">
|
|
{hours.map((hour) => (
|
|
<div className="calendar-hour-label" key={hour}>
|
|
{String(hour).padStart(2, "0")}:00
|
|
</div>
|
|
))}
|
|
</div>
|
|
{days.map((day) => (
|
|
<TimedDayColumn
|
|
key={dayKey(day)}
|
|
day={day}
|
|
events={eventsByDay.get(dayKey(day)) ?? []}
|
|
calendarColorById={calendarColorById}
|
|
dimWeekend={dimWeekends && isWeekend(day)}
|
|
canWrite={canWrite}
|
|
draggingEventId={draggingEventId}
|
|
hoveredEventId={hoveredEventId}
|
|
dropTarget={
|
|
dropTarget?.kind === "time" &&
|
|
dropTarget.key === dayKey(day)
|
|
? dropTarget
|
|
: null
|
|
}
|
|
onEventSelect={onEventSelect}
|
|
onEventHover={onEventHover}
|
|
onEventDragStart={onEventDragStart}
|
|
onEventResizeDragStart={onEventResizeDragStart}
|
|
onEventDragEnd={onEventDragEnd}
|
|
onEventDragOverTime={onEventDragOverTime}
|
|
onDropTargetLeave={onDropTargetLeave}
|
|
onEventDropOnTime={onEventDropOnTime}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type TimedDayColumnProps = {
|
|
day: Date;
|
|
events: CalendarEvent[];
|
|
calendarColorById: Map<string, string>;
|
|
dimWeekend: boolean;
|
|
canWrite: boolean;
|
|
draggingEventId: string;
|
|
hoveredEventId: string;
|
|
dropTarget: CalendarTimeDropTarget | null;
|
|
onEventSelect: (event: CalendarEvent) => void;
|
|
onEventHover: (eventId: string) => void;
|
|
onEventDragStart: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
event: CalendarEvent,
|
|
) => void;
|
|
onEventResizeDragStart: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
event: CalendarEvent,
|
|
edge: CalendarResizeEdge,
|
|
) => void;
|
|
onEventDragEnd: () => void;
|
|
onEventDragOverTime: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
) => void;
|
|
onDropTargetLeave: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
) => void;
|
|
onEventDropOnTime: (
|
|
dropEvent: ReactDragEvent<HTMLElement>,
|
|
day: Date,
|
|
minuteOfDay: number,
|
|
) => void;
|
|
};
|
|
|
|
function TimedDayColumn({
|
|
day,
|
|
events,
|
|
calendarColorById,
|
|
dimWeekend,
|
|
canWrite,
|
|
draggingEventId,
|
|
hoveredEventId,
|
|
dropTarget,
|
|
onEventSelect,
|
|
onEventHover,
|
|
onEventDragStart,
|
|
onEventResizeDragStart,
|
|
onEventDragEnd,
|
|
onEventDragOverTime,
|
|
onDropTargetLeave,
|
|
onEventDropOnTime,
|
|
}: TimedDayColumnProps) {
|
|
const layout = useMemo(
|
|
() => layoutTimedEventsForDay(events, day),
|
|
[day, events],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={[
|
|
"calendar-day-time-column",
|
|
dimWeekend ? "is-weekend" : "",
|
|
dropTarget ? "is-drop-target" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
onDragOver={
|
|
canWrite
|
|
? (event) => onEventDragOverTime(event, day)
|
|
: undefined
|
|
}
|
|
onDragLeave={canWrite ? onDropTargetLeave : undefined}
|
|
onDrop={
|
|
canWrite
|
|
? (event) =>
|
|
onEventDropOnTime(event, day, dropSlotMinuteOfDay(event))
|
|
: undefined
|
|
}
|
|
>
|
|
{dropTarget && (
|
|
<div
|
|
className="calendar-time-drop-marker"
|
|
style={{
|
|
top: `${
|
|
(dropTarget.minuteOfDay / 60) * HOUR_ROW_HEIGHT
|
|
}px`,
|
|
}}
|
|
aria-hidden="true"
|
|
>
|
|
<span>{minuteOfDayLabel(dropTarget.minuteOfDay)}</span>
|
|
</div>
|
|
)}
|
|
{layout.items.map((item) => (
|
|
<div
|
|
key={item.event.id}
|
|
className={[
|
|
"calendar-timed-event",
|
|
draggingEventId === item.event.id ? "is-dragging" : "",
|
|
hoveredEventId === item.event.id
|
|
? "is-linked-hover"
|
|
: "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
style={timedEventStyle(
|
|
item,
|
|
calendarColorById.get(item.event.calendar_id),
|
|
)}
|
|
draggable={canWrite}
|
|
onMouseEnter={() => onEventHover(item.event.id)}
|
|
onMouseLeave={() => onEventHover("")}
|
|
onDragStart={
|
|
canWrite
|
|
? (event) => onEventDragStart(event, item.event)
|
|
: undefined
|
|
}
|
|
onDragEnd={canWrite ? onEventDragEnd : undefined}
|
|
title={i18nMessage(
|
|
"i18n:govoplan-calendar.edit_value.fad75899",
|
|
{ value0: item.event.summary },
|
|
)}
|
|
>
|
|
{canWrite && (
|
|
<span
|
|
className="calendar-event-resize-handle is-start"
|
|
draggable
|
|
title="i18n:govoplan-calendar.change_start_time.06eea3d3"
|
|
onDragStart={(event) =>
|
|
onEventResizeDragStart(event, item.event, "start")
|
|
}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="calendar-timed-event-content"
|
|
onClick={() => onEventSelect(item.event)}
|
|
onFocus={() => onEventHover(item.event.id)}
|
|
onBlur={() => onEventHover("")}
|
|
title={i18nMessage(
|
|
"i18n:govoplan-calendar.edit_value.fad75899",
|
|
{ value0: item.event.summary },
|
|
)}
|
|
>
|
|
<EventInlineLabel event={item.event} />
|
|
</button>
|
|
{canWrite && (
|
|
<span
|
|
className="calendar-event-resize-handle is-end"
|
|
draggable
|
|
title="i18n:govoplan-calendar.change_end_time.db66ef4b"
|
|
onDragStart={(event) =>
|
|
onEventResizeDragStart(event, item.event, "end")
|
|
}
|
|
onDragEnd={onEventDragEnd}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
{layout.overflows.map((overflow) => (
|
|
<button
|
|
key={overflow.key}
|
|
type="button"
|
|
className="calendar-timed-overflow"
|
|
style={timedOverflowStyle(overflow)}
|
|
title={overflow.events
|
|
.map((event) => event.summary)
|
|
.join(", ")}
|
|
onClick={() => onEventSelect(overflow.events[0])}
|
|
>
|
|
+{overflow.events.length}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type CalendarEventChipProps = {
|
|
event: CalendarEvent;
|
|
color?: string | null;
|
|
compact?: boolean;
|
|
canDrag?: boolean;
|
|
dragging?: boolean;
|
|
linkedHover?: boolean;
|
|
onSelect: (event: CalendarEvent) => void;
|
|
onHover?: (eventId: string) => void;
|
|
onDragStart?: (
|
|
dragEvent: ReactDragEvent<HTMLElement>,
|
|
event: CalendarEvent,
|
|
) => void;
|
|
onDragEnd?: () => void;
|
|
};
|
|
|
|
function CalendarEventChip({
|
|
event,
|
|
color,
|
|
compact = false,
|
|
canDrag = false,
|
|
dragging = false,
|
|
linkedHover = false,
|
|
onSelect,
|
|
onHover,
|
|
onDragStart,
|
|
onDragEnd,
|
|
}: CalendarEventChipProps) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={[
|
|
compact
|
|
? "calendar-event-chip is-compact"
|
|
: "calendar-event-chip",
|
|
dragging ? "is-dragging" : "",
|
|
linkedHover ? "is-linked-hover" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")}
|
|
style={calendarEventColorStyle(color)}
|
|
draggable={canDrag}
|
|
onClick={() => onSelect(event)}
|
|
onMouseEnter={onHover ? () => onHover(event.id) : undefined}
|
|
onMouseLeave={onHover ? () => onHover("") : undefined}
|
|
onFocus={onHover ? () => onHover(event.id) : undefined}
|
|
onBlur={onHover ? () => onHover("") : undefined}
|
|
onDragStart={
|
|
canDrag && onDragStart
|
|
? (dragEvent) => onDragStart(dragEvent, event)
|
|
: undefined
|
|
}
|
|
onDragEnd={canDrag ? onDragEnd : undefined}
|
|
title={i18nMessage(
|
|
"i18n:govoplan-calendar.edit_value.fad75899",
|
|
{ value0: event.summary },
|
|
)}
|
|
>
|
|
<EventInlineLabel event={event} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function EventInlineLabel({
|
|
event,
|
|
}: {
|
|
event: CalendarEvent;
|
|
}) {
|
|
return (
|
|
<span className="calendar-event-line">
|
|
<span className="calendar-event-time">
|
|
{eventTimeLabel(event)}
|
|
</span>
|
|
<span className="calendar-event-separator">-</span>
|
|
<strong>{event.summary}</strong>
|
|
</span>
|
|
);
|
|
}
|