Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- Introduced turbopack alias for canvas in next.config.mjs. - Updated package.json scripts for development and branding tasks. - Added new dependencies for Tiptap extensions. - Implemented new demo layouts for agenda, contacts, drive, and mail applications. - Enhanced globals.css for improved theming and splash screen animations. - Added OAuth callback handling for drive mounts. - Updated layout components to integrate new demo shells and improve structure.
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
|
import { addDays, addHours, addMinutes, addMonths, startOfHour } from "date-fns"
|
|
import { AgendaEventDialog, type AgendaEventDialogState } from "@/components/agenda/agenda-event-dialog"
|
|
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
|
|
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
|
import { AgendaHeader } from "@/components/agenda/agenda-header"
|
|
import { AgendaQuickCreate, type AgendaQuickCreateState } from "@/components/agenda/agenda-quick-create"
|
|
import { AgendaSidebar } from "@/components/agenda/agenda-sidebar"
|
|
import { AgendaViewMonth } from "@/components/agenda/agenda-view-month"
|
|
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { useAgendaEvents } from "@/lib/api/hooks/use-calendar-queries"
|
|
import { isExternalCalendarId } from "@/lib/agenda/agenda-calendar-visibility"
|
|
import { parseICSDate, viewDays, viewRange } from "@/lib/agenda/agenda-date"
|
|
import { useExternalAgendaEvents } from "@/lib/agenda/use-external-agenda-events"
|
|
import { useResolvedWeekStartsOn } from "@/lib/agenda/use-resolved-week-start"
|
|
import { draftToPendingEvent } from "@/lib/agenda/agenda-pending-event"
|
|
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
|
|
import { useAgendaEventMove } from "@/lib/agenda/use-agenda-event-move"
|
|
import { useVisibleAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
|
|
import type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
|
|
import { useAgendaRouteRoot } from "@/lib/agenda/agenda-route-context"
|
|
import {
|
|
buildAgendaPath,
|
|
parseAgendaSegments,
|
|
type AgendaView,
|
|
} from "@/lib/agenda/agenda-url"
|
|
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
|
import { useIsMobile } from "@/hooks/use-mobile"
|
|
import { AGENDA_CALENDAR_CARD_CLASS, AGENDA_MAIN_INSET_X } from "@/lib/agenda/agenda-chrome-classes"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export function AgendaPage() {
|
|
const router = useRouter()
|
|
const params = useParams()
|
|
const searchParams = useSearchParams()
|
|
const agendaRouteRoot = useAgendaRouteRoot()
|
|
const isMobile = useIsMobile()
|
|
const identity = useChromeIdentity()
|
|
|
|
const route = useMemo(
|
|
() => parseAgendaSegments(params.segments as string[] | undefined),
|
|
[params.segments],
|
|
)
|
|
const lastView = useAgendaSettingsStore((s) => s.lastView)
|
|
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
|
|
const externalCalendars = useAgendaSettingsStore((s) => s.externalCalendars)
|
|
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
|
|
const weekStartsOn = useResolvedWeekStartsOn(weekStart)
|
|
const defaultQuickDurationMinutes = useAgendaSettingsStore(
|
|
(s) => s.defaultQuickDurationMinutes,
|
|
)
|
|
|
|
const view: AgendaView = route.view ?? (isMobile ? "day" : lastView)
|
|
const date = route.date
|
|
|
|
useEffect(() => {
|
|
if (!route.view) {
|
|
router.replace(
|
|
buildAgendaPath(view, date, agendaRouteRoot) + window.location.search
|
|
)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [route.view])
|
|
|
|
useEffect(() => {
|
|
if (route.view) setLastView(route.view)
|
|
}, [route.view, setLastView])
|
|
|
|
const navigate = useCallback(
|
|
(nextView: AgendaView, nextDate: Date) => {
|
|
router.push(buildAgendaPath(nextView, nextDate, agendaRouteRoot))
|
|
},
|
|
[router, agendaRouteRoot],
|
|
)
|
|
|
|
const step = useCallback(
|
|
(delta: 1 | -1) => {
|
|
const next =
|
|
view === "month"
|
|
? addMonths(date, delta)
|
|
: addDays(date, view === "week" ? delta * 7 : delta)
|
|
navigate(view, next)
|
|
},
|
|
[view, date, navigate],
|
|
)
|
|
|
|
const {
|
|
calendars,
|
|
visibleCalendars,
|
|
isLoading: calendarsLoading,
|
|
} = useVisibleAgendaCalendars()
|
|
|
|
const visibleApiCalendars = useMemo(
|
|
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
|
|
[visibleCalendars],
|
|
)
|
|
const visibleExternalCalendars = useMemo(
|
|
() => externalCalendars.filter((calendar) =>
|
|
visibleCalendars.some((visible) => visible.id === calendar.id),
|
|
),
|
|
[externalCalendars, visibleCalendars],
|
|
)
|
|
|
|
const writableCalendars = useMemo(
|
|
() => calendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
|
|
[calendars],
|
|
)
|
|
const writableVisibleCalendars = useMemo(
|
|
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
|
|
[visibleCalendars],
|
|
)
|
|
|
|
const fetchRange = useMemo(
|
|
() => viewRange("month", date, weekStart, weekStartsOn),
|
|
[date, weekStart, weekStartsOn],
|
|
)
|
|
const { events: apiEvents } = useAgendaEvents(
|
|
visibleApiCalendars,
|
|
fetchRange.start,
|
|
fetchRange.end,
|
|
)
|
|
const { events: externalEvents } = useExternalAgendaEvents(
|
|
visibleExternalCalendars,
|
|
fetchRange.start,
|
|
fetchRange.end,
|
|
)
|
|
const events = useMemo(
|
|
() =>
|
|
[...apiEvents, ...externalEvents].sort(
|
|
(a, b) => a.start.getTime() - b.start.getTime(),
|
|
),
|
|
[apiEvents, externalEvents],
|
|
)
|
|
|
|
const [quickCreate, setQuickCreate] = useState<AgendaQuickCreateState | null>(null)
|
|
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
|
|
const [popover, setPopover] = useState<AgendaEventPopoverState | null>(null)
|
|
/** Brouillon affiché sur la grille tant que la modale est ouverte. */
|
|
const [pendingDraft, setPendingDraft] = useState<AgendaEventDraft | null>(null)
|
|
|
|
const defaultCalendarId =
|
|
writableVisibleCalendars[0]?.id ?? writableCalendars[0]?.id ?? ""
|
|
const userEmail = identity?.email
|
|
|
|
const clearPending = useCallback(() => setPendingDraft(null), [])
|
|
|
|
const closeOverlays = useCallback(() => {
|
|
setQuickCreate(null)
|
|
setPopover(null)
|
|
clearPending()
|
|
}, [clearPending])
|
|
|
|
const showPendingDraft = useCallback((draft: AgendaEventDraft) => {
|
|
setPendingDraft(draft)
|
|
}, [])
|
|
|
|
const openCreateDialog = useCallback(
|
|
(base?: Partial<AgendaEventDraft>, opts?: { keepPending?: boolean }) => {
|
|
setPopover(null)
|
|
setQuickCreate(null)
|
|
if (!opts?.keepPending) clearPending()
|
|
|
|
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
|
|
const end = base?.end ?? addMinutes(start, defaultQuickDurationMinutes)
|
|
const draft: AgendaEventDraft = {
|
|
title: "",
|
|
start,
|
|
end,
|
|
allDay: false,
|
|
calendarId: defaultCalendarId,
|
|
...base,
|
|
}
|
|
showPendingDraft(draft)
|
|
setDialogState({ mode: "create", draft })
|
|
},
|
|
[clearPending, defaultCalendarId, defaultQuickDurationMinutes, showPendingDraft],
|
|
)
|
|
|
|
const openEditDialog = useCallback(
|
|
(event: AgendaEvent) => {
|
|
closeOverlays()
|
|
const masterStart = parseICSDate(event.master.start) ?? event.start
|
|
const masterEnd = parseICSDate(event.master.end) ?? event.end
|
|
setDialogState({
|
|
mode: "edit",
|
|
event,
|
|
draft: {
|
|
title: event.title === "(Sans titre)" ? "" : event.title,
|
|
start: masterStart,
|
|
end: masterEnd,
|
|
allDay: event.allDay,
|
|
calendarId: event.calendarId,
|
|
description: event.description,
|
|
location: event.location,
|
|
attendees: event.attendees,
|
|
rrule: event.rrule,
|
|
color: event.master.color,
|
|
},
|
|
})
|
|
},
|
|
[closeOverlays],
|
|
)
|
|
|
|
const handledNewParam = useRef(false)
|
|
useEffect(() => {
|
|
if (handledNewParam.current || !route.view) return
|
|
if (searchParams.get("new") !== "1") return
|
|
handledNewParam.current = true
|
|
const guest = searchParams.get("guest")?.trim()
|
|
const guestName = searchParams.get("guest_name")?.trim()
|
|
openCreateDialog({
|
|
title: searchParams.get("title")?.trim() ?? "",
|
|
attendees: guest
|
|
? [{ email: guest, name: guestName || guest, status: "NEEDS-ACTION" }]
|
|
: [],
|
|
})
|
|
router.replace(buildAgendaPath(view, date))
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [searchParams, route.view])
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement
|
|
if (
|
|
e.metaKey ||
|
|
e.ctrlKey ||
|
|
e.altKey ||
|
|
target.closest("input, textarea, select, [contenteditable], [role=dialog]")
|
|
)
|
|
return
|
|
switch (e.key.toLowerCase()) {
|
|
case "j":
|
|
case "d":
|
|
navigate("day", date)
|
|
break
|
|
case "s":
|
|
case "w":
|
|
navigate("week", date)
|
|
break
|
|
case "m":
|
|
navigate("month", date)
|
|
break
|
|
case "t":
|
|
navigate(view, new Date())
|
|
break
|
|
case "c":
|
|
openCreateDialog()
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
e.preventDefault()
|
|
}
|
|
window.addEventListener("keydown", onKey)
|
|
return () => window.removeEventListener("keydown", onKey)
|
|
}, [navigate, view, date, openCreateDialog])
|
|
|
|
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
|
|
setQuickCreate(null)
|
|
clearPending()
|
|
setPopover({ event, anchor })
|
|
}, [clearPending])
|
|
|
|
const handleCreateRange = useCallback(
|
|
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect, viaDrag: boolean) => {
|
|
setPopover(null)
|
|
const draft: AgendaEventDraft = {
|
|
title: "",
|
|
start,
|
|
end,
|
|
allDay,
|
|
calendarId: defaultCalendarId,
|
|
}
|
|
showPendingDraft(draft)
|
|
|
|
if (viaDrag) {
|
|
openCreateDialog(draft, { keepPending: true })
|
|
} else {
|
|
setDialogState(null)
|
|
setQuickCreate({ start, end, allDay, anchor })
|
|
}
|
|
},
|
|
[defaultCalendarId, openCreateDialog, showPendingDraft],
|
|
)
|
|
|
|
const { moveEvent } = useAgendaEventMove()
|
|
|
|
const handleEventMove = useCallback(
|
|
(event: AgendaEvent, targetStart: Date) => {
|
|
void moveEvent(event, targetStart)
|
|
},
|
|
[moveEvent],
|
|
)
|
|
|
|
const pendingEvent = useMemo(() => {
|
|
if (!pendingDraft) return null
|
|
return draftToPendingEvent(pendingDraft, visibleCalendars.length > 0 ? visibleCalendars : calendars)
|
|
}, [pendingDraft, visibleCalendars, calendars])
|
|
|
|
const days = useMemo(
|
|
() => viewDays(view, date, weekStart, weekStartsOn),
|
|
[view, date, weekStart, weekStartsOn],
|
|
)
|
|
|
|
return (
|
|
<>
|
|
<AgendaHeader
|
|
view={view}
|
|
date={date}
|
|
weekStart={weekStart}
|
|
weekStartsOn={weekStartsOn}
|
|
onToday={() => navigate(view, new Date())}
|
|
onStep={step}
|
|
onViewChange={(v) => navigate(v, date)}
|
|
/>
|
|
<div className="flex min-h-0 flex-1">
|
|
<AgendaSidebar
|
|
selectedDate={date}
|
|
onSelectDate={(d) => navigate(view, d)}
|
|
onCreateEvent={() => openCreateDialog()}
|
|
/>
|
|
<main className={cn("flex min-h-0 min-w-0 flex-1 flex-col pb-1 max-sm:pb-0", AGENDA_MAIN_INSET_X)}>
|
|
<div className={AGENDA_CALENDAR_CARD_CLASS} data-agenda-calendar-card>
|
|
{calendarsLoading ? (
|
|
<div className="flex h-full flex-col gap-3 p-6">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="min-h-0 flex-1" />
|
|
</div>
|
|
) : view === "month" ? (
|
|
<AgendaViewMonth
|
|
date={date}
|
|
weekStart={weekStart}
|
|
weekStartsOn={weekStartsOn}
|
|
events={events}
|
|
pendingEvent={pendingEvent}
|
|
onCreateRange={handleCreateRange}
|
|
onEventClick={handleEventClick}
|
|
onEventMove={handleEventMove}
|
|
onOpenDay={(day) => navigate("day", day)}
|
|
/>
|
|
) : (
|
|
<AgendaViewWeek
|
|
days={days}
|
|
events={events}
|
|
pendingEvent={pendingEvent}
|
|
onCreateRange={handleCreateRange}
|
|
onEventClick={handleEventClick}
|
|
onEventMove={handleEventMove}
|
|
onOpenDay={(day) => navigate("day", day)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<AgendaQuickCreate
|
|
state={quickCreate}
|
|
calendars={writableVisibleCalendars.length > 0 ? writableVisibleCalendars : writableCalendars}
|
|
defaultCalendarId={defaultCalendarId}
|
|
userEmail={userEmail}
|
|
onClose={() => {
|
|
setQuickCreate(null)
|
|
clearPending()
|
|
}}
|
|
onMoreOptions={(draft) => {
|
|
setQuickCreate(null)
|
|
showPendingDraft(draft)
|
|
setDialogState({ mode: "create", draft })
|
|
}}
|
|
/>
|
|
|
|
<AgendaEventPopover
|
|
state={popover}
|
|
calendars={calendars}
|
|
userEmail={userEmail}
|
|
onClose={() => setPopover(null)}
|
|
onEdit={openEditDialog}
|
|
/>
|
|
|
|
<AgendaEventDialog
|
|
state={dialogState}
|
|
onClose={() => {
|
|
setDialogState(null)
|
|
clearPending()
|
|
}}
|
|
calendars={writableCalendars}
|
|
userEmail={userEmail}
|
|
onDraftChange={showPendingDraft}
|
|
/>
|
|
</>
|
|
)
|
|
}
|