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.
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"
|
|
import { addHours } from "date-fns"
|
|
import {
|
|
FocusableStepValue,
|
|
handleStepAdjustKeyDown,
|
|
StepAdjustGroup,
|
|
StepAdjustTimeDecreaseButton,
|
|
StepAdjustTimeIncreaseButton,
|
|
} from "@/components/agenda/agenda-step-adjust"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Switch } from "@/components/ui/switch"
|
|
import { formatAgendaStepDurationMinutes } from "@/lib/agenda/agenda-date"
|
|
import type { AgendaDurationStep } from "@/lib/agenda/agenda-settings-types"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
function toDateInput(d: Date): string {
|
|
const y = d.getFullYear()
|
|
const m = String(d.getMonth() + 1).padStart(2, "0")
|
|
const day = String(d.getDate()).padStart(2, "0")
|
|
return `${y}-${m}-${day}`
|
|
}
|
|
|
|
function toTimeInput(d: Date): string {
|
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
|
}
|
|
|
|
function fromDateAndTime(date: string, time: string): Date | null {
|
|
const [y, mo, d] = date.split("-").map(Number)
|
|
const [h, mi] = time.split(":").map(Number)
|
|
if (![y, mo, d, h, mi].every(Number.isFinite)) return null
|
|
const parsed = new Date(y, mo - 1, d, h, mi, 0, 0)
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed
|
|
}
|
|
|
|
function tabAt(base: number | undefined, offset: number): number | undefined {
|
|
return base !== undefined ? base + offset : undefined
|
|
}
|
|
|
|
const AGENDA_STEP_GROUP_CLASS =
|
|
"w-fit gap-0 border-border/70 bg-mail-surface px-0.5 py-0.5"
|
|
const AGENDA_STEP_BUTTON_CLASS = "size-6 shrink-0 [&_svg]:size-3.5"
|
|
const AGENDA_STEP_VALUE_SLOT_CLASS =
|
|
"flex h-8 w-[3.875rem] min-w-[3.875rem] max-w-[3.875rem] shrink-0 items-center justify-center"
|
|
const AGENDA_STEP_VALUE_CLASS =
|
|
"w-full min-w-0 px-0 text-center text-sm leading-none tabular-nums"
|
|
const AGENDA_TIME_INPUT_CLASS = cn(
|
|
AGENDA_STEP_VALUE_CLASS,
|
|
"h-full border-0 bg-transparent font-medium shadow-none focus-visible:ring-0",
|
|
"[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none",
|
|
"[&::-webkit-datetime-edit]:m-0 [&::-webkit-datetime-edit]:p-0 [&::-webkit-datetime-edit]:text-center",
|
|
"[&::-webkit-datetime-edit-fields-wrapper]:flex [&::-webkit-datetime-edit-fields-wrapper]:w-full [&::-webkit-datetime-edit-fields-wrapper]:items-center [&::-webkit-datetime-edit-fields-wrapper]:justify-center",
|
|
"[&::-webkit-datetime-edit-hour-field]:p-0 [&::-webkit-datetime-edit-minute-field]:p-0 [&::-webkit-datetime-edit-text]:p-0",
|
|
)
|
|
const AGENDA_DURATION_VALUE_CLASS = "text-center text-sm font-medium leading-none tabular-nums"
|
|
|
|
function ScheduleLabeledRow({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-8 shrink-0 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{children}</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AgendaStepTimeInput({
|
|
value,
|
|
tabIndex,
|
|
onChange,
|
|
onKeyDown,
|
|
}: {
|
|
value: string
|
|
tabIndex?: number
|
|
onChange: (value: string) => void
|
|
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => void
|
|
}) {
|
|
return (
|
|
<div className={AGENDA_STEP_VALUE_SLOT_CLASS}>
|
|
<Input
|
|
type="time"
|
|
value={value}
|
|
tabIndex={tabIndex}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
onKeyDown={onKeyDown}
|
|
className={AGENDA_TIME_INPUT_CLASS}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Nombre de tab stops internes (base inclusif → dernier = base + count - 1). */
|
|
export function agendaScheduleFieldCount(options: {
|
|
allDay: boolean
|
|
showAllDayToggle: boolean
|
|
compact: boolean
|
|
}): number {
|
|
if (options.compact) {
|
|
if (options.allDay) return 1
|
|
return 3
|
|
}
|
|
if (options.allDay) {
|
|
return options.showAllDayToggle ? 3 : 2
|
|
}
|
|
return options.showAllDayToggle ? 6 : 5
|
|
}
|
|
|
|
export function AgendaEventScheduleFields({
|
|
start,
|
|
end,
|
|
allDay,
|
|
stepMinutes,
|
|
onChange,
|
|
showAllDayToggle = false,
|
|
onAllDayChange,
|
|
compact = false,
|
|
showRowLabels = false,
|
|
className,
|
|
tabIndexBase,
|
|
}: {
|
|
start: Date
|
|
end: Date
|
|
allDay: boolean
|
|
stepMinutes: AgendaDurationStep
|
|
onChange: (start: Date, end: Date) => void
|
|
showAllDayToggle?: boolean
|
|
onAllDayChange?: (allDay: boolean) => void
|
|
compact?: boolean
|
|
/** Modale avancée : libellés Début / Fin à gauche des deux lignes. */
|
|
showRowLabels?: boolean
|
|
className?: string
|
|
tabIndexBase?: number
|
|
}) {
|
|
const startDate = toDateInput(start)
|
|
const endDate = toDateInput(end)
|
|
const startTime = toTimeInput(start)
|
|
const endTime = toTimeInput(end)
|
|
const [endDateOpen, setEndDateOpen] = useState(false)
|
|
const endDateInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
useEffect(() => {
|
|
setEndDateOpen(false)
|
|
}, [startDate, endDate, allDay, compact])
|
|
|
|
useEffect(() => {
|
|
if (!endDateOpen) return
|
|
const timer = window.setTimeout(() => endDateInputRef.current?.focus(), 0)
|
|
return () => window.clearTimeout(timer)
|
|
}, [endDateOpen])
|
|
|
|
const durationMinutes = Math.max(
|
|
stepMinutes,
|
|
Math.round((end.getTime() - start.getTime()) / 60_000),
|
|
)
|
|
|
|
const showEndDateField = startDate !== endDate || endDateOpen
|
|
|
|
const apply = (nextStart: Date, nextEnd: Date) => {
|
|
if (nextEnd.getTime() <= nextStart.getTime()) {
|
|
onChange(nextStart, addHours(nextStart, 1))
|
|
return
|
|
}
|
|
onChange(nextStart, nextEnd)
|
|
}
|
|
|
|
const patchStartDate = (date: string) => {
|
|
const nextStart = fromDateAndTime(date, allDay ? "00:00" : startTime) ?? start
|
|
let nextEnd = end
|
|
if (date > endDate) nextEnd = fromDateAndTime(date, allDay ? "00:00" : endTime) ?? nextStart
|
|
apply(nextStart, nextEnd)
|
|
}
|
|
|
|
const patchEndDate = (date: string) => {
|
|
const nextEnd = fromDateAndTime(date, allDay ? "00:00" : endTime) ?? end
|
|
apply(start, nextEnd)
|
|
}
|
|
|
|
const patchStartTime = (time: string) => {
|
|
const nextStart = fromDateAndTime(startDate, time)
|
|
if (!nextStart) return
|
|
apply(nextStart, end.getTime() <= nextStart.getTime() ? addHours(nextStart, 1) : end)
|
|
}
|
|
|
|
const patchEndTime = (time: string) => {
|
|
const nextEnd = fromDateAndTime(endDate, time)
|
|
if (!nextEnd) return
|
|
apply(start, nextEnd.getTime() <= start.getTime() ? addHours(start, 1) : nextEnd)
|
|
}
|
|
|
|
const shiftStart = (deltaMinutes: number) => {
|
|
const nextStart = new Date(start.getTime() + deltaMinutes * 60_000)
|
|
const durationMs = end.getTime() - start.getTime()
|
|
apply(nextStart, new Date(nextStart.getTime() + durationMs))
|
|
}
|
|
|
|
const shiftEnd = (deltaMinutes: number) => {
|
|
const nextEnd = new Date(end.getTime() + deltaMinutes * 60_000)
|
|
if (nextEnd.getTime() <= start.getTime()) return
|
|
apply(start, nextEnd)
|
|
}
|
|
|
|
const shiftDuration = (deltaMinutes: number) => {
|
|
const nextDuration = Math.max(stepMinutes, durationMinutes + deltaMinutes)
|
|
apply(start, new Date(start.getTime() + nextDuration * 60_000))
|
|
}
|
|
|
|
const startDateInput = (
|
|
<Input
|
|
type="date"
|
|
value={startDate}
|
|
tabIndex={tabAt(tabIndexBase, 0)}
|
|
onChange={(e) => patchStartDate(e.target.value)}
|
|
className="h-9 w-fit"
|
|
/>
|
|
)
|
|
|
|
const startTimeControl = (
|
|
<StepAdjustGroup className={AGENDA_STEP_GROUP_CLASS}>
|
|
<StepAdjustTimeDecreaseButton
|
|
onClick={() => shiftStart(-stepMinutes)}
|
|
label={`Reculer le début de ${stepMinutes} minutes`}
|
|
className={AGENDA_STEP_BUTTON_CLASS}
|
|
/>
|
|
<AgendaStepTimeInput
|
|
value={startTime}
|
|
tabIndex={tabAt(tabIndexBase, 1)}
|
|
onChange={patchStartTime}
|
|
onKeyDown={(e) =>
|
|
handleStepAdjustKeyDown(
|
|
e,
|
|
() => shiftStart(-stepMinutes),
|
|
() => shiftStart(stepMinutes),
|
|
)
|
|
}
|
|
/>
|
|
<StepAdjustTimeIncreaseButton
|
|
onClick={() => shiftStart(stepMinutes)}
|
|
label={`Avancer le début de ${stepMinutes} minutes`}
|
|
className={AGENDA_STEP_BUTTON_CLASS}
|
|
/>
|
|
</StepAdjustGroup>
|
|
)
|
|
|
|
const endTimeControl = (
|
|
<StepAdjustGroup className={AGENDA_STEP_GROUP_CLASS}>
|
|
<StepAdjustTimeDecreaseButton
|
|
onClick={() => shiftEnd(-stepMinutes)}
|
|
label={`Reculer la fin de ${stepMinutes} minutes`}
|
|
className={AGENDA_STEP_BUTTON_CLASS}
|
|
/>
|
|
<AgendaStepTimeInput
|
|
value={endTime}
|
|
tabIndex={tabAt(tabIndexBase, 3)}
|
|
onChange={patchEndTime}
|
|
onKeyDown={(e) =>
|
|
handleStepAdjustKeyDown(
|
|
e,
|
|
() => shiftEnd(-stepMinutes),
|
|
() => shiftEnd(stepMinutes),
|
|
)
|
|
}
|
|
/>
|
|
<StepAdjustTimeIncreaseButton
|
|
onClick={() => shiftEnd(stepMinutes)}
|
|
label={`Avancer la fin de ${stepMinutes} minutes`}
|
|
className={AGENDA_STEP_BUTTON_CLASS}
|
|
/>
|
|
</StepAdjustGroup>
|
|
)
|
|
|
|
const durationControl = (
|
|
<FocusableStepValue
|
|
tabIndex={tabAt(tabIndexBase, 2)}
|
|
value={formatAgendaStepDurationMinutes(durationMinutes)}
|
|
ariaLabel="Durée"
|
|
onDecrease={() => shiftDuration(-stepMinutes)}
|
|
onIncrease={() => shiftDuration(stepMinutes)}
|
|
decreaseDisabled={durationMinutes <= stepMinutes}
|
|
increaseDisabled={false}
|
|
decreaseLabel={`Réduire la durée de ${stepMinutes} minutes`}
|
|
increaseLabel={`Augmenter la durée de ${stepMinutes} minutes`}
|
|
className={AGENDA_STEP_GROUP_CLASS}
|
|
buttonClassName={AGENDA_STEP_BUTTON_CLASS}
|
|
valueWrapperClassName={AGENDA_STEP_VALUE_SLOT_CLASS}
|
|
valueClassName={AGENDA_DURATION_VALUE_CLASS}
|
|
/>
|
|
)
|
|
|
|
const endTimeLabel = (
|
|
<span
|
|
className="flex h-8 items-center text-xs tabular-nums text-foreground/80"
|
|
aria-label={`Fin à ${endTime}`}
|
|
>
|
|
{endTime}
|
|
</span>
|
|
)
|
|
|
|
const endDateControl = showEndDateField ? (
|
|
<Input
|
|
ref={endDateInputRef}
|
|
type="date"
|
|
value={endDate}
|
|
min={startDate}
|
|
tabIndex={tabAt(tabIndexBase, allDay ? 1 : 4)}
|
|
onChange={(e) => patchEndDate(e.target.value)}
|
|
className="h-9 w-fit"
|
|
/>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
tabIndex={tabAt(tabIndexBase, allDay ? 1 : 4)}
|
|
className="h-9 rounded-full px-3 text-sm font-normal"
|
|
onClick={() => setEndDateOpen(true)}
|
|
>
|
|
Ajouter une date de fin
|
|
</Button>
|
|
)
|
|
|
|
const allDayToggle =
|
|
showAllDayToggle && onAllDayChange ? (
|
|
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-foreground/80">
|
|
<Switch
|
|
checked={allDay}
|
|
tabIndex={tabAt(tabIndexBase, allDay ? 2 : 5)}
|
|
onCheckedChange={onAllDayChange}
|
|
/>
|
|
Toute la journée
|
|
</label>
|
|
) : null
|
|
|
|
if (compact && allDay) {
|
|
return (
|
|
<div className={cn("flex flex-col items-start gap-2", className)}>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{startDateInput}
|
|
{startDate !== endDate ? endDateControl : null}
|
|
</div>
|
|
<span className="text-sm text-muted-foreground">Toute la journée</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (compact) {
|
|
return (
|
|
<div className={cn("flex flex-col items-start gap-2", className)}>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{startDateInput}
|
|
{startTimeControl}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{durationControl}
|
|
{endTimeLabel}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const scheduleRow = (label: string | null, children: ReactNode) =>
|
|
showRowLabels && label ? (
|
|
<ScheduleLabeledRow label={label}>{children}</ScheduleLabeledRow>
|
|
) : (
|
|
<div className="flex flex-wrap items-center gap-2">{children}</div>
|
|
)
|
|
|
|
if (allDay) {
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{scheduleRow("Début", startDateInput)}
|
|
{scheduleRow("Fin", endDateControl)}
|
|
{allDayToggle}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{scheduleRow("Début", (
|
|
<>
|
|
{startDateInput}
|
|
{startTimeControl}
|
|
</>
|
|
))}
|
|
{scheduleRow("Fin", (
|
|
<>
|
|
{endDateControl}
|
|
{endTimeControl}
|
|
{durationControl}
|
|
</>
|
|
))}
|
|
{allDayToggle}
|
|
</div>
|
|
)
|
|
}
|