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.
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type MouseEvent,
|
|
type PointerEvent as ReactPointerEvent,
|
|
} from "react"
|
|
import { addDays, format, isSameDay, isSameMonth, startOfDay } from "date-fns"
|
|
import { fr } from "date-fns/locale"
|
|
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
|
|
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
|
import { bindAgendaEventDragSession } from "@/lib/agenda/agenda-event-drag-session"
|
|
import { viewDays, type WeekStartsOn } from "@/lib/agenda/agenda-date"
|
|
import type { AgendaWeekStart } from "@/lib/agenda/agenda-settings-types"
|
|
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
|
|
import { isPendingEvent } from "@/lib/agenda/agenda-pending-event"
|
|
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const MAX_CHIPS = 4
|
|
|
|
function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
|
}
|
|
|
|
function dayIndex(days: Date[], day: Date): number {
|
|
return days.findIndex((d) => isSameDay(d, day))
|
|
}
|
|
|
|
interface MonthDragState {
|
|
startIndex: number
|
|
endIndex: number
|
|
moved: boolean
|
|
}
|
|
|
|
interface MonthEventMoveState {
|
|
event: AgendaEvent
|
|
originIndex: number
|
|
currentIndex: number
|
|
positionChanged: boolean
|
|
}
|
|
|
|
export function AgendaViewMonth({
|
|
date,
|
|
weekStart = "auto",
|
|
weekStartsOn,
|
|
events,
|
|
pendingEvent,
|
|
onCreateRange,
|
|
onEventClick,
|
|
onEventMove,
|
|
onOpenDay,
|
|
}: {
|
|
date: Date
|
|
weekStart?: AgendaWeekStart
|
|
weekStartsOn?: WeekStartsOn
|
|
events: AgendaEvent[]
|
|
pendingEvent?: AgendaEvent | null
|
|
onCreateRange: (
|
|
start: Date,
|
|
end: Date,
|
|
allDay: boolean,
|
|
anchor: AnchorRect,
|
|
viaDrag: boolean,
|
|
) => void
|
|
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
|
|
onEventMove?: (event: AgendaEvent, targetStart: Date) => void
|
|
onOpenDay: (day: Date) => void
|
|
}) {
|
|
const days = viewDays("month", date, weekStart, weekStartsOn)
|
|
const weeks: Date[][] = []
|
|
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
|
|
const today = new Date()
|
|
|
|
const cellRefs = useRef<(HTMLDivElement | null)[]>([])
|
|
const dragRef = useRef<MonthDragState | null>(null)
|
|
const eventMoveRef = useRef<MonthEventMoveState | null>(null)
|
|
const dragSessionCleanupRef = useRef<(() => void) | null>(null)
|
|
const suppressClickRef = useRef(false)
|
|
const [drag, setDrag] = useState<MonthDragState | null>(null)
|
|
const [eventMove, setEventMove] = useState<MonthEventMoveState | null>(null)
|
|
|
|
const merged = pendingEvent ? [...events, pendingEvent] : events
|
|
|
|
useEffect(
|
|
() => () => {
|
|
dragSessionCleanupRef.current?.()
|
|
dragSessionCleanupRef.current = null
|
|
},
|
|
[],
|
|
)
|
|
|
|
const indexFromElement = (el: HTMLElement | null): number | null => {
|
|
if (!el) return null
|
|
const idx = cellRefs.current.findIndex((cell) => cell && cell.contains(el))
|
|
return idx >= 0 ? idx : null
|
|
}
|
|
|
|
const indexFromClient = (clientX: number, clientY: number): number | null => {
|
|
const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null
|
|
const cell = el?.closest("[data-agenda-month-cell]") as HTMLElement | null
|
|
return indexFromElement(cell)
|
|
}
|
|
|
|
const finishDrag = (anchorEl: HTMLElement | null) => {
|
|
const d = dragRef.current
|
|
dragRef.current = null
|
|
setDrag(null)
|
|
if (!d) return
|
|
|
|
const minIdx = Math.min(d.startIndex, d.endIndex)
|
|
const maxIdx = Math.max(d.startIndex, d.endIndex)
|
|
const start = startOfDay(days[minIdx])
|
|
const end = addDays(startOfDay(days[maxIdx]), 1)
|
|
const anchor = anchorEl
|
|
? anchorFromEvent({ currentTarget: anchorEl } as MouseEvent<HTMLElement>)
|
|
: { left: 0, top: 0, width: 0, height: 0 }
|
|
|
|
if (d.moved) {
|
|
onCreateRange(start, end, true, anchor, true)
|
|
} else {
|
|
onCreateRange(start, end, true, anchor, false)
|
|
}
|
|
}
|
|
|
|
const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (e.button !== 0 || eventMoveRef.current) return
|
|
if ((e.target as HTMLElement).closest("[data-agenda-event]")) return
|
|
const next: MonthDragState = { startIndex: dayIndex, endIndex: dayIndex, moved: false }
|
|
dragRef.current = next
|
|
setDrag(next)
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
}
|
|
|
|
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
const d = dragRef.current
|
|
if (!d) return
|
|
const idx = indexFromClient(e.clientX, e.clientY)
|
|
if (idx === null || idx === d.endIndex) return
|
|
const next: MonthDragState = { ...d, endIndex: idx, moved: true }
|
|
dragRef.current = next
|
|
setDrag(next)
|
|
}
|
|
|
|
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
|
finishDrag(cellRefs.current[dayIndex])
|
|
}
|
|
|
|
const handlePointerCancel = () => {
|
|
dragRef.current = null
|
|
setDrag(null)
|
|
}
|
|
|
|
const finishEventMove = (didDrag: boolean) => {
|
|
const move = eventMoveRef.current
|
|
eventMoveRef.current = null
|
|
setEventMove(null)
|
|
if (!move || !onEventMove) return
|
|
|
|
const positionChanged = move.currentIndex !== move.originIndex
|
|
if (didDrag) {
|
|
suppressClickRef.current = true
|
|
}
|
|
if (!didDrag || !positionChanged) return
|
|
|
|
const delta = move.currentIndex - move.originIndex
|
|
const targetStart = addDays(move.event.start, delta)
|
|
onEventMove(move.event, targetStart)
|
|
}
|
|
|
|
const updateEventMove = (clientX: number, clientY: number) => {
|
|
const move = eventMoveRef.current
|
|
if (!move) return
|
|
const idx = indexFromClient(clientX, clientY)
|
|
if (idx === null) return
|
|
|
|
const positionChanged = idx !== move.originIndex
|
|
const next: MonthEventMoveState = {
|
|
...move,
|
|
currentIndex: idx,
|
|
positionChanged,
|
|
}
|
|
eventMoveRef.current = next
|
|
setEventMove(next)
|
|
}
|
|
|
|
const shouldSuppressEventClick = () => {
|
|
if (!suppressClickRef.current) return false
|
|
suppressClickRef.current = false
|
|
return true
|
|
}
|
|
|
|
const startEventMove =
|
|
(event: AgendaEvent, dayIdx: number) =>
|
|
(e: ReactPointerEvent<HTMLElement>) => {
|
|
if (e.button !== 0 || isPendingEvent(event) || !onEventMove) return
|
|
e.stopPropagation()
|
|
e.preventDefault()
|
|
|
|
const originIndex = dayIndex(days, event.start)
|
|
const resolvedOrigin = originIndex >= 0 ? originIndex : dayIdx
|
|
const next: MonthEventMoveState = {
|
|
event,
|
|
originIndex: resolvedOrigin,
|
|
currentIndex: resolvedOrigin,
|
|
positionChanged: false,
|
|
}
|
|
eventMoveRef.current = next
|
|
setEventMove(next)
|
|
|
|
dragSessionCleanupRef.current?.()
|
|
dragSessionCleanupRef.current = bindAgendaEventDragSession({
|
|
pointerId: e.pointerId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
onMove: updateEventMove,
|
|
onFinish: (clientX, clientY, didDrag) => {
|
|
dragSessionCleanupRef.current = null
|
|
if (didDrag) {
|
|
updateEventMove(clientX, clientY)
|
|
}
|
|
finishEventMove(didDrag)
|
|
},
|
|
})
|
|
}
|
|
|
|
const dragRange =
|
|
drag && drag.moved
|
|
? {
|
|
min: Math.min(drag.startIndex, drag.endIndex),
|
|
max: Math.max(drag.startIndex, drag.endIndex),
|
|
}
|
|
: null
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-mail-surface">
|
|
<div className="grid shrink-0 grid-cols-7 border-b border-border/60">
|
|
{days.slice(0, 7).map((d) => (
|
|
<div
|
|
key={d.getTime()}
|
|
className="py-1.5 text-center text-[0.7rem] font-medium tracking-wide text-muted-foreground uppercase"
|
|
>
|
|
{format(d, "EEE", { locale: fr }).replace(".", "")}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div
|
|
className="grid min-h-0 flex-1 grid-cols-7"
|
|
style={{ gridTemplateRows: `repeat(${weeks.length}, minmax(0, 1fr))` }}
|
|
>
|
|
{weeks.map((week) =>
|
|
week.map((day) => {
|
|
const idx = dayIndex(days, day)
|
|
const dayEvents = eventsOnDay(merged, day).sort((a, b) => {
|
|
const aBanner = a.allDay || isMultiDay(a)
|
|
const bBanner = b.allDay || isMultiDay(b)
|
|
if (isPendingEvent(a)) return 1
|
|
if (isPendingEvent(b)) return -1
|
|
if (aBanner !== bBanner) return aBanner ? -1 : 1
|
|
return a.start.getTime() - b.start.getTime()
|
|
})
|
|
const visible = dayEvents.slice(0, MAX_CHIPS)
|
|
const hidden = dayEvents.length - visible.length
|
|
const isToday = isSameDay(day, today)
|
|
const inMonth = isSameMonth(day, date)
|
|
const inDragRange = dragRange && idx >= dragRange.min && idx <= dragRange.max
|
|
const inEventMoveTarget =
|
|
eventMove?.positionChanged && eventMove.currentIndex === idx
|
|
|
|
return (
|
|
<div
|
|
key={day.getTime()}
|
|
ref={(el) => {
|
|
cellRefs.current[idx] = el
|
|
}}
|
|
role="gridcell"
|
|
data-agenda-month-cell
|
|
className={cn(
|
|
"relative flex min-h-0 cursor-pointer touch-none flex-col gap-0.5 overflow-hidden border-r border-b border-border/40 px-1 pb-1",
|
|
!inMonth && "bg-muted/30",
|
|
inDragRange && "bg-primary/10",
|
|
inEventMoveTarget && "bg-primary/10 ring-1 ring-inset ring-primary/40",
|
|
)}
|
|
onPointerDown={handlePointerDown(idx)}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp(idx)}
|
|
onPointerCancel={handlePointerCancel}
|
|
>
|
|
<div className="flex justify-center pt-1">
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex h-6 min-w-6 items-center justify-center rounded-full px-1 text-xs hover:bg-mail-nav-hover",
|
|
isToday && "bg-primary font-semibold text-primary-foreground hover:bg-primary",
|
|
!inMonth && "text-muted-foreground/60",
|
|
)}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenDay(day)
|
|
}}
|
|
>
|
|
{day.getDate() === 1 && inMonth
|
|
? format(day, "d MMM", { locale: fr })
|
|
: day.getDate()}
|
|
</button>
|
|
</div>
|
|
{visible.map((event) => {
|
|
const isDragging =
|
|
eventMove?.event.key === event.key && eventMove.positionChanged
|
|
return (
|
|
<div
|
|
key={event.key}
|
|
data-agenda-event
|
|
className={cn(
|
|
onEventMove &&
|
|
!isPendingEvent(event) &&
|
|
"touch-none cursor-grab active:cursor-grabbing",
|
|
isDragging && "opacity-40",
|
|
)}
|
|
onPointerDown={startEventMove(event, idx)}
|
|
>
|
|
<AgendaEventChip
|
|
event={event}
|
|
filled={event.allDay || isMultiDay(event)}
|
|
pending={isPendingEvent(event)}
|
|
onClick={(e) => {
|
|
if (shouldSuppressEventClick()) {
|
|
e.preventDefault()
|
|
return
|
|
}
|
|
e.stopPropagation()
|
|
if (!isPendingEvent(event)) {
|
|
onEventClick(event, anchorFromEvent(e))
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
})}
|
|
{hidden > 0 && (
|
|
<button
|
|
type="button"
|
|
className="w-full truncate rounded-md px-1.5 py-[1px] text-left text-xs font-medium text-muted-foreground hover:bg-mail-nav-hover"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onOpenDay(day)
|
|
}}
|
|
>
|
|
{hidden} autre{hidden > 1 ? "s" : ""}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}),
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|