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.
416 lines
13 KiB
TypeScript
416 lines
13 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
import { addDays, addHours } from "date-fns"
|
|
import { toast } from "sonner"
|
|
import {
|
|
AlignLeft,
|
|
CalendarDays,
|
|
MapPin,
|
|
Repeat,
|
|
Trash2,
|
|
Users,
|
|
Video,
|
|
} from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import { AgendaEventScheduleFields, agendaScheduleFieldCount } from "@/components/agenda/agenda-event-schedule-fields"
|
|
import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker"
|
|
import { AgendaVideoToggle } from "@/components/agenda/agenda-video-toggle"
|
|
import {
|
|
createAgendaEventWithVideo,
|
|
saveAgendaEventEdit,
|
|
} from "@/lib/agenda/agenda-save-with-video"
|
|
import {
|
|
useCreateAgendaEvent,
|
|
useCreateAgendaMeetLink,
|
|
useDeleteAgendaEvent,
|
|
useUpdateAgendaEvent,
|
|
} from "@/lib/api/hooks/use-calendar-mutations"
|
|
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
|
|
import { calendarColor } from "@/lib/agenda/agenda-events"
|
|
import { describeRRule, recurrenceOptionsFor } from "@/lib/agenda/agenda-recurrence"
|
|
import type {
|
|
AgendaCalendar,
|
|
AgendaEvent,
|
|
AgendaEventAttendee,
|
|
AgendaEventDraft,
|
|
} from "@/lib/agenda/agenda-types"
|
|
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
export interface AgendaEventDialogState {
|
|
mode: "create" | "edit"
|
|
draft: AgendaEventDraft
|
|
/** Présent en édition. */
|
|
event?: AgendaEvent
|
|
}
|
|
|
|
export function AgendaEventDialog({
|
|
state,
|
|
onClose,
|
|
calendars,
|
|
userEmail,
|
|
onDraftChange,
|
|
}: {
|
|
state: AgendaEventDialogState | null
|
|
onClose: () => void
|
|
calendars: AgendaCalendar[]
|
|
userEmail?: string
|
|
onDraftChange?: (draft: AgendaEventDraft) => void
|
|
}) {
|
|
const createMutation = useCreateAgendaEvent()
|
|
const updateMutation = useUpdateAgendaEvent()
|
|
const deleteMutation = useDeleteAgendaEvent()
|
|
const meetLinkMutation = useCreateAgendaMeetLink()
|
|
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
|
|
|
|
const [title, setTitle] = useState("")
|
|
const [allDay, setAllDay] = useState(false)
|
|
const [start, setStart] = useState(() => new Date())
|
|
const [end, setEnd] = useState(() => new Date())
|
|
const [calendarId, setCalendarId] = useState("")
|
|
const [rrule, setRRule] = useState("")
|
|
const [color, setColor] = useState("")
|
|
const [location, setLocation] = useState("")
|
|
const [description, setDescription] = useState("")
|
|
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
|
|
const [includeVideo, setIncludeVideo] = useState(false)
|
|
const [meetUrl, setMeetUrl] = useState("")
|
|
const titleRef = useRef<HTMLInputElement>(null)
|
|
|
|
const open = state !== null
|
|
const isEdit = state?.mode === "edit"
|
|
|
|
useEffect(() => {
|
|
if (!state) return
|
|
const d = state.draft
|
|
setTitle(d.title)
|
|
setAllDay(d.allDay)
|
|
setStart(d.start)
|
|
setEnd(d.allDay ? addDays(d.end, -1) : d.end)
|
|
setCalendarId(d.calendarId || calendars[0]?.id || "")
|
|
setRRule(d.rrule ?? "")
|
|
setColor(d.color ?? "")
|
|
setLocation(d.location ?? "")
|
|
setDescription(d.description ?? "")
|
|
setAttendees(d.attendees ?? [])
|
|
setIncludeVideo(Boolean(d.includeVideo || state.event?.meetUrl))
|
|
setMeetUrl(state.event?.meetUrl ?? "")
|
|
if (state.mode === "create") {
|
|
const timer = window.setTimeout(() => titleRef.current?.focus(), 0)
|
|
return () => window.clearTimeout(timer)
|
|
}
|
|
}, [state, calendars])
|
|
|
|
useEffect(() => {
|
|
if (allDay) setIncludeVideo(false)
|
|
}, [allDay])
|
|
|
|
const recurrenceOptions = useMemo(() => {
|
|
const options = recurrenceOptionsFor(start)
|
|
if (rrule && !options.some((o) => o.value === rrule)) {
|
|
options.push({ value: rrule, label: describeRRule(rrule) })
|
|
}
|
|
return options
|
|
}, [start, rrule])
|
|
|
|
const pending =
|
|
createMutation.isPending ||
|
|
updateMutation.isPending ||
|
|
deleteMutation.isPending ||
|
|
meetLinkMutation.isPending
|
|
|
|
const buildDraft = (): AgendaEventDraft | null => {
|
|
if (!calendarId) return null
|
|
let eventStart = start
|
|
let eventEnd = allDay ? addDays(end, 1) : end
|
|
if (allDay && eventEnd <= eventStart) eventEnd = addDays(eventStart, 1)
|
|
if (!allDay && eventEnd <= eventStart) eventEnd = addHours(eventStart, 1)
|
|
return {
|
|
title,
|
|
start: eventStart,
|
|
end: eventEnd,
|
|
allDay,
|
|
calendarId,
|
|
description,
|
|
location,
|
|
attendees,
|
|
rrule,
|
|
color: color || undefined,
|
|
includeVideo: includeVideo && !allDay,
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open || isEdit || !onDraftChange) return
|
|
const draft = buildDraft()
|
|
if (draft) onDraftChange(draft)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
open,
|
|
isEdit,
|
|
title,
|
|
allDay,
|
|
start,
|
|
end,
|
|
calendarId,
|
|
color,
|
|
includeVideo,
|
|
onDraftChange,
|
|
])
|
|
|
|
const calendar = calendars.find((c) => c.id === calendarId) ?? calendars[0]
|
|
|
|
const submit = async () => {
|
|
const draft = buildDraft()
|
|
if (!draft || !calendar) return
|
|
try {
|
|
if (isEdit && state?.event) {
|
|
await saveAgendaEventEdit({
|
|
draft,
|
|
path: state.event.path,
|
|
etag: state.event.etag,
|
|
master: state.event.master,
|
|
includeVideo: includeVideo && !allDay,
|
|
meetUrl,
|
|
videoProvider: defaultVideoProvider,
|
|
updateMutation,
|
|
meetLinkMutation,
|
|
})
|
|
toast.success("Événement mis à jour")
|
|
} else {
|
|
await createAgendaEventWithVideo({
|
|
draft,
|
|
calendar,
|
|
userEmail,
|
|
includeVideo: includeVideo && !allDay,
|
|
videoProvider: defaultVideoProvider,
|
|
createMutation,
|
|
meetLinkMutation,
|
|
})
|
|
toast.success(
|
|
includeVideo && !allDay ? "Événement et visio créés" : "Événement créé",
|
|
)
|
|
}
|
|
onClose()
|
|
} catch {
|
|
toast.error("Impossible d'enregistrer l'événement")
|
|
}
|
|
}
|
|
|
|
const remove = async () => {
|
|
if (!state?.event) return
|
|
try {
|
|
await deleteMutation.mutateAsync({ path: state.event.path })
|
|
toast.success("Événement supprimé")
|
|
onClose()
|
|
} catch {
|
|
toast.error("Impossible de supprimer l'événement")
|
|
}
|
|
}
|
|
|
|
const handleVideoChange = (enabled: boolean) => {
|
|
setIncludeVideo(enabled)
|
|
if (!enabled) setMeetUrl("")
|
|
}
|
|
|
|
const scheduleTabCount = agendaScheduleFieldCount({
|
|
allDay,
|
|
showAllDayToggle: true,
|
|
compact: false,
|
|
})
|
|
const tab = (offset: number) => 2 + scheduleTabCount + offset
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!o) onClose()
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
|
aria-describedby={undefined}
|
|
>
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>
|
|
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 pb-4 pt-4">
|
|
<Input
|
|
ref={titleRef}
|
|
value={title}
|
|
tabIndex={1}
|
|
autoFocus={!isEdit}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="Ajouter un titre"
|
|
className="h-11 rounded-none border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-xl shadow-none focus-visible:border-primary focus-visible:ring-0"
|
|
/>
|
|
|
|
<AgendaEventScheduleFields
|
|
start={start}
|
|
end={end}
|
|
allDay={allDay}
|
|
stepMinutes={buttonSnapMinutes}
|
|
tabIndexBase={2}
|
|
showRowLabels
|
|
showAllDayToggle
|
|
onAllDayChange={setAllDay}
|
|
onChange={(nextStart, nextEnd) => {
|
|
setStart(nextStart)
|
|
setEnd(allDay ? nextEnd : nextEnd)
|
|
}}
|
|
/>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Repeat className="size-5 shrink-0 text-muted-foreground" />
|
|
<Select value={rrule || "none"} onValueChange={(v) => setRRule(v === "none" ? "" : v)}>
|
|
<SelectTrigger tabIndex={tab(0)} className="h-9 w-fit min-w-52 border-0 bg-muted/60 shadow-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{recurrenceOptions.map((o) => (
|
|
<SelectItem key={o.value || "none"} value={o.value || "none"}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3">
|
|
<Users className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<AgendaGuestPicker
|
|
attendees={attendees}
|
|
onChange={setAttendees}
|
|
organizerEmail={userEmail}
|
|
tabIndex={tab(1)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{!allDay && defaultVideoProvider !== "none" ? (
|
|
<div className="flex items-start gap-3">
|
|
<Video className="mt-2 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
|
<AgendaVideoToggle
|
|
provider={defaultVideoProvider}
|
|
enabled={includeVideo}
|
|
meetUrl={includeVideo ? meetUrl : undefined}
|
|
onEnabledChange={handleVideoChange}
|
|
pending={pending}
|
|
tabIndex={tab(2)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex items-center gap-3">
|
|
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
|
<Input
|
|
value={location}
|
|
tabIndex={tab(3)}
|
|
onChange={(e) => setLocation(e.target.value)}
|
|
placeholder="Ajouter un lieu"
|
|
className="h-9 flex-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-start gap-3">
|
|
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
|
<Textarea
|
|
value={description}
|
|
tabIndex={tab(4)}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Ajouter une description"
|
|
className="min-h-20 flex-1"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
|
<Select value={calendarId} onValueChange={setCalendarId} disabled={isEdit}>
|
|
<SelectTrigger tabIndex={tab(5)} className="h-9 w-fit min-w-40 border-0 bg-muted/60 shadow-none">
|
|
<SelectValue placeholder="Agenda" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{calendars.map((cal) => (
|
|
<SelectItem key={cal.id} value={cal.id}>
|
|
<span className="flex items-center gap-2">
|
|
<span
|
|
className="size-3 rounded-full"
|
|
style={{ backgroundColor: calendarColor(cal) }}
|
|
/>
|
|
{cal.display_name}
|
|
</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="flex items-center gap-1.5">
|
|
{AGENDA_COLOR_PALETTE.slice(0, 8).map((c, i) => (
|
|
<button
|
|
key={c.value}
|
|
type="button"
|
|
tabIndex={tab(6 + i)}
|
|
title={c.label}
|
|
aria-label={`Couleur ${c.label}`}
|
|
onClick={() => setColor(color === c.value ? "" : c.value)}
|
|
className={cn(
|
|
"size-5 rounded-full transition-transform hover:scale-110",
|
|
color === c.value &&
|
|
"ring-2 ring-foreground/60 ring-offset-1 ring-offset-background",
|
|
)}
|
|
style={{ backgroundColor: c.value }}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="flex-row items-center border-t border-border/60 px-5 py-3">
|
|
{isEdit && (
|
|
<Button
|
|
variant="ghost"
|
|
tabIndex={tab(14)}
|
|
className="mr-auto gap-2 text-destructive hover:text-destructive"
|
|
disabled={pending}
|
|
onClick={() => void remove()}
|
|
>
|
|
<Trash2 className="size-4" /> Supprimer
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" tabIndex={tab(15)} onClick={onClose} disabled={pending}>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
tabIndex={tab(16)}
|
|
onClick={() => void submit()}
|
|
disabled={pending || !calendarId}
|
|
className="rounded-full px-6"
|
|
>
|
|
Enregistrer
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|