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.
717 lines
26 KiB
TypeScript
717 lines
26 KiB
TypeScript
"use client"
|
||
|
||
import {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type MouseEvent,
|
||
type PointerEvent as ReactPointerEvent,
|
||
} from "react"
|
||
import { addDays, format, isSameDay, 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 { formatEventTime, formatHourLabel, roundToStep } from "@/lib/agenda/agenda-date"
|
||
import { readableTextColor } from "@/lib/agenda/agenda-colors"
|
||
import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout"
|
||
import { bindAgendaEventDragSession } from "@/lib/agenda/agenda-event-drag-session"
|
||
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
|
||
import { isPendingEvent } from "@/lib/agenda/agenda-pending-event"
|
||
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
||
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
const HOUR_PX = 48
|
||
const GUTTER_PX = 56
|
||
const MIN_EVENT_PX = 22
|
||
|
||
/** Scroll sans barre visible — évite le décalage header / colonnes. */
|
||
const SCROLL_CLASS =
|
||
"min-h-0 flex-1 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||
|
||
function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||
}
|
||
|
||
interface DragState {
|
||
startDayIndex: number
|
||
endDayIndex: number
|
||
anchorMin: number
|
||
startMin: number
|
||
endMin: number
|
||
moved: boolean
|
||
/** Glissé horizontalement sur plusieurs colonnes → journée entière. */
|
||
multiDay: boolean
|
||
}
|
||
|
||
interface EventMoveState {
|
||
event: AgendaEvent
|
||
durationMin: number
|
||
originDayIndex: number
|
||
originStartMin: number
|
||
currentDayIndex: number
|
||
currentStartMin: number
|
||
positionChanged: boolean
|
||
/** Offset pointeur vs haut événement — évite snap au clic/début drag. */
|
||
grabOffsetY: number
|
||
}
|
||
|
||
export function AgendaViewWeek({
|
||
days,
|
||
events,
|
||
pendingEvent,
|
||
onCreateRange,
|
||
onEventClick,
|
||
onEventMove,
|
||
onOpenDay,
|
||
}: {
|
||
days: Date[]
|
||
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 scrollRef = useRef<HTMLDivElement>(null)
|
||
const columnRefs = useRef<(HTMLDivElement | null)[]>([])
|
||
const dragRef = useRef<DragState | null>(null)
|
||
const eventMoveRef = useRef<EventMoveState | null>(null)
|
||
const dragSessionCleanupRef = useRef<(() => void) | null>(null)
|
||
const suppressClickRef = useRef(false)
|
||
const [drag, setDrag] = useState<DragState | null>(null)
|
||
const [eventMove, setEventMove] = useState<EventMoveState | null>(null)
|
||
const [now, setNow] = useState(() => new Date())
|
||
const settings = useEffectiveAgendaSettings()
|
||
const {
|
||
visibleHoursStart,
|
||
visibleHoursEnd,
|
||
dragSnapMinutes,
|
||
defaultQuickDurationMinutes,
|
||
timeFormat,
|
||
} = settings
|
||
const gridMinutes = Math.max(60, visibleHoursEnd - visibleHoursStart + 1)
|
||
const gridHeightPx = (gridMinutes / 60) * HOUR_PX
|
||
const hourMarks = useMemo(() => {
|
||
const startHour = Math.floor(visibleHoursStart / 60)
|
||
const endHour = Math.ceil(visibleHoursEnd / 60)
|
||
const marks: number[] = []
|
||
for (let h = startHour + 1; h <= endHour; h++) marks.push(h)
|
||
return marks
|
||
}, [visibleHoursStart, visibleHoursEnd])
|
||
|
||
useEffect(() => {
|
||
const id = window.setInterval(() => setNow(new Date()), 60_000)
|
||
return () => window.clearInterval(id)
|
||
}, [])
|
||
|
||
useEffect(
|
||
() => () => {
|
||
dragSessionCleanupRef.current?.()
|
||
dragSessionCleanupRef.current = null
|
||
},
|
||
[],
|
||
)
|
||
|
||
useEffect(() => {
|
||
const el = scrollRef.current
|
||
if (!el) return
|
||
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
||
const target = Math.max(
|
||
0,
|
||
(Math.min(nowMinutes, visibleHoursEnd) - visibleHoursStart - 90) * (HOUR_PX / 60),
|
||
)
|
||
el.scrollTop = target
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [visibleHoursStart, visibleHoursEnd])
|
||
|
||
const dayIndexFromClientX = (clientX: number): number | null => {
|
||
for (let i = 0; i < columnRefs.current.length; i++) {
|
||
const el = columnRefs.current[i]
|
||
if (!el) continue
|
||
const rect = el.getBoundingClientRect()
|
||
if (clientX >= rect.left && clientX <= rect.right) return i
|
||
}
|
||
return null
|
||
}
|
||
|
||
const perDay = useMemo(() => {
|
||
const merged = pendingEvent ? [...events, pendingEvent] : events
|
||
return days.map((day) => {
|
||
const dayEvents = eventsOnDay(merged, day)
|
||
const banners = dayEvents
|
||
.filter((e) => e.allDay || isMultiDay(e))
|
||
.sort((a, b) => {
|
||
if (isPendingEvent(a)) return 1
|
||
if (isPendingEvent(b)) return -1
|
||
return a.start.getTime() - b.start.getTime()
|
||
})
|
||
const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e))
|
||
return { day, banners, positioned: layoutDayEvents(timed, day) }
|
||
})
|
||
}, [days, events, pendingEvent])
|
||
|
||
const hasBanners =
|
||
perDay.some((d) => d.banners.length > 0) || (drag?.multiDay ?? false)
|
||
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
|
||
|
||
const minuteFromClientY = (clientY: number, dayIndex: number): number | null => {
|
||
const el = columnRefs.current[dayIndex]
|
||
if (!el) return null
|
||
const rect = el.getBoundingClientRect()
|
||
const relativeMinutes = ((clientY - rect.top) / HOUR_PX) * 60
|
||
const absolute = visibleHoursStart + relativeMinutes
|
||
return Math.min(
|
||
visibleHoursEnd,
|
||
Math.max(visibleHoursStart, roundToStep(absolute, dragSnapMinutes)),
|
||
)
|
||
}
|
||
|
||
const finishDrag = () => {
|
||
const d = dragRef.current
|
||
dragRef.current = null
|
||
setDrag(null)
|
||
if (!d) return
|
||
|
||
const minDay = Math.min(d.startDayIndex, d.endDayIndex)
|
||
const maxDay = Math.max(d.startDayIndex, d.endDayIndex)
|
||
const viaDrag = d.moved
|
||
|
||
if (d.multiDay) {
|
||
const start = startOfDay(days[minDay])
|
||
const end = addDays(startOfDay(days[maxDay]), 1)
|
||
const el = columnRefs.current[minDay]
|
||
const anchor: AnchorRect = el
|
||
? {
|
||
left: el.getBoundingClientRect().left,
|
||
top: el.getBoundingClientRect().top,
|
||
width:
|
||
(columnRefs.current[maxDay]?.getBoundingClientRect().right ?? el.getBoundingClientRect().right) -
|
||
el.getBoundingClientRect().left,
|
||
height: 24,
|
||
}
|
||
: { left: 0, top: 0, width: 0, height: 0 }
|
||
onCreateRange(start, end, true, anchor, true)
|
||
return
|
||
}
|
||
|
||
const day = days[d.startDayIndex]
|
||
const el = columnRefs.current[d.startDayIndex]
|
||
if (!el) return
|
||
|
||
const startMin = d.startMin
|
||
const endMin = viaDrag
|
||
? d.endMin
|
||
: Math.min(visibleHoursEnd, d.startMin + defaultQuickDurationMinutes)
|
||
const start = new Date(day)
|
||
start.setHours(0, startMin, 0, 0)
|
||
const end = new Date(day)
|
||
end.setHours(0, Math.max(endMin, startMin + dragSnapMinutes), 0, 0)
|
||
|
||
const colRect = el.getBoundingClientRect()
|
||
onCreateRange(start, end, false, {
|
||
left: colRect.left,
|
||
top: colRect.top + ((startMin - visibleHoursStart) / 60) * HOUR_PX,
|
||
width: colRect.width,
|
||
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
|
||
}, viaDrag)
|
||
}
|
||
|
||
const finishEventMove = (didDrag: boolean) => {
|
||
const move = eventMoveRef.current
|
||
eventMoveRef.current = null
|
||
setEventMove(null)
|
||
if (!move || !onEventMove) return
|
||
|
||
const positionChanged = move.event.allDay
|
||
? move.currentDayIndex !== move.originDayIndex
|
||
: move.currentDayIndex !== move.originDayIndex ||
|
||
move.currentStartMin !== move.originStartMin
|
||
|
||
if (didDrag) {
|
||
suppressClickRef.current = true
|
||
}
|
||
if (!didDrag || !positionChanged) return
|
||
|
||
const day = days[move.currentDayIndex]
|
||
if (!day) return
|
||
|
||
if (move.event.allDay) {
|
||
onEventMove(move.event, startOfDay(day))
|
||
return
|
||
}
|
||
|
||
const start = new Date(day)
|
||
start.setHours(0, move.currentStartMin, 0, 0)
|
||
onEventMove(move.event, start)
|
||
}
|
||
|
||
const updateEventMove = (clientX: number, clientY: number) => {
|
||
const move = eventMoveRef.current
|
||
if (!move) return
|
||
|
||
const hoverDay = dayIndexFromClientX(clientX)
|
||
if (hoverDay === null) return
|
||
|
||
let currentStartMin = move.originStartMin
|
||
if (!move.event.allDay) {
|
||
const min = minuteFromClientY(clientY - move.grabOffsetY, hoverDay)
|
||
if (min === null) return
|
||
currentStartMin = min
|
||
}
|
||
|
||
const positionChanged = move.event.allDay
|
||
? hoverDay !== move.originDayIndex
|
||
: hoverDay !== move.originDayIndex || currentStartMin !== move.originStartMin
|
||
|
||
const next: EventMoveState = {
|
||
...move,
|
||
currentDayIndex: hoverDay,
|
||
currentStartMin,
|
||
positionChanged,
|
||
}
|
||
eventMoveRef.current = next
|
||
setEventMove(next)
|
||
}
|
||
|
||
const shouldSuppressEventClick = () => {
|
||
if (!suppressClickRef.current) return false
|
||
suppressClickRef.current = false
|
||
return true
|
||
}
|
||
|
||
const startEventMove =
|
||
(event: AgendaEvent, displayDayIndex: number) =>
|
||
(e: ReactPointerEvent<HTMLElement>) => {
|
||
if (e.button !== 0 || isPendingEvent(event) || !onEventMove) return
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
|
||
const originDayIndex = days.findIndex((d) => isSameDay(d, event.start))
|
||
const resolvedOriginDay = originDayIndex >= 0 ? originDayIndex : displayDayIndex
|
||
const originStartMin = event.allDay
|
||
? 0
|
||
: event.start.getHours() * 60 + event.start.getMinutes()
|
||
const durationMin = Math.max(
|
||
dragSnapMinutes,
|
||
Math.round((event.end.getTime() - event.start.getTime()) / 60_000),
|
||
)
|
||
const grabOffsetY = e.clientY - e.currentTarget.getBoundingClientRect().top
|
||
const next: EventMoveState = {
|
||
event,
|
||
durationMin,
|
||
originDayIndex: resolvedOriginDay,
|
||
originStartMin,
|
||
currentDayIndex: resolvedOriginDay,
|
||
currentStartMin: originStartMin,
|
||
positionChanged: false,
|
||
grabOffsetY,
|
||
}
|
||
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 handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
if (e.button !== 0 || eventMoveRef.current) return
|
||
if ((e.target as HTMLElement).closest("[data-agenda-event]")) return
|
||
const min = minuteFromClientY(e.clientY, dayIndex)
|
||
if (min === null) return
|
||
const next: DragState = {
|
||
startDayIndex: dayIndex,
|
||
endDayIndex: dayIndex,
|
||
anchorMin: min,
|
||
startMin: min,
|
||
endMin: min + dragSnapMinutes,
|
||
moved: false,
|
||
multiDay: false,
|
||
}
|
||
dragRef.current = next
|
||
setDrag(next)
|
||
e.currentTarget.setPointerCapture(e.pointerId)
|
||
}
|
||
|
||
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||
const d = dragRef.current
|
||
if (!d) return
|
||
|
||
const hoverDay = dayIndexFromClientX(e.clientX)
|
||
if (hoverDay === null) return
|
||
|
||
if (hoverDay !== d.startDayIndex) {
|
||
const next: DragState = {
|
||
...d,
|
||
endDayIndex: hoverDay,
|
||
moved: true,
|
||
multiDay: true,
|
||
}
|
||
dragRef.current = next
|
||
setDrag(next)
|
||
return
|
||
}
|
||
|
||
if (d.multiDay) return
|
||
|
||
const min = minuteFromClientY(e.clientY, d.startDayIndex)
|
||
if (min === null) return
|
||
if (min === d.anchorMin && !d.moved) return
|
||
const next: DragState = {
|
||
...d,
|
||
endDayIndex: d.startDayIndex,
|
||
moved: true,
|
||
multiDay: false,
|
||
startMin: Math.min(d.anchorMin, min),
|
||
endMin: Math.max(
|
||
d.anchorMin,
|
||
min,
|
||
Math.min(d.anchorMin, min) + dragSnapMinutes,
|
||
),
|
||
}
|
||
dragRef.current = next
|
||
setDrag(next)
|
||
}
|
||
|
||
const handlePointerUp = () => {
|
||
finishDrag()
|
||
}
|
||
|
||
const handlePointerCancel = () => {
|
||
dragRef.current = null
|
||
setDrag(null)
|
||
}
|
||
|
||
const dragMultiDayRange =
|
||
drag?.multiDay && drag.moved
|
||
? {
|
||
min: Math.min(drag.startDayIndex, drag.endDayIndex),
|
||
max: Math.max(drag.startDayIndex, drag.endDayIndex),
|
||
}
|
||
: null
|
||
|
||
return (
|
||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-mail-surface">
|
||
{/* En-tête : jours + rangée journée entière */}
|
||
<div className="shrink-0 border-b border-border/60">
|
||
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
|
||
<div />
|
||
{days.map((day) => {
|
||
const isToday = isSameDay(day, now)
|
||
return (
|
||
<div
|
||
key={day.getTime()}
|
||
className="flex flex-col items-center gap-0.5 border-l border-border/40 pt-2 pb-1"
|
||
>
|
||
<span className="text-[0.7rem] font-medium tracking-wide text-muted-foreground uppercase">
|
||
{format(day, "EEE", { locale: fr }).replace(".", "")}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenDay(day)}
|
||
className={cn(
|
||
"flex size-10 items-center justify-center rounded-full text-[1.45rem] font-normal text-foreground/80 hover:bg-mail-nav-hover",
|
||
isToday &&
|
||
"bg-primary font-medium text-primary-foreground hover:bg-primary",
|
||
)}
|
||
>
|
||
{day.getDate()}
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{hasBanners && (
|
||
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
|
||
<div className="pt-0.5 pr-2 text-right text-[0.65rem] text-muted-foreground" />
|
||
{perDay.map(({ day, banners }, dayIndex) => {
|
||
const inDragRange =
|
||
dragMultiDayRange &&
|
||
dayIndex >= dragMultiDayRange.min &&
|
||
dayIndex <= dragMultiDayRange.max
|
||
const inEventMoveTarget =
|
||
eventMove?.positionChanged &&
|
||
eventMove.event.allDay &&
|
||
eventMove.currentDayIndex === dayIndex
|
||
return (
|
||
<div
|
||
key={day.getTime()}
|
||
className="relative flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
|
||
>
|
||
{inDragRange && (
|
||
<div
|
||
aria-hidden
|
||
className={cn(
|
||
"absolute inset-x-0.5 top-0.5 bottom-0.5 rounded-md bg-primary/25 ring-1 ring-primary/50",
|
||
dayIndex === dragMultiDayRange!.min && "rounded-r-none",
|
||
dayIndex === dragMultiDayRange!.max && "rounded-l-none",
|
||
dragMultiDayRange!.min !== dragMultiDayRange!.max &&
|
||
dayIndex > dragMultiDayRange!.min &&
|
||
dayIndex < dragMultiDayRange!.max &&
|
||
"rounded-none",
|
||
)}
|
||
/>
|
||
)}
|
||
{inEventMoveTarget && (
|
||
<div
|
||
aria-hidden
|
||
className="absolute inset-x-0.5 top-0.5 bottom-0.5 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
||
/>
|
||
)}
|
||
{banners.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, dayIndex)}
|
||
>
|
||
<AgendaEventChip
|
||
event={event}
|
||
filled
|
||
pending={isPendingEvent(event)}
|
||
onClick={(e) => {
|
||
if (shouldSuppressEventClick()) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
e.stopPropagation()
|
||
if (!isPendingEvent(event)) {
|
||
onEventClick(event, anchorFromEvent(e))
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Grille horaire */}
|
||
<div ref={scrollRef} className={SCROLL_CLASS}>
|
||
<div
|
||
className="relative grid"
|
||
style={{ gridTemplateColumns: colTemplate, height: gridHeightPx }}
|
||
>
|
||
<div className="relative">
|
||
{hourMarks.map((h) => (
|
||
<span
|
||
key={h}
|
||
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
|
||
style={{ top: ((h * 60 - visibleHoursStart) / 60) * HOUR_PX }}
|
||
>
|
||
{formatHourLabel(h % 24, timeFormat)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{perDay.map(({ day, positioned }, dayIndex) => {
|
||
const isToday = isSameDay(day, now)
|
||
const nowMinutes = now.getHours() * 60 + now.getMinutes()
|
||
const nowTop = (nowMinutes - visibleHoursStart) * (HOUR_PX / 60)
|
||
const showNowLine =
|
||
nowMinutes >= visibleHoursStart && nowMinutes <= visibleHoursEnd
|
||
const showTimedDrag =
|
||
drag && !drag.multiDay && drag.moved && drag.startDayIndex === dayIndex
|
||
const showEventMove =
|
||
eventMove?.positionChanged &&
|
||
eventMove.currentDayIndex === dayIndex &&
|
||
!eventMove.event.allDay
|
||
|
||
return (
|
||
<div
|
||
key={day.getTime()}
|
||
ref={(el) => {
|
||
columnRefs.current[dayIndex] = el
|
||
}}
|
||
className="relative cursor-pointer touch-none border-l border-border/40"
|
||
onPointerDown={handlePointerDown(dayIndex)}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onPointerCancel={handlePointerCancel}
|
||
>
|
||
{hourMarks.map((h) => (
|
||
<div
|
||
key={h}
|
||
aria-hidden
|
||
className="absolute right-0 left-0 border-t border-border/40"
|
||
style={{ top: ((h * 60 - visibleHoursStart) / 60) * HOUR_PX }}
|
||
/>
|
||
))}
|
||
|
||
{showTimedDrag && (
|
||
<div
|
||
aria-hidden
|
||
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
||
style={{
|
||
top: ((drag.startMin - visibleHoursStart) / 60) * HOUR_PX,
|
||
height: Math.max(
|
||
((drag.endMin - drag.startMin) / 60) * HOUR_PX,
|
||
8,
|
||
),
|
||
}}
|
||
>
|
||
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
|
||
{formatMinutes(drag.startMin, timeFormat)} –{" "}
|
||
{formatMinutes(drag.endMin, timeFormat)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{showEventMove && eventMove && (
|
||
<div
|
||
aria-hidden
|
||
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
||
style={{
|
||
top: ((eventMove.currentStartMin - visibleHoursStart) / 60) * HOUR_PX,
|
||
height: Math.max(
|
||
(eventMove.durationMin / 60) * HOUR_PX,
|
||
MIN_EVENT_PX,
|
||
),
|
||
}}
|
||
>
|
||
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
|
||
{formatMinutes(eventMove.currentStartMin, timeFormat)} –{" "}
|
||
{formatMinutes(
|
||
eventMove.currentStartMin + eventMove.durationMin,
|
||
timeFormat,
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{positioned.map(({ event, top, duration, leftPct, widthPct }) => {
|
||
const isDragging =
|
||
eventMove?.event.key === event.key && eventMove.positionChanged
|
||
const compact = (duration / 60) * HOUR_PX < 40
|
||
const pending = isPendingEvent(event)
|
||
return (
|
||
<div
|
||
key={event.key}
|
||
role={pending ? "presentation" : "button"}
|
||
tabIndex={pending ? undefined : 0}
|
||
data-agenda-event
|
||
className={cn(
|
||
"absolute z-20 flex flex-col overflow-hidden rounded-md px-1.5 py-0.5 text-left shadow-sm ring-1 ring-black/5 transition-[filter,opacity] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110",
|
||
pending && "pointer-events-none z-[25] ring-2 ring-dashed ring-primary/60",
|
||
!pending && onEventMove && "cursor-grab touch-none active:cursor-grabbing",
|
||
!pending && !onEventMove && "cursor-pointer",
|
||
isDragging && "opacity-40",
|
||
)}
|
||
style={{
|
||
top: ((top - visibleHoursStart) / 60) * HOUR_PX,
|
||
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
|
||
left: `calc(${leftPct}% + 1px)`,
|
||
width: `calc(${widthPct}% - 3px)`,
|
||
backgroundColor: pending ? `${event.color}99` : event.color,
|
||
color: readableTextColor(event.color),
|
||
}}
|
||
onClick={
|
||
pending
|
||
? undefined
|
||
: (e) => {
|
||
if (shouldSuppressEventClick()) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
e.stopPropagation()
|
||
onEventClick(event, anchorFromEvent(e as unknown as MouseEvent<HTMLElement>))
|
||
}
|
||
}
|
||
onPointerDown={
|
||
pending ? undefined : startEventMove(event, dayIndex)
|
||
}
|
||
onKeyDown={
|
||
pending
|
||
? undefined
|
||
: (e) => {
|
||
if (e.key === "Enter" || e.key === " ") {
|
||
e.stopPropagation()
|
||
onEventClick(event, {
|
||
left: 0,
|
||
top: 0,
|
||
width: 0,
|
||
height: 0,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"truncate text-xs leading-tight font-medium",
|
||
compact && "text-[0.7rem]",
|
||
)}
|
||
>
|
||
{event.title}
|
||
{compact && (
|
||
<span className="font-normal opacity-90">
|
||
{" "}
|
||
⋅ {formatEventTime(event.start, timeFormat)}
|
||
</span>
|
||
)}
|
||
</span>
|
||
{!compact && (
|
||
<span className="truncate text-[0.7rem] leading-tight opacity-90">
|
||
{formatEventTime(event.start, timeFormat)} –{" "}
|
||
{formatEventTime(event.end, timeFormat)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{isToday && showNowLine && (
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute right-0 left-0 z-30"
|
||
style={{ top: nowTop }}
|
||
>
|
||
<div className="relative h-0.5 bg-red-500">
|
||
<span className="absolute top-1/2 -left-1 size-3 -translate-y-1/2 rounded-full bg-red-500" />
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function formatMinutes(min: number, timeFormat: "24h" | "12h" = "24h"): string {
|
||
const d = new Date(2000, 0, 1, Math.floor(min / 60), min % 60, 0)
|
||
return formatEventTime(d, timeFormat)
|
||
}
|