Merge pull request #36 from suitenumerique/responsive/make-first-responsive-pass
✨(front) add responsive/mobile support for calendar
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0 <25.0.0",
|
"node": ">=24.0.0 <=25.8.0",
|
||||||
"npm": ">=10.0.0"
|
"npm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -144,6 +144,10 @@
|
|||||||
opacity 0.15s,
|
opacity 0.15s,
|
||||||
background-color 0.15s,
|
background-color 0.15s,
|
||||||
color 0.15s;
|
color 0.15s;
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__menu {
|
&__menu {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import {
|
import {
|
||||||
addMonths,
|
addMonths,
|
||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
@@ -21,6 +22,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useCalendarContext } from "../../contexts";
|
import { useCalendarContext } from "../../contexts";
|
||||||
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
|
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
|
||||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||||
|
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
|
||||||
|
|
||||||
interface MiniCalendarProps {
|
interface MiniCalendarProps {
|
||||||
selectedDate: Date;
|
selectedDate: Date;
|
||||||
@@ -42,6 +44,9 @@ export const MiniCalendar = ({
|
|||||||
}: MiniCalendarProps) => {
|
}: MiniCalendarProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { goToDate, currentDate } = useCalendarContext();
|
const { goToDate, currentDate } = useCalendarContext();
|
||||||
|
const leftPanel = useLeftPanel();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const { dateFnsLocale, firstDayOfWeek } = useCalendarLocale();
|
const { dateFnsLocale, firstDayOfWeek } = useCalendarLocale();
|
||||||
const [viewDate, setViewDate] = useState(selectedDate);
|
const [viewDate, setViewDate] = useState(selectedDate);
|
||||||
|
|
||||||
@@ -98,6 +103,9 @@ export const MiniCalendar = ({
|
|||||||
const handleDayClick = (day: Date) => {
|
const handleDayClick = (day: Date) => {
|
||||||
onDateSelect(day);
|
onDateSelect(day);
|
||||||
goToDate(day);
|
goToDate(day);
|
||||||
|
if (isMobile) {
|
||||||
|
leftPanel.setIsLeftPanelOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.datetime-section__inputs {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-section__arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// VideoConferenceSection layout
|
// VideoConferenceSection layout
|
||||||
.video-conference-section {
|
.video-conference-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { InvitationResponseSection } from "./event-modal-sections/InvitationResp
|
|||||||
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
|
import { FreeBusySection } from "./event-modal-sections/FreeBusySection";
|
||||||
import { SectionPills } from "./event-modal-sections/SectionPills";
|
import { SectionPills } from "./event-modal-sections/SectionPills";
|
||||||
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
import { useResourcePrincipals } from "@/features/resources/api/useResourcePrincipals";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
import type { EventModalProps, RecurringDeleteOption } from "./types";
|
||||||
import { SectionRow } from "./event-modal-sections/SectionRow";
|
import { SectionRow } from "./event-modal-sections/SectionRow";
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const EventModal = ({
|
|||||||
}: EventModalProps) => {
|
}: EventModalProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ export const EventModal = ({
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size={ModalSize.MEDIUM}
|
size={isMobile ? ModalSize.FULL : ModalSize.MEDIUM}
|
||||||
title={
|
title={
|
||||||
mode === "create"
|
mode === "create"
|
||||||
? t("calendar.event.createTitle")
|
? t("calendar.event.createTitle")
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
#event-calendar {
|
.scheduler {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduler__calendar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,3 +221,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ec-col-head {
|
||||||
|
background-color: var(
|
||||||
|
--c--contextuals--background--surface--secondary
|
||||||
|
) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* - Click to create (dateClick)
|
* - Click to create (dateClick)
|
||||||
* - Select range to create (select)
|
* - Select range to create (select)
|
||||||
* - Custom toolbar with navigation and view selection
|
* - Custom toolbar with navigation and view selection
|
||||||
|
* - Mobile-optimized views (1 day, 2 days, list)
|
||||||
*
|
*
|
||||||
* Next.js consideration: This component must be client-side only
|
* Next.js consideration: This component must be client-side only
|
||||||
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
|
* due to DOM manipulation. Use dynamic import with ssr: false if needed.
|
||||||
@@ -16,19 +17,57 @@
|
|||||||
|
|
||||||
import "@event-calendar/core/index.css";
|
import "@event-calendar/core/index.css";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { useCalendarContext } from "../../contexts/CalendarContext";
|
import { useCalendarContext } from "../../contexts/CalendarContext";
|
||||||
|
import { useCalendarLocale } from "../../hooks/useCalendarLocale";
|
||||||
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
import type { CalDavCalendar } from "../../services/dav/types/caldav-service";
|
||||||
|
import type { CalDavExtendedProps } from "../../services/dav/EventCalendarAdapter";
|
||||||
|
import type { EventCalendarEvent } from "../../services/dav/types/event-calendar";
|
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
|
||||||
import { EventModal } from "./EventModal";
|
import { EventModal } from "./EventModal";
|
||||||
import { SchedulerToolbar } from "./SchedulerToolbar";
|
import { SchedulerToolbar } from "./SchedulerToolbar";
|
||||||
import type { SchedulerProps, EventModalState } from "./types";
|
import type { SchedulerProps, EventModalState, MobileListEvent } from "./types";
|
||||||
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
|
import { useSchedulerHandlers } from "./hooks/useSchedulerHandlers";
|
||||||
import {
|
import {
|
||||||
useSchedulerInit,
|
useSchedulerInit,
|
||||||
useSchedulingCapabilitiesCheck,
|
useSchedulingCapabilitiesCheck,
|
||||||
} from "./hooks/useSchedulerInit";
|
} from "./hooks/useSchedulerInit";
|
||||||
|
import { useMobileNavigation } from "./hooks/useMobileNavigation";
|
||||||
|
|
||||||
|
const MobileToolbar = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./mobile/MobileToolbar").then((m) => ({
|
||||||
|
default: m.MobileToolbar,
|
||||||
|
})),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const WeekDayBar = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./mobile/WeekDayBar").then((m) => ({ default: m.WeekDayBar })),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const FloatingActionButton = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./mobile/FloatingActionButton").then((m) => ({
|
||||||
|
default: m.FloatingActionButton,
|
||||||
|
})),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const MobileListView = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./mobile/MobileListView").then((m) => ({
|
||||||
|
default: m.MobileListView,
|
||||||
|
})),
|
||||||
|
{ ssr: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const BROWSER_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
||||||
const {
|
const {
|
||||||
@@ -38,9 +77,13 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
|||||||
visibleCalendarUrls,
|
visibleCalendarUrls,
|
||||||
isConnected,
|
isConnected,
|
||||||
calendarRef: contextCalendarRef,
|
calendarRef: contextCalendarRef,
|
||||||
|
currentDate,
|
||||||
setCurrentDate,
|
setCurrentDate,
|
||||||
} = useCalendarContext();
|
} = useCalendarContext();
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { firstDayOfWeek, intlLocale } = useCalendarLocale();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const calendarRef = contextCalendarRef;
|
const calendarRef = contextCalendarRef;
|
||||||
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
|
const [calendarUrl, setCalendarUrl] = useState(defaultCalendarUrl || "");
|
||||||
@@ -103,8 +146,15 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
|||||||
view?: { type: string; title: string };
|
view?: { type: string; title: string };
|
||||||
}) => {
|
}) => {
|
||||||
// Update current date for MiniCalendar sync
|
// Update current date for MiniCalendar sync
|
||||||
const midTime = (info.start.getTime() + info.end.getTime()) / 2;
|
// Use start for short views (day, 2-day) to avoid mid-point drift,
|
||||||
setCurrentDate(new Date(midTime));
|
// use midpoint for longer views (week, month) for better centering
|
||||||
|
const durationMs = info.end.getTime() - info.start.getTime();
|
||||||
|
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
||||||
|
const syncDate =
|
||||||
|
durationMs <= threeDaysMs
|
||||||
|
? info.start
|
||||||
|
: new Date((info.start.getTime() + info.end.getTime()) / 2);
|
||||||
|
setCurrentDate(syncDate);
|
||||||
|
|
||||||
// Update toolbar state
|
// Update toolbar state
|
||||||
if (calendarRef.current) {
|
if (calendarRef.current) {
|
||||||
@@ -118,6 +168,12 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
|||||||
[setCurrentDate, calendarRef],
|
[setCurrentDate, calendarRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Counter incremented each time the calendar finishes loading events
|
||||||
|
const [eventsLoadedCounter, setEventsLoadedCounter] = useState(0);
|
||||||
|
const handleEventsLoaded = useCallback(() => {
|
||||||
|
setEventsLoadedCounter((c) => c + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize calendar
|
// Initialize calendar
|
||||||
// Cast handlers to bypass library type differences between specific event types and unknown
|
// Cast handlers to bypass library type differences between specific event types and unknown
|
||||||
useSchedulerInit({
|
useSchedulerInit({
|
||||||
@@ -129,12 +185,14 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
|||||||
adapter,
|
adapter,
|
||||||
visibleCalendarUrlsRef,
|
visibleCalendarUrlsRef,
|
||||||
davCalendarsRef,
|
davCalendarsRef,
|
||||||
|
initialView: isMobile ? "timeGridDay" : "timeGridWeek",
|
||||||
setCurrentDate: handleDatesSet,
|
setCurrentDate: handleDatesSet,
|
||||||
handleEventClick: handleEventClick as (info: unknown) => void,
|
handleEventClick: handleEventClick as (info: unknown) => void,
|
||||||
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
|
handleEventDrop: handleEventDrop as unknown as (info: unknown) => void,
|
||||||
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
|
handleEventResize: handleEventResize as unknown as (info: unknown) => void,
|
||||||
handleDateClick: handleDateClick as (info: unknown) => void,
|
handleDateClick: handleDateClick as (info: unknown) => void,
|
||||||
handleSelect: handleSelect as (info: unknown) => void,
|
handleSelect: handleSelect as (info: unknown) => void,
|
||||||
|
onEventsLoaded: handleEventsLoaded,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update toolbar title on initial render
|
// Update toolbar title on initial render
|
||||||
@@ -151,32 +209,173 @@ export const Scheduler = ({ defaultCalendarUrl }: SchedulerProps) => {
|
|||||||
// Update eventFilter when visible calendars change
|
// Update eventFilter when visible calendars change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (calendarRef.current) {
|
if (calendarRef.current) {
|
||||||
// The refs are already updated (synchronously during render)
|
|
||||||
// Now trigger a re-evaluation of eventFilter by calling refetchEvents
|
|
||||||
calendarRef.current.refetchEvents();
|
calendarRef.current.refetchEvents();
|
||||||
}
|
}
|
||||||
}, [visibleCalendarUrls, davCalendars]);
|
}, [visibleCalendarUrls, davCalendars]);
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback(
|
||||||
setCurrentView(view);
|
(view: string) => {
|
||||||
}, []);
|
calendarRef.current?.setOption("view", view);
|
||||||
|
setCurrentView(view);
|
||||||
|
},
|
||||||
|
[calendarRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-switch view when crossing mobile/desktop breakpoint
|
||||||
|
const prevIsMobileRef = useRef(isMobile);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevIsMobileRef.current === isMobile) return;
|
||||||
|
prevIsMobileRef.current = isMobile;
|
||||||
|
const targetView = isMobile ? "timeGridDay" : "timeGridWeek";
|
||||||
|
handleViewChange(targetView);
|
||||||
|
}, [isMobile, handleViewChange]);
|
||||||
|
|
||||||
|
// Mobile list view events (extracted from ref via effect to avoid ref access during render)
|
||||||
|
const [listEvents, setListEvents] = useState<MobileListEvent[]>([]);
|
||||||
|
const isListView = isMobile && currentView === "listWeek";
|
||||||
|
const currentDateMs = currentDate.getTime();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isListView || !calendarRef.current) {
|
||||||
|
setListEvents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractTitle = (
|
||||||
|
title: string | { html: string } | { domNodes: Node[] } | undefined,
|
||||||
|
): string => {
|
||||||
|
if (!title) return "";
|
||||||
|
if (typeof title === "string") return title;
|
||||||
|
if ("html" in title) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.innerHTML = title.html;
|
||||||
|
return div.textContent || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const rawEvents = calendarRef.current.getEvents();
|
||||||
|
const parsed: MobileListEvent[] = rawEvents.map((e) => ({
|
||||||
|
id: String(e.id),
|
||||||
|
title: extractTitle(e.title),
|
||||||
|
start: new Date(e.start),
|
||||||
|
end: e.end ? new Date(e.end) : new Date(e.start),
|
||||||
|
allDay: e.allDay ?? false,
|
||||||
|
backgroundColor: e.backgroundColor || "#2563eb",
|
||||||
|
extendedProps: e.extendedProps ?? {},
|
||||||
|
}));
|
||||||
|
setListEvents(parsed);
|
||||||
|
}, [isListView, currentDateMs, visibleCalendarUrls, eventsLoadedCounter]);
|
||||||
|
|
||||||
|
// Mobile navigation
|
||||||
|
const {
|
||||||
|
weekDays,
|
||||||
|
handleWeekPrev,
|
||||||
|
handleWeekNext,
|
||||||
|
handleDayClick,
|
||||||
|
handleTodayClick,
|
||||||
|
} = useMobileNavigation({
|
||||||
|
currentDate,
|
||||||
|
firstDayOfWeek,
|
||||||
|
calendarRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
// FAB click: open create modal with current date/time
|
||||||
|
const handleFabClick = useCallback(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(currentDate);
|
||||||
|
startDate.setHours(now.getHours(), 0, 0, 0);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setHours(startDate.getHours() + 1);
|
||||||
|
|
||||||
|
const defaultUrl = calendarUrl || davCalendars[0]?.url || "";
|
||||||
|
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
mode: "create",
|
||||||
|
event: {
|
||||||
|
uid: crypto.randomUUID(),
|
||||||
|
stamp: { date: new Date() },
|
||||||
|
start: { date: startDate, type: "DATE-TIME" },
|
||||||
|
end: { date: endDate, type: "DATE-TIME" },
|
||||||
|
},
|
||||||
|
calendarUrl: defaultUrl,
|
||||||
|
});
|
||||||
|
}, [currentDate, calendarUrl, davCalendars]);
|
||||||
|
|
||||||
|
// Mobile list view event click
|
||||||
|
const handleMobileEventClick = useCallback(
|
||||||
|
(eventId: string, extendedProps: Record<string, unknown>) => {
|
||||||
|
const events = calendarRef.current?.getEvents() ?? [];
|
||||||
|
const event = events.find((e) => String(e.id) === eventId);
|
||||||
|
if (!event) return;
|
||||||
|
|
||||||
|
const extProps = extendedProps as CalDavExtendedProps;
|
||||||
|
const icsEvent = adapter.toIcsEvent(event as EventCalendarEvent, {
|
||||||
|
defaultTimezone: extProps?.timezone || BROWSER_TIMEZONE,
|
||||||
|
});
|
||||||
|
|
||||||
|
setModalState({
|
||||||
|
isOpen: true,
|
||||||
|
mode: "edit",
|
||||||
|
event: icsEvent,
|
||||||
|
calendarUrl: extProps?.calendarUrl || calendarUrl,
|
||||||
|
eventUrl: extProps?.eventUrl,
|
||||||
|
etag: extProps?.etag,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[adapter, calendarUrl, calendarRef],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scheduler">
|
<div className="scheduler">
|
||||||
<SchedulerToolbar
|
{isMobile ? (
|
||||||
calendarRef={calendarRef}
|
<>
|
||||||
currentView={currentView}
|
<MobileToolbar
|
||||||
viewTitle={viewTitle}
|
calendarRef={calendarRef}
|
||||||
onViewChange={handleViewChange}
|
currentView={currentView}
|
||||||
/>
|
currentDate={currentDate}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
onWeekPrev={handleWeekPrev}
|
||||||
|
onWeekNext={handleWeekNext}
|
||||||
|
onTodayClick={handleTodayClick}
|
||||||
|
/>
|
||||||
|
<WeekDayBar
|
||||||
|
currentDate={currentDate}
|
||||||
|
currentView={currentView}
|
||||||
|
intlLocale={intlLocale}
|
||||||
|
weekDays={weekDays}
|
||||||
|
onDayClick={handleDayClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SchedulerToolbar
|
||||||
|
calendarRef={calendarRef}
|
||||||
|
currentView={currentView}
|
||||||
|
viewTitle={viewTitle}
|
||||||
|
onViewChange={handleViewChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isListView && (
|
||||||
|
<MobileListView
|
||||||
|
weekDays={weekDays}
|
||||||
|
events={listEvents}
|
||||||
|
intlLocale={intlLocale}
|
||||||
|
onEventClick={handleMobileEventClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
id="event-calendar"
|
id="event-calendar"
|
||||||
className="scheduler__calendar"
|
className="scheduler__calendar"
|
||||||
style={{ height: "calc(100vh - 52px - 90px)" }}
|
style={{
|
||||||
|
display: isListView ? "none" : undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isMobile && <FloatingActionButton onClick={handleFabClick} />}
|
||||||
|
|
||||||
<EventModal
|
<EventModal
|
||||||
isOpen={modalState.isOpen}
|
isOpen={modalState.isOpen}
|
||||||
mode={modalState.mode}
|
mode={modalState.mode}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section-pill {
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.section-pills {
|
.section-pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -101,6 +101,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section-row__content {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes sectionSlideDown {
|
@keyframes sectionSlideDown {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import type { CalendarApi } from "../types";
|
||||||
|
import { addDays, getWeekStart } from "@/utils/date";
|
||||||
|
|
||||||
|
interface UseMobileNavigationProps {
|
||||||
|
currentDate: Date;
|
||||||
|
firstDayOfWeek: number;
|
||||||
|
calendarRef: React.RefObject<CalendarApi | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMobileNavigation({
|
||||||
|
currentDate,
|
||||||
|
firstDayOfWeek,
|
||||||
|
calendarRef,
|
||||||
|
}: UseMobileNavigationProps) {
|
||||||
|
const weekStart = useMemo(
|
||||||
|
() => getWeekStart(currentDate, firstDayOfWeek),
|
||||||
|
[currentDate, firstDayOfWeek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const weekDays = useMemo(() => {
|
||||||
|
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
|
||||||
|
}, [weekStart]);
|
||||||
|
|
||||||
|
const navigatePreservingScroll = useCallback(
|
||||||
|
(date: Date) => {
|
||||||
|
const ecBody = calendarRef.current
|
||||||
|
? (document.querySelector(".ec-main") as HTMLElement | null)
|
||||||
|
: null;
|
||||||
|
const scrollTop = ecBody?.scrollTop ?? 0;
|
||||||
|
|
||||||
|
calendarRef.current?.setOption("date", date);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (ecBody) ecBody.scrollTop = scrollTop;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[calendarRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWeekPrev = useCallback(() => {
|
||||||
|
navigatePreservingScroll(addDays(currentDate, -7));
|
||||||
|
}, [currentDate, navigatePreservingScroll]);
|
||||||
|
|
||||||
|
const handleWeekNext = useCallback(() => {
|
||||||
|
navigatePreservingScroll(addDays(currentDate, 7));
|
||||||
|
}, [currentDate, navigatePreservingScroll]);
|
||||||
|
|
||||||
|
const handleDayClick = useCallback(
|
||||||
|
(date: Date) => {
|
||||||
|
navigatePreservingScroll(date);
|
||||||
|
},
|
||||||
|
[navigatePreservingScroll],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTodayClick = useCallback(() => {
|
||||||
|
calendarRef.current?.setOption("date", new Date());
|
||||||
|
}, [calendarRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekStart,
|
||||||
|
weekDays,
|
||||||
|
handleWeekPrev,
|
||||||
|
handleWeekNext,
|
||||||
|
handleDayClick,
|
||||||
|
handleTodayClick,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -33,12 +33,14 @@ interface UseSchedulerInitProps {
|
|||||||
adapter: EventCalendarAdapter;
|
adapter: EventCalendarAdapter;
|
||||||
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
|
visibleCalendarUrlsRef: MutableRefObject<Set<string>>;
|
||||||
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
|
davCalendarsRef: MutableRefObject<CalDavCalendar[]>;
|
||||||
|
initialView?: string;
|
||||||
setCurrentDate: (info: { start: Date; end: Date }) => void;
|
setCurrentDate: (info: { start: Date; end: Date }) => void;
|
||||||
handleEventClick: (info: unknown) => void;
|
handleEventClick: (info: unknown) => void;
|
||||||
handleEventDrop: (info: unknown) => void;
|
handleEventDrop: (info: unknown) => void;
|
||||||
handleEventResize: (info: unknown) => void;
|
handleEventResize: (info: unknown) => void;
|
||||||
handleDateClick: (info: unknown) => void;
|
handleDateClick: (info: unknown) => void;
|
||||||
handleSelect: (info: unknown) => void;
|
handleSelect: (info: unknown) => void;
|
||||||
|
onEventsLoaded?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get current time as HH:MM string
|
// Helper to get current time as HH:MM string
|
||||||
@@ -56,12 +58,14 @@ export const useSchedulerInit = ({
|
|||||||
adapter,
|
adapter,
|
||||||
visibleCalendarUrlsRef,
|
visibleCalendarUrlsRef,
|
||||||
davCalendarsRef,
|
davCalendarsRef,
|
||||||
|
initialView = "timeGridWeek",
|
||||||
setCurrentDate,
|
setCurrentDate,
|
||||||
handleEventClick,
|
handleEventClick,
|
||||||
handleEventDrop,
|
handleEventDrop,
|
||||||
handleEventResize,
|
handleEventResize,
|
||||||
handleDateClick,
|
handleDateClick,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
|
onEventsLoaded,
|
||||||
}: UseSchedulerInitProps) => {
|
}: UseSchedulerInitProps) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
|
const { calendarLocale, firstDayOfWeek, formatDayHeader } = useCalendarLocale();
|
||||||
@@ -78,6 +82,7 @@ export const useSchedulerInit = ({
|
|||||||
handleDateClick,
|
handleDateClick,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
setCurrentDate,
|
setCurrentDate,
|
||||||
|
onEventsLoaded,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update refs when handlers change (no effect dependencies = no calendar recreation)
|
// Update refs when handlers change (no effect dependencies = no calendar recreation)
|
||||||
@@ -89,6 +94,7 @@ export const useSchedulerInit = ({
|
|||||||
handleDateClick,
|
handleDateClick,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
setCurrentDate,
|
setCurrentDate,
|
||||||
|
onEventsLoaded,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +106,18 @@ export const useSchedulerInit = ({
|
|||||||
[TimeGrid, DayGrid, List, Interaction],
|
[TimeGrid, DayGrid, List, Interaction],
|
||||||
{
|
{
|
||||||
// View configuration
|
// View configuration
|
||||||
view: "timeGridWeek",
|
view: initialView,
|
||||||
// Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
|
// Native toolbar disabled - using custom React toolbar (SchedulerToolbar)
|
||||||
headerToolbar: false,
|
headerToolbar: false,
|
||||||
|
|
||||||
|
// Custom views
|
||||||
|
views: {
|
||||||
|
timeGridTwoDays: {
|
||||||
|
type: "timeGridDay",
|
||||||
|
duration: { days: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Locale & time settings
|
// Locale & time settings
|
||||||
locale: calendarLocale,
|
locale: calendarLocale,
|
||||||
firstDay: firstDayOfWeek,
|
firstDay: firstDayOfWeek,
|
||||||
@@ -246,7 +260,12 @@ export const useSchedulerInit = ({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Loading state is handled internally by the calendar
|
// Notify when events finish loading (used by mobile list view)
|
||||||
|
loading: (isLoading: boolean) => {
|
||||||
|
if (!isLoading) {
|
||||||
|
handlersRef.current.onEventsLoaded?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
.fab-create-event {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 28px;
|
||||||
|
border: none;
|
||||||
|
background: var(--c--contextuals--background--semantic--brand--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { FloatingActionButtonProps } from "../types";
|
||||||
|
|
||||||
|
export const FloatingActionButton = ({ onClick }: FloatingActionButtonProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="fab-create-event"
|
||||||
|
onClick={onClick}
|
||||||
|
type="button"
|
||||||
|
aria-label={t("calendar.leftPanel.newEvent")}
|
||||||
|
>
|
||||||
|
<span className="material-icons fab-create-event__icon" aria-hidden="true">add</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
.mobile-list {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&__day {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--today {
|
||||||
|
background: var(--c--contextuals--background--semantic--brand--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__today-tag {
|
||||||
|
background: var(--c--contextuals--background--semantic--brand--secondary);
|
||||||
|
color: var(--c--contextuals--content--semantic--brand--primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__events {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__event-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--c--contextuals--border--surface--primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--c--contextuals--background--surface--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--c--contextuals--background--surface--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__color-strip {
|
||||||
|
width: 4px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__event-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__event-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__event-time {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chevron {
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--c--contextuals--background--surface--secondary);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { MobileListViewProps, MobileListEvent } from "../types";
|
||||||
|
import { isSameDay, isToday } from "@/utils/date";
|
||||||
|
|
||||||
|
function groupEventsByDay(
|
||||||
|
events: MobileListEvent[],
|
||||||
|
weekDays: Date[],
|
||||||
|
): Map<string, MobileListEvent[]> {
|
||||||
|
const grouped = new Map<string, MobileListEvent[]>();
|
||||||
|
for (const day of weekDays) {
|
||||||
|
grouped.set(day.toISOString(), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
for (const day of weekDays) {
|
||||||
|
if (isSameDay(event.start, day)) {
|
||||||
|
grouped.get(day.toISOString())?.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, dayEvents] of grouped) {
|
||||||
|
dayEvents.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileListView = ({
|
||||||
|
weekDays,
|
||||||
|
events,
|
||||||
|
intlLocale,
|
||||||
|
onEventClick,
|
||||||
|
}: MobileListViewProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const eventsByDay = useMemo(
|
||||||
|
() => groupEventsByDay(events, weekDays),
|
||||||
|
[events, weekDays],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayHeaderFormatter = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(intlLocale, {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
}),
|
||||||
|
[intlLocale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFormatter = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(intlLocale, {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
}),
|
||||||
|
[intlLocale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-list">
|
||||||
|
{weekDays.map((day) => {
|
||||||
|
const dayKey = day.toISOString();
|
||||||
|
const dayEvents = eventsByDay.get(dayKey) ?? [];
|
||||||
|
const dayIsToday = isToday(day);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={dayKey} className="mobile-list__day">
|
||||||
|
<div className="mobile-list__day-header">
|
||||||
|
<span
|
||||||
|
className={`mobile-list__day-dot ${
|
||||||
|
dayIsToday ? "mobile-list__day-dot--today" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className="mobile-list__day-title">
|
||||||
|
{dayHeaderFormatter.format(day)}
|
||||||
|
</span>
|
||||||
|
{dayIsToday && (
|
||||||
|
<span className="mobile-list__today-tag">
|
||||||
|
{t("calendar.views.today")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayEvents.length === 0 ? (
|
||||||
|
<div className="mobile-list__empty">
|
||||||
|
<span className="material-icons mobile-list__empty-icon">
|
||||||
|
event_busy
|
||||||
|
</span>
|
||||||
|
<span className="mobile-list__empty-text">
|
||||||
|
{t("calendar.views.mobile.noEvents")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mobile-list__events">
|
||||||
|
{dayEvents.map((event) => (
|
||||||
|
<button
|
||||||
|
key={String(event.id)}
|
||||||
|
className="mobile-list__event-card"
|
||||||
|
onClick={() =>
|
||||||
|
onEventClick(String(event.id), event.extendedProps)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mobile-list__color-strip"
|
||||||
|
style={{ backgroundColor: event.backgroundColor }}
|
||||||
|
/>
|
||||||
|
<div className="mobile-list__event-info">
|
||||||
|
<span className="mobile-list__event-title">
|
||||||
|
{event.title || t("calendar.event.titlePlaceholder")}
|
||||||
|
</span>
|
||||||
|
<span className="mobile-list__event-time">
|
||||||
|
{event.allDay
|
||||||
|
? t("calendar.event.allDay")
|
||||||
|
: `${timeFormatter.format(event.start)} - ${timeFormatter.format(event.end)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-icons mobile-list__chevron">
|
||||||
|
chevron_right
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
.mobile-toolbar {
|
||||||
|
background: var(--c--contextuals--background--surface--primary);
|
||||||
|
|
||||||
|
&__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__today-btn {
|
||||||
|
border-radius: 20px !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__nav-arrows {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-wrapper {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid var(--c--contextuals--border--surface--primary);
|
||||||
|
background: var(--c--contextuals--background--surface--primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--c--contextuals--background--surface--secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__view-arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMemo, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||||
|
import { DropdownMenu, type DropdownMenuOption } from "@gouvfr-lasuite/ui-kit";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { useCalendarLocale } from "../../../hooks/useCalendarLocale";
|
||||||
|
import type { MobileToolbarProps } from "../types";
|
||||||
|
|
||||||
|
function formatMobileTitle(
|
||||||
|
currentDate: Date,
|
||||||
|
intlLocale: string,
|
||||||
|
): string {
|
||||||
|
return new Intl.DateTimeFormat(intlLocale, {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(currentDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileToolbar = ({
|
||||||
|
currentView,
|
||||||
|
currentDate,
|
||||||
|
onViewChange,
|
||||||
|
onWeekPrev,
|
||||||
|
onWeekNext,
|
||||||
|
onTodayClick,
|
||||||
|
}: MobileToolbarProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { intlLocale } = useCalendarLocale();
|
||||||
|
const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
onViewChange(value);
|
||||||
|
setIsViewDropdownOpen(false);
|
||||||
|
},
|
||||||
|
[onViewChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewOptions: DropdownMenuOption[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
value: "timeGridDay",
|
||||||
|
label: t("calendar.views.mobile.oneDay"),
|
||||||
|
callback: () => handleViewChange("timeGridDay"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "timeGridTwoDays",
|
||||||
|
label: t("calendar.views.mobile.twoDays"),
|
||||||
|
callback: () => handleViewChange("timeGridTwoDays"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "listWeek",
|
||||||
|
label: t("calendar.views.mobile.list"),
|
||||||
|
callback: () => handleViewChange("listWeek"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, handleViewChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentViewLabel = useMemo(() => {
|
||||||
|
const option = viewOptions.find((opt) => opt.value === currentView);
|
||||||
|
return option?.label || t("calendar.views.mobile.oneDay");
|
||||||
|
}, [currentView, viewOptions, t]);
|
||||||
|
|
||||||
|
const title = useMemo(
|
||||||
|
() => formatMobileTitle(currentDate, intlLocale),
|
||||||
|
[currentDate, intlLocale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-toolbar">
|
||||||
|
<div className="mobile-toolbar__nav">
|
||||||
|
<Button
|
||||||
|
color="neutral"
|
||||||
|
variant="bordered"
|
||||||
|
size="small"
|
||||||
|
onClick={onTodayClick}
|
||||||
|
className="mobile-toolbar__today-btn"
|
||||||
|
>
|
||||||
|
{t("calendar.views.today")}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="mobile-toolbar__nav-arrows">
|
||||||
|
<Button
|
||||||
|
color="neutral"
|
||||||
|
variant="tertiary"
|
||||||
|
size="small"
|
||||||
|
onClick={onWeekPrev}
|
||||||
|
icon={<span className="material-icons">chevron_left</span>}
|
||||||
|
aria-label={t("calendar.navigation.previous")}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="neutral"
|
||||||
|
variant="tertiary"
|
||||||
|
size="small"
|
||||||
|
onClick={onWeekNext}
|
||||||
|
icon={<span className="material-icons">chevron_right</span>}
|
||||||
|
aria-label={t("calendar.navigation.next")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="mobile-toolbar__date-title">{title}</span>
|
||||||
|
|
||||||
|
<div className="mobile-toolbar__view-wrapper">
|
||||||
|
<DropdownMenu
|
||||||
|
options={viewOptions}
|
||||||
|
isOpen={isViewDropdownOpen}
|
||||||
|
onOpenChange={setIsViewDropdownOpen}
|
||||||
|
selectedValues={[currentView]}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="mobile-toolbar__view-selector"
|
||||||
|
onClick={() => setIsViewDropdownOpen(!isViewDropdownOpen)}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isViewDropdownOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
>
|
||||||
|
<span>{currentViewLabel}</span>
|
||||||
|
<span
|
||||||
|
className={`material-icons mobile-toolbar__view-arrow ${
|
||||||
|
isViewDropdownOpen ? "mobile-toolbar__view-arrow--open" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
expand_more
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.week-day-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
|
||||||
|
background: var(--c--contextuals--background--surface--primary);
|
||||||
|
|
||||||
|
// Row 1: day name letters
|
||||||
|
&__names {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__day-name {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&--weekend {
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 2: day numbers
|
||||||
|
&__numbers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__number {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
|
||||||
|
&--weekend {
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected: circle drawn as pseudo-element so flex: 1 stays
|
||||||
|
&--selected {
|
||||||
|
color: var(--c--contextuals--content--semantic--neutral--on-neutral);
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c--contextuals--background--semantic--brand--primary);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { WeekDayBarProps } from "../types";
|
||||||
|
import { isSameDay, isWeekend, addDays } from "@/utils/date";
|
||||||
|
|
||||||
|
export const WeekDayBar = ({
|
||||||
|
currentDate,
|
||||||
|
currentView,
|
||||||
|
intlLocale,
|
||||||
|
weekDays,
|
||||||
|
onDayClick,
|
||||||
|
}: WeekDayBarProps) => {
|
||||||
|
const today = useMemo(() => new Date(), []);
|
||||||
|
|
||||||
|
const narrowDayFormatter = useMemo(
|
||||||
|
() => new Intl.DateTimeFormat(intlLocale, { weekday: "narrow" }),
|
||||||
|
[intlLocale],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTwoDays = currentView === "timeGridTwoDays";
|
||||||
|
|
||||||
|
const isSelected = (date: Date): boolean => {
|
||||||
|
if (isTwoDays) {
|
||||||
|
const nextDay = addDays(currentDate, 1);
|
||||||
|
return isSameDay(date, currentDate) || isSameDay(date, nextDay);
|
||||||
|
}
|
||||||
|
if (currentView === "timeGridDay") {
|
||||||
|
return isSameDay(date, currentDate);
|
||||||
|
}
|
||||||
|
if (currentView === "listWeek") {
|
||||||
|
return isSameDay(date, today);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = (date: Date) => {
|
||||||
|
if (currentView !== "listWeek") {
|
||||||
|
onDayClick(date);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="week-day-bar">
|
||||||
|
<div className="week-day-bar__names">
|
||||||
|
{weekDays.map((date) => (
|
||||||
|
<span
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={`week-day-bar__day-name${isWeekend(date) ? " week-day-bar__day-name--weekend" : ""}`}
|
||||||
|
>
|
||||||
|
{narrowDayFormatter.format(date)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="week-day-bar__numbers">
|
||||||
|
{weekDays.map((date) => {
|
||||||
|
const classes = [
|
||||||
|
"week-day-bar__number",
|
||||||
|
isSelected(date) && "week-day-bar__number--selected",
|
||||||
|
isWeekend(date) && "week-day-bar__number--weekend",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date.toISOString()}
|
||||||
|
className={classes}
|
||||||
|
onClick={() => handleClick(date)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -259,3 +259,12 @@
|
|||||||
border-right: 1px solid var(--ec-border-color);
|
border-right: 1px solid var(--ec-border-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 9. Mobile adjustments - Hide native day headers (WeekDayBar replaces them)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ec-header {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ export interface CalendarApi {
|
|||||||
addEvent: (event: unknown) => void;
|
addEvent: (event: unknown) => void;
|
||||||
unselect: () => void;
|
unselect: () => void;
|
||||||
refetchEvents: () => void;
|
refetchEvents: () => void;
|
||||||
|
getEvents: () => Array<{
|
||||||
|
id: string | number;
|
||||||
|
title?: string | { html: string } | { domNodes: Node[] };
|
||||||
|
start: Date | string;
|
||||||
|
end?: Date | string;
|
||||||
|
allDay?: boolean;
|
||||||
|
backgroundColor?: string;
|
||||||
|
extendedProps?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -142,3 +151,59 @@ export interface SchedulerToolbarProps {
|
|||||||
viewTitle: string;
|
viewTitle: string;
|
||||||
onViewChange?: (view: string) => void;
|
onViewChange?: (view: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile-specific view types.
|
||||||
|
*/
|
||||||
|
export type MobileView = "timeGridDay" | "timeGridTwoDays" | "listWeek";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the MobileToolbar component.
|
||||||
|
*/
|
||||||
|
export interface MobileToolbarProps {
|
||||||
|
calendarRef: React.RefObject<CalendarApi | null>;
|
||||||
|
currentView: string;
|
||||||
|
currentDate: Date;
|
||||||
|
onViewChange: (view: string) => void;
|
||||||
|
onWeekPrev: () => void;
|
||||||
|
onWeekNext: () => void;
|
||||||
|
onTodayClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the WeekDayBar component.
|
||||||
|
*/
|
||||||
|
export interface WeekDayBarProps {
|
||||||
|
currentDate: Date;
|
||||||
|
currentView: string;
|
||||||
|
intlLocale: string;
|
||||||
|
weekDays: Date[];
|
||||||
|
onDayClick: (date: Date) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the FloatingActionButton component.
|
||||||
|
*/
|
||||||
|
export interface FloatingActionButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the MobileListView component.
|
||||||
|
*/
|
||||||
|
export interface MobileListEvent {
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
allDay: boolean;
|
||||||
|
backgroundColor: string;
|
||||||
|
extendedProps: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileListViewProps {
|
||||||
|
weekDays: Date[];
|
||||||
|
events: MobileListEvent[];
|
||||||
|
intlLocale: string;
|
||||||
|
onEventClick: (eventId: string, extendedProps: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,13 @@
|
|||||||
"listWeek": "Week list",
|
"listWeek": "Week list",
|
||||||
"listMonth": "Month list",
|
"listMonth": "Month list",
|
||||||
"listYear": "Year list",
|
"listYear": "Year list",
|
||||||
"today": "Today"
|
"today": "Today",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "One day",
|
||||||
|
"twoDays": "Two days",
|
||||||
|
"list": "List",
|
||||||
|
"noEvents": "No events"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
@@ -982,7 +988,13 @@
|
|||||||
"listWeek": "Liste semaine",
|
"listWeek": "Liste semaine",
|
||||||
"listMonth": "Liste mois",
|
"listMonth": "Liste mois",
|
||||||
"listYear": "Liste année",
|
"listYear": "Liste année",
|
||||||
"today": "Aujourd'hui"
|
"today": "Aujourd'hui",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "Un jour",
|
||||||
|
"twoDays": "2 jours",
|
||||||
|
"list": "Liste",
|
||||||
|
"noEvents": "Aucun événement"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Précédent",
|
"previous": "Précédent",
|
||||||
@@ -1575,7 +1587,13 @@
|
|||||||
"listWeek": "Week lijst",
|
"listWeek": "Week lijst",
|
||||||
"listMonth": "Maand lijst",
|
"listMonth": "Maand lijst",
|
||||||
"listYear": "Jaar lijst",
|
"listYear": "Jaar lijst",
|
||||||
"today": "Vandaag"
|
"today": "Vandaag",
|
||||||
|
"mobile": {
|
||||||
|
"oneDay": "Een dag",
|
||||||
|
"twoDays": "Twee dagen",
|
||||||
|
"list": "Lijst",
|
||||||
|
"noEvents": "Geen evenementen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"previous": "Vorige",
|
"previous": "Vorige",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { GlobalLayout } from "../global/GlobalLayout";
|
|||||||
import { HeaderRight } from "../header/Header";
|
import { HeaderRight } from "../header/Header";
|
||||||
import { Toaster } from "@/features/ui/components/toaster/Toaster";
|
import { Toaster } from "@/features/ui/components/toaster/Toaster";
|
||||||
import { LeftPanelMobile } from "@/features/layouts/components/left-panel/LeftPanelMobile";
|
import { LeftPanelMobile } from "@/features/layouts/components/left-panel/LeftPanelMobile";
|
||||||
|
import { useLeftPanel } from "../../contexts/LeftPanelContext";
|
||||||
|
|
||||||
export const getSimpleLayout = (page: React.ReactElement) => {
|
export const getSimpleLayout = (page: React.ReactElement) => {
|
||||||
return <SimpleLayout>{page}</SimpleLayout>;
|
return <SimpleLayout>{page}</SimpleLayout>;
|
||||||
@@ -14,12 +15,15 @@ export const getSimpleLayout = (page: React.ReactElement) => {
|
|||||||
* Auth context to the children.
|
* Auth context to the children.
|
||||||
*/
|
*/
|
||||||
export const SimpleLayout = ({ children }: { children: React.ReactNode }) => {
|
export const SimpleLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isLeftPanelOpen, setIsLeftPanelOpen } = useLeftPanel();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<GlobalLayout>
|
<GlobalLayout>
|
||||||
<MainLayout
|
<MainLayout
|
||||||
enableResize
|
enableResize
|
||||||
hideLeftPanelOnDesktop={true}
|
hideLeftPanelOnDesktop={true}
|
||||||
|
isLeftPanelOpen={isLeftPanelOpen}
|
||||||
|
setIsLeftPanelOpen={setIsLeftPanelOpen}
|
||||||
leftPanelContent={<LeftPanelMobile />}
|
leftPanelContent={<LeftPanelMobile />}
|
||||||
rightHeaderContent={<HeaderRight />}
|
rightHeaderContent={<HeaderRight />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
interface LeftPanelContextType {
|
||||||
|
isLeftPanelOpen: boolean;
|
||||||
|
setIsLeftPanelOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LeftPanelContext = createContext<LeftPanelContextType | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useLeftPanel = () => {
|
||||||
|
const context = useContext(LeftPanelContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useLeftPanel must be used within a LeftPanelProvider",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LeftPanelProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeftPanelProvider = ({ children }: LeftPanelProviderProps) => {
|
||||||
|
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ isLeftPanelOpen, setIsLeftPanelOpen }),
|
||||||
|
[isLeftPanelOpen, setIsLeftPanelOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LeftPanelContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</LeftPanelContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
src/frontend/apps/calendars/src/hooks/useIsMobile.ts
Normal file
23
src/frontend/apps/calendars/src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
export const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
const MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||||
|
|
||||||
|
function subscribe(callback: () => void): () => void {
|
||||||
|
const mql = window.matchMedia(MEDIA_QUERY);
|
||||||
|
mql.addEventListener("change", callback);
|
||||||
|
return () => mql.removeEventListener("change", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): boolean {
|
||||||
|
return window.matchMedia(MEDIA_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@/features/ui/cunningham/useCunninghamTheme";
|
} from "@/features/ui/cunningham/useCunninghamTheme";
|
||||||
import { FeedbackFooterMobile } from "@/features/feedback/Feedback";
|
import { FeedbackFooterMobile } from "@/features/feedback/Feedback";
|
||||||
import { useDynamicFavicon } from "@/features/ui/hooks/useDynamicFavicon";
|
import { useDynamicFavicon } from "@/features/ui/hooks/useDynamicFavicon";
|
||||||
|
import { LeftPanelProvider } from "@/features/layouts/contexts/LeftPanelContext";
|
||||||
|
|
||||||
export type NextPageWithLayout<P = object, IP = P> = NextPage<P, IP> & {
|
export type NextPageWithLayout<P = object, IP = P> = NextPage<P, IP> & {
|
||||||
getLayout?: (page: ReactElement) => ReactNode;
|
getLayout?: (page: ReactElement) => ReactNode;
|
||||||
@@ -134,8 +135,10 @@ const MyAppInner = ({ Component, pageProps }: AppPropsWithLayout) => {
|
|||||||
|
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AnalyticsProvider>
|
<AnalyticsProvider>
|
||||||
{getLayout(<Component {...pageProps} />)}
|
<LeftPanelProvider>
|
||||||
<FeedbackFooterMobile />
|
{getLayout(<Component {...pageProps} />)}
|
||||||
|
<FeedbackFooterMobile />
|
||||||
|
</LeftPanelProvider>
|
||||||
</AnalyticsProvider>
|
</AnalyticsProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&__sidebar {
|
&__sidebar {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -18,12 +22,16 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.calendars__calendar {
|
.calendars__calendar {
|
||||||
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@@ -39,4 +47,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override feedback footer padding - calendar has internal scroll
|
||||||
|
.c__main-layout__content__center__children {
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
HeaderIcon,
|
HeaderIcon,
|
||||||
HeaderRight,
|
HeaderRight,
|
||||||
} from "@/features/layouts/components/header/Header";
|
} from "@/features/layouts/components/header/Header";
|
||||||
|
import { useLeftPanel } from "@/features/layouts/contexts/LeftPanelContext";
|
||||||
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
|
import { SpinnerPage } from "@/features/ui/components/spinner/SpinnerPage";
|
||||||
import { Toaster } from "@/features/ui/components/toaster/Toaster";
|
import { Toaster } from "@/features/ui/components/toaster/Toaster";
|
||||||
import { Scheduler } from "@/features/calendar/components/scheduler/Scheduler";
|
import { Scheduler } from "@/features/calendar/components/scheduler/Scheduler";
|
||||||
@@ -56,7 +57,9 @@ export default function CalendarPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
|
const CalendarLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { isLeftPanelOpen, setIsLeftPanelOpen } = useLeftPanel();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarContextProvider>
|
<CalendarContextProvider>
|
||||||
<div className="calendars__calendar">
|
<div className="calendars__calendar">
|
||||||
@@ -66,11 +69,17 @@ CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
|
|||||||
leftPanelContent={<LeftPanel />}
|
leftPanelContent={<LeftPanel />}
|
||||||
icon={<HeaderIcon />}
|
icon={<HeaderIcon />}
|
||||||
rightHeaderContent={<HeaderRight />}
|
rightHeaderContent={<HeaderRight />}
|
||||||
|
isLeftPanelOpen={isLeftPanelOpen}
|
||||||
|
setIsLeftPanelOpen={setIsLeftPanelOpen}
|
||||||
>
|
>
|
||||||
{page}
|
{children}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
</GlobalLayout>
|
</GlobalLayout>
|
||||||
</div>
|
</div>
|
||||||
</CalendarContextProvider>
|
</CalendarContextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CalendarPage.getLayout = function getLayout(page: React.ReactElement) {
|
||||||
|
return <CalendarLayout>{page}</CalendarLayout>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
|
@use "./../features/calendar/components/scheduler/event-modal-sections/InvitationResponseSection";
|
||||||
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
|
@use "./../features/calendar/components/scheduler/event-modal-sections/SectionPill";
|
||||||
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
|
@use "./../features/calendar/components/scheduler/FreeBusyTimeline";
|
||||||
|
@use "./../features/calendar/components/scheduler/mobile/WeekDayBar";
|
||||||
|
@use "./../features/calendar/components/scheduler/mobile/MobileToolbar";
|
||||||
|
@use "./../features/calendar/components/scheduler/mobile/FloatingActionButton";
|
||||||
|
@use "./../features/calendar/components/scheduler/mobile/MobileListView";
|
||||||
@use "./../features/resources/components/Resources";
|
@use "./../features/resources/components/Resources";
|
||||||
@use "./../features/settings/components/WorkingHoursSettings";
|
@use "./../features/settings/components/WorkingHoursSettings";
|
||||||
@use "./../pages/index" as *;
|
@use "./../pages/index" as *;
|
||||||
@@ -103,6 +107,13 @@ body {
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scoped to full-screen modals (mobile event modal uses FULL size)
|
||||||
|
.c__modal--full .c__modal__close button {
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
right: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.c__modal__scroller {
|
.c__modal__scroller {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/frontend/apps/calendars/src/utils/date.ts
Normal file
31
src/frontend/apps/calendars/src/utils/date.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export function isSameDay(a: Date, b: Date): boolean {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isToday(date: Date): boolean {
|
||||||
|
return isSameDay(date, new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(date: Date): boolean {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: Date, days: number): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekStart(date: Date, firstDayOfWeek: number): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
const diff = (dayOfWeek - firstDayOfWeek + 7) % 7;
|
||||||
|
d.setDate(d.getDate() - diff);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.0.0 <25.0.0"
|
"node": ">=24.0.0 <=25.8.0"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*"
|
"apps/*"
|
||||||
|
|||||||
7531
src/frontend/yarn.lock
Normal file
7531
src/frontend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user