From 3bbf3691b0834a322b9d4811db385aaa6e6e4c74 Mon Sep 17 00:00:00 2001 From: R3D347HR4Y Date: Thu, 11 Jun 2026 10:10:39 +0200 Subject: [PATCH] bordel c'est beau --- app/agenda/[[...segments]]/page.tsx | 12 + app/agenda/layout.tsx | 9 + app/compte/[[...section]]/page.tsx | 10 + app/compte/layout.tsx | 15 + app/mail/mail-app-shell.tsx | 2 + components/agenda/agenda-app-shell.tsx | 44 ++ components/agenda/agenda-calendar-dialog.tsx | 121 +++++ components/agenda/agenda-event-chip.tsx | 63 +++ components/agenda/agenda-event-dialog.tsx | 436 ++++++++++++++++++ components/agenda/agenda-event-popover.tsx | 329 +++++++++++++ components/agenda/agenda-floating-card.tsx | 96 ++++ components/agenda/agenda-guest-picker.tsx | 181 ++++++++ components/agenda/agenda-header.tsx | 140 ++++++ components/agenda/agenda-mini-month.tsx | 101 ++++ components/agenda/agenda-page.tsx | 284 ++++++++++++ components/agenda/agenda-quick-create.tsx | 157 +++++++ components/agenda/agenda-sidebar.tsx | 238 ++++++++++ components/agenda/agenda-view-month.tsx | 124 +++++ components/agenda/agenda-view-week.tsx | 319 +++++++++++++ components/compte/compte-settings-header.tsx | 53 +++ components/compte/compte-settings-layout.tsx | 114 +++++ .../compte/compte-settings-section-view.tsx | 33 ++ .../compte/sections/compte-home-section.tsx | 77 ++++ .../sections/compte-personal-info-section.tsx | 95 ++++ .../sections/compte-security-section.tsx | 114 +++++ .../gmail/account-switcher-dropdown.tsx | 6 +- .../gmail/calendar-invitation-preview.tsx | 7 + components/gmail/contact-hover-card.tsx | 6 +- components/gmail/pending-compose-bridge.tsx | 29 ++ components/gmail/right-panel.tsx | 13 +- components/landing/landing-data.ts | 4 +- components/suite/account-switcher-panel.tsx | 6 +- lib/agenda/agenda-colors.ts | 46 ++ lib/agenda/agenda-date.ts | 135 ++++++ lib/agenda/agenda-event-layout.ts | 89 ++++ lib/agenda/agenda-events.ts | 111 +++++ lib/agenda/agenda-mail-compose.ts | 36 ++ lib/agenda/agenda-recurrence.test.ts | 116 +++++ lib/agenda/agenda-recurrence.ts | 182 ++++++++ lib/agenda/agenda-store.ts | 50 ++ lib/agenda/agenda-types.ts | 81 ++++ lib/agenda/agenda-url.ts | 32 ++ lib/api/hooks/use-calendar-mutations.ts | 158 +++++++ lib/api/hooks/use-calendar-queries.ts | 77 ++++ lib/auth/authentik-user-url.ts | 18 + lib/compte-settings/settings-nav.ts | 59 +++ lib/suite/favorite-apps.ts | 12 +- lib/suite/page-metadata.ts | 19 +- tsconfig.tsbuildinfo | 2 +- 49 files changed, 4450 insertions(+), 11 deletions(-) create mode 100644 app/agenda/[[...segments]]/page.tsx create mode 100644 app/agenda/layout.tsx create mode 100644 app/compte/[[...section]]/page.tsx create mode 100644 app/compte/layout.tsx create mode 100644 components/agenda/agenda-app-shell.tsx create mode 100644 components/agenda/agenda-calendar-dialog.tsx create mode 100644 components/agenda/agenda-event-chip.tsx create mode 100644 components/agenda/agenda-event-dialog.tsx create mode 100644 components/agenda/agenda-event-popover.tsx create mode 100644 components/agenda/agenda-floating-card.tsx create mode 100644 components/agenda/agenda-guest-picker.tsx create mode 100644 components/agenda/agenda-header.tsx create mode 100644 components/agenda/agenda-mini-month.tsx create mode 100644 components/agenda/agenda-page.tsx create mode 100644 components/agenda/agenda-quick-create.tsx create mode 100644 components/agenda/agenda-sidebar.tsx create mode 100644 components/agenda/agenda-view-month.tsx create mode 100644 components/agenda/agenda-view-week.tsx create mode 100644 components/compte/compte-settings-header.tsx create mode 100644 components/compte/compte-settings-layout.tsx create mode 100644 components/compte/compte-settings-section-view.tsx create mode 100644 components/compte/sections/compte-home-section.tsx create mode 100644 components/compte/sections/compte-personal-info-section.tsx create mode 100644 components/compte/sections/compte-security-section.tsx create mode 100644 components/gmail/pending-compose-bridge.tsx create mode 100644 lib/agenda/agenda-colors.ts create mode 100644 lib/agenda/agenda-date.ts create mode 100644 lib/agenda/agenda-event-layout.ts create mode 100644 lib/agenda/agenda-events.ts create mode 100644 lib/agenda/agenda-mail-compose.ts create mode 100644 lib/agenda/agenda-recurrence.test.ts create mode 100644 lib/agenda/agenda-recurrence.ts create mode 100644 lib/agenda/agenda-store.ts create mode 100644 lib/agenda/agenda-types.ts create mode 100644 lib/agenda/agenda-url.ts create mode 100644 lib/api/hooks/use-calendar-mutations.ts create mode 100644 lib/api/hooks/use-calendar-queries.ts create mode 100644 lib/auth/authentik-user-url.ts create mode 100644 lib/compte-settings/settings-nav.ts diff --git a/app/agenda/[[...segments]]/page.tsx b/app/agenda/[[...segments]]/page.tsx new file mode 100644 index 0000000..ee9b90a --- /dev/null +++ b/app/agenda/[[...segments]]/page.tsx @@ -0,0 +1,12 @@ +"use client" + +import { Suspense } from "react" +import { AgendaPage } from "@/components/agenda/agenda-page" + +export default function AgendaRoutePage() { + return ( + + + + ) +} diff --git a/app/agenda/layout.tsx b/app/agenda/layout.tsx new file mode 100644 index 0000000..cc8c4fc --- /dev/null +++ b/app/agenda/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from "react" +import { AgendaAppShell } from "@/components/agenda/agenda-app-shell" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata = suitePageMetadata({ app: "agenda" }) + +export default function AgendaLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/app/compte/[[...section]]/page.tsx b/app/compte/[[...section]]/page.tsx new file mode 100644 index 0000000..2fbe3ec --- /dev/null +++ b/app/compte/[[...section]]/page.tsx @@ -0,0 +1,10 @@ +import { CompteSettingsSectionFromSegments } from "@/components/compte/compte-settings-section-view" + +export default async function CompteSectionPage({ + params, +}: { + params: Promise<{ section?: string[] }> +}) { + const { section } = await params + return +} diff --git a/app/compte/layout.tsx b/app/compte/layout.tsx new file mode 100644 index 0000000..b9ee6e4 --- /dev/null +++ b/app/compte/layout.tsx @@ -0,0 +1,15 @@ +import { CompteSettingsLayout } from "@/components/compte/compte-settings-layout" +import type { Metadata } from "next" +import { suitePageMetadata } from "@/lib/suite/page-metadata" + +export const metadata: Metadata = suitePageMetadata({ + app: "compte", +}) + +export default function CompteRootLayout({ + children, +}: { + children: React.ReactNode +}) { + return {children} +} diff --git a/app/mail/mail-app-shell.tsx b/app/mail/mail-app-shell.tsx index 1e99781..62b2786 100644 --- a/app/mail/mail-app-shell.tsx +++ b/app/mail/mail-app-shell.tsx @@ -27,6 +27,7 @@ import { MoveDragIndicator } from "@/components/gmail/move-drag-indicator" import { ComposeProvider } from "@/lib/compose-context" import { ScheduledMailProvider } from "@/lib/scheduled-mail-context" import { ComposeModalManager } from "@/components/gmail/compose-modal" +import { PendingComposeBridge } from "@/components/gmail/pending-compose-bridge" import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { mailNavVisitKey } from "@/lib/mail-folder-display" import { MailDocumentTitle } from "@/components/gmail/mail-document-title" @@ -270,6 +271,7 @@ export function MailAppShell({ + diff --git a/components/agenda/agenda-app-shell.tsx b/components/agenda/agenda-app-shell.tsx new file mode 100644 index 0000000..01cd86e --- /dev/null +++ b/components/agenda/agenda-app-shell.tsx @@ -0,0 +1,44 @@ +"use client" + +import { useEffect, useLayoutEffect, type ReactNode } from "react" +import { AiChatPanel } from "@/components/ai/ai-chat-panel" +import { SuiteThemeShell } from "@/components/suite/suite-theme-shell" +import { TooltipProvider } from "@/components/ui/tooltip" +import { useIsMobile } from "@/hooks/use-mobile" +import { useAgendaUIStore } from "@/lib/agenda/agenda-store" + +export function AgendaAppShell({ children }: { children: ReactNode }) { + const isMobile = useIsMobile() + const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed) + const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed) + + useLayoutEffect(() => { + if (!isMobile) setSidebarCollapsed(false) + }, [isMobile, setSidebarCollapsed]) + + useEffect(() => { + if (isMobile) setSidebarCollapsed(true) + }, [isMobile, setSidebarCollapsed]) + + return ( + + +
+ {isMobile && !sidebarCollapsed && ( +
+
+
+ ) +} diff --git a/components/agenda/agenda-calendar-dialog.tsx b/components/agenda/agenda-calendar-dialog.tsx new file mode 100644 index 0000000..5d84bae --- /dev/null +++ b/components/agenda/agenda-calendar-dialog.tsx @@ -0,0 +1,121 @@ +"use client" + +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + useCreateAgendaCalendar, + useUpdateAgendaCalendar, +} from "@/lib/api/hooks/use-calendar-mutations" +import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors" +import { calendarColor } from "@/lib/agenda/agenda-events" +import type { AgendaCalendar } from "@/lib/agenda/agenda-types" +import { cn } from "@/lib/utils" + +export function AgendaCalendarDialog({ + open, + onOpenChange, + calendar, +}: { + open: boolean + onOpenChange: (open: boolean) => void + /** Agenda à modifier — absent en création. */ + calendar?: AgendaCalendar | null +}) { + const [name, setName] = useState("") + const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value) + const createMutation = useCreateAgendaCalendar() + const updateMutation = useUpdateAgendaCalendar() + const pending = createMutation.isPending || updateMutation.isPending + + useEffect(() => { + if (!open) return + setName(calendar?.display_name ?? "") + setColor(calendar ? calendarColor(calendar) : AGENDA_COLOR_PALETTE[0].value) + }, [open, calendar]) + + const submit = async () => { + const displayName = name.trim() + if (!displayName) return + try { + if (calendar) { + await updateMutation.mutateAsync({ + id: calendar.id, + display_name: displayName, + color, + }) + toast.success("Agenda mis à jour") + } else { + await createMutation.mutateAsync({ display_name: displayName, color }) + toast.success(`Agenda « ${displayName} » créé`) + } + onOpenChange(false) + } catch { + toast.error("Impossible d'enregistrer l'agenda") + } + } + + return ( + + + + + {calendar ? "Modifier l'agenda" : "Nouvel agenda"} + + +
+
+ + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void submit() + }} + /> +
+
+ +
+ {AGENDA_COLOR_PALETTE.map((c) => ( +
+
+
+ + + + +
+
+ ) +} diff --git a/components/agenda/agenda-event-chip.tsx b/components/agenda/agenda-event-chip.tsx new file mode 100644 index 0000000..9f2bb69 --- /dev/null +++ b/components/agenda/agenda-event-chip.tsx @@ -0,0 +1,63 @@ +"use client" + +import type { CSSProperties, MouseEvent } from "react" +import { formatEventTime } from "@/lib/agenda/agenda-date" +import { readableTextColor } from "@/lib/agenda/agenda-colors" +import type { AgendaEvent } from "@/lib/agenda/agenda-types" +import { cn } from "@/lib/utils" + +/** Chip compact (vue mois / rangée journée entière). */ +export function AgendaEventChip({ + event, + filled, + onClick, + className, +}: { + event: AgendaEvent + /** Fond plein (journée entière / multi-jours) vs point coloré + heure. */ + filled?: boolean + onClick?: (e: MouseEvent) => void + className?: string +}) { + const solid = filled ?? event.allDay + const style: CSSProperties = solid + ? { backgroundColor: event.color, color: readableTextColor(event.color) } + : {} + const declined = isDeclinedForAll(event) + + return ( + + ) +} + +function isDeclinedForAll(event: AgendaEvent): boolean { + return ( + event.attendees.length > 0 && + event.attendees.every((a) => a.status === "DECLINED") + ) +} diff --git a/components/agenda/agenda-event-dialog.tsx b/components/agenda/agenda-event-dialog.tsx new file mode 100644 index 0000000..8f370f7 --- /dev/null +++ b/components/agenda/agenda-event-dialog.tsx @@ -0,0 +1,436 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { addDays, addHours, format, parse } from "date-fns" +import { toast } from "sonner" +import { Icon } from "@iconify/react" +import { + AlignLeft, + CalendarDays, + Clock, + MapPin, + Repeat, + Trash2, + Users, +} 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 { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker" +import { + draftToApiEvent, + 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 { cn } from "@/lib/utils" + +export interface AgendaEventDialogState { + mode: "create" | "edit" + draft: AgendaEventDraft + /** Présent en édition. */ + event?: AgendaEvent +} + +function toDateInput(d: Date): string { + return format(d, "yyyy-MM-dd") +} + +function toTimeInput(d: Date): string { + return format(d, "HH:mm") +} + +function fromInputs(date: string, time: string): Date | null { + const parsed = parse(`${date} ${time}`, "yyyy-MM-dd HH:mm", new Date()) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +export function AgendaEventDialog({ + state, + onClose, + calendars, + userEmail, +}: { + state: AgendaEventDialogState | null + onClose: () => void + calendars: AgendaCalendar[] + userEmail?: string +}) { + const createMutation = useCreateAgendaEvent() + const updateMutation = useUpdateAgendaEvent() + const deleteMutation = useDeleteAgendaEvent() + const meetLinkMutation = useCreateAgendaMeetLink() + + const [title, setTitle] = useState("") + const [allDay, setAllDay] = useState(false) + const [startDate, setStartDate] = useState("") + const [startTime, setStartTime] = useState("09:00") + const [endDate, setEndDate] = useState("") + const [endTime, setEndTime] = useState("10:00") + const [calendarId, setCalendarId] = useState("") + const [rrule, setRRule] = useState("") + const [color, setColor] = useState("") + const [location, setLocation] = useState("") + const [description, setDescription] = useState("") + const [attendees, setAttendees] = useState([]) + + const open = state !== null + const isEdit = state?.mode === "edit" + + useEffect(() => { + if (!state) return + const d = state.draft + setTitle(d.title) + setAllDay(d.allDay) + setStartDate(toDateInput(d.start)) + setStartTime(toTimeInput(d.start)) + // ICS : fin exclusive pour les journées entières → réaffiche la date incluse. + const displayEnd = d.allDay ? addDays(d.end, -1) : d.end + setEndDate(toDateInput(displayEnd < d.start ? d.start : displayEnd)) + setEndTime(toTimeInput(d.end)) + setCalendarId(d.calendarId || calendars[0]?.id || "") + setRRule(d.rrule ?? "") + setColor(d.color ?? "") + setLocation(d.location ?? "") + setDescription(d.description ?? "") + setAttendees(d.attendees ?? []) + }, [state, calendars]) + + const recurrenceOptions = useMemo(() => { + const start = fromInputs(startDate, startTime) ?? new Date() + const options = recurrenceOptionsFor(start) + if (rrule && !options.some((o) => o.value === rrule)) { + options.push({ value: rrule, label: describeRRule(rrule) }) + } + return options + }, [startDate, startTime, rrule]) + + const pending = + createMutation.isPending || updateMutation.isPending || deleteMutation.isPending + + const buildDraft = (): AgendaEventDraft | null => { + const start = fromInputs(startDate, allDay ? "00:00" : startTime) + if (!start) return null + let end = fromInputs(endDate, allDay ? "00:00" : endTime) + if (!end) return null + if (allDay) { + // Fin exclusive : jour affiché inclus + 1. + end = addDays(end, 1) + if (end <= start) end = addDays(start, 1) + } else if (end <= start) { + end = addHours(start, 1) + } + return { + title, + start, + end, + allDay, + calendarId, + description, + location, + attendees, + rrule, + color: color || undefined, + } + } + + const submit = async () => { + const draft = buildDraft() + if (!draft || !calendarId) return + try { + if (isEdit && state?.event) { + await updateMutation.mutateAsync({ + path: state.event.path, + etag: state.event.etag, + event: draftToApiEvent(draft, state.event.master), + }) + toast.success("Événement mis à jour") + } else { + const apiEvent = draftToApiEvent(draft) + if (userEmail) apiEvent.organizer = userEmail + await createMutation.mutateAsync({ calendarId, event: apiEvent }) + toast.success("É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 addMeetLink = async () => { + if (!state?.event) return + try { + const res = await meetLinkMutation.mutateAsync({ + path: state.event.path, + etag: state.event.etag, + }) + toast.success("Visio UltiMeet ajoutée") + window.open(res.meet_url, "_blank", "noopener") + onClose() + } catch { + toast.error("Impossible de créer le lien UltiMeet") + } + } + + const meetUrl = state?.event?.meetUrl + + return ( + { + if (!o) onClose() + }} + > + + + + {isEdit ? "Modifier l'événement" : "Nouvel événement"} + + + +
+ setTitle(e.target.value)} + placeholder="Ajouter un titre" + className="h-11 border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-xl shadow-none rounded-none focus-visible:border-primary focus-visible:ring-0" + /> + + {/* Dates */} +
+
+ +
+
+ { + setStartDate(e.target.value) + if (e.target.value > endDate) setEndDate(e.target.value) + }} + className="h-9 w-fit" + /> + {!allDay && ( + setStartTime(e.target.value)} + className="h-9 w-fit" + /> + )} + + {!allDay && ( + setEndTime(e.target.value)} + className="h-9 w-fit" + /> + )} + setEndDate(e.target.value)} + className="h-9 w-fit" + /> +
+ +
+
+ +
+ + +
+
+ + {/* Invités */} +
+ +
+ +
+
+ + {/* Visio */} +
+ + {meetUrl ? ( + + ) : isEdit ? ( + + ) : ( + + La visio peut être ajoutée après création + + )} +
+ + {/* Lieu */} +
+ + setLocation(e.target.value)} + placeholder="Ajouter un lieu" + className="h-9 flex-1" + /> +
+ + {/* Description */} +
+ +