This commit is contained in:
parent
303b2b1074
commit
3bbf3691b0
12
app/agenda/[[...segments]]/page.tsx
Normal file
12
app/agenda/[[...segments]]/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { AgendaPage } from "@/components/agenda/agenda-page"
|
||||
|
||||
export default function AgendaRoutePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgendaPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
9
app/agenda/layout.tsx
Normal file
9
app/agenda/layout.tsx
Normal file
@ -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 <AgendaAppShell>{children}</AgendaAppShell>
|
||||
}
|
||||
10
app/compte/[[...section]]/page.tsx
Normal file
10
app/compte/[[...section]]/page.tsx
Normal file
@ -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 <CompteSettingsSectionFromSegments segments={section} />
|
||||
}
|
||||
15
app/compte/layout.tsx
Normal file
15
app/compte/layout.tsx
Normal file
@ -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 <CompteSettingsLayout>{children}</CompteSettingsLayout>
|
||||
}
|
||||
@ -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({
|
||||
<MailNotificationsBridge />
|
||||
<QuickSettingsRoot />
|
||||
<MoveDragIndicator />
|
||||
<PendingComposeBridge />
|
||||
<ComposeModalManager />
|
||||
<FilePreviewDialog />
|
||||
</EmailDragProvider>
|
||||
|
||||
44
components/agenda/agenda-app-shell.tsx
Normal file
44
components/agenda/agenda-app-shell.tsx
Normal file
@ -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 (
|
||||
<SuiteThemeShell>
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
className="ultimail-app relative flex h-dvh flex-col overflow-hidden bg-app-canvas"
|
||||
data-agenda-app
|
||||
>
|
||||
{isMobile && !sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer le menu"
|
||||
className="absolute inset-0 z-40 bg-black/20"
|
||||
onClick={() => setSidebarCollapsed(true)}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
<AiChatPanel />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SuiteThemeShell>
|
||||
)
|
||||
}
|
||||
121
components/agenda/agenda-calendar-dialog.tsx
Normal file
121
components/agenda/agenda-calendar-dialog.tsx
Normal file
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{calendar ? "Modifier l'agenda" : "Nouvel agenda"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="agenda-cal-name">Nom</Label>
|
||||
<Input
|
||||
id="agenda-cal-name"
|
||||
value={name}
|
||||
autoFocus
|
||||
placeholder="Mon agenda"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submit()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Couleur</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AGENDA_COLOR_PALETTE.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
title={c.label}
|
||||
aria-label={c.label}
|
||||
onClick={() => setColor(c.value)}
|
||||
className={cn(
|
||||
"size-7 rounded-full transition-transform hover:scale-110",
|
||||
color === c.value &&
|
||||
"ring-2 ring-foreground/70 ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
style={{ backgroundColor: c.value }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={() => void submit()} disabled={!name.trim() || pending}>
|
||||
{calendar ? "Enregistrer" : "Créer"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
63
components/agenda/agenda-event-chip.tsx
Normal file
63
components/agenda/agenda-event-chip.tsx
Normal file
@ -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<HTMLButtonElement>) => void
|
||||
className?: string
|
||||
}) {
|
||||
const solid = filled ?? event.allDay
|
||||
const style: CSSProperties = solid
|
||||
? { backgroundColor: event.color, color: readableTextColor(event.color) }
|
||||
: {}
|
||||
const declined = isDeclinedForAll(event)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-[1px] text-left text-xs leading-[1.4] transition-[filter] hover:brightness-95 dark:hover:brightness-110",
|
||||
!solid && "hover:bg-mail-nav-hover",
|
||||
declined && "opacity-50 line-through",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
title={event.title}
|
||||
>
|
||||
{!solid && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
)}
|
||||
{!solid && !event.allDay && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{formatEventTime(event.start)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate font-medium">{event.title}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function isDeclinedForAll(event: AgendaEvent): boolean {
|
||||
return (
|
||||
event.attendees.length > 0 &&
|
||||
event.attendees.every((a) => a.status === "DECLINED")
|
||||
)
|
||||
}
|
||||
436
components/agenda/agenda-event-dialog.tsx
Normal file
436
components/agenda/agenda-event-dialog.tsx
Normal file
@ -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<AgendaEventAttendee[]>([])
|
||||
|
||||
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 (
|
||||
<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">
|
||||
<DialogHeader className="border-b border-border/60 px-5 py-3">
|
||||
<DialogTitle className="text-base font-medium">
|
||||
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
<Input
|
||||
value={title}
|
||||
autoFocus={!isEdit}
|
||||
onChange={(e) => 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 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => {
|
||||
setStartDate(e.target.value)
|
||||
if (e.target.value > endDate) setEndDate(e.target.value)
|
||||
}}
|
||||
className="h-9 w-fit"
|
||||
/>
|
||||
{!allDay && (
|
||||
<Input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="h-9 w-fit"
|
||||
/>
|
||||
)}
|
||||
<span className="px-0.5 text-muted-foreground">–</span>
|
||||
{!allDay && (
|
||||
<Input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="h-9 w-fit"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={startDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="h-9 w-fit"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-foreground/80">
|
||||
<Switch checked={allDay} onCheckedChange={setAllDay} />
|
||||
Toute la journée
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
|
||||
{/* Invités */}
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visio */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon
|
||||
icon="simple-icons:jitsi"
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
{meetUrl ? (
|
||||
<Button asChild className="h-9 rounded-full">
|
||||
<a href={meetUrl} target="_blank" rel="noopener noreferrer">
|
||||
Rejoindre la visio UltiMeet
|
||||
</a>
|
||||
</Button>
|
||||
) : isEdit ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 rounded-full"
|
||||
disabled={meetLinkMutation.isPending}
|
||||
onClick={() => void addMeetLink()}
|
||||
>
|
||||
Ajouter une visio UltiMeet
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
La visio peut être ajoutée après création
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lieu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Ajouter un lieu"
|
||||
className="h-9 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex items-start gap-3">
|
||||
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Ajouter une description"
|
||||
className="min-h-20 flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agenda + couleur */}
|
||||
<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 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) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
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"
|
||||
className="mr-auto gap-2 text-destructive hover:text-destructive"
|
||||
disabled={pending}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 className="size-4" /> Supprimer
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
disabled={pending || !calendarId || !startDate || !endDate}
|
||||
className="rounded-full px-6"
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
329
components/agenda/agenda-event-popover.tsx
Normal file
329
components/agenda/agenda-event-popover.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Icon } from "@iconify/react"
|
||||
import {
|
||||
Check,
|
||||
CircleHelp,
|
||||
Mail,
|
||||
MapPin,
|
||||
Pencil,
|
||||
Repeat,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
|
||||
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import {
|
||||
useDeleteAgendaEvent,
|
||||
useRespondAgendaInvitation,
|
||||
} from "@/lib/api/hooks/use-calendar-mutations"
|
||||
import { formatEventRange } from "@/lib/agenda/agenda-date"
|
||||
import { describeRRule } from "@/lib/agenda/agenda-recurrence"
|
||||
import { stashPendingCompose } from "@/lib/agenda/agenda-mail-compose"
|
||||
import type { AgendaCalendar, AgendaEvent } from "@/lib/agenda/agenda-types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AgendaEventPopoverState {
|
||||
event: AgendaEvent
|
||||
anchor: AnchorRect
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
ACCEPTED: "accepté",
|
||||
DECLINED: "refusé",
|
||||
TENTATIVE: "peut-être",
|
||||
"NEEDS-ACTION": "en attente",
|
||||
}
|
||||
|
||||
export function AgendaEventPopover({
|
||||
state,
|
||||
calendars,
|
||||
userEmail,
|
||||
onClose,
|
||||
onEdit,
|
||||
}: {
|
||||
state: AgendaEventPopoverState | null
|
||||
calendars: AgendaCalendar[]
|
||||
userEmail?: string
|
||||
onClose: () => void
|
||||
onEdit: (event: AgendaEvent) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const deleteMutation = useDeleteAgendaEvent()
|
||||
const respondMutation = useRespondAgendaInvitation()
|
||||
|
||||
if (!state) return null
|
||||
const { event, anchor } = state
|
||||
const calendar = calendars.find((c) => c.id === event.calendarId)
|
||||
|
||||
const selfAttendee = userEmail
|
||||
? event.attendees.find((a) => a.email.toLowerCase() === userEmail.toLowerCase())
|
||||
: undefined
|
||||
|
||||
const remove = async () => {
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ path: event.path })
|
||||
toast.success("Événement supprimé")
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error("Impossible de supprimer l'événement")
|
||||
}
|
||||
}
|
||||
|
||||
const respond = async (response: "accepted" | "declined" | "tentative") => {
|
||||
try {
|
||||
await respondMutation.mutateAsync({ path: event.path, response, etag: event.etag })
|
||||
toast.success("Réponse enregistrée")
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error("Impossible d'enregistrer la réponse")
|
||||
}
|
||||
}
|
||||
|
||||
const emailGuests = () => {
|
||||
const recipients = event.attendees
|
||||
.filter((a) => a.email.toLowerCase() !== (userEmail ?? "").toLowerCase())
|
||||
.map((a) => ({ name: a.name ?? a.email, email: a.email }))
|
||||
if (event.organizer && event.organizer.toLowerCase() !== (userEmail ?? "").toLowerCase()) {
|
||||
if (!recipients.some((r) => r.email.toLowerCase() === event.organizer.toLowerCase())) {
|
||||
recipients.push({ name: event.organizer, email: event.organizer })
|
||||
}
|
||||
}
|
||||
stashPendingCompose({ to: recipients, subject: event.title })
|
||||
router.push("/mail")
|
||||
}
|
||||
|
||||
return (
|
||||
<AgendaFloatingCard anchor={anchor} onClose={onClose} width={420}>
|
||||
{/* Barre d'actions */}
|
||||
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground"
|
||||
aria-label="Modifier"
|
||||
onClick={() => {
|
||||
onEdit(event)
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Modifier</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground"
|
||||
aria-label="Supprimer"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => void remove()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Supprimer</TooltipContent>
|
||||
</Tooltip>
|
||||
{event.attendees.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground"
|
||||
aria-label="Envoyer un email aux invités"
|
||||
onClick={emailGuests}
|
||||
>
|
||||
<Mail className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Envoyer un email aux invités</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground"
|
||||
aria-label="Fermer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-1 pb-5">
|
||||
{/* Titre */}
|
||||
<div className="flex items-start gap-3.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1.5 size-4 shrink-0 rounded-[5px]"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-[1.3rem] leading-snug font-normal break-words text-foreground">
|
||||
{event.title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatEventRange(event.start, event.end, event.allDay)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.recurring && (
|
||||
<Row icon={<Repeat className="size-4.5" />}>
|
||||
{describeRRule(event.rrule)}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{event.meetUrl && (
|
||||
<Row
|
||||
icon={
|
||||
<Icon icon="simple-icons:jitsi" className="size-4.5" aria-hidden />
|
||||
}
|
||||
>
|
||||
<Button asChild className="h-9 rounded-full">
|
||||
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
|
||||
Rejoindre la visio
|
||||
</a>
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{event.location && (
|
||||
<Row icon={<MapPin className="size-4.5" />}>
|
||||
<span className="break-words">{event.location}</span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{event.attendees.length > 0 && (
|
||||
<Row icon={<Users className="size-4.5" />} alignTop>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
<span className="text-sm text-foreground/85">
|
||||
{event.attendees.length} invité
|
||||
{event.attendees.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
{event.attendees.map((a) => (
|
||||
<div key={a.email} className="flex items-center gap-2.5">
|
||||
<div className="relative">
|
||||
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
|
||||
{a.status === "ACCEPTED" && (
|
||||
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-popover">
|
||||
<Check className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{a.status === "DECLINED" && (
|
||||
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-red-600 text-white ring-2 ring-popover">
|
||||
<X className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{a.status === "TENTATIVE" && (
|
||||
<span className="absolute -right-0.5 -bottom-0.5 flex size-3.5 items-center justify-center rounded-full bg-amber-500 text-white ring-2 ring-popover">
|
||||
<CircleHelp className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">
|
||||
{a.name || a.email}
|
||||
{event.organizer &&
|
||||
a.email.toLowerCase() === event.organizer.toLowerCase() && (
|
||||
<span className="text-muted-foreground"> — organisateur</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{STATUS_LABELS[a.status ?? "NEEDS-ACTION"] ?? a.status?.toLowerCase()}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{event.description && (
|
||||
<Row
|
||||
icon={<span className="block w-4.5" aria-hidden />}
|
||||
alignTop
|
||||
>
|
||||
<p className="text-sm break-words whitespace-pre-wrap text-foreground/80">
|
||||
{event.description}
|
||||
</p>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{calendar && (
|
||||
<Row
|
||||
icon={
|
||||
<span
|
||||
aria-hidden
|
||||
className="block size-3.5 rounded-full"
|
||||
style={{ backgroundColor: event.color }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="text-sm text-foreground/80">{calendar.display_name}</span>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* RSVP */}
|
||||
{selfAttendee && (
|
||||
<div className="mt-1 flex items-center justify-between gap-2 border-t border-border/60 pt-3">
|
||||
<span className="text-sm text-foreground/85">Vous participez ?</span>
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
["accepted", "Oui", "ACCEPTED"],
|
||||
["tentative", "Peut-être", "TENTATIVE"],
|
||||
["declined", "Non", "DECLINED"],
|
||||
] as const
|
||||
).map(([value, label, partstat]) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={selfAttendee.status === partstat ? "default" : "outline"}
|
||||
className={cn("h-8 rounded-full px-3 text-xs")}
|
||||
disabled={respondMutation.isPending}
|
||||
onClick={() => void respond(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AgendaFloatingCard>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
icon,
|
||||
children,
|
||||
alignTop,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
alignTop?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex gap-3.5", alignTop ? "items-start" : "items-center")}>
|
||||
<span
|
||||
className={cn(
|
||||
"flex w-4 shrink-0 justify-center text-muted-foreground",
|
||||
alignTop && "mt-0.5",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-sm text-foreground/85">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
components/agenda/agenda-floating-card.tsx
Normal file
96
components/agenda/agenda-floating-card.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AnchorRect {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Carte flottante ancrée à un rectangle (popover détails / création rapide),
|
||||
* positionnée en `fixed` et recadrée dans la fenêtre.
|
||||
*/
|
||||
export function AgendaFloatingCard({
|
||||
anchor,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
width = 416,
|
||||
}: {
|
||||
anchor: AnchorRect
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
className?: string
|
||||
width?: number
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const card = cardRef.current
|
||||
if (!card) return
|
||||
const margin = 8
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const rect = card.getBoundingClientRect()
|
||||
const w = Math.min(width, vw - margin * 2)
|
||||
const h = rect.height
|
||||
|
||||
// Préférence : à droite de l'ancre, sinon à gauche, sinon en dessous.
|
||||
let left = anchor.left + anchor.width + margin
|
||||
if (left + w > vw - margin) left = anchor.left - w - margin
|
||||
if (left < margin) left = Math.min(Math.max(margin, anchor.left), vw - w - margin)
|
||||
|
||||
let top = anchor.top
|
||||
if (top + h > vh - margin) top = vh - h - margin
|
||||
if (top < margin) top = margin
|
||||
|
||||
setPos({ left, top })
|
||||
}, [anchor, width, children])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey, true)
|
||||
return () => window.removeEventListener("keydown", onKey, true)
|
||||
}, [onClose])
|
||||
|
||||
if (typeof document === "undefined") return null
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[60]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Fermer"
|
||||
className="absolute inset-0 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
ref={cardRef}
|
||||
role="dialog"
|
||||
className={cn(
|
||||
"absolute flex max-h-[85vh] flex-col overflow-hidden rounded-xl border border-border/60 bg-popover text-popover-foreground shadow-2xl",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: Math.min(width, window.innerWidth - 16),
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
181
components/agenda/agenda-guest-picker.tsx
Normal file
181
components/agenda/agenda-guest-picker.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useRef, useState } from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { fullContactDisplayName } from "@/lib/contacts/types"
|
||||
import type { AgendaEventAttendee } from "@/lib/agenda/agenda-types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
interface Suggestion {
|
||||
email: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
/** Champ « Ajouter des invités » avec autocomplétion sur le carnet d'adresses. */
|
||||
export function AgendaGuestPicker({
|
||||
attendees,
|
||||
onChange,
|
||||
organizerEmail,
|
||||
}: {
|
||||
attendees: AgendaEventAttendee[]
|
||||
onChange: (attendees: AgendaEventAttendee[]) => void
|
||||
organizerEmail?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState("")
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const blurTimer = useRef<number | null>(null)
|
||||
const { contacts } = useContactsList()
|
||||
|
||||
const suggestions: Suggestion[] = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q.length < 1) return []
|
||||
const taken = new Set(attendees.map((a) => a.email.toLowerCase()))
|
||||
if (organizerEmail) taken.add(organizerEmail.toLowerCase())
|
||||
const out: Suggestion[] = []
|
||||
for (const contact of contacts) {
|
||||
const name = fullContactDisplayName(contact)
|
||||
for (const { value: email } of contact.emails) {
|
||||
if (!email || taken.has(email.toLowerCase())) continue
|
||||
if (
|
||||
name.toLowerCase().includes(q) ||
|
||||
email.toLowerCase().includes(q)
|
||||
) {
|
||||
out.push({ email, name: name || email, avatarUrl: contact.avatarUrl })
|
||||
}
|
||||
}
|
||||
if (out.length >= 6) break
|
||||
}
|
||||
return out
|
||||
}, [contacts, query, attendees, organizerEmail])
|
||||
|
||||
const addAttendee = (s: Suggestion) => {
|
||||
onChange([...attendees, { email: s.email, name: s.name, status: "NEEDS-ACTION" }])
|
||||
setQuery("")
|
||||
setActiveIndex(0)
|
||||
}
|
||||
|
||||
const tryAddRaw = () => {
|
||||
const email = query.trim().replace(/[,;]$/, "")
|
||||
if (!EMAIL_RE.test(email)) return false
|
||||
if (attendees.some((a) => a.email.toLowerCase() === email.toLowerCase())) {
|
||||
setQuery("")
|
||||
return true
|
||||
}
|
||||
addAttendee({ email, name: email })
|
||||
return true
|
||||
}
|
||||
|
||||
const showSuggestions = focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Ajouter des invités"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setActiveIndex(0)
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (blurTimer.current) window.clearTimeout(blurTimer.current)
|
||||
setFocused(true)
|
||||
}}
|
||||
onBlur={() => {
|
||||
blurTimer.current = window.setTimeout(() => setFocused(false), 150)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1))
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
setActiveIndex((i) => Math.max(i - 1, 0))
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
if (suggestions[activeIndex]) addAttendee(suggestions[activeIndex])
|
||||
else tryAddRaw()
|
||||
} else if ((e.key === "," || e.key === ";") && query.trim()) {
|
||||
e.preventDefault()
|
||||
tryAddRaw()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{showSuggestions && (
|
||||
<div className="absolute top-full right-0 left-0 z-50 mt-1 overflow-hidden rounded-lg border border-border/60 bg-popover py-1 shadow-lg">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={s.email}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left",
|
||||
i === activeIndex ? "bg-mail-nav-hover" : "hover:bg-mail-nav-hover",
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
addAttendee(s)
|
||||
}}
|
||||
>
|
||||
<ContactAvatar name={s.name} email={s.email} avatarUrl={s.avatarUrl} size="xs" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{s.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{s.email}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{suggestions.length === 0 && EMAIL_RE.test(query.trim()) && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-mail-nav-hover"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
tryAddRaw()
|
||||
}}
|
||||
>
|
||||
Ajouter « {query.trim()} »
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attendees.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{attendees.map((a) => (
|
||||
<div
|
||||
key={a.email}
|
||||
className="group flex items-center gap-2.5 rounded-lg px-1.5 py-1 hover:bg-mail-nav-hover"
|
||||
>
|
||||
<ContactAvatar name={a.name || a.email} email={a.email} size="xs" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{a.name || a.email}</span>
|
||||
{a.name && a.name !== a.email && (
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{a.email}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Retirer ${a.email}`}
|
||||
className="rounded-full p-1 text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => onChange(attendees.filter((x) => x.email !== a.email))}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
components/agenda/agenda-header.tsx
Normal file
140
components/agenda/agenda-header.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, Menu } from "lucide-react"
|
||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { viewTitle } from "@/lib/agenda/agenda-date"
|
||||
import {
|
||||
AGENDA_VIEW_LABELS,
|
||||
AGENDA_VIEWS,
|
||||
type AgendaView,
|
||||
} from "@/lib/agenda/agenda-url"
|
||||
import { useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const VIEW_SHORTCUTS: Record<AgendaView, string> = {
|
||||
day: "J",
|
||||
week: "S",
|
||||
month: "M",
|
||||
}
|
||||
|
||||
export function AgendaHeader({
|
||||
view,
|
||||
date,
|
||||
onToday,
|
||||
onStep,
|
||||
onViewChange,
|
||||
}: {
|
||||
view: AgendaView
|
||||
date: Date
|
||||
onToday: () => void
|
||||
onStep: (delta: 1 | -1) => void
|
||||
onViewChange: (view: AgendaView) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
||||
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b border-border/60 bg-app-canvas px-2 sm:px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 shrink-0 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
|
||||
aria-label={sidebarCollapsed ? "Ouvrir le menu" : "Fermer le menu"}
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
|
||||
<Link href="/agenda" className="mr-1 hidden min-w-0 items-center gap-2 sm:flex">
|
||||
<img src="/agenda-mark.svg" alt="" className="size-9 shrink-0" />
|
||||
<span className="hidden truncate text-[1.35rem] leading-none text-foreground/80 md:block">
|
||||
Agenda
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="ml-1 h-9 rounded-full px-4 text-sm font-medium sm:ml-4"
|
||||
onClick={onToday}
|
||||
>
|
||||
Aujourd'hui
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
|
||||
aria-label="Période précédente"
|
||||
onClick={() => onStep(-1)}
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Période précédente</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 rounded-full text-muted-foreground hover:bg-mail-nav-hover"
|
||||
aria-label="Période suivante"
|
||||
onClick={() => onStep(1)}
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Période suivante</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className={cn(
|
||||
"min-w-0 truncate text-[1.05rem] font-normal text-foreground/90 sm:text-[1.35rem]",
|
||||
)}
|
||||
>
|
||||
{viewTitle(view, date)}
|
||||
</h1>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-9 gap-1.5 rounded-full px-3 text-sm sm:px-4"
|
||||
>
|
||||
{isMobile
|
||||
? AGENDA_VIEW_LABELS[view].slice(0, 1)
|
||||
: AGENDA_VIEW_LABELS[view]}
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{AGENDA_VIEWS.map((v) => (
|
||||
<DropdownMenuItem key={v} onSelect={() => onViewChange(v)}>
|
||||
{AGENDA_VIEW_LABELS[v]}
|
||||
<DropdownMenuShortcut>{VIEW_SHORTCUTS[v]}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<HeaderAccountActions className="pl-1" settingsHref="/mail/settings" />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
101
components/agenda/agenda-mini-month.tsx
Normal file
101
components/agenda/agenda-mini-month.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
import { fr } from "date-fns/locale"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { WEEK_OPTS } from "@/lib/agenda/agenda-date"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const WEEKDAYS = ["L", "M", "M", "J", "V", "S", "D"]
|
||||
|
||||
export function AgendaMiniMonth({
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: Date
|
||||
onSelect: (date: Date) => void
|
||||
}) {
|
||||
const [cursor, setCursor] = useState(() => startOfMonth(selected))
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(startOfMonth(selected))
|
||||
}, [selected])
|
||||
|
||||
const gridStart = startOfWeek(cursor, WEEK_OPTS)
|
||||
const today = new Date()
|
||||
const cells: Date[] = []
|
||||
for (let i = 0; i < 42; i++) cells.push(addDays(gridStart, i))
|
||||
|
||||
return (
|
||||
<div className="select-none px-1">
|
||||
<div className="flex items-center justify-between pb-1 pl-2">
|
||||
<span className="text-sm font-medium text-foreground/90">
|
||||
{format(cursor, "MMMM yyyy", { locale: fr }).replace(/^./, (c) =>
|
||||
c.toUpperCase(),
|
||||
)}
|
||||
</span>
|
||||
<div className="flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-full text-muted-foreground"
|
||||
aria-label="Mois précédent"
|
||||
onClick={() => setCursor((c) => addMonths(c, -1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-full text-muted-foreground"
|
||||
aria-label="Mois suivant"
|
||||
onClick={() => setCursor((c) => addMonths(c, 1))}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 text-center">
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<span
|
||||
key={`${d}-${i}`}
|
||||
className="py-1 text-[0.65rem] font-medium text-muted-foreground"
|
||||
>
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
{cells.map((day) => {
|
||||
const isToday = isSameDay(day, today)
|
||||
const isSelected = isSameDay(day, selected)
|
||||
const inMonth = isSameMonth(day, cursor)
|
||||
return (
|
||||
<button
|
||||
key={day.getTime()}
|
||||
type="button"
|
||||
onClick={() => onSelect(day)}
|
||||
className={cn(
|
||||
"mx-auto flex size-6 items-center justify-center rounded-full text-[0.7rem] transition-colors",
|
||||
inMonth ? "text-foreground/85" : "text-muted-foreground/50",
|
||||
isToday && "bg-primary font-semibold text-primary-foreground",
|
||||
isSelected && !isToday && "bg-primary/15 font-semibold text-primary",
|
||||
!isToday && !isSelected && "hover:bg-mail-nav-hover",
|
||||
)}
|
||||
>
|
||||
{format(day, "d")}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
components/agenda/agenda-page.tsx
Normal file
284
components/agenda/agenda-page.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
||||
import { addDays, addHours, addMonths, startOfHour } from "date-fns"
|
||||
import { AgendaEventDialog, type AgendaEventDialogState } from "@/components/agenda/agenda-event-dialog"
|
||||
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
|
||||
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
||||
import { AgendaHeader } from "@/components/agenda/agenda-header"
|
||||
import { AgendaQuickCreate, type AgendaQuickCreateState } from "@/components/agenda/agenda-quick-create"
|
||||
import { AgendaSidebar } from "@/components/agenda/agenda-sidebar"
|
||||
import { AgendaViewMonth } from "@/components/agenda/agenda-view-month"
|
||||
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useAgendaCalendars, useAgendaEvents } from "@/lib/api/hooks/use-calendar-queries"
|
||||
import { parseICSDate, viewDays, viewRange } from "@/lib/agenda/agenda-date"
|
||||
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
|
||||
import type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
|
||||
import {
|
||||
buildAgendaPath,
|
||||
parseAgendaSegments,
|
||||
type AgendaView,
|
||||
} from "@/lib/agenda/agenda-url"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
|
||||
export function AgendaPage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const searchParams = useSearchParams()
|
||||
const isMobile = useIsMobile()
|
||||
const identity = useChromeIdentity()
|
||||
|
||||
const route = useMemo(
|
||||
() => parseAgendaSegments(params.segments as string[] | undefined),
|
||||
[params.segments],
|
||||
)
|
||||
const lastView = useAgendaSettingsStore((s) => s.lastView)
|
||||
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
|
||||
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
|
||||
|
||||
const view: AgendaView = route.view ?? (isMobile ? "day" : lastView)
|
||||
const date = route.date
|
||||
|
||||
// Normalise l'URL quand la vue est implicite.
|
||||
useEffect(() => {
|
||||
if (!route.view) {
|
||||
router.replace(buildAgendaPath(view, date) + window.location.search)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [route.view])
|
||||
|
||||
useEffect(() => {
|
||||
if (route.view) setLastView(route.view)
|
||||
}, [route.view, setLastView])
|
||||
|
||||
const navigate = useCallback(
|
||||
(nextView: AgendaView, nextDate: Date) => {
|
||||
router.push(buildAgendaPath(nextView, nextDate))
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
const step = useCallback(
|
||||
(delta: 1 | -1) => {
|
||||
const next =
|
||||
view === "month"
|
||||
? addMonths(date, delta)
|
||||
: addDays(date, view === "week" ? delta * 7 : delta)
|
||||
navigate(view, next)
|
||||
},
|
||||
[view, date, navigate],
|
||||
)
|
||||
|
||||
// Données
|
||||
const { data: calendars = [], isLoading: calendarsLoading } = useAgendaCalendars()
|
||||
const visibleCalendars = useMemo(
|
||||
() => calendars.filter((c) => !hiddenIds.includes(c.id)),
|
||||
[calendars, hiddenIds],
|
||||
)
|
||||
const fetchRange = useMemo(() => viewRange("month", date), [date])
|
||||
const { events } = useAgendaEvents(visibleCalendars, fetchRange.start, fetchRange.end)
|
||||
|
||||
// UI : création rapide / dialog / détails
|
||||
const [quickCreate, setQuickCreate] = useState<AgendaQuickCreateState | null>(null)
|
||||
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
|
||||
const [popover, setPopover] = useState<AgendaEventPopoverState | null>(null)
|
||||
|
||||
const defaultCalendarId = visibleCalendars[0]?.id ?? calendars[0]?.id ?? ""
|
||||
const userEmail = identity?.email
|
||||
|
||||
const closeOverlays = useCallback(() => {
|
||||
setQuickCreate(null)
|
||||
setPopover(null)
|
||||
}, [])
|
||||
|
||||
const openCreateDialog = useCallback(
|
||||
(base?: Partial<AgendaEventDraft>) => {
|
||||
closeOverlays()
|
||||
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
|
||||
const end = base?.end ?? addHours(start, 1)
|
||||
setDialogState({
|
||||
mode: "create",
|
||||
draft: {
|
||||
title: "",
|
||||
start,
|
||||
end,
|
||||
allDay: false,
|
||||
calendarId: defaultCalendarId,
|
||||
...base,
|
||||
},
|
||||
})
|
||||
},
|
||||
[closeOverlays, defaultCalendarId],
|
||||
)
|
||||
|
||||
const openEditDialog = useCallback(
|
||||
(event: AgendaEvent) => {
|
||||
closeOverlays()
|
||||
const masterStart = parseICSDate(event.master.start) ?? event.start
|
||||
const masterEnd = parseICSDate(event.master.end) ?? event.end
|
||||
setDialogState({
|
||||
mode: "edit",
|
||||
event,
|
||||
draft: {
|
||||
title: event.title === "(Sans titre)" ? "" : event.title,
|
||||
start: masterStart,
|
||||
end: masterEnd,
|
||||
allDay: event.allDay,
|
||||
calendarId: event.calendarId,
|
||||
description: event.description,
|
||||
location: event.location,
|
||||
attendees: event.attendees,
|
||||
rrule: event.rrule,
|
||||
color: event.master.color,
|
||||
},
|
||||
})
|
||||
},
|
||||
[closeOverlays],
|
||||
)
|
||||
|
||||
// Interop : /agenda?new=1&guest=…&title=…
|
||||
const handledNewParam = useRef(false)
|
||||
useEffect(() => {
|
||||
if (handledNewParam.current || !route.view) return
|
||||
if (searchParams.get("new") !== "1") return
|
||||
handledNewParam.current = true
|
||||
const guest = searchParams.get("guest")?.trim()
|
||||
const guestName = searchParams.get("guest_name")?.trim()
|
||||
openCreateDialog({
|
||||
title: searchParams.get("title")?.trim() ?? "",
|
||||
attendees: guest
|
||||
? [{ email: guest, name: guestName || guest, status: "NEEDS-ACTION" }]
|
||||
: [],
|
||||
})
|
||||
router.replace(buildAgendaPath(view, date))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, route.view])
|
||||
|
||||
// Raccourcis clavier façon Google Calendar.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (
|
||||
e.metaKey ||
|
||||
e.ctrlKey ||
|
||||
e.altKey ||
|
||||
target.closest("input, textarea, select, [contenteditable], [role=dialog]")
|
||||
)
|
||||
return
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "j":
|
||||
case "d":
|
||||
navigate("day", date)
|
||||
break
|
||||
case "s":
|
||||
case "w":
|
||||
navigate("week", date)
|
||||
break
|
||||
case "m":
|
||||
navigate("month", date)
|
||||
break
|
||||
case "t":
|
||||
navigate(view, new Date())
|
||||
break
|
||||
case "c":
|
||||
openCreateDialog()
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [navigate, view, date, openCreateDialog])
|
||||
|
||||
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
|
||||
setQuickCreate(null)
|
||||
setPopover({ event, anchor })
|
||||
}, [])
|
||||
|
||||
const handleCreateRange = useCallback(
|
||||
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => {
|
||||
setPopover(null)
|
||||
setQuickCreate({ start, end, allDay, anchor })
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const days = useMemo(() => viewDays(view, date), [view, date])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AgendaHeader
|
||||
view={view}
|
||||
date={date}
|
||||
onToday={() => navigate(view, new Date())}
|
||||
onStep={step}
|
||||
onViewChange={(v) => navigate(v, date)}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<AgendaSidebar
|
||||
selectedDate={date}
|
||||
onSelectDate={(d) => navigate(view, d)}
|
||||
onCreateEvent={() => openCreateDialog()}
|
||||
/>
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{calendarsLoading ? (
|
||||
<div className="flex h-full flex-col gap-3 p-6">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="min-h-0 flex-1" />
|
||||
</div>
|
||||
) : view === "month" ? (
|
||||
<AgendaViewMonth
|
||||
date={date}
|
||||
events={events}
|
||||
onCreateAt={(day, anchor) =>
|
||||
handleCreateRange(day, addDays(day, 1), true, anchor)
|
||||
}
|
||||
onEventClick={handleEventClick}
|
||||
onOpenDay={(day) => navigate("day", day)}
|
||||
/>
|
||||
) : (
|
||||
<AgendaViewWeek
|
||||
days={days}
|
||||
events={events}
|
||||
onCreateRange={handleCreateRange}
|
||||
onEventClick={handleEventClick}
|
||||
onOpenDay={(day) => navigate("day", day)}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AgendaQuickCreate
|
||||
state={quickCreate}
|
||||
calendars={visibleCalendars.length > 0 ? visibleCalendars : calendars}
|
||||
defaultCalendarId={defaultCalendarId}
|
||||
userEmail={userEmail}
|
||||
onClose={() => setQuickCreate(null)}
|
||||
onMoreOptions={(draft) => {
|
||||
setQuickCreate(null)
|
||||
setDialogState({ mode: "create", draft })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgendaEventPopover
|
||||
state={popover}
|
||||
calendars={calendars}
|
||||
userEmail={userEmail}
|
||||
onClose={() => setPopover(null)}
|
||||
onEdit={openEditDialog}
|
||||
/>
|
||||
|
||||
<AgendaEventDialog
|
||||
state={dialogState}
|
||||
onClose={() => setDialogState(null)}
|
||||
calendars={calendars}
|
||||
userEmail={userEmail}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
157
components/agenda/agenda-quick-create.tsx
Normal file
157
components/agenda/agenda-quick-create.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { fr } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
import { X } from "lucide-react"
|
||||
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
draftToApiEvent,
|
||||
useCreateAgendaEvent,
|
||||
} from "@/lib/api/hooks/use-calendar-mutations"
|
||||
import { formatEventTime } from "@/lib/agenda/agenda-date"
|
||||
import { calendarColor } from "@/lib/agenda/agenda-events"
|
||||
import type {
|
||||
AgendaCalendar,
|
||||
AgendaEventDraft,
|
||||
} from "@/lib/agenda/agenda-types"
|
||||
|
||||
export interface AgendaQuickCreateState {
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
anchor: AnchorRect
|
||||
}
|
||||
|
||||
export function AgendaQuickCreate({
|
||||
state,
|
||||
calendars,
|
||||
defaultCalendarId,
|
||||
userEmail,
|
||||
onClose,
|
||||
onMoreOptions,
|
||||
}: {
|
||||
state: AgendaQuickCreateState | null
|
||||
calendars: AgendaCalendar[]
|
||||
defaultCalendarId: string
|
||||
userEmail?: string
|
||||
onClose: () => void
|
||||
onMoreOptions: (draft: AgendaEventDraft) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState("")
|
||||
const [calendarId, setCalendarId] = useState(defaultCalendarId)
|
||||
const createMutation = useCreateAgendaEvent()
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
setTitle("")
|
||||
setCalendarId(defaultCalendarId)
|
||||
}
|
||||
}, [state, defaultCalendarId])
|
||||
|
||||
if (!state) return null
|
||||
|
||||
const draft: AgendaEventDraft = {
|
||||
title,
|
||||
start: state.start,
|
||||
end: state.end,
|
||||
allDay: state.allDay,
|
||||
calendarId,
|
||||
}
|
||||
|
||||
const dateLabel = format(state.start, "EEEE d MMMM", { locale: fr }).replace(
|
||||
/^./,
|
||||
(c) => c.toUpperCase(),
|
||||
)
|
||||
const timeLabel = state.allDay
|
||||
? "Toute la journée"
|
||||
: `${formatEventTime(state.start)} – ${formatEventTime(state.end)}`
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
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 de créer l'événement")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AgendaFloatingCard anchor={state.anchor} onClose={onClose} width={400}>
|
||||
<div className="flex items-center justify-end px-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full text-muted-foreground"
|
||||
aria-label="Fermer"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-5 pb-5">
|
||||
<Input
|
||||
value={title}
|
||||
autoFocus
|
||||
placeholder="Ajouter un titre"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void save()
|
||||
}}
|
||||
className="h-11 rounded-none border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-lg shadow-none focus-visible:border-primary focus-visible:ring-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-1 text-sm text-foreground/85">
|
||||
<span>{dateLabel}</span>
|
||||
<span className="text-muted-foreground">{timeLabel}</span>
|
||||
</div>
|
||||
<Select value={calendarId} onValueChange={setCalendarId}>
|
||||
<SelectTrigger className="h-9 w-fit min-w-44 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 justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="rounded-full"
|
||||
onClick={() => onMoreOptions(draft)}
|
||||
>
|
||||
Autres options
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-full px-5"
|
||||
disabled={createMutation.isPending || !calendarId}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AgendaFloatingCard>
|
||||
)
|
||||
}
|
||||
238
components/agenda/agenda-sidebar.tsx
Normal file
238
components/agenda/agenda-sidebar.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { MoreVertical, Pencil, Plus, Trash2 } from "lucide-react"
|
||||
import { AgendaCalendarDialog } from "@/components/agenda/agenda-calendar-dialog"
|
||||
import { AgendaMiniMonth } from "@/components/agenda/agenda-mini-month"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useAgendaCalendars } from "@/lib/api/hooks/use-calendar-queries"
|
||||
import { useDeleteAgendaCalendar } from "@/lib/api/hooks/use-calendar-mutations"
|
||||
import { calendarColor } from "@/lib/agenda/agenda-events"
|
||||
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
||||
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AgendaSidebar({
|
||||
selectedDate,
|
||||
onSelectDate,
|
||||
onCreateEvent,
|
||||
}: {
|
||||
selectedDate: Date
|
||||
onSelectDate: (date: Date) => void
|
||||
onCreateEvent: () => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
||||
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
|
||||
const toggleCalendar = useAgendaSettingsStore((s) => s.toggleCalendarVisible)
|
||||
const { data: calendars, isLoading } = useAgendaCalendars()
|
||||
const deleteMutation = useDeleteAgendaCalendar()
|
||||
|
||||
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
|
||||
const [editingCalendar, setEditingCalendar] = useState<AgendaCalendar | null>(null)
|
||||
const [deletingCalendar, setDeletingCalendar] = useState<AgendaCalendar | null>(null)
|
||||
|
||||
const open = !sidebarCollapsed
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingCalendar) return
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ id: deletingCalendar.id })
|
||||
toast.success(`Agenda « ${deletingCalendar.display_name} » supprimé`)
|
||||
} catch {
|
||||
toast.error("Impossible de supprimer cet agenda")
|
||||
} finally {
|
||||
setDeletingCalendar(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto bg-app-canvas px-3 pt-3 pb-4",
|
||||
isMobile
|
||||
? cn(
|
||||
"fixed inset-y-0 left-0 z-50 shadow-xl transition-transform duration-200 ease-linear",
|
||||
open ? "translate-x-0" : "-translate-x-full pointer-events-none",
|
||||
)
|
||||
: cn(!open && "hidden"),
|
||||
)}
|
||||
aria-hidden={isMobile && !open}
|
||||
>
|
||||
<Button
|
||||
className="h-13 w-fit gap-3 rounded-2xl border border-border/60 bg-card px-5 text-[0.95rem] font-medium text-foreground shadow-md hover:bg-mail-nav-hover hover:shadow-lg"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onCreateEvent()
|
||||
if (isMobile) setSidebarCollapsed(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-6 text-primary" />
|
||||
Créer
|
||||
</Button>
|
||||
|
||||
<AgendaMiniMonth
|
||||
selected={selectedDate}
|
||||
onSelect={(d) => {
|
||||
onSelectDate(d)
|
||||
if (isMobile) setSidebarCollapsed(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between pr-1 pl-2">
|
||||
<span className="py-1 text-sm font-medium text-foreground/90">
|
||||
Mes agendas
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-full text-muted-foreground"
|
||||
aria-label="Créer un agenda"
|
||||
onClick={() => {
|
||||
setEditingCalendar(null)
|
||||
setCalendarDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(calendars ?? []).map((cal) => {
|
||||
const color = calendarColor(cal)
|
||||
const visible = !hiddenIds.includes(cal.id)
|
||||
return (
|
||||
<div
|
||||
key={cal.id}
|
||||
className="group flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover"
|
||||
>
|
||||
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visible}
|
||||
onChange={() => toggleCalendar(cal.id)}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex size-4.5 shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors",
|
||||
)}
|
||||
style={{
|
||||
borderColor: color,
|
||||
backgroundColor: visible ? color : "transparent",
|
||||
}}
|
||||
>
|
||||
{visible && (
|
||||
<svg viewBox="0 0 24 24" className="size-3.5 text-white" fill="none">
|
||||
<path
|
||||
d="M5 13l4 4L19 7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-sm text-foreground/85">
|
||||
{cal.display_name}
|
||||
</span>
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-full text-muted-foreground opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Options de ${cal.display_name}`}
|
||||
>
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setEditingCalendar(cal)
|
||||
setCalendarDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" /> Modifier
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => setDeletingCalendar(cal)}
|
||||
>
|
||||
<Trash2 className="size-4" /> Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<AgendaCalendarDialog
|
||||
open={calendarDialogOpen}
|
||||
onOpenChange={setCalendarDialogOpen}
|
||||
calendar={editingCalendar}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deletingCalendar !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeletingCalendar(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Supprimer « {deletingCalendar?.display_name} » ?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tous les événements de cet agenda seront définitivement supprimés.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
onClick={() => void confirmDelete()}
|
||||
>
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
124
components/agenda/agenda-view-month.tsx
Normal file
124
components/agenda/agenda-view-month.tsx
Normal file
@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import type { MouseEvent } from "react"
|
||||
import { format, isSameDay, isSameMonth } 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 { viewDays } from "@/lib/agenda/agenda-date"
|
||||
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
|
||||
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 }
|
||||
}
|
||||
|
||||
export function AgendaViewMonth({
|
||||
date,
|
||||
events,
|
||||
onCreateAt,
|
||||
onEventClick,
|
||||
onOpenDay,
|
||||
}: {
|
||||
date: Date
|
||||
events: AgendaEvent[]
|
||||
onCreateAt: (day: Date, anchor: AnchorRect) => void
|
||||
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
|
||||
onOpenDay: (day: Date) => void
|
||||
}) {
|
||||
const days = viewDays("month", date)
|
||||
const weeks: Date[][] = []
|
||||
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
|
||||
const today = new Date()
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-tl-2xl border-t border-l border-border/60 bg-card">
|
||||
<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 dayEvents = eventsOnDay(events, day).sort((a, b) => {
|
||||
const aBanner = a.allDay || isMultiDay(a)
|
||||
const bBanner = b.allDay || isMultiDay(b)
|
||||
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)
|
||||
return (
|
||||
<div
|
||||
key={day.getTime()}
|
||||
role="gridcell"
|
||||
className={cn(
|
||||
"flex min-h-0 cursor-pointer flex-col gap-0.5 overflow-hidden border-r border-b border-border/40 px-1 pb-1",
|
||||
!inMonth && "bg-muted/30",
|
||||
)}
|
||||
onClick={(e) => onCreateAt(day, anchorFromEvent(e))}
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenDay(day)
|
||||
}}
|
||||
>
|
||||
{day.getDate() === 1 && inMonth
|
||||
? format(day, "d MMM", { locale: fr })
|
||||
: day.getDate()}
|
||||
</button>
|
||||
</div>
|
||||
{visible.map((event) => (
|
||||
<AgendaEventChip
|
||||
key={event.key}
|
||||
event={event}
|
||||
filled={event.allDay || isMultiDay(event)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEventClick(event, anchorFromEvent(e))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpenDay(day)
|
||||
}}
|
||||
>
|
||||
{hidden} autre{hidden > 1 ? "s" : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
319
components/agenda/agenda-view-week.tsx
Normal file
319
components/agenda/agenda-view-week.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react"
|
||||
import { format, isSameDay } 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, roundToStep } from "@/lib/agenda/agenda-date"
|
||||
import { readableTextColor } from "@/lib/agenda/agenda-colors"
|
||||
import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout"
|
||||
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
|
||||
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
|
||||
|
||||
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 {
|
||||
dayIndex: number
|
||||
anchorMin: number
|
||||
startMin: number
|
||||
endMin: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function AgendaViewWeek({
|
||||
days,
|
||||
events,
|
||||
onCreateRange,
|
||||
onEventClick,
|
||||
onOpenDay,
|
||||
}: {
|
||||
days: Date[]
|
||||
events: AgendaEvent[]
|
||||
onCreateRange: (start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => void
|
||||
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
|
||||
onOpenDay: (day: Date) => void
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), 60_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const target = Math.max(0, (Math.min(now.getHours(), 18) - 1.5) * HOUR_PX)
|
||||
el.scrollTop = target
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const perDay = useMemo(
|
||||
() =>
|
||||
days.map((day) => {
|
||||
const dayEvents = eventsOnDay(events, day)
|
||||
const banners = dayEvents
|
||||
.filter((e) => e.allDay || isMultiDay(e))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e))
|
||||
return { day, banners, positioned: layoutDayEvents(timed, day) }
|
||||
}),
|
||||
[days, events],
|
||||
)
|
||||
|
||||
const hasBanners = perDay.some((d) => d.banners.length > 0)
|
||||
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
|
||||
|
||||
const minuteFromPointer = (e: ReactPointerEvent<HTMLElement>): number => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const minutes = ((e.clientY - rect.top) / HOUR_PX) * 60
|
||||
return Math.min(24 * 60, Math.max(0, roundToStep(minutes, 15)))
|
||||
}
|
||||
|
||||
const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 || (e.target as HTMLElement).closest("[data-agenda-event]")) return
|
||||
const min = minuteFromPointer(e)
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
setDrag({ dayIndex, anchorMin: min, startMin: min, endMin: min + 15, moved: false })
|
||||
}
|
||||
|
||||
const handlePointerMove = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
setDrag((d) => {
|
||||
if (!d || d.dayIndex !== dayIndex) return d
|
||||
const min = minuteFromPointer(e)
|
||||
if (min === d.anchorMin && !d.moved) return d
|
||||
return {
|
||||
...d,
|
||||
moved: true,
|
||||
startMin: Math.min(d.anchorMin, min),
|
||||
endMin: Math.max(d.anchorMin, min, Math.min(d.anchorMin, min) + 15),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!drag || drag.dayIndex !== dayIndex) return
|
||||
const day = days[dayIndex]
|
||||
const startMin = drag.startMin
|
||||
const endMin = drag.moved ? drag.endMin : drag.startMin + 60
|
||||
const start = new Date(day)
|
||||
start.setHours(0, startMin, 0, 0)
|
||||
const end = new Date(day)
|
||||
end.setHours(0, Math.max(endMin, startMin + 15), 0, 0)
|
||||
|
||||
const colRect = e.currentTarget.getBoundingClientRect()
|
||||
const anchor: AnchorRect = {
|
||||
left: colRect.left,
|
||||
top: colRect.top + (startMin / 60) * HOUR_PX,
|
||||
width: colRect.width,
|
||||
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
|
||||
}
|
||||
setDrag(null)
|
||||
onCreateRange(start, end, false, anchor)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-tl-2xl border-t border-l border-border/60 bg-card">
|
||||
{/* En-tête : jours + rangée journée entière */}
|
||||
<div className="shrink-0 border-b border-border/60 pr-[var(--agenda-sbw,0px)]">
|
||||
<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 }) => (
|
||||
<div
|
||||
key={day.getTime()}
|
||||
className="flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
|
||||
>
|
||||
{banners.map((event) => (
|
||||
<AgendaEventChip
|
||||
key={event.key}
|
||||
event={event}
|
||||
filled
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEventClick(event, anchorFromEvent(e))
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grille horaire */}
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
className="relative grid"
|
||||
style={{ gridTemplateColumns: colTemplate, height: 24 * HOUR_PX }}
|
||||
>
|
||||
{/* Gouttière heures */}
|
||||
<div className="relative">
|
||||
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
|
||||
<span
|
||||
key={h}
|
||||
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
|
||||
style={{ top: h * HOUR_PX }}
|
||||
>
|
||||
{String(h).padStart(2, "0")}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{perDay.map(({ day, positioned }, dayIndex) => {
|
||||
const isToday = isSameDay(day, now)
|
||||
const nowTop = (now.getHours() * 60 + now.getMinutes()) * (HOUR_PX / 60)
|
||||
return (
|
||||
<div
|
||||
key={day.getTime()}
|
||||
className="relative cursor-pointer touch-none border-l border-border/40"
|
||||
onPointerDown={handlePointerDown(dayIndex)}
|
||||
onPointerMove={handlePointerMove(dayIndex)}
|
||||
onPointerUp={handlePointerUp(dayIndex)}
|
||||
>
|
||||
{/* Lignes d'heures */}
|
||||
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
|
||||
<div
|
||||
key={h}
|
||||
aria-hidden
|
||||
className="absolute right-0 left-0 border-t border-border/40"
|
||||
style={{ top: h * HOUR_PX }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Sélection en cours */}
|
||||
{drag && drag.dayIndex === dayIndex && drag.moved && (
|
||||
<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 / 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)} – {formatMinutes(drag.endMin)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Événements positionnés */}
|
||||
{positioned.map(({ event, top, duration, leftPct, widthPct }) => {
|
||||
const compact = (duration / 60) * HOUR_PX < 40
|
||||
return (
|
||||
<button
|
||||
key={event.key}
|
||||
type="button"
|
||||
data-agenda-event
|
||||
className="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] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110"
|
||||
style={{
|
||||
top: (top / 60) * HOUR_PX,
|
||||
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
|
||||
left: `calc(${leftPct}% + 1px)`,
|
||||
width: `calc(${widthPct}% - 3px)`,
|
||||
backgroundColor: event.color,
|
||||
color: readableTextColor(event.color),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEventClick(event, anchorFromEvent(e))
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="truncate text-[0.7rem] leading-tight opacity-90">
|
||||
{formatEventTime(event.start)} – {formatEventTime(event.end)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Indicateur maintenant */}
|
||||
{isToday && (
|
||||
<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): string {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
|
||||
}
|
||||
53
components/compte/compte-settings-header.tsx
Normal file
53
components/compte/compte-settings-header.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
||||
|
||||
const COMPTE_HREF = "/compte"
|
||||
|
||||
export function CompteSettingsHeader() {
|
||||
return (
|
||||
<header
|
||||
data-compte-settings-chrome-header
|
||||
className="flex h-16 w-full shrink-0 items-center gap-0 bg-app-canvas pr-4 sm:gap-2"
|
||||
>
|
||||
<div className="hidden h-full w-64 shrink-0 items-center gap-2 pl-4 md:flex lg:w-72">
|
||||
<Link href={COMPTE_HREF} className="inline-flex shrink-0 items-center gap-2">
|
||||
<Image
|
||||
src={suitePublicAsset("/compte-mark.svg")}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
priority
|
||||
/>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Compte Ulti
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center pl-2 md:hidden">
|
||||
<Link href={COMPTE_HREF} className="inline-flex shrink-0 items-center">
|
||||
<Image
|
||||
src={suitePublicAsset("/compte-mark.svg")}
|
||||
alt="Compte Ulti"
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1" />
|
||||
|
||||
<HeaderAccountActions
|
||||
className="ml-auto shrink-0 pl-2 sm:pl-4"
|
||||
settingsHref={COMPTE_HREF}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
114
components/compte/compte-settings-layout.tsx
Normal file
114
components/compte/compte-settings-layout.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
COMPTE_SETTINGS_NAV,
|
||||
isCompteSettingsNavActive,
|
||||
} from "@/lib/compte-settings/settings-nav"
|
||||
import {
|
||||
mailNavRowClass,
|
||||
MAIL_SETTINGS_MAIN_CARD_CLASS,
|
||||
MAIL_SETTINGS_MAIN_INSET_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { CompteSettingsHeader } from "@/components/compte/compte-settings-header"
|
||||
|
||||
export function CompteSettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-compte-settings-app
|
||||
className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas"
|
||||
>
|
||||
<CompteSettingsHeader />
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<aside
|
||||
data-compte-settings-sidebar
|
||||
className="hidden w-64 shrink-0 overflow-y-auto bg-app-canvas p-3 md:block lg:w-72"
|
||||
>
|
||||
<nav className="space-y-1" aria-label="Sections du compte">
|
||||
{COMPTE_SETTINGS_NAV.map((item) => {
|
||||
const active = isCompteSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||
active
|
||||
? "bg-mail-nav-selected"
|
||||
: "hover:bg-mail-nav-hover"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0 opacity-70",
|
||||
active ? "text-mail-nav-selected" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-medium",
|
||||
active ? "text-mail-nav-selected" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className={MAIL_SETTINGS_MAIN_INSET_CLASS}>
|
||||
<div data-compte-settings-main className={MAIL_SETTINGS_MAIN_CARD_CLASS}>
|
||||
<nav
|
||||
className="shrink-0 border-b border-border px-2 py-2 md:hidden"
|
||||
aria-label="Sections du compte"
|
||||
>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{COMPTE_SETTINGS_NAV.map((item) => {
|
||||
const active = isCompteSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center rounded-lg",
|
||||
active
|
||||
? cn("gap-2 px-3 py-2", mailNavRowClass({ isSelected: true }))
|
||||
: cn("size-9 justify-center", mailNavRowClass({ isSelected: false }))
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 opacity-70" />
|
||||
{active ? (
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
<div className="mx-auto w-full max-w-3xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
components/compte/compte-settings-section-view.tsx
Normal file
33
components/compte/compte-settings-section-view.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
resolveCompteSettingsSection,
|
||||
type CompteSettingsSectionId,
|
||||
} from "@/lib/compte-settings/settings-nav"
|
||||
import { CompteHomeSection } from "@/components/compte/sections/compte-home-section"
|
||||
import { ComptePersonalInfoSection } from "@/components/compte/sections/compte-personal-info-section"
|
||||
import { CompteSecuritySection } from "@/components/compte/sections/compte-security-section"
|
||||
|
||||
const SECTIONS: Record<CompteSettingsSectionId, React.ComponentType> = {
|
||||
home: CompteHomeSection,
|
||||
"personal-info": ComptePersonalInfoSection,
|
||||
security: CompteSecuritySection,
|
||||
}
|
||||
|
||||
export function CompteSettingsSectionView({
|
||||
sectionId,
|
||||
}: {
|
||||
sectionId: CompteSettingsSectionId
|
||||
}) {
|
||||
const Section = SECTIONS[sectionId]
|
||||
return <Section />
|
||||
}
|
||||
|
||||
export function CompteSettingsSectionFromSegments({
|
||||
segments,
|
||||
}: {
|
||||
segments?: string[]
|
||||
}) {
|
||||
const sectionId = resolveCompteSettingsSection(segments)
|
||||
return <CompteSettingsSectionView sectionId={sectionId} />
|
||||
}
|
||||
77
components/compte/sections/compte-home-section.tsx
Normal file
77
components/compte/sections/compte-home-section.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { ChevronRight, ShieldCheck, UserRound } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/suite/account-avatar"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
href: "/compte/informations",
|
||||
icon: UserRound,
|
||||
title: "Informations personnelles",
|
||||
description:
|
||||
"Consultez votre nom, votre adresse e-mail et votre identifiant Ulti.",
|
||||
},
|
||||
{
|
||||
href: "/compte/securite",
|
||||
icon: ShieldCheck,
|
||||
title: "Sécurité",
|
||||
description:
|
||||
"Gérez votre mot de passe, la validation en deux étapes et vos sessions.",
|
||||
},
|
||||
] as const
|
||||
|
||||
export function CompteHomeSection() {
|
||||
const identity = useChromeIdentity()
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="mb-8 flex flex-col items-center text-center">
|
||||
{identity ? (
|
||||
<AccountAvatar
|
||||
account={{ name: identity.name, email: identity.email }}
|
||||
size="lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-20 rounded-full bg-muted" aria-hidden />
|
||||
)}
|
||||
<h2 className="mt-4 text-2xl font-normal text-foreground">
|
||||
{identity ? `Bonjour ${identity.firstName} !` : "Votre compte Ulti"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Gérez vos informations et la sécurité de votre compte sur l'ensemble
|
||||
de la suite Ulti.
|
||||
</p>
|
||||
{identity ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{identity.email}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{CARDS.map((card) => {
|
||||
const Icon = card.icon
|
||||
return (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="group flex flex-col rounded-2xl border border-border bg-background p-5 transition-colors hover:bg-accent"
|
||||
>
|
||||
<Icon className="size-6 text-muted-foreground" aria-hidden />
|
||||
<span className="mt-3 flex items-center gap-1 text-sm font-medium text-foreground">
|
||||
{card.title}
|
||||
<ChevronRight
|
||||
className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-1 text-sm text-muted-foreground">
|
||||
{card.description}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
95
components/compte/sections/compte-personal-info-section.tsx
Normal file
95
components/compte/sections/compte-personal-info-section.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
import { authentikUserSettingsUrl } from "@/lib/auth/authentik-user-url"
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
admin: "Administrateur",
|
||||
user: "Utilisateur",
|
||||
guest: "Invité",
|
||||
suspended: "Suspendu",
|
||||
}
|
||||
|
||||
export function ComptePersonalInfoSection() {
|
||||
const identity = useChromeIdentity()
|
||||
const { data: user, isFetching, isError, refetch } = useCurrentUser()
|
||||
const idpUrl = authentikUserSettingsUrl()
|
||||
|
||||
const name = user?.name || identity?.name || "—"
|
||||
const email = user?.email || identity?.email || "—"
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Informations personnelles"
|
||||
description="Informations de votre compte Ulti, partagées par toutes les applications de la suite."
|
||||
/>
|
||||
<SettingsSyncBanner
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-border">
|
||||
<InfoRow label="Nom" value={name} />
|
||||
<InfoRow label="Adresse e-mail" value={email} />
|
||||
<InfoRow label="Identifiant" value={user?.sub ?? "—"} mono />
|
||||
{user ? (
|
||||
<InfoRow label="Rôle" value={ROLE_LABELS[user.role] ?? user.role} />
|
||||
) : null}
|
||||
{user?.groups?.length ? (
|
||||
<InfoRow label="Groupes" value={user.groups.join(", ")} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Votre identité est gérée par le fournisseur d'identité de votre
|
||||
organisation. Pour modifier votre nom ou votre adresse e-mail,
|
||||
rapprochez-vous de votre administrateur
|
||||
{idpUrl ? " ou utilisez le portail d'identité" : ""}.
|
||||
</p>
|
||||
{idpUrl ? (
|
||||
<a
|
||||
href={idpUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Ouvrir le portail d'identité
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 border-b border-border px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-4">
|
||||
<span className="w-40 shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
mono
|
||||
? "min-w-0 break-all font-mono text-xs text-foreground"
|
||||
: "min-w-0 truncate text-sm text-foreground"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
components/compte/sections/compte-security-section.tsx
Normal file
114
components/compte/sections/compte-security-section.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink, KeyRound, LogOut, Smartphone } from "lucide-react"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuthLogout } from "@/components/auth/auth-provider"
|
||||
import { authentikUserSettingsUrl } from "@/lib/auth/authentik-user-url"
|
||||
|
||||
export function CompteSecuritySection() {
|
||||
const signOut = useAuthLogout()
|
||||
const idpUrl = authentikUserSettingsUrl()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Sécurité"
|
||||
description="Paramètres de connexion et de protection de votre compte Ulti."
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SecurityCard
|
||||
icon={<KeyRound className="size-5" aria-hidden />}
|
||||
title="Mot de passe"
|
||||
description="Modifiez votre mot de passe depuis le portail d'identité de votre organisation."
|
||||
action={
|
||||
idpUrl ? (
|
||||
<ExternalAction href={idpUrl} label="Changer le mot de passe" />
|
||||
) : (
|
||||
<UnavailableNote />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
icon={<Smartphone className="size-5" aria-hidden />}
|
||||
title="Validation en deux étapes"
|
||||
description="Ajoutez ou gérez vos appareils de validation (application TOTP, WebAuthn, clés de sécurité)."
|
||||
action={
|
||||
idpUrl ? (
|
||||
<ExternalAction href={idpUrl} label="Gérer la validation" />
|
||||
) : (
|
||||
<UnavailableNote />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<SecurityCard
|
||||
icon={<LogOut className="size-5" aria-hidden />}
|
||||
title="Session sur cet appareil"
|
||||
description="Met fin à votre session Ulti sur ce navigateur. Vous devrez vous reconnecter."
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-full px-4 text-sm font-medium"
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
Se déconnecter
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SecurityCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
action: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-5 sm:flex-row sm:items-center sm:gap-4">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-muted-foreground">
|
||||
{icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{action}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalAction({ href, label }: { href: string; label: string }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9 rounded-full px-4 text-sm font-medium"
|
||||
asChild
|
||||
>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function UnavailableNote() {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Portail d'identité non configuré
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||
@ -141,8 +142,11 @@ export function AccountSwitcherDropdown({
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
|
||||
asChild
|
||||
>
|
||||
<Link href="/compte" onClick={() => onOpenChange(false)}>
|
||||
Gérer votre compte
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
|
||||
import { Icon } from "@iconify/react"
|
||||
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
|
||||
@ -121,6 +122,12 @@ export function CalendarInvitationPreview({
|
||||
<button type="button" className={RSVP_SECONDARY}>
|
||||
Proposer un autre horaire
|
||||
</button>
|
||||
<a
|
||||
href={`/agenda/day/${format(invitation.start, "yyyy-MM-dd")}`}
|
||||
className={RSVP_SECONDARY}
|
||||
>
|
||||
Ouvrir dans Agenda
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto flex size-10 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground hover:bg-accent md:ml-0"
|
||||
|
||||
@ -235,7 +235,11 @@ export function ContactHoverCard({
|
||||
<button
|
||||
type="button"
|
||||
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
|
||||
aria-label="Planifier"
|
||||
aria-label="Planifier un événement"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = `/agenda?new=1&guest=${encodeURIComponent(email)}&guest_name=${encodeURIComponent(name)}`
|
||||
}}
|
||||
>
|
||||
<Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
<span className="absolute right-1 top-1 size-1.5 rounded-full bg-[#1a73e8]" aria-hidden />
|
||||
|
||||
29
components/gmail/pending-compose-bridge.tsx
Normal file
29
components/gmail/pending-compose-bridge.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useComposeActions } from "@/lib/compose-context"
|
||||
import { takePendingCompose } from "@/lib/agenda/agenda-mail-compose"
|
||||
|
||||
/**
|
||||
* Récupère un brouillon déposé par une autre app de la suite (ex. Agenda →
|
||||
* « Envoyer un email aux invités ») et ouvre la fenêtre de composition.
|
||||
*/
|
||||
export function PendingComposeBridge() {
|
||||
const { openComposeWithInitial } = useComposeActions()
|
||||
|
||||
useEffect(() => {
|
||||
const pending = takePendingCompose()
|
||||
if (!pending || pending.to.length === 0) return
|
||||
openComposeWithInitial({
|
||||
to: pending.to,
|
||||
subject: pending.subject ?? "",
|
||||
bodyHtml: pending.bodyText
|
||||
? `<p>${pending.bodyText.replace(/&/g, "&").replace(/</g, "<").replace(/\n/g, "<br/>")}</p>`
|
||||
: undefined,
|
||||
focusBodyOnMount: true,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Calendar, Users, CheckSquare, Plus, Sparkles } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContactsStore } from "@/lib/contacts/contacts-store"
|
||||
@ -13,8 +14,16 @@ export function RightPanel() {
|
||||
|
||||
return (
|
||||
<aside className="hidden w-10 shrink-0 flex-col items-center gap-2 bg-transparent py-3 sm:flex">
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-gray-600 rounded-full"
|
||||
aria-label="Ouvrir l'agenda"
|
||||
asChild
|
||||
>
|
||||
<Link href="/agenda">
|
||||
<Calendar className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-600 rounded-full">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
|
||||
@ -61,9 +61,9 @@ export const LANDING_APPS: LandingApp[] = [
|
||||
name: "Agenda",
|
||||
tagline: "Calendrier",
|
||||
description:
|
||||
"Agenda partagé, invitations et disponibilités — bientôt dans la suite.",
|
||||
"Agenda partagé, invitations et disponibilités, connecté au mail et aux contacts.",
|
||||
icon: suitePublicAsset("/agenda-mark.svg"),
|
||||
soon: true,
|
||||
href: "/agenda",
|
||||
accent: "#34c77b",
|
||||
},
|
||||
{
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Camera, LogOut, Plus, X } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/suite/account-avatar"
|
||||
@ -60,8 +61,11 @@ export function AccountSwitcherPanel({ onClose }: { onClose: () => void }) {
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
|
||||
asChild
|
||||
>
|
||||
<Link href="/compte" onClick={onClose}>
|
||||
Gérer votre compte
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
46
lib/agenda/agenda-colors.ts
Normal file
46
lib/agenda/agenda-colors.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/** Palette inspirée de Google Calendar. */
|
||||
export const AGENDA_COLOR_PALETTE: { value: string; label: string }[] = [
|
||||
{ value: "#039be5", label: "Paon" },
|
||||
{ value: "#7986cb", label: "Lavande" },
|
||||
{ value: "#33b679", label: "Sauge" },
|
||||
{ value: "#8e24aa", label: "Raisin" },
|
||||
{ value: "#e67c73", label: "Flamant rose" },
|
||||
{ value: "#f6bf26", label: "Banane" },
|
||||
{ value: "#f4511e", label: "Mandarine" },
|
||||
{ value: "#616161", label: "Graphite" },
|
||||
{ value: "#3f51b5", label: "Myrtille" },
|
||||
{ value: "#0b8043", label: "Basilic" },
|
||||
{ value: "#d50000", label: "Tomate" },
|
||||
]
|
||||
|
||||
export const AGENDA_DEFAULT_COLOR = AGENDA_COLOR_PALETTE[0].value
|
||||
|
||||
/** Normalise une couleur DAV (`#RRGGBBAA` Nextcloud → `#RRGGBB`). */
|
||||
export function normalizeAgendaColor(color: string | undefined | null): string {
|
||||
const c = (color ?? "").trim()
|
||||
if (/^#[0-9a-fA-F]{8}$/.test(c)) return c.slice(0, 7).toLowerCase()
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(c)) return c.toLowerCase()
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(c)) {
|
||||
return `#${c[1]}${c[1]}${c[2]}${c[2]}${c[3]}${c[3]}`.toLowerCase()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Couleur par défaut stable pour un agenda sans couleur. */
|
||||
export function fallbackCalendarColor(calendarId: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < calendarId.length; i++) {
|
||||
hash = (hash * 31 + calendarId.charCodeAt(i)) | 0
|
||||
}
|
||||
return AGENDA_COLOR_PALETTE[Math.abs(hash) % AGENDA_COLOR_PALETTE.length].value
|
||||
}
|
||||
|
||||
/** Couleur de texte lisible (blanc/noir) sur un fond hex. */
|
||||
export function readableTextColor(hex: string): string {
|
||||
const c = normalizeAgendaColor(hex)
|
||||
if (!c) return "#fff"
|
||||
const r = parseInt(c.slice(1, 3), 16)
|
||||
const g = parseInt(c.slice(3, 5), 16)
|
||||
const b = parseInt(c.slice(5, 7), 16)
|
||||
return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#1f1f1f" : "#fff"
|
||||
}
|
||||
135
lib/agenda/agenda-date.ts
Normal file
135
lib/agenda/agenda-date.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
addDays,
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
parse,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
} from "date-fns"
|
||||
import { fr } from "date-fns/locale"
|
||||
import type { AgendaView } from "./agenda-url.ts"
|
||||
|
||||
export const WEEK_OPTS = { weekStartsOn: 1 as const, locale: fr }
|
||||
|
||||
/** Parse une valeur de date ICS (`YYYYMMDD` ou `YYYYMMDDTHHMMSS[Z]`). */
|
||||
export function parseICSDate(value: string): Date | null {
|
||||
const v = value.trim()
|
||||
if (/^\d{8}$/.test(v)) {
|
||||
const y = Number(v.slice(0, 4))
|
||||
const m = Number(v.slice(4, 6))
|
||||
const d = Number(v.slice(6, 8))
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)$/.exec(v)
|
||||
if (!match) {
|
||||
const fallback = new Date(v)
|
||||
return Number.isNaN(fallback.getTime()) ? null : fallback
|
||||
}
|
||||
const [, y, mo, d, h, mi, s, z] = match
|
||||
if (z === "Z") {
|
||||
return new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s))
|
||||
}
|
||||
return new Date(+y, +mo - 1, +d, +h, +mi, +s)
|
||||
}
|
||||
|
||||
/** Format ICS UTC : `YYYYMMDDTHHMMSSZ`. */
|
||||
export function formatICSDateTimeUTC(date: Date): string {
|
||||
const p = (n: number, w = 2) => String(n).padStart(w, "0")
|
||||
return (
|
||||
`${p(date.getUTCFullYear(), 4)}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
|
||||
`T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
|
||||
)
|
||||
}
|
||||
|
||||
/** Format ICS date locale (journée entière) : `YYYYMMDD`. */
|
||||
export function formatICSDateOnly(date: Date): string {
|
||||
return format(date, "yyyyMMdd")
|
||||
}
|
||||
|
||||
export function dateKey(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd")
|
||||
}
|
||||
|
||||
export function parseDateKey(key: string): Date | null {
|
||||
const parsed = parse(key, "yyyy-MM-dd", new Date())
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
/** Bornes affichées pour une vue (le mois inclut les semaines débordantes). */
|
||||
export function viewRange(view: AgendaView, date: Date): { start: Date; end: Date } {
|
||||
switch (view) {
|
||||
case "day":
|
||||
return { start: startOfDay(date), end: endOfDay(date) }
|
||||
case "week":
|
||||
return {
|
||||
start: startOfWeek(date, WEEK_OPTS),
|
||||
end: endOfWeek(date, WEEK_OPTS),
|
||||
}
|
||||
case "month":
|
||||
return {
|
||||
start: startOfWeek(startOfMonth(date), WEEK_OPTS),
|
||||
end: endOfWeek(endOfMonth(date), WEEK_OPTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function viewDays(view: AgendaView, date: Date): Date[] {
|
||||
const { start, end } = viewRange(view, date)
|
||||
const days: Date[] = []
|
||||
for (let d = start; d <= end; d = addDays(d, 1)) days.push(d)
|
||||
return days
|
||||
}
|
||||
|
||||
function capitalize(value: string): string {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
/** Titre du header façon Google Calendar. */
|
||||
export function viewTitle(view: AgendaView, date: Date): string {
|
||||
if (view === "day") {
|
||||
return capitalize(format(date, "EEEE d MMMM yyyy", { locale: fr }))
|
||||
}
|
||||
if (view === "month") {
|
||||
return capitalize(format(date, "MMMM yyyy", { locale: fr }))
|
||||
}
|
||||
const { start, end } = viewRange("week", date)
|
||||
if (isSameMonth(start, end)) {
|
||||
return capitalize(format(start, "MMMM yyyy", { locale: fr }))
|
||||
}
|
||||
if (isSameYear(start, end)) {
|
||||
return `${capitalize(format(start, "MMM", { locale: fr }))} – ${format(end, "MMM yyyy", { locale: fr })}`
|
||||
}
|
||||
return `${capitalize(format(start, "MMM yyyy", { locale: fr }))} – ${format(end, "MMM yyyy", { locale: fr })}`
|
||||
}
|
||||
|
||||
export function formatEventTime(date: Date): string {
|
||||
return format(date, "HH:mm")
|
||||
}
|
||||
|
||||
/** Plage horaire lisible pour le popover de détails. */
|
||||
export function formatEventRange(start: Date, end: Date, allDay: boolean): string {
|
||||
const sameDay = dateKey(start) === dateKey(end)
|
||||
const dayLabel = (d: Date) => capitalize(format(d, "EEEE d MMMM", { locale: fr }))
|
||||
if (allDay) {
|
||||
const endIncl = addDays(end, -1)
|
||||
if (dateKey(start) === dateKey(endIncl) || end <= start) return dayLabel(start)
|
||||
return `${dayLabel(start)} – ${dayLabel(endIncl)}`
|
||||
}
|
||||
if (sameDay) {
|
||||
return `${dayLabel(start)} ⋅ ${formatEventTime(start)} – ${formatEventTime(end)}`
|
||||
}
|
||||
return `${dayLabel(start)} ${formatEventTime(start)} – ${dayLabel(end)} ${formatEventTime(end)}`
|
||||
}
|
||||
|
||||
export function minutesSinceMidnight(date: Date): number {
|
||||
return date.getHours() * 60 + date.getMinutes()
|
||||
}
|
||||
|
||||
export function roundToStep(minutes: number, step = 15): number {
|
||||
return Math.round(minutes / step) * step
|
||||
}
|
||||
89
lib/agenda/agenda-event-layout.ts
Normal file
89
lib/agenda/agenda-event-layout.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import type { AgendaEvent } from "./agenda-types.ts"
|
||||
|
||||
export interface PositionedEvent {
|
||||
event: AgendaEvent
|
||||
/** Minutes depuis minuit. */
|
||||
top: number
|
||||
/** Durée en minutes (minimum appliqué côté rendu). */
|
||||
duration: number
|
||||
leftPct: number
|
||||
widthPct: number
|
||||
}
|
||||
|
||||
interface WorkItem {
|
||||
event: AgendaEvent
|
||||
startMin: number
|
||||
endMin: number
|
||||
col: number
|
||||
cluster: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionne les événements horaires d'une colonne jour façon Google Calendar :
|
||||
* les événements qui se chevauchent se partagent la largeur par colonnes.
|
||||
*/
|
||||
export function layoutDayEvents(events: AgendaEvent[], day: Date): PositionedEvent[] {
|
||||
const dayStart = new Date(day)
|
||||
dayStart.setHours(0, 0, 0, 0)
|
||||
const dayStartMs = dayStart.getTime()
|
||||
const minutesInDay = 24 * 60
|
||||
|
||||
const items: WorkItem[] = events
|
||||
.map((event) => {
|
||||
const startMin = Math.max(0, (event.start.getTime() - dayStartMs) / 60000)
|
||||
const endMin = Math.min(minutesInDay, (event.end.getTime() - dayStartMs) / 60000)
|
||||
return { event, startMin, endMin: Math.max(endMin, startMin + 15), col: 0, cluster: 0 }
|
||||
})
|
||||
.filter((it) => it.startMin < minutesInDay && it.endMin > 0)
|
||||
.sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin)
|
||||
|
||||
// Regroupe en clusters d'événements transitivement chevauchants.
|
||||
let clusterId = 0
|
||||
let clusterEnd = -1
|
||||
for (const it of items) {
|
||||
if (it.startMin >= clusterEnd) {
|
||||
clusterId++
|
||||
clusterEnd = it.endMin
|
||||
} else {
|
||||
clusterEnd = Math.max(clusterEnd, it.endMin)
|
||||
}
|
||||
it.cluster = clusterId
|
||||
}
|
||||
|
||||
const positioned: PositionedEvent[] = []
|
||||
const clusters = new Map<number, WorkItem[]>()
|
||||
for (const it of items) {
|
||||
const list = clusters.get(it.cluster) ?? []
|
||||
list.push(it)
|
||||
clusters.set(it.cluster, list)
|
||||
}
|
||||
|
||||
for (const list of clusters.values()) {
|
||||
// Attribution gloutonne de colonnes.
|
||||
const colEnds: number[] = []
|
||||
for (const it of list) {
|
||||
let col = colEnds.findIndex((end) => end <= it.startMin)
|
||||
if (col === -1) {
|
||||
col = colEnds.length
|
||||
colEnds.push(it.endMin)
|
||||
} else {
|
||||
colEnds[col] = it.endMin
|
||||
}
|
||||
it.col = col
|
||||
}
|
||||
const colCount = colEnds.length
|
||||
const width = 100 / colCount
|
||||
for (const it of list) {
|
||||
positioned.push({
|
||||
event: it.event,
|
||||
top: it.startMin,
|
||||
duration: it.endMin - it.startMin,
|
||||
leftPct: it.col * width,
|
||||
// Léger débord à la Google quand il reste de la place à droite.
|
||||
widthPct: it.col === colCount - 1 ? width : Math.min(width * 1.7, 100 - it.col * width),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return positioned.sort((a, b) => a.top - b.top)
|
||||
}
|
||||
111
lib/agenda/agenda-events.ts
Normal file
111
lib/agenda/agenda-events.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { addDays } from "date-fns"
|
||||
import { parseICSDate } from "./agenda-date.ts"
|
||||
import {
|
||||
fallbackCalendarColor,
|
||||
normalizeAgendaColor,
|
||||
} from "./agenda-colors.ts"
|
||||
import { expandOccurrences, parseRRule } from "./agenda-recurrence.ts"
|
||||
import type {
|
||||
AgendaApiEvent,
|
||||
AgendaCalendar,
|
||||
AgendaEvent,
|
||||
} from "./agenda-types.ts"
|
||||
|
||||
export function calendarColor(calendar: AgendaCalendar): string {
|
||||
return normalizeAgendaColor(calendar.color) || fallbackCalendarColor(calendar.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforme les événements API d'un agenda en occurrences affichables,
|
||||
* en développant les récurrences dans la fenêtre demandée.
|
||||
*/
|
||||
export function expandApiEvents(
|
||||
calendar: AgendaCalendar,
|
||||
events: AgendaApiEvent[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
): AgendaEvent[] {
|
||||
const baseColor = calendarColor(calendar)
|
||||
const out: AgendaEvent[] = []
|
||||
|
||||
for (const api of events) {
|
||||
const start = parseICSDate(api.start)
|
||||
if (!start) continue
|
||||
let end = api.end ? parseICSDate(api.end) : null
|
||||
if (!end || end <= start) {
|
||||
end = api.all_day ? addDays(start, 1) : new Date(start.getTime() + 30 * 60000)
|
||||
}
|
||||
const durationMs = end.getTime() - start.getTime()
|
||||
const color = normalizeAgendaColor(api.color) || baseColor
|
||||
|
||||
const base: Omit<AgendaEvent, "key" | "start" | "end"> = {
|
||||
calendarId: calendar.id,
|
||||
path: api.path ?? "",
|
||||
etag: api.etag ?? "",
|
||||
uid: api.uid,
|
||||
title: api.summary || "(Sans titre)",
|
||||
description: api.description ?? "",
|
||||
location: api.location ?? "",
|
||||
meetUrl: api.meet_url ?? "",
|
||||
organizer: api.organizer ?? "",
|
||||
attendees: api.attendees ?? [],
|
||||
allDay: api.all_day,
|
||||
color,
|
||||
rrule: api.rrule ?? "",
|
||||
recurring: Boolean(api.rrule),
|
||||
master: api,
|
||||
}
|
||||
|
||||
const rule = api.rrule ? parseRRule(api.rrule) : null
|
||||
if (!rule) {
|
||||
if (end >= rangeStart && start <= rangeEnd) {
|
||||
out.push({ ...base, key: `${base.path}@${start.getTime()}`, start, end })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const exdates = new Set<number>(
|
||||
(api.exdates ?? [])
|
||||
.map((ex) => parseICSDate(ex)?.getTime())
|
||||
.filter((t): t is number => typeof t === "number"),
|
||||
)
|
||||
// Étend la fenêtre vers l'amont pour capter les occurrences qui débordent.
|
||||
const occurrenceWindowStart = new Date(rangeStart.getTime() - durationMs)
|
||||
for (const occStart of expandOccurrences(
|
||||
start,
|
||||
rule,
|
||||
exdates,
|
||||
occurrenceWindowStart,
|
||||
rangeEnd,
|
||||
)) {
|
||||
const occEnd = new Date(occStart.getTime() + durationMs)
|
||||
out.push({
|
||||
...base,
|
||||
key: `${base.path}@${occStart.getTime()}`,
|
||||
start: occStart,
|
||||
end: occEnd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
}
|
||||
|
||||
/** Événements (déjà développés) qui couvrent un jour donné. */
|
||||
export function eventsOnDay(events: AgendaEvent[], day: Date): AgendaEvent[] {
|
||||
const dayStart = new Date(day)
|
||||
dayStart.setHours(0, 0, 0, 0)
|
||||
const dayEnd = addDays(dayStart, 1)
|
||||
return events.filter((e) => e.start < dayEnd && e.end > dayStart)
|
||||
}
|
||||
|
||||
export function isMultiDay(event: AgendaEvent): boolean {
|
||||
if (event.allDay) {
|
||||
return event.end.getTime() - event.start.getTime() > 24 * 3600 * 1000
|
||||
}
|
||||
const sameDay =
|
||||
event.start.getFullYear() === event.end.getFullYear() &&
|
||||
event.start.getMonth() === event.end.getMonth() &&
|
||||
event.start.getDate() === event.end.getDate()
|
||||
return !sameDay
|
||||
}
|
||||
36
lib/agenda/agenda-mail-compose.ts
Normal file
36
lib/agenda/agenda-mail-compose.ts
Normal file
@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* Passage de relais Agenda → Ultimail : on dépose un brouillon en
|
||||
* sessionStorage puis on navigue vers /mail, où le shell mail le récupère
|
||||
* et ouvre la fenêtre de composition.
|
||||
*/
|
||||
|
||||
export const PENDING_COMPOSE_KEY = "ulti_pending_compose"
|
||||
|
||||
export interface PendingCompose {
|
||||
to: { name: string; email: string }[]
|
||||
subject?: string
|
||||
bodyText?: string
|
||||
}
|
||||
|
||||
export function stashPendingCompose(compose: PendingCompose): void {
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_COMPOSE_KEY, JSON.stringify(compose))
|
||||
} catch {
|
||||
// sessionStorage indisponible : on navigue quand même, sans préremplissage.
|
||||
}
|
||||
}
|
||||
|
||||
export function takePendingCompose(): PendingCompose | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_COMPOSE_KEY)
|
||||
if (!raw) return null
|
||||
sessionStorage.removeItem(PENDING_COMPOSE_KEY)
|
||||
const parsed = JSON.parse(raw) as PendingCompose
|
||||
if (!Array.isArray(parsed.to)) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
116
lib/agenda/agenda-recurrence.test.ts
Normal file
116
lib/agenda/agenda-recurrence.test.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import { describeRRule, expandOccurrences, parseRRule } from "./agenda-recurrence.ts"
|
||||
|
||||
const d = (iso: string) => new Date(iso)
|
||||
|
||||
describe("parseRRule", () => {
|
||||
it("parse une règle hebdomadaire avec BYDAY", () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,TH")
|
||||
assert.deepEqual(rule, {
|
||||
freq: "WEEKLY",
|
||||
interval: 2,
|
||||
count: undefined,
|
||||
until: undefined,
|
||||
byday: [1, 4],
|
||||
})
|
||||
})
|
||||
|
||||
it("rejette une fréquence inconnue", () => {
|
||||
assert.equal(parseRRule("FREQ=HOURLY"), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("expandOccurrences", () => {
|
||||
it("développe une règle quotidienne dans la fenêtre", () => {
|
||||
const rule = parseRRule("FREQ=DAILY")!
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-01T10:00:00"),
|
||||
rule,
|
||||
new Set(),
|
||||
d("2026-06-10T00:00:00"),
|
||||
d("2026-06-12T23:59:59"),
|
||||
)
|
||||
assert.deepEqual(out.map((x) => x.getDate()), [10, 11, 12])
|
||||
})
|
||||
|
||||
it("respecte COUNT depuis la première occurrence", () => {
|
||||
const rule = parseRRule("FREQ=DAILY;COUNT=5")!
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-01T10:00:00"),
|
||||
rule,
|
||||
new Set(),
|
||||
d("2026-06-04T00:00:00"),
|
||||
d("2026-06-30T23:59:59"),
|
||||
)
|
||||
// Occurrences 1–5 = 1er au 5 juin ; fenêtre depuis le 4 → 4 et 5 juin.
|
||||
assert.deepEqual(out.map((x) => x.getDate()), [4, 5])
|
||||
})
|
||||
|
||||
it("respecte UNTIL", () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;UNTIL=20260615T000000Z")!
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-01T10:00:00"),
|
||||
rule,
|
||||
new Set(),
|
||||
d("2026-06-01T00:00:00"),
|
||||
d("2026-07-31T23:59:59"),
|
||||
)
|
||||
assert.equal(out.length, 2) // 1er et 8 juin (15 juin 10:00 > UNTIL)
|
||||
})
|
||||
|
||||
it("développe BYDAY hebdomadaire en gardant l'heure", () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,WE")!
|
||||
// Lundi 1er juin 2026 à 09:30.
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-01T09:30:00"),
|
||||
rule,
|
||||
new Set(),
|
||||
d("2026-06-01T00:00:00"),
|
||||
d("2026-06-07T23:59:59"),
|
||||
)
|
||||
assert.deepEqual(
|
||||
out.map((x) => [x.getDate(), x.getHours(), x.getMinutes()]),
|
||||
[
|
||||
[1, 9, 30],
|
||||
[3, 9, 30],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
it("exclut les EXDATE", () => {
|
||||
const rule = parseRRule("FREQ=DAILY")!
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-01T10:00:00"),
|
||||
rule,
|
||||
new Set([d("2026-06-02T10:00:00").getTime()]),
|
||||
d("2026-06-01T00:00:00"),
|
||||
d("2026-06-03T23:59:59"),
|
||||
)
|
||||
assert.deepEqual(out.map((x) => x.getDate()), [1, 3])
|
||||
})
|
||||
|
||||
it("ne produit aucune occurrence avant le début du master", () => {
|
||||
const rule = parseRRule("FREQ=WEEKLY;BYDAY=MO,FR")!
|
||||
// Master mercredi 3 juin 2026 → seul le vendredi 5 reste cette semaine-là.
|
||||
const out = expandOccurrences(
|
||||
d("2026-06-03T10:00:00"),
|
||||
rule,
|
||||
new Set(),
|
||||
d("2026-06-01T00:00:00"),
|
||||
d("2026-06-07T23:59:59"),
|
||||
)
|
||||
assert.deepEqual(out.map((x) => x.getDate()), [5])
|
||||
})
|
||||
})
|
||||
|
||||
describe("describeRRule", () => {
|
||||
it("décrit les règles courantes", () => {
|
||||
assert.equal(describeRRule("FREQ=DAILY"), "Tous les jours")
|
||||
assert.equal(
|
||||
describeRRule("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"),
|
||||
"Tous les jours ouvrés (lun.–ven.)",
|
||||
)
|
||||
assert.equal(describeRRule("FREQ=MONTHLY;INTERVAL=3"), "Tous les 3 mois")
|
||||
})
|
||||
})
|
||||
182
lib/agenda/agenda-recurrence.ts
Normal file
182
lib/agenda/agenda-recurrence.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { addDays, addMonths, addWeeks, addYears, format, startOfWeek } from "date-fns"
|
||||
import { fr } from "date-fns/locale"
|
||||
import { parseICSDate, WEEK_OPTS } from "./agenda-date.ts"
|
||||
|
||||
export type RRuleFreq = "DAILY" | "WEEKLY" | "MONTHLY" | "YEARLY"
|
||||
|
||||
export interface ParsedRRule {
|
||||
freq: RRuleFreq
|
||||
interval: number
|
||||
count?: number
|
||||
until?: Date
|
||||
/** Jours (0 = dimanche … 6 = samedi), uniquement pour WEEKLY. */
|
||||
byday: number[]
|
||||
}
|
||||
|
||||
const BYDAY_TO_INDEX: Record<string, number> = {
|
||||
SU: 0,
|
||||
MO: 1,
|
||||
TU: 2,
|
||||
WE: 3,
|
||||
TH: 4,
|
||||
FR: 5,
|
||||
SA: 6,
|
||||
}
|
||||
|
||||
const INDEX_TO_BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]
|
||||
|
||||
export function parseRRule(rrule: string): ParsedRRule | null {
|
||||
const value = rrule.trim().replace(/^RRULE:/i, "")
|
||||
if (!value) return null
|
||||
const parts: Record<string, string> = {}
|
||||
for (const seg of value.split(";")) {
|
||||
const [k, v] = seg.split("=")
|
||||
if (k && v) parts[k.toUpperCase()] = v.toUpperCase()
|
||||
}
|
||||
const freq = parts.FREQ as RRuleFreq | undefined
|
||||
if (!freq || !["DAILY", "WEEKLY", "MONTHLY", "YEARLY"].includes(freq)) return null
|
||||
const interval = Math.max(1, Number(parts.INTERVAL ?? "1") || 1)
|
||||
const count = parts.COUNT ? Math.max(1, Number(parts.COUNT) || 1) : undefined
|
||||
const until = parts.UNTIL ? (parseICSDate(parts.UNTIL) ?? undefined) : undefined
|
||||
const byday =
|
||||
freq === "WEEKLY" && parts.BYDAY
|
||||
? parts.BYDAY.split(",")
|
||||
.map((d) => BYDAY_TO_INDEX[d.trim()])
|
||||
.filter((d): d is number => d !== undefined)
|
||||
: []
|
||||
return { freq, interval, count, until, byday }
|
||||
}
|
||||
|
||||
/**
|
||||
* Développe les occurrences d'une règle dans une fenêtre donnée.
|
||||
* COUNT est compté depuis la première occurrence, même hors fenêtre.
|
||||
*/
|
||||
export function expandOccurrences(
|
||||
masterStart: Date,
|
||||
rule: ParsedRRule,
|
||||
exdates: Set<number>,
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
maxOccurrences = 400,
|
||||
): Date[] {
|
||||
const out: Date[] = []
|
||||
let produced = 0
|
||||
|
||||
const push = (occurrence: Date): boolean => {
|
||||
if (rule.until && occurrence > rule.until) return false
|
||||
produced++
|
||||
if (rule.count && produced > rule.count) return false
|
||||
if (
|
||||
occurrence >= rangeStart &&
|
||||
occurrence <= rangeEnd &&
|
||||
!exdates.has(occurrence.getTime())
|
||||
) {
|
||||
out.push(occurrence)
|
||||
}
|
||||
return out.length < maxOccurrences
|
||||
}
|
||||
|
||||
if (rule.freq === "WEEKLY" && rule.byday.length > 0) {
|
||||
const days = [...rule.byday].sort((a, b) => a - b)
|
||||
let weekStart = startOfWeek(masterStart, WEEK_OPTS)
|
||||
// Garde-fou : 1 000 semaines ≈ 19 ans.
|
||||
for (let w = 0; w < 1000; w++) {
|
||||
for (const day of days) {
|
||||
// weekStart est un lundi (index 1) — offset vers le jour demandé.
|
||||
const offset = (day - 1 + 7) % 7
|
||||
const occurrence = new Date(addDays(weekStart, offset))
|
||||
occurrence.setHours(
|
||||
masterStart.getHours(),
|
||||
masterStart.getMinutes(),
|
||||
masterStart.getSeconds(),
|
||||
0,
|
||||
)
|
||||
if (occurrence < masterStart) continue
|
||||
if (!push(occurrence)) return out
|
||||
}
|
||||
weekStart = addWeeks(weekStart, rule.interval)
|
||||
if (weekStart > rangeEnd && (!rule.count || produced > rule.count)) break
|
||||
if (weekStart > rangeEnd && !rule.count) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
let occurrence = new Date(masterStart)
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
if (!push(occurrence)) return out
|
||||
switch (rule.freq) {
|
||||
case "DAILY":
|
||||
occurrence = addDays(occurrence, rule.interval)
|
||||
break
|
||||
case "WEEKLY":
|
||||
occurrence = addWeeks(occurrence, rule.interval)
|
||||
break
|
||||
case "MONTHLY":
|
||||
occurrence = addMonths(occurrence, rule.interval)
|
||||
break
|
||||
case "YEARLY":
|
||||
occurrence = addYears(occurrence, rule.interval)
|
||||
break
|
||||
}
|
||||
if (occurrence > rangeEnd && !rule.count && !rule.until) break
|
||||
if (occurrence > rangeEnd && rule.until && rule.until <= rangeEnd) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface RecurrenceOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** Options de récurrence proposées dans l'éditeur, dépendantes de la date. */
|
||||
export function recurrenceOptionsFor(start: Date): RecurrenceOption[] {
|
||||
const weekday = format(start, "EEEE", { locale: fr })
|
||||
const dayOfMonth = format(start, "d", { locale: fr })
|
||||
const monthDay = format(start, "d MMMM", { locale: fr })
|
||||
return [
|
||||
{ value: "", label: "Ne se répète pas" },
|
||||
{ value: "FREQ=DAILY", label: "Tous les jours" },
|
||||
{
|
||||
value: `FREQ=WEEKLY;BYDAY=${INDEX_TO_BYDAY[start.getDay()]}`,
|
||||
label: `Toutes les semaines le ${weekday}`,
|
||||
},
|
||||
{ value: "FREQ=MONTHLY", label: `Tous les mois le ${dayOfMonth}` },
|
||||
{ value: "FREQ=YEARLY", label: `Tous les ans le ${monthDay}` },
|
||||
{
|
||||
value: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
||||
label: "Tous les jours ouvrés (lun.–ven.)",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const FREQ_LABELS: Record<RRuleFreq, [string, string]> = {
|
||||
DAILY: ["Tous les jours", "Tous les {n} jours"],
|
||||
WEEKLY: ["Toutes les semaines", "Toutes les {n} semaines"],
|
||||
MONTHLY: ["Tous les mois", "Tous les {n} mois"],
|
||||
YEARLY: ["Tous les ans", "Tous les {n} ans"],
|
||||
}
|
||||
|
||||
const DAY_LABELS = ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."]
|
||||
|
||||
/** Libellé français d'une règle de récurrence arbitraire. */
|
||||
export function describeRRule(rrule: string): string {
|
||||
const rule = parseRRule(rrule)
|
||||
if (!rule) return "Récurrence personnalisée"
|
||||
const [singular, plural] = FREQ_LABELS[rule.freq]
|
||||
let label =
|
||||
rule.interval === 1 ? singular : plural.replace("{n}", String(rule.interval))
|
||||
if (rule.freq === "WEEKLY" && rule.byday.length > 0) {
|
||||
const sorted = [...rule.byday].sort((a, b) => ((a + 6) % 7) - ((b + 6) % 7))
|
||||
if (sorted.length === 5 && sorted.every((d) => d >= 1 && d <= 5)) {
|
||||
label = "Tous les jours ouvrés (lun.–ven.)"
|
||||
} else {
|
||||
label += ` le ${sorted.map((d) => DAY_LABELS[d]).join(", ")}`
|
||||
}
|
||||
}
|
||||
if (rule.count) label += `, ${rule.count} fois`
|
||||
if (rule.until) {
|
||||
label += `, jusqu'au ${format(rule.until, "d MMM yyyy", { locale: fr })}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
50
lib/agenda/agenda-store.ts
Normal file
50
lib/agenda/agenda-store.ts
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import type { AgendaView } from "./agenda-url.ts"
|
||||
|
||||
interface AgendaSettingsState {
|
||||
/** Agendas décochés dans la barre latérale. */
|
||||
hiddenCalendarIds: string[]
|
||||
lastView: AgendaView
|
||||
toggleCalendarVisible: (id: string) => void
|
||||
setLastView: (view: AgendaView) => void
|
||||
}
|
||||
|
||||
export const useAgendaSettingsStore = create<AgendaSettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
hiddenCalendarIds: [],
|
||||
lastView: "week",
|
||||
toggleCalendarVisible: (id) => {
|
||||
const hidden = get().hiddenCalendarIds
|
||||
set({
|
||||
hiddenCalendarIds: hidden.includes(id)
|
||||
? hidden.filter((h) => h !== id)
|
||||
: [...hidden, id],
|
||||
})
|
||||
},
|
||||
setLastView: (lastView) => set({ lastView }),
|
||||
}),
|
||||
{
|
||||
name: "agenda-settings-store",
|
||||
storage: debouncedPersistJSONStorage,
|
||||
partialize: (s) => ({
|
||||
hiddenCalendarIds: s.hiddenCalendarIds,
|
||||
lastView: s.lastView,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
interface AgendaUIState {
|
||||
sidebarCollapsed: boolean
|
||||
setSidebarCollapsed: (v: boolean) => void
|
||||
}
|
||||
|
||||
export const useAgendaUIStore = create<AgendaUIState>((set) => ({
|
||||
sidebarCollapsed: false,
|
||||
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||
}))
|
||||
81
lib/agenda/agenda-types.ts
Normal file
81
lib/agenda/agenda-types.ts
Normal file
@ -0,0 +1,81 @@
|
||||
export interface AgendaCalendar {
|
||||
id: string
|
||||
display_name: string
|
||||
color: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface AgendaEventAttendee {
|
||||
email: string
|
||||
name?: string
|
||||
/** PARTSTAT : NEEDS-ACTION | ACCEPTED | DECLINED | TENTATIVE */
|
||||
status?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** Événement tel que renvoyé par l'API (`/calendar/{id}/events`). */
|
||||
export interface AgendaApiEvent {
|
||||
uid: string
|
||||
summary: string
|
||||
description: string
|
||||
location: string
|
||||
/** ICS : `YYYYMMDD` (journée entière) ou `YYYYMMDDTHHMMSSZ` (UTC). */
|
||||
start: string
|
||||
end: string
|
||||
all_day: boolean
|
||||
path?: string
|
||||
etag?: string
|
||||
organizer?: string
|
||||
attendees?: AgendaEventAttendee[]
|
||||
meet_url?: string
|
||||
color?: string
|
||||
rrule?: string
|
||||
exdates?: string[]
|
||||
raw_ics?: string
|
||||
}
|
||||
|
||||
export interface AgendaEventsResponse {
|
||||
events: AgendaApiEvent[]
|
||||
}
|
||||
|
||||
export interface AgendaCalendarsResponse {
|
||||
calendars: AgendaCalendar[]
|
||||
}
|
||||
|
||||
/** Occurrence affichable (événement simple ou instance d'une récurrence). */
|
||||
export interface AgendaEvent {
|
||||
/** Clé unique d'occurrence : `path@startMs`. */
|
||||
key: string
|
||||
calendarId: string
|
||||
path: string
|
||||
etag: string
|
||||
uid: string
|
||||
title: string
|
||||
description: string
|
||||
location: string
|
||||
meetUrl: string
|
||||
organizer: string
|
||||
attendees: AgendaEventAttendee[]
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
/** Couleur résolue : événement sinon agenda. */
|
||||
color: string
|
||||
rrule: string
|
||||
recurring: boolean
|
||||
/** Données API du master, pour l'édition aller-retour. */
|
||||
master: AgendaApiEvent
|
||||
}
|
||||
|
||||
export interface AgendaEventDraft {
|
||||
title: string
|
||||
start: Date
|
||||
end: Date
|
||||
allDay: boolean
|
||||
calendarId: string
|
||||
description?: string
|
||||
location?: string
|
||||
attendees?: AgendaEventAttendee[]
|
||||
rrule?: string
|
||||
color?: string
|
||||
}
|
||||
32
lib/agenda/agenda-url.ts
Normal file
32
lib/agenda/agenda-url.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { dateKey, parseDateKey } from "./agenda-date.ts"
|
||||
|
||||
export type AgendaView = "day" | "week" | "month"
|
||||
|
||||
export const AGENDA_VIEWS: AgendaView[] = ["day", "week", "month"]
|
||||
|
||||
export const AGENDA_VIEW_LABELS: Record<AgendaView, string> = {
|
||||
day: "Jour",
|
||||
week: "Semaine",
|
||||
month: "Mois",
|
||||
}
|
||||
|
||||
export interface AgendaRoute {
|
||||
view: AgendaView | null
|
||||
date: Date
|
||||
}
|
||||
|
||||
function isAgendaView(value: string): value is AgendaView {
|
||||
return (AGENDA_VIEWS as string[]).includes(value)
|
||||
}
|
||||
|
||||
/** `/agenda[/{view}[/{yyyy-MM-dd}]]` — view null = laisser le choix au client. */
|
||||
export function parseAgendaSegments(segments?: string[]): AgendaRoute {
|
||||
const [rawView, rawDate] = segments ?? []
|
||||
const view = rawView && isAgendaView(rawView) ? rawView : null
|
||||
const date = (rawDate ? parseDateKey(rawDate) : null) ?? new Date()
|
||||
return { view, date }
|
||||
}
|
||||
|
||||
export function buildAgendaPath(view: AgendaView, date: Date): string {
|
||||
return `/agenda/${view}/${dateKey(date)}`
|
||||
}
|
||||
158
lib/api/hooks/use-calendar-mutations.ts
Normal file
158
lib/api/hooks/use-calendar-mutations.ts
Normal file
@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { formatICSDateOnly, formatICSDateTimeUTC } from "@/lib/agenda/agenda-date"
|
||||
import type { AgendaApiEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
|
||||
|
||||
function eventApiPath(davPath: string): string {
|
||||
return `/calendar/events/${davPath.replace(/^\/+/, "")}`
|
||||
}
|
||||
|
||||
/** Convertit un brouillon UI en payload API (dates ICS). */
|
||||
export function draftToApiEvent(
|
||||
draft: AgendaEventDraft,
|
||||
existing?: AgendaApiEvent,
|
||||
): Partial<AgendaApiEvent> {
|
||||
const start = draft.allDay
|
||||
? formatICSDateOnly(draft.start)
|
||||
: formatICSDateTimeUTC(draft.start)
|
||||
const end = draft.allDay
|
||||
? formatICSDateOnly(draft.end)
|
||||
: formatICSDateTimeUTC(draft.end)
|
||||
return {
|
||||
uid: existing?.uid,
|
||||
summary: draft.title.trim() || "(Sans titre)",
|
||||
description: draft.description ?? existing?.description ?? "",
|
||||
location: draft.location ?? existing?.location ?? "",
|
||||
start,
|
||||
end,
|
||||
all_day: draft.allDay,
|
||||
attendees: draft.attendees ?? existing?.attendees ?? [],
|
||||
organizer: existing?.organizer,
|
||||
meet_url: existing?.meet_url,
|
||||
color: draft.color ?? existing?.color,
|
||||
rrule: draft.rrule ?? existing?.rrule ?? "",
|
||||
exdates: existing?.exdates,
|
||||
}
|
||||
}
|
||||
|
||||
function useInvalidateAgenda() {
|
||||
const queryClient = useQueryClient()
|
||||
return () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agenda", "events"] })
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateAgendaEvent() {
|
||||
const invalidate = useInvalidateAgenda()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
calendarId,
|
||||
event,
|
||||
}: {
|
||||
calendarId: string
|
||||
event: Partial<AgendaApiEvent>
|
||||
}) =>
|
||||
apiClient.post(`/calendar/${encodeURIComponent(calendarId)}/events`, event),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAgendaEvent() {
|
||||
const invalidate = useInvalidateAgenda()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
path,
|
||||
etag,
|
||||
event,
|
||||
}: {
|
||||
path: string
|
||||
etag: string
|
||||
event: Partial<AgendaApiEvent>
|
||||
}) =>
|
||||
apiClient.put<{ etag: string }>(eventApiPath(path), event, {
|
||||
"If-Match": etag || "*",
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAgendaEvent() {
|
||||
const invalidate = useInvalidateAgenda()
|
||||
return useMutation({
|
||||
mutationFn: ({ path }: { path: string }) => apiClient.delete(eventApiPath(path)),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useRespondAgendaInvitation() {
|
||||
const invalidate = useInvalidateAgenda()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
path,
|
||||
response,
|
||||
etag,
|
||||
}: {
|
||||
path: string
|
||||
response: "accepted" | "declined" | "tentative"
|
||||
etag?: string
|
||||
}) =>
|
||||
apiClient.post<{ etag: string }>(
|
||||
`/calendar/events/response/${path.replace(/^\/+/, "")}`,
|
||||
{ response, if_match: etag ?? "" },
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateAgendaMeetLink() {
|
||||
const invalidate = useInvalidateAgenda()
|
||||
return useMutation({
|
||||
mutationFn: ({ path, etag }: { path: string; etag?: string }) =>
|
||||
apiClient.post<{ meet_url: string; etag: string }>(
|
||||
`/calendar/events/meet-link/${path.replace(/^\/+/, "")}`,
|
||||
{ if_match: etag ?? "" },
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateAgendaCalendar() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (input: { display_name: string; color?: string }) =>
|
||||
apiClient.post<{ id: string }>("/calendar", input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agenda", "calendars"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateAgendaCalendar() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...input
|
||||
}: {
|
||||
id: string
|
||||
display_name?: string
|
||||
color?: string
|
||||
}) => apiClient.patch(`/calendar/${encodeURIComponent(id)}`, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agenda"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAgendaCalendar() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
apiClient.delete(`/calendar/${encodeURIComponent(id)}`),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agenda"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
77
lib/api/hooks/use-calendar-queries.ts
Normal file
77
lib/api/hooks/use-calendar-queries.ts
Normal file
@ -0,0 +1,77 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQueries, useQuery } from "@tanstack/react-query"
|
||||
import { format } from "date-fns"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { expandApiEvents } from "@/lib/agenda/agenda-events"
|
||||
import type {
|
||||
AgendaCalendar,
|
||||
AgendaCalendarsResponse,
|
||||
AgendaEvent,
|
||||
AgendaEventsResponse,
|
||||
} from "@/lib/agenda/agenda-types"
|
||||
|
||||
export const agendaCalendarsKey = ["agenda", "calendars"] as const
|
||||
|
||||
export function agendaEventsKey(calendarId: string, from: string, to: string) {
|
||||
return ["agenda", "events", calendarId, from, to] as const
|
||||
}
|
||||
|
||||
export function useAgendaCalendars() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
return useQuery({
|
||||
queryKey: agendaCalendarsKey,
|
||||
enabled: ready && authenticated,
|
||||
staleTime: 5 * 60_000,
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<AgendaCalendarsResponse>("/calendar")
|
||||
return res.calendars ?? []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge et développe les événements de plusieurs agendas sur une fenêtre.
|
||||
* La fenêtre est élargie au mois pour profiter du cache entre vues.
|
||||
*/
|
||||
export function useAgendaEvents(
|
||||
calendars: AgendaCalendar[],
|
||||
rangeStart: Date,
|
||||
rangeEnd: Date,
|
||||
) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const from = format(rangeStart, "yyyy-MM-dd")
|
||||
const to = format(rangeEnd, "yyyy-MM-dd")
|
||||
|
||||
const results = useQueries({
|
||||
queries: calendars.map((cal) => ({
|
||||
queryKey: agendaEventsKey(cal.id, from, to),
|
||||
enabled: ready && authenticated,
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<AgendaEventsResponse>(
|
||||
`/calendar/${encodeURIComponent(cal.id)}/events`,
|
||||
{ from, to, page_size: "500" },
|
||||
)
|
||||
return res.events ?? []
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
const isLoading = results.some((r) => r.isLoading)
|
||||
const isError = results.every((r) => r.isError) && results.length > 0
|
||||
|
||||
const events: AgendaEvent[] = useMemo(() => {
|
||||
const all: AgendaEvent[] = []
|
||||
calendars.forEach((cal, i) => {
|
||||
const data = results[i]?.data
|
||||
if (data) all.push(...expandApiEvents(cal, data, rangeStart, rangeEnd))
|
||||
})
|
||||
return all.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [calendars, rangeStart.getTime(), rangeEnd.getTime(), ...results.map((r) => r.data)])
|
||||
|
||||
return { events, isLoading, isError }
|
||||
}
|
||||
18
lib/auth/authentik-user-url.ts
Normal file
18
lib/auth/authentik-user-url.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* URLs de l'interface utilisateur Authentik, dérivées de l'issuer OIDC.
|
||||
* Issuer attendu : `https://host/auth/application/o/<slug>/` → base `https://host/auth`.
|
||||
*/
|
||||
|
||||
const OIDC_ISSUER = process.env.NEXT_PUBLIC_OIDC_ISSUER ?? ""
|
||||
|
||||
function authentikBaseUrl(): string | null {
|
||||
const idx = OIDC_ISSUER.indexOf("/application/")
|
||||
if (idx === -1) return null
|
||||
return OIDC_ISSUER.slice(0, idx)
|
||||
}
|
||||
|
||||
/** Page « Paramètres » du portail utilisateur Authentik (mot de passe, MFA, sessions). */
|
||||
export function authentikUserSettingsUrl(): string | null {
|
||||
const base = authentikBaseUrl()
|
||||
return base ? `${base}/if/user/#/settings` : null
|
||||
}
|
||||
59
lib/compte-settings/settings-nav.ts
Normal file
59
lib/compte-settings/settings-nav.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Home, ShieldCheck, UserRound } from "lucide-react"
|
||||
|
||||
export type CompteSettingsSectionId = "home" | "personal-info" | "security"
|
||||
|
||||
export type CompteSettingsNavItem = {
|
||||
id: CompteSettingsSectionId
|
||||
label: string
|
||||
description: string
|
||||
href: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const COMPTE_SETTINGS_NAV: CompteSettingsNavItem[] = [
|
||||
{
|
||||
id: "home",
|
||||
label: "Accueil",
|
||||
description: "Vue d'ensemble de votre compte Ulti",
|
||||
href: "/compte",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
id: "personal-info",
|
||||
label: "Informations personnelles",
|
||||
description: "Nom, adresse e-mail et identifiant",
|
||||
href: "/compte/informations",
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
label: "Sécurité",
|
||||
description: "Mot de passe, sessions et appareils",
|
||||
href: "/compte/securite",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
]
|
||||
|
||||
export function isCompteSettingsNavActive(
|
||||
pathname: string | null,
|
||||
item: CompteSettingsNavItem
|
||||
): boolean {
|
||||
if (item.href === "/compte") {
|
||||
return pathname === "/compte" || pathname === "/compte/accueil"
|
||||
}
|
||||
return (
|
||||
pathname === item.href || Boolean(pathname?.startsWith(`${item.href}/`))
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveCompteSettingsSection(
|
||||
segments: string[] | undefined
|
||||
): CompteSettingsSectionId {
|
||||
const slug = segments?.[0]
|
||||
const match = COMPTE_SETTINGS_NAV.find((item) => {
|
||||
if (item.id === "home") return !slug || slug === "accueil"
|
||||
return item.href.endsWith(`/${slug}`)
|
||||
})
|
||||
return match?.id ?? "home"
|
||||
}
|
||||
@ -11,8 +11,16 @@ export type FavoriteApp = {
|
||||
}
|
||||
|
||||
export const SUITE_FAVORITE_APPS: FavoriteApp[] = [
|
||||
{ name: "Compte", icon: suitePublicAsset("/compte-mark.svg") },
|
||||
{ name: "Agenda", icon: suitePublicAsset("/agenda-mark.svg") },
|
||||
{
|
||||
name: "Compte",
|
||||
icon: suitePublicAsset("/compte-mark.svg"),
|
||||
href: "/compte",
|
||||
},
|
||||
{
|
||||
name: "Agenda",
|
||||
icon: suitePublicAsset("/agenda-mark.svg"),
|
||||
href: "/agenda",
|
||||
},
|
||||
{ name: "Photos", icon: suitePublicAsset("/photos-mark.svg") },
|
||||
{
|
||||
name: "Ultimail",
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Metadata } from "next"
|
||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||
import { parseDriveSegments } from "@/lib/drive/drive-url"
|
||||
|
||||
export type SuiteApp = "mail" | "drive" | "contacts" | "admin" | "suite"
|
||||
export type SuiteApp = "mail" | "drive" | "contacts" | "agenda" | "admin" | "compte" | "suite"
|
||||
|
||||
/** Separator between page segment and product name in document titles. */
|
||||
export const SUITE_TITLE_SEP = " - "
|
||||
@ -13,7 +13,9 @@ const DESCRIPTIONS: Record<SuiteApp, string> = {
|
||||
mail: "Client mail Ultimail — suite souveraine",
|
||||
drive: "Stockage de fichiers UltiDrive — suite UltiSuite",
|
||||
contacts: "Carnet d'adresses — UltiSuite",
|
||||
agenda: "Agenda partagé, invitations et disponibilités — UltiSuite",
|
||||
admin: "Console d'administration — UltiSuite",
|
||||
compte: "Réglages du compte — UltiSuite",
|
||||
suite: "Ultimail, UltiDrive et contacts — interface suite unifiée",
|
||||
}
|
||||
|
||||
@ -21,7 +23,9 @@ const APP_LABELS: Record<SuiteApp, string> = {
|
||||
mail: "Ultimail",
|
||||
drive: "UltiDrive",
|
||||
contacts: "UltiSuite",
|
||||
agenda: "Agenda",
|
||||
admin: "Administration",
|
||||
compte: "Compte Ulti",
|
||||
suite: "UltiSuite",
|
||||
}
|
||||
|
||||
@ -64,6 +68,19 @@ const ICONS: Record<SuiteApp, Metadata["icons"]> = {
|
||||
apple: [{ url: "/contacts-mark.svg", type: "image/svg+xml" }],
|
||||
shortcut: "/contacts-mark.svg",
|
||||
},
|
||||
compte: {
|
||||
icon: [{ url: "/compte-mark.svg", type: "image/svg+xml" }],
|
||||
apple: [{ url: "/compte-mark.svg", type: "image/svg+xml" }],
|
||||
shortcut: "/compte-mark.svg",
|
||||
},
|
||||
agenda: {
|
||||
icon: [
|
||||
{ url: "/agenda-mark.svg", type: "image/svg+xml" },
|
||||
{ url: "/icon.png", sizes: "32x32", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/agenda-mark.svg", type: "image/svg+xml" }],
|
||||
shortcut: "/agenda-mark.svg",
|
||||
},
|
||||
}
|
||||
|
||||
type PageMetadataOptions = {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user