feat: enhance configuration and add new demo layouts
Some checks are pending
E2E / Playwright e2e (push) Waiting to run

- Introduced turbopack alias for canvas in next.config.mjs.
- Updated package.json scripts for development and branding tasks.
- Added new dependencies for Tiptap extensions.
- Implemented new demo layouts for agenda, contacts, drive, and mail applications.
- Enhanced globals.css for improved theming and splash screen animations.
- Added OAuth callback handling for drive mounts.
- Updated layout components to integrate new demo shells and improve structure.
This commit is contained in:
R3D347HR4Y 2026-06-12 19:10:24 +02:00
parent 3bbf3691b0
commit ad1370ea7e
313 changed files with 16203 additions and 2373 deletions

View File

@ -0,0 +1,60 @@
import { NextResponse } from "next/server"
const MAX_ICS_BYTES = 2 * 1024 * 1024
function isAllowedIcsUrl(raw: string): boolean {
try {
const url = new URL(raw)
if (url.protocol !== "http:" && url.protocol !== "https:") return false
const host = url.hostname.toLowerCase()
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
return false
}
return true
} catch {
return false
}
}
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get("url")?.trim()
if (!url || !isAllowedIcsUrl(url)) {
return NextResponse.json({ error: "invalid_url" }, { status: 400 })
}
try {
const res = await fetch(url, {
headers: { Accept: "text/calendar, text/plain, */*" },
redirect: "follow",
cache: "no-store",
})
if (!res.ok) {
return NextResponse.json({ error: "fetch_failed" }, { status: 502 })
}
const contentType = res.headers.get("content-type") ?? ""
if (
contentType &&
!contentType.includes("text/calendar") &&
!contentType.includes("text/plain") &&
!contentType.includes("application/octet-stream")
) {
return NextResponse.json({ error: "unsupported_content_type" }, { status: 415 })
}
const buffer = await res.arrayBuffer()
if (buffer.byteLength > MAX_ICS_BYTES) {
return NextResponse.json({ error: "payload_too_large" }, { status: 413 })
}
return new NextResponse(new TextDecoder("utf-8").decode(buffer), {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Cache-Control": "private, max-age=300",
},
})
} catch {
return NextResponse.json({ error: "fetch_failed" }, { status: 502 })
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1,4 +1,5 @@
import { CompteSettingsLayout } from "@/components/compte/compte-settings-layout"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
@ -11,5 +12,9 @@ export default function CompteRootLayout({
}: {
children: React.ReactNode
}) {
return <CompteSettingsLayout>{children}</CompteSettingsLayout>
return (
<SuiteThemeShell>
<CompteSettingsLayout>{children}</CompteSettingsLayout>
</SuiteThemeShell>
)
}

View File

@ -0,0 +1,12 @@
"use client"
import { Suspense } from "react"
import { AgendaPage } from "@/components/agenda/agenda-page"
export default function DemoAgendaRoutePage() {
return (
<Suspense fallback={null}>
<AgendaPage />
</Suspense>
)
}

View File

@ -0,0 +1,22 @@
import { DemoAgendaShell } from "@/components/demo/demo-agenda-shell"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "agenda",
title: "Démo UltiAgenda",
absoluteTitle: true,
description:
"Essayez l'agenda UltiAgenda sans compte — démo interactive, zéro rétention.",
}),
robots: { index: false },
}
export default function DemoAgendaLayout({
children,
}: {
children: React.ReactNode
}) {
return <DemoAgendaShell>{children}</DemoAgendaShell>
}

View File

@ -0,0 +1,22 @@
import { DemoContactsShell } from "@/components/demo/demo-contacts-shell"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "contacts",
title: "Démo Contacts",
absoluteTitle: true,
description:
"Essayez les contacts Ulti Suite sans compte — démo interactive, zéro rétention.",
}),
robots: { index: false },
}
export default function DemoContactsLayout({
children,
}: {
children: React.ReactNode
}) {
return <DemoContactsShell>{children}</DemoContactsShell>
}

View File

@ -0,0 +1,4 @@
/** Route racine : l'interface contacts est rendue par `app/demo/contacts/layout.tsx`. */
export default function DemoContactsPage() {
return null
}

View File

@ -0,0 +1 @@
export { default } from "@/app/drive/(browser)/[[...segments]]/page"

28
app/demo/drive/layout.tsx Normal file
View File

@ -0,0 +1,28 @@
import { DriveRouteScope } from "@/components/drive/drive-route-scope"
import { DemoDriveShell } from "@/components/demo/demo-drive-shell"
import type { Metadata } from "next"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
...suitePageMetadata({
app: "drive",
title: "Démo UltiDrive",
absoluteTitle: true,
description:
"Essayez le drive UltiDrive sans compte — démo interactive, zéro rétention.",
}),
robots: { index: false },
}
export default function DemoDriveLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<DriveRouteScope />
<DemoDriveShell>{children}</DemoDriveShell>
</>
)
}

View File

@ -0,0 +1,4 @@
/** Route catch-all : l'interface mail réelle est rendue par `app/demo/mail/layout.tsx`. */
export default function DemoMailSegmentsPage() {
return null
}

View File

@ -1,5 +1,5 @@
import { DemoMailShell } from "@/components/demo/demo-mail-shell"
import type { Metadata } from "next"
import { DemoMailApp } from "@/components/demo/demo-mail-app"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata: Metadata = {
@ -13,6 +13,10 @@ export const metadata: Metadata = {
robots: { index: false },
}
export default function DemoMailPage() {
return <DemoMailApp />
export default function DemoMailLayout({
children,
}: {
children: React.ReactNode
}) {
return <DemoMailShell>{children}</DemoMailShell>
}

View File

@ -27,12 +27,15 @@ import {
import { cn } from "@/lib/utils"
import {
useDriveList,
useDriveMountList,
useDriveOrgList,
useDriveRecent,
useDriveSearch,
useDriveSharedWithMe,
useDriveStarred,
useDriveTrash,
} from "@/lib/api/hooks/use-drive-queries"
import { pathRefFromRoute } from "@/lib/api/drive-roots"
export default function DriveBrowserPage() {
const params = useParams()
@ -42,7 +45,13 @@ export default function DriveBrowserPage() {
const folderPath = folderPathFromSegments(route.pathSegments)
const contextView =
route.view === "shared" ? "shared" : route.view === "search" ? "files" : route.view
route.view === "shared"
? "shared"
: route.view === "search"
? "files"
: route.view === "org" || route.view === "mount"
? route.view
: route.view
const fallbackScope = defaultDriveSearchScope(
route.view === "shared" ? "shared" : "files",
folderPath
@ -78,6 +87,8 @@ export default function DriveBrowserPage() {
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
const list = useDriveList(folderPath, route.page, "", route.view === "files")
const orgList = useDriveOrgList(route.rootId ?? "", folderPath, route.page, route.view === "org" && Boolean(route.rootId))
const mountList = useDriveMountList(route.rootId ?? "", folderPath, route.page, route.view === "mount" && Boolean(route.rootId))
const shared = useDriveSharedWithMe(
route.page,
"",
@ -113,7 +124,11 @@ export default function DriveBrowserPage() {
? route.pathSegments.length === 0
? shared
: sharedFolder
: list
: route.view === "org"
? orgList
: route.view === "mount"
? mountList
: list
const files = active.data?.files ?? []
@ -186,6 +201,7 @@ export default function DriveBrowserPage() {
<DriveBrowserChrome
view={route.view}
segments={route.pathSegments}
rootId={route.rootId}
isTrash={isTrash}
items={filteredFiles}
searchState={committedSearch}
@ -247,7 +263,14 @@ export default function DriveBrowserPage() {
{filteredFiles.length > 0 ? (
<FileBrowser
items={filteredFiles}
view={isSearchView ? searchBrowserView : route.view === "shared" ? "shared" : "files"}
view={
isSearchView
? searchBrowserView
: route.view === "shared"
? "shared"
: route.view
}
rootId={route.rootId}
isTrash={isTrash}
/>
) : null}

View File

@ -0,0 +1,57 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { useDriveMountMutations } from "@/lib/api/hooks/use-drive-queries"
import { Button } from "@/components/ui/button"
import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
function DriveMountOAuthCallbackInner() {
const searchParams = useSearchParams()
const { completeOAuth } = useDriveMountMutations()
const [message, setMessage] = useState("Finalisation de la connexion…")
const [done, setDone] = useState(false)
useEffect(() => {
const code = searchParams.get("code")
const mountId = searchParams.get("state") ?? searchParams.get("mount_id")
if (!code || !mountId) {
setMessage("Paramètres OAuth manquants.")
setDone(true)
return
}
void completeOAuth
.mutateAsync({ mountId, code, redirectUri: buildDriveMountOAuthRedirectURI() })
.then(() => {
setMessage("Volume connecté avec succès.")
setDone(true)
if (window.opener) {
window.opener.postMessage({ type: "drive-mount-oauth-complete", mountId }, window.location.origin)
window.setTimeout(() => window.close(), 800)
}
})
.catch(() => {
setMessage("Échec de la connexion OAuth. Réessayez depuis UltiDrive.")
setDone(true)
})
}, [completeOAuth, searchParams])
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 p-6 text-center">
<p className="text-sm text-muted-foreground">{message}</p>
{done && !window.opener ? (
<Button asChild variant="outline">
<a href="/drive">Retour à UltiDrive</a>
</Button>
) : null}
</div>
)
}
export default function DriveMountOAuthCallbackPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-muted-foreground">Chargement</div>}>
<DriveMountOAuthCallbackInner />
</Suspense>
)
}

View File

@ -158,8 +158,8 @@
}
@theme inline {
--font-sans: 'Geist', 'Geist Fallback';
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@ -573,6 +573,14 @@ html[data-splash-seen='1'] .app-first-launch-splash {
animation: splash-logo-float 2s ease-in-out infinite;
}
.app-first-launch-splash__mark {
transform-origin: center center;
}
.app-first-launch-splash__mark--spin {
animation: splash-logo-spin 0.72s linear infinite;
}
.app-first-launch-splash__loader {
width: min(58vw, 230px);
height: 4px;
@ -595,6 +603,7 @@ html[data-splash-seen='1'] .app-first-launch-splash {
.app-first-launch-splash__grain,
.app-first-launch-splash__content,
.app-first-launch-splash__logo,
.app-first-launch-splash__mark--spin,
.app-first-launch-splash__loader > span {
animation: none !important;
}
@ -609,7 +618,7 @@ html:has(.ultimail-login) body {
background-color: transparent !important;
}
/* ── Drive : pas de fond décoratif mail ni splash Ultimail (y compris chargement) ── */
/* ── Drive : pas de fond décoratif mail ── */
html[data-route-scope='drive']::before,
html:has([data-drive-app])::before {
opacity: 0 !important;
@ -623,12 +632,6 @@ html[data-route-scope='drive'] body {
background-color: var(--app-canvas) !important;
}
html[data-route-scope='drive'] .app-first-launch-splash {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
@media (min-width: 640px) {
.ultimail-login-card-frame {
padding: 3px;
@ -727,9 +730,31 @@ html[data-mail-background]:not([data-mail-background='none']) [data-contacts-app
background-color: var(--mail-surface-muted) !important;
}
/* Réglages / administration : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
/* Agenda : pas de fond décoratif mail — surfaces opaques (carte arrondie + chrome). */
html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app].ultimail-app {
background-color: var(--app-canvas) !important;
}
html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-app-canvas) {
background-color: var(--app-canvas) !important;
}
html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-mail-surface, .bg-white) {
background-color: var(--mail-surface) !important;
}
html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] :where(.bg-mail-surface-elevated) {
background-color: var(--mail-surface-elevated) !important;
}
html[data-mail-background]:not([data-mail-background='none']) [data-agenda-app] [data-agenda-calendar-card] {
background-color: var(--mail-surface) !important;
}
/* Réglages / administration / compte : fond décoratif visible uniquement derrière la sidebar (contenu opaque). */
html[data-mail-background]:not([data-mail-background='none']) [data-mail-settings-app].ultimail-app,
html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app {
html[data-mail-background]:not([data-mail-background='none']) [data-admin-settings-app].ultimail-app,
html[data-mail-background]:not([data-mail-background='none']) [data-compte-settings-app].ultimail-app {
background-color: var(--app-canvas) !important;
}
@ -738,7 +763,10 @@ html[data-mail-background]:not([data-mail-background='none'])
[data-mail-settings-sidebar],
html[data-mail-background]:not([data-mail-background='none'])
[data-admin-settings-app]
[data-admin-settings-sidebar] {
[data-admin-settings-sidebar],
html[data-mail-background]:not([data-mail-background='none'])
[data-compte-settings-app]
[data-compte-settings-sidebar] {
background-color: color-mix(in srgb, var(--app-canvas) 72%, transparent) !important;
}
@ -747,7 +775,10 @@ html[data-mail-background]:not([data-mail-background='none'])
:where([data-mail-settings-main]),
html[data-mail-background]:not([data-mail-background='none'])
[data-admin-settings-app]
:where([data-admin-settings-main]) {
:where([data-admin-settings-main]),
html[data-mail-background]:not([data-mail-background='none'])
[data-compte-settings-app]
:where([data-compte-settings-main]) {
background-color: var(--mail-surface) !important;
}
@ -1239,8 +1270,9 @@ html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .
border-color: var(--border) !important;
}
/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */
html.dark [data-mail-settings-main] {
/* Réglages mail / compte : cartes cohérentes en dark mode (fond + bordure plus visible) */
html.dark [data-mail-settings-main],
html.dark [data-compte-settings-main] {
--border: var(--mail-border);
}
@ -1270,6 +1302,18 @@ html.dark [data-mail-settings-main] :where(
border-color: color-mix(in srgb, var(--mail-border) 72%, transparent) !important;
}
html.dark [data-compte-settings-main] :where(
.mail-settings-card,
[data-slot='card'],
[class*='rounded-lg'].border,
[class*='rounded-xl'].border,
[class*='rounded-md'].border,
[class*='rounded-2xl'].border
) {
background-color: var(--mail-surface-elevated) !important;
border-color: var(--mail-border) !important;
}
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
html.dark .ultimail-app :where(.bg-background) {
background-color: var(--mail-surface-muted) !important;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 956 B

View File

@ -10,8 +10,8 @@ import { SessionGuard } from '@/components/auth/session-guard'
import { MailToaster } from '@/components/gmail/mail-toaster'
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' })
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' })
export const metadata: Metadata = suiteRootMetadata()
@ -30,7 +30,11 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="fr" suppressHydrationWarning className="h-dvh max-h-dvh overflow-hidden">
<html
lang="fr"
suppressHydrationWarning
className={`${geistSans.variable} ${geistMono.variable} h-dvh max-h-dvh overflow-hidden`}
>
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
<QueryProvider>

View File

@ -7,6 +7,7 @@ import {
useLayoutEffect,
useState,
} from "react"
import { usePathname } from "next/navigation"
import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view"
@ -16,7 +17,6 @@ import { searchParamsToDisplayQuery } from "@/lib/mail-search/search-filter"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import { useRouter, usePathname } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header"
import { EmailList } from "@/components/gmail/email-list"
@ -55,7 +55,6 @@ function isMailSettingsPath(pathname: string | null): boolean {
}
function MailAppInner() {
const router = useRouter()
const { route, navigateRoute, searchParams: currentSearchParams } =
useMailRoute()
const activeSearchQuery =
@ -204,7 +203,14 @@ function MailAppInner() {
xsViewChrome={xsViewChrome}
onOpenSearch={() => setMobileSearchOpen(true)}
searchQuery={activeSearchQuery}
onClearSearch={() => router.push("/mail/inbox")}
onClearSearch={() =>
navigateRoute({
folderId: "inbox",
inboxTab: DEFAULT_INBOX_TAB,
page: 1,
mailId: null,
})
}
/>
) : null}
<MobileSearchOverlay

24
app/meet/[room]/page.tsx Normal file
View File

@ -0,0 +1,24 @@
import { Suspense } from "react"
import { Loader2 } from "lucide-react"
import { MeetRoomClient } from "@/components/meet/meet-room-client"
export default async function MeetRoomPage({
params,
}: {
params: Promise<{ room: string }>
}) {
const { room } = await params
return (
<Suspense
fallback={
<div className="flex h-dvh items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
Chargement
</div>
}
>
<MeetRoomClient room={decodeURIComponent(room)} />
</Suspense>
)
}

18
app/meet/join/page.tsx Normal file
View File

@ -0,0 +1,18 @@
import { Suspense } from "react"
import { Loader2 } from "lucide-react"
import { MeetJoinClient } from "@/components/meet/meet-join-client"
export default function MeetJoinPage() {
return (
<Suspense
fallback={
<div className="flex h-dvh items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden />
Chargement
</div>
}
>
<MeetJoinClient />
</Suspense>
)
}

9
app/meet/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
import type { ReactNode } from "react"
import { MeetAppShell } from "@/components/meet/meet-app-shell"
import { suitePageMetadata } from "@/lib/suite/page-metadata"
export const metadata = suitePageMetadata({ app: "meet" })
export default function MeetLayout({ children }: { children: ReactNode }) {
return <MeetAppShell>{children}</MeetAppShell>
}

5
app/meet/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { MeetLobby } from "@/components/meet/meet-lobby"
export default function MeetPage() {
return <MeetLobby />
}

View File

@ -1,16 +1,13 @@
"use client"
import Link from "next/link"
import { useAuthStore } from "@/lib/api/auth-store"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { adminScopesFromToken, isPlatformAdminFromToken } from "@/lib/auth/admin"
import { usePlatformAdminAccess } from "@/lib/auth/use-platform-admin-access"
import { Button } from "@/components/ui/button"
export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
const { ready, authenticated } = useAuthReady()
const token = useAuthStore((s) => s.accessToken)
const { data: me, isFetching: meLoading } = useCurrentUser()
const { isAdmin, adminReady } = usePlatformAdminAccess()
if (!ready) {
return (
@ -29,16 +26,12 @@ export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
)
}
if (meLoading && !me) {
if (!adminReady) {
return (
<p className="text-sm text-muted-foreground">Vérification des droits administrateur</p>
)
}
const scopes = adminScopesFromToken(token)
const isAdmin =
isPlatformAdminFromToken(token) || scopes.read || me?.platform_admin === true
if (!isAdmin) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">

View File

@ -2,6 +2,12 @@
import { AdminLogo } from "@/components/admin/admin-logo"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
import { cn } from "@/lib/utils"
const SETTINGS_HREF = "/admin/settings"
@ -11,13 +17,18 @@ export function AdminSettingsHeader() {
data-admin-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">
<AdminLogo className="min-h-8 shrink-0" />
<span className="text-sm font-medium text-muted-foreground">Administration</span>
<div
className={cn(
"hidden h-full w-64 shrink-0 pl-4 md:flex lg:w-72",
SUITE_APP_LOGO_LOCKUP_CLASS,
)}
>
<AdminLogo variant="mark" className={SUITE_APP_LOGO_MARK_CLASS} />
<span className={SUITE_APP_LOGO_TEXT_CLASS}>Administration</span>
</div>
<div className="flex shrink-0 items-center pl-2 md:hidden">
<AdminLogo variant="mark" className="h-8 w-8" />
<AdminLogo variant="mark" className={SUITE_APP_LOGO_MARK_CLASS} />
</div>
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1" />

View File

@ -21,6 +21,8 @@ import { MailingSection } from "@/components/admin/settings/sections/mailing-sec
import { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-section"
import { AgendaSection } from "@/components/admin/settings/sections/agenda-section"
import { UltimeetSection } from "@/components/admin/settings/sections/ultimeet-section"
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
@ -36,6 +38,8 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
search: SearchSection,
plugins: PluginsSection,
nextcloud: NextcloudSection,
agenda: AgendaSection,
ultimeet: UltimeetSection,
mailing: MailingSection,
onlyoffice: OnlyofficeSection,
richtext: RichtextSection,

View File

@ -28,9 +28,10 @@ export function OrgSettingsSection({
}) {
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
const { isFetching, isError, refetch } = useOrgSettings()
const { isFetching, isError, refetch, isFetched } = useOrgSettings()
const savePolicy = useSaveOrgPolicy()
const apiSynced = useOrgSettingsStore((s) => s.apiSynced)
const showPendingBanner = !apiSynced && !isError && (isFetching || !isFetched)
const hasSave = Boolean(policySection)
async function handleSave() {
@ -51,7 +52,7 @@ export function OrgSettingsSection({
<>
<SettingsSectionHeader title={title} description={description} />
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
{!apiSynced ? <AdminPendingApiBanner /> : null}
{!showPendingBanner ? null : <AdminPendingApiBanner />}
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
<div className="space-y-6">{children}</div>
{hasSave ? (

View File

@ -14,13 +14,18 @@ export function OrgSettingsSync() {
useEffect(() => {
if (!data) return
hydratingRef.current = true
const mapped = apiOrgPolicyToStore(data.policy)
const meta = apiOrgSettingsMeta(data)
useOrgSettingsStore.getState().hydrateFromApi(mapped, meta)
queueMicrotask(() => {
hydratingRef.current = false
})
try {
hydratingRef.current = true
const mapped = apiOrgPolicyToStore(data.policy)
const meta = apiOrgSettingsMeta(data)
useOrgSettingsStore.getState().hydrateFromApi(mapped, meta)
} catch (err) {
console.error("org settings hydrate failed", err)
} finally {
queueMicrotask(() => {
hydratingRef.current = false
})
}
}, [data])
return null

View File

@ -0,0 +1,158 @@
"use client"
import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import {
AGENDA_VIDEO_PROVIDER_LABELS,
AGENDA_VIDEO_PROVIDERS,
type AgendaVideoProvider,
} from "@/lib/agenda/agenda-settings-types"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { MailThemeMode } from "@/lib/mail-settings/types"
const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [
{ id: "light", label: "Clair" },
{ id: "dark", label: "Sombre" },
{ id: "system", label: "Système" },
]
export function AgendaSection() {
const agenda = useOrgSettingsStore((s) => s.agenda)
const setAgenda = useOrgSettingsStore((s) => s.setAgenda)
const [draft, setDraft] = useState(agenda)
useEffect(() => {
setDraft(agenda)
}, [agenda])
const updateApiKey = (provider: AgendaVideoProvider, value: string) => {
setDraft((prev) => ({
...prev,
video_provider_api_keys: {
...prev.video_provider_api_keys,
[provider]: value,
},
}))
}
return (
<OrgSettingsSection
title="Agenda"
description="Thème et visioconférence par défaut pour toute l'organisation."
policySection="agenda"
beforeSave={() => setAgenda(draft)}
>
<div className="space-y-6 rounded-lg border p-4">
<div className="space-y-3">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Imposer le thème organisationnel</p>
<p className="text-xs text-muted-foreground">
Les utilisateurs ne peuvent plus changer le mode clair/sombre.
</p>
</div>
<Switch
checked={draft.enforce_org_theme}
onCheckedChange={(v) => setDraft((p) => ({ ...p, enforce_org_theme: v }))}
/>
</label>
<div>
<Label>Thème par défaut</Label>
<Select
value={draft.default_theme_mode}
onValueChange={(v) =>
setDraft((p) => ({ ...p, default_theme_mode: v as MailThemeMode }))
}
>
<SelectTrigger className="mt-1 h-9 w-full max-w-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THEME_OPTIONS.map((opt) => (
<SelectItem key={opt.id} value={opt.id}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3 border-t pt-4">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Imposer le fournisseur visio</p>
<p className="text-xs text-muted-foreground">
Les utilisateurs ne peuvent plus choisir Zoom, Meet, etc.
</p>
</div>
<Switch
checked={draft.enforce_org_video_provider}
onCheckedChange={(v) =>
setDraft((p) => ({ ...p, enforce_org_video_provider: v }))
}
/>
</label>
<div>
<Label>Fournisseur visio par défaut</Label>
<Select
value={draft.default_video_provider}
onValueChange={(v) =>
setDraft((p) => ({
...p,
default_video_provider: v as AgendaVideoProvider,
}))
}
>
<SelectTrigger className="mt-1 h-9 w-full max-w-xs">
<SelectValue>
<AgendaVideoProviderSelectLabel provider={draft.default_video_provider} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_VIDEO_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
<AgendaVideoProviderSelectLabel provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium">Clés API visioconférence (organisation)</p>
<p className="text-xs text-muted-foreground">
Stockées côté serveur. UltiMeet n&apos;exige pas de clé API.
</p>
{(["zoom", "google_meet", "teams", "jitsi"] as AgendaVideoProvider[]).map(
(provider) => (
<div key={provider}>
<Label>{AGENDA_VIDEO_PROVIDER_LABELS[provider]}</Label>
<Input
type="password"
autoComplete="off"
className="mt-1 h-9"
placeholder="Clé API (laisser vide pour conserver l'existante)"
value={draft.video_provider_api_keys[provider] ?? ""}
onChange={(e) => updateApiKey(provider, e.target.value)}
/>
</div>
),
)}
</div>
</div>
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,154 @@
"use client"
import { useEffect, useState } from "react"
import { Check, Copy } from "lucide-react"
import { toast } from "sonner"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import type { DriveMountOAuthProvider, DriveMountOAuthSettings } from "@/lib/admin-settings/org-settings-types"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
const PROVIDERS: { id: DriveMountOAuthProvider; label: string; hint: string }[] = [
{
id: "google",
label: "Google Drive",
hint: "Console Google Cloud — API Drive, redirect URI ci-dessous",
},
{
id: "dropbox",
label: "Dropbox",
hint: "App Dropbox — permissions files.metadata.read, files.content.read/write",
},
{
id: "microsoft",
label: "Microsoft OneDrive",
hint: "Azure AD — Microsoft Graph Files.ReadWrite",
},
]
const SECRET_KEYS: Record<DriveMountOAuthProvider, "mount_oauth_google" | "mount_oauth_dropbox" | "mount_oauth_microsoft"> = {
google: "mount_oauth_google",
dropbox: "mount_oauth_dropbox",
microsoft: "mount_oauth_microsoft",
}
export function DriveMountOAuthSection({
draft,
onChange,
}: {
draft: DriveMountOAuthSettings
onChange: (next: DriveMountOAuthSettings) => void
}) {
const secrets = useOrgSettingsStore((s) => s.meta?.secrets)
const [redirectUri, setRedirectUri] = useState("")
const [copied, setCopied] = useState(false)
useEffect(() => {
setRedirectUri(buildDriveMountOAuthRedirectURI())
}, [])
const updateProvider = (provider: DriveMountOAuthProvider, patch: Partial<DriveMountOAuthSettings[typeof provider]>) => {
onChange({
...draft,
[provider]: { ...draft[provider], ...patch },
})
}
const copyRedirectUri = async () => {
const uri = redirectUri || buildDriveMountOAuthRedirectURI()
try {
await navigator.clipboard.writeText(uri)
setCopied(true)
toast.success("URI de redirection copiée")
window.setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Impossible de copier l'URI")
}
}
return (
<div className="space-y-4 rounded-lg border p-4">
<div>
<h3 className="text-sm font-medium">Connexion cloud (OAuth)</h3>
<p className="mt-1 text-xs text-muted-foreground">
Permet aux utilisateurs de monter Google Drive, Dropbox ou OneDrive depuis UltiDrive.
</p>
</div>
<div>
<Label>URI de redirection OAuth</Label>
<div className="mt-1 flex gap-2">
<Input
className="h-9 flex-1 font-mono text-xs"
readOnly
value={redirectUri}
placeholder="Chargement…"
/>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 shrink-0 gap-1.5 px-3"
onClick={() => void copyRedirectUri()}
disabled={!redirectUri}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
Copier
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Basée sur l&apos;URL actuelle du navigateur. Enregistrez-la chez chaque fournisseur OAuth (Google, Dropbox, Microsoft).
</p>
</div>
<div className="space-y-4">
{PROVIDERS.map(({ id, label, hint }) => {
const provider = draft[id]
const configured = Boolean(secrets?.[SECRET_KEYS[id]]?.configured)
return (
<div key={id} className="space-y-3 rounded-md border p-3">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
<Switch
checked={provider.enabled}
onCheckedChange={(enabled) => updateProvider(id, { enabled })}
/>
</label>
{provider.enabled ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label>Client ID</Label>
<Input
className="mt-1 h-9 font-mono text-xs"
value={provider.client_id}
onChange={(e) => updateProvider(id, { client_id: e.target.value })}
autoComplete="off"
/>
</div>
<div className="sm:col-span-2">
<Label>Client secret</Label>
<Input
className="mt-1 h-9 font-mono text-xs"
type="password"
value={provider.client_secret}
onChange={(e) => updateProvider(id, { client_secret: e.target.value })}
placeholder={configured ? "•••••••• (laisser vide pour conserver)" : "Coller le secret"}
autoComplete="off"
/>
{configured && !provider.client_secret.trim() ? (
<p className="mt-1 text-xs text-muted-foreground">Secret configuré</p>
) : null}
</div>
</div>
) : null}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,94 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
useAdminDriveOrgFolderMutations,
useAdminDriveOrgFolders,
} from "@/lib/api/hooks/use-admin-drive-queries"
export function DriveOrgFoldersSection() {
const folders = useAdminDriveOrgFolders()
const { create, remove, sync } = useAdminDriveOrgFolderMutations()
const [orgSlug, setOrgSlug] = useState("")
const [mountPoint, setMountPoint] = useState("")
const [syncSlugs, setSyncSlugs] = useState("")
return (
<div className="space-y-6 rounded-lg border p-4">
<div>
<h3 className="text-sm font-medium">Dossiers d&apos;organisation</h3>
<p className="text-xs text-muted-foreground">
Group folders Nextcloud liés aux organisations Authentik.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="org-slug">Slug organisation</Label>
<Input id="org-slug" value={orgSlug} onChange={(e) => setOrgSlug(e.target.value)} placeholder="acme" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="org-mount">Nom du dossier</Label>
<Input id="org-mount" value={mountPoint} onChange={(e) => setMountPoint(e.target.value)} placeholder="Acme Corp" />
</div>
</div>
<Button
size="sm"
disabled={!orgSlug.trim() || !mountPoint.trim() || create.isPending}
onClick={() =>
void create.mutateAsync({ org_slug: orgSlug.trim(), mount_point: mountPoint.trim() })
}
>
Créer le dossier
</Button>
<div className="grid gap-1.5">
<Label htmlFor="sync-orgs">Sync auto (slugs séparés par des virgules)</Label>
<Input
id="sync-orgs"
value={syncSlugs}
onChange={(e) => setSyncSlugs(e.target.value)}
placeholder="acme, beta"
/>
<Button
size="sm"
variant="outline"
disabled={!syncSlugs.trim() || sync.isPending}
onClick={() =>
void sync.mutateAsync(
syncSlugs.split(",").map((s) => s.trim()).filter(Boolean)
)
}
>
Provisionner
</Button>
</div>
<ul className="divide-y rounded-md border text-sm">
{(folders.data ?? []).map((folder) => (
<li key={folder.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="truncate font-medium">{folder.mount_point}</p>
<p className="truncate text-xs text-muted-foreground">{folder.org_slug}</p>
</div>
<Button
size="sm"
variant="ghost"
className="shrink-0 text-destructive"
disabled={remove.isPending}
onClick={() => void remove.mutateAsync(folder.id)}
>
Supprimer
</Button>
</li>
))}
{folders.data?.length === 0 ? (
<li className="px-3 py-4 text-center text-muted-foreground">Aucun dossier d&apos;organisation</li>
) : null}
</ul>
</div>
)
}

View File

@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import { Input } from "@/components/ui/input"
@ -13,10 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DriveOrgFoldersSection } from "@/components/admin/settings/sections/drive-org-section"
import { DriveMountOAuthSection } from "@/components/admin/settings/sections/drive-mount-oauth-section"
export function FilePoliciesSection() {
const filePolicies = useOrgSettingsStore((s) => s.filePolicies)
const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies)
const [mountOAuthDraft, setMountOAuthDraft] = useState(filePolicies.mount_oauth)
const vtKeyConfigured = useOrgSettingsStore(
(s) => s.meta?.secrets?.virustotal_api_key?.configured ?? false
)
@ -25,11 +29,16 @@ export function FilePoliciesSection() {
!vtKeyConfigured &&
!(filePolicies.virustotal_api_key ?? "").trim()
useEffect(() => {
setMountOAuthDraft(filePolicies.mount_oauth)
}, [filePolicies.mount_oauth])
return (
<OrgSettingsSection
title="Politiques fichiers"
description="Règles d'upload, partage externe et rétention pour UltiDrive."
policySection="file_policies"
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
>
<div className="grid gap-4 sm:grid-cols-2">
<div>
@ -143,6 +152,8 @@ export function FilePoliciesSection() {
</div>
) : null}
</div>
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} />
<DriveOrgFoldersSection />
</OrgSettingsSection>
)
}

View File

@ -0,0 +1,347 @@
"use client"
import { useEffect, useState } from "react"
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
import {
MEET_EMAIL_RECIPIENTS_LABELS,
MEET_EXTERNAL_API_PROVIDER_LABELS,
MEET_TRANSCRIPTION_ENGINE_LABELS,
MEET_TRANSCRIPTION_MODE_LABELS,
type MeetOrgPolicySettings,
} from "@/lib/meet/meet-settings-types"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
export function UltimeetSection() {
const meet = useOrgSettingsStore((s) => s.meet)
const llmProviders = useOrgSettingsStore((s) => s.llm.providers)
const setMeet = useOrgSettingsStore((s) => s.setMeet)
const effective = useOrgSettingsStore((s) => s.meta?.effective.jitsi)
const [draft, setDraft] = useState(meet)
useEffect(() => {
setDraft(meet)
}, [meet])
const patch = (next: Partial<MeetOrgPolicySettings>) =>
setDraft((prev) => ({ ...prev, ...next }))
const patchPost = (next: Partial<MeetOrgPolicySettings["post_actions"]>) =>
setDraft((prev) => ({
...prev,
post_actions: { ...prev.post_actions, ...next },
}))
return (
<OrgSettingsSection
title="UltiMeet"
description="Visioconférence Jitsi, transcription et traitements post-réunion."
policySection="meet"
beforeSave={() => setMeet(draft)}
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
<CardDescription>
Jitsi {effective?.enabled ? "actif" : "inactif"}
{effective?.public_url ? `${effective.public_url}` : ""}
</CardDescription>
</CardHeader>
</Card>
<div className="space-y-6 rounded-lg border p-4">
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Transcription activée</p>
<p className="text-xs text-muted-foreground">
Active les sous-titres Jitsi (live) ou le pipeline différé selon le mode.
</p>
</div>
<Switch
checked={draft.transcription_enabled}
onCheckedChange={(v) => patch({ transcription_enabled: v })}
/>
</label>
{draft.transcription_enabled ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Mode</Label>
<Select
value={draft.transcription_mode}
onValueChange={(v) =>
patch({ transcription_mode: v as MeetOrgPolicySettings["transcription_mode"] })
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_TRANSCRIPTION_MODE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Moteur</Label>
<Select
value={draft.transcription_engine}
onValueChange={(v) =>
patch({
transcription_engine: v as MeetOrgPolicySettings["transcription_engine"],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_TRANSCRIPTION_ENGINE_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Démarrage automatique</p>
<p className="text-xs text-muted-foreground">
Lance la transcription dès l&apos;ouverture de la salle (sinon via bouton
Sous-titres pour les modérateurs).
</p>
</div>
<Switch
checked={draft.auto_start_transcription}
onCheckedChange={(v) => patch({ auto_start_transcription: v })}
/>
</label>
{draft.transcription_engine === "faster_whisper_local" ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>URL Skynet (interne)</Label>
<Input
value={draft.skynet_url}
onChange={(e) => patch({ skynet_url: e.target.value })}
placeholder="http://skynet:8000"
/>
</div>
<div className="space-y-2">
<Label>Modèle Whisper</Label>
<Input
value={draft.whisper_model}
onChange={(e) => patch({ whisper_model: e.target.value })}
placeholder="tiny, base, small…"
/>
</div>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2 sm:col-span-2">
<Label>Fournisseur API</Label>
<Select
value={draft.external_api_provider}
onValueChange={(v) =>
patch({
external_api_provider:
v as MeetOrgPolicySettings["external_api_provider"],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_EXTERNAL_API_PROVIDER_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>URL API</Label>
<Input
value={draft.external_api_url}
onChange={(e) => patch({ external_api_url: e.target.value })}
placeholder="https://api.openai.com/v1/audio/transcriptions"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Clé API</Label>
<Input
type="password"
value={draft.external_api_key}
onChange={(e) => patch({ external_api_key: e.target.value })}
placeholder="Laisser vide pour conserver la clé enregistrée"
autoComplete="off"
/>
</div>
</div>
)}
</>
) : null}
</div>
{draft.transcription_enabled ? (
<div className="space-y-4 rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Après la réunion</p>
<p className="text-xs text-muted-foreground">
Actions exécutées quand le transcript est reçu par le backend.
</p>
</div>
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Enregistrer dans UltiDrive</p>
</div>
<Switch
checked={draft.post_actions.drive_enabled}
onCheckedChange={(v) => patchPost({ drive_enabled: v })}
/>
</label>
{draft.post_actions.drive_enabled ? (
<div className="space-y-2">
<Label>Dossier Drive</Label>
<Input
value={draft.post_actions.drive_folder_path}
onChange={(e) => patchPost({ drive_folder_path: e.target.value })}
placeholder="/UltiMeet/Transcripts"
/>
</div>
) : null}
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Envoyer par mail</p>
<p className="text-xs text-muted-foreground">
Utilise le SMTP organisationnel (réglages Mailing).
</p>
</div>
<Switch
checked={draft.post_actions.email_enabled}
onCheckedChange={(v) => patchPost({ email_enabled: v })}
/>
</label>
{draft.post_actions.email_enabled ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Destinataires</Label>
<Select
value={draft.post_actions.email_recipients}
onValueChange={(v) =>
patchPost({
email_recipients: v as MeetOrgPolicySettings["post_actions"]["email_recipients"],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MEET_EMAIL_RECIPIENTS_LABELS).map(([id, label]) => (
<SelectItem key={id} value={id}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{draft.post_actions.email_recipients === "custom" ? (
<div className="space-y-2 sm:col-span-2">
<Label>Adresses (séparées par des virgules)</Label>
<Input
value={draft.post_actions.email_custom_addresses}
onChange={(e) => patchPost({ email_custom_addresses: e.target.value })}
/>
</div>
) : null}
</div>
) : null}
<label className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Traitement LLM</p>
<p className="text-xs text-muted-foreground">
Résume ou transforme le transcript via un fournisseur LLM organisationnel.
</p>
</div>
<Switch
checked={draft.post_actions.llm_enabled}
onCheckedChange={(v) => patchPost({ llm_enabled: v })}
/>
</label>
{draft.post_actions.llm_enabled ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Fournisseur LLM</Label>
<Select
value={draft.post_actions.llm_provider_id || "__default__"}
onValueChange={(v) =>
patchPost({ llm_provider_id: v === "__default__" ? "" : v })
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Par défaut (organisation)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Par défaut (organisation)</SelectItem>
{llmProviders.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Prompt</Label>
<Textarea
value={draft.post_actions.llm_prompt}
onChange={(e) => patchPost({ llm_prompt: e.target.value })}
rows={4}
/>
</div>
<label className="flex items-center justify-between gap-4">
<span className="text-sm">Envoyer le résultat LLM par mail</span>
<Switch
checked={draft.post_actions.llm_then_email}
onCheckedChange={(v) => patchPost({ llm_then_email: v })}
/>
</label>
<label className="flex items-center justify-between gap-4">
<span className="text-sm">Enregistrer le résultat LLM dans Drive</span>
<Switch
checked={draft.post_actions.llm_then_drive}
onCheckedChange={(v) => patchPost({ llm_then_drive: v })}
/>
</label>
</div>
) : null}
</div>
) : null}
</OrgSettingsSection>
)
}

View File

@ -2,12 +2,22 @@
import { useEffect, useLayoutEffect, type ReactNode } from "react"
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
import { AgendaOrgPolicySync } from "@/components/agenda/agenda-org-policy-sync"
import { AgendaQuickSettingsRoot } from "@/components/agenda/agenda-quick-settings-panel"
import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync"
import { AgendaRouteRootProvider } from "@/lib/agenda/agenda-route-context"
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 }) {
export function AgendaAppShell({
children,
routeRoot,
}: {
children: ReactNode
routeRoot?: string
}) {
const isMobile = useIsMobile()
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
@ -21,6 +31,7 @@ export function AgendaAppShell({ children }: { children: ReactNode }) {
}, [isMobile, setSidebarCollapsed])
return (
<AgendaRouteRootProvider routeRoot={routeRoot}>
<SuiteThemeShell>
<TooltipProvider delayDuration={400}>
<div
@ -37,8 +48,12 @@ export function AgendaAppShell({ children }: { children: ReactNode }) {
)}
{children}
<AiChatPanel />
<ComposeIdentitiesSync />
<AgendaOrgPolicySync />
<AgendaQuickSettingsRoot />
</div>
</TooltipProvider>
</SuiteThemeShell>
</AgendaRouteRootProvider>
)
}

View File

@ -25,11 +25,14 @@ export function AgendaCalendarDialog({
open,
onOpenChange,
calendar,
onAddExternalICal,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** Agenda à modifier — absent en création. */
calendar?: AgendaCalendar | null
/** Création uniquement — ouvre la modale iCal externe. */
onAddExternalICal?: () => void
}) {
const [name, setName] = useState("")
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
@ -66,7 +69,7 @@ export function AgendaCalendarDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{calendar ? "Modifier l'agenda" : "Nouvel agenda"}
@ -107,13 +110,24 @@ export function AgendaCalendarDialog({
</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 className={!calendar && onAddExternalICal ? "sm:justify-between" : undefined}>
{!calendar && onAddExternalICal ? (
<button
type="button"
className="text-xs text-[#1a73e8] hover:underline"
onClick={onAddExternalICal}
>
Ajouter un agenda externe iCal
</button>
) : null}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={() => void submit()} disabled={!name.trim() || pending}>
{calendar ? "Enregistrer" : "Créer"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -0,0 +1,629 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { Link2, Pencil, Plus, Trash2 } from "lucide-react"
import { toast } from "sonner"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
import { isCalendarEnabledForAccount, isReservedAgendaViewName } from "@/lib/agenda/agenda-calendar-visibility"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendarView, AgendaExternalCalendar } from "@/lib/agenda/agenda-settings-types"
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
function CalendarCheckboxRow({
calendarId,
label,
color,
checked,
onToggle,
}: {
calendarId: string
label: string
color: string
checked: boolean
onToggle: () => void
}) {
return (
<label className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover">
<Checkbox checked={checked} onCheckedChange={onToggle} />
<span
aria-hidden
className="size-3.5 shrink-0 rounded-[4px]"
style={{ backgroundColor: color }}
/>
<span className="min-w-0 truncate text-sm text-foreground/85">{label}</span>
<span className="sr-only">{calendarId}</span>
</label>
)
}
export function ExternalCalendarDialog({
open,
onOpenChange,
calendar,
accounts,
}: {
open: boolean
onOpenChange: (open: boolean) => void
calendar: AgendaExternalCalendar | null
accounts: { id: string; name: string; email: string }[]
}) {
const addExternalCalendar = useAgendaSettingsStore((s) => s.addExternalCalendar)
const updateExternalCalendar = useAgendaSettingsStore((s) => s.updateExternalCalendar)
const [name, setName] = useState("")
const [url, setUrl] = useState("")
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
const [accountId, setAccountId] = useState<string>("all")
useEffect(() => {
if (!open) return
setName(calendar?.display_name ?? "")
setUrl(calendar?.url ?? "")
setColor(calendar?.color ?? AGENDA_COLOR_PALETTE[0].value)
setAccountId(calendar?.account_id ?? "all")
}, [open, calendar])
const submit = () => {
const displayName = name.trim()
const feedUrl = url.trim()
if (!displayName || !feedUrl) return
try {
const parsed = new URL(feedUrl)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
toast.error("L'URL doit commencer par http:// ou https://")
return
}
} catch {
toast.error("URL iCal invalide")
return
}
const payload = {
display_name: displayName,
url: feedUrl,
color,
account_id: accountId === "all" ? null : accountId,
}
if (calendar) {
updateExternalCalendar(calendar.id, payload)
toast.success("Calendrier externe mis à jour")
} else {
addExternalCalendar(payload)
toast.success("Calendrier externe ajouté")
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>
{calendar ? "Modifier le calendrier externe" : "Ajouter un calendrier iCal"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor="ext-cal-name">Nom</Label>
<Input
id="ext-cal-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Calendrier externe"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ext-cal-url">Lien iCal</Label>
<Input
id="ext-cal-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://…/calendar.ics"
/>
</div>
<div className="space-y-2">
<Label>Compte mail</Label>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les comptes</SelectItem>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
{AGENDA_COLOR_PALETTE.map((entry) => (
<button
key={entry.value}
type="button"
title={entry.label}
aria-label={entry.label}
onClick={() => setColor(entry.value)}
className={cn(
"size-7 rounded-full transition-transform hover:scale-110",
color === entry.value &&
"ring-2 ring-foreground/70 ring-offset-2 ring-offset-background",
)}
style={{ backgroundColor: entry.value }}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={submit} disabled={!name.trim() || !url.trim()}>
{calendar ? "Enregistrer" : "Ajouter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function CalendarViewDialog({
open,
onOpenChange,
view,
calendarOptions,
labelOptions,
}: {
open: boolean
onOpenChange: (open: boolean) => void
view: AgendaCalendarView | null
calendarOptions: { id: string; label: string; color: string }[]
labelOptions: { id: string; label: string }[]
}) {
const addCalendarView = useAgendaSettingsStore((s) => s.addCalendarView)
const updateCalendarView = useAgendaSettingsStore((s) => s.updateCalendarView)
const [name, setName] = useState("")
const [calendarIds, setCalendarIds] = useState<string[]>([])
const [labelIds, setLabelIds] = useState<string[]>([])
useEffect(() => {
if (!open) return
setName(view?.name ?? "")
setCalendarIds(view?.calendar_ids ?? [])
setLabelIds(view?.label_ids ?? [])
}, [open, view])
const toggleCalendar = (calendarId: string) => {
setCalendarIds((current) =>
current.includes(calendarId)
? current.filter((id) => id !== calendarId)
: [...current, calendarId],
)
}
const toggleLabel = (labelId: string) => {
setLabelIds((current) =>
current.includes(labelId)
? current.filter((id) => id !== labelId)
: [...current, labelId],
)
}
const submit = () => {
const trimmed = name.trim()
if (!trimmed) return
if (isReservedAgendaViewName(trimmed)) {
toast.error('« Tous les agendas » est réservé à l\'affichage sans vue.')
return
}
if (calendarIds.length === 0) {
toast.error("Sélectionnez au moins un agenda")
return
}
const payload = {
name: trimmed,
calendar_ids: calendarIds,
label_ids: labelIds,
}
if (view) {
updateCalendarView(view.id, payload)
toast.success("Vue mise à jour")
} else {
addCalendarView(payload)
toast.success("Vue créée")
}
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{view ? "Modifier la vue" : "Nouvelle vue d'agendas"}</DialogTitle>
</DialogHeader>
<div className="flex max-h-[min(70vh,520px)] flex-col gap-4 overflow-y-auto pr-1">
<div className="space-y-2">
<Label htmlFor="view-name">Nom de la vue</Label>
<Input
id="view-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Travail, Perso…"
/>
</div>
<div className="space-y-2">
<Label>Agendas inclus</Label>
<div className="max-h-44 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1">
{calendarOptions.length === 0 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">
Aucun agenda disponible.
</p>
) : (
calendarOptions.map((calendar) => (
<CalendarCheckboxRow
key={calendar.id}
calendarId={calendar.id}
label={calendar.label}
color={calendar.color}
checked={calendarIds.includes(calendar.id)}
onToggle={() => toggleCalendar(calendar.id)}
/>
))
)}
</div>
</div>
<div className="space-y-2">
<Label>Libellés associés</Label>
<p className="text-[11px] text-muted-foreground">
Pour filtrer les invitations importées depuis le mail (optionnel).
</p>
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-lg border border-border p-1">
{labelOptions.length === 0 ? (
<p className="px-2 py-3 text-xs text-muted-foreground">Aucun libellé.</p>
) : (
labelOptions.map((label) => (
<label
key={label.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-mail-nav-hover"
>
<Checkbox
checked={labelIds.includes(label.id)}
onCheckedChange={() => toggleLabel(label.id)}
/>
<span className="truncate text-sm">{label.label}</span>
</label>
))
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={submit} disabled={!name.trim()}>
{view ? "Enregistrer" : "Créer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function AgendaCalendarsSettingsFields({
variant = "panel",
}: {
variant?: "panel" | "page"
}) {
const { data: accounts = [] } = useMailAccounts()
const { data: labels = [] } = useLabels()
const { calendars, externalCalendars } = useMergedAgendaCalendars()
const accountEnabledCalendarIds = useAgendaSettingsStore(
(s) => s.accountEnabledCalendarIds,
)
const toggleAccountCalendar = useAgendaSettingsStore((s) => s.toggleAccountCalendar)
const removeExternalCalendar = useAgendaSettingsStore((s) => s.removeExternalCalendar)
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
const defaultCalendarViewId = useAgendaSettingsStore((s) => s.defaultCalendarViewId)
const setDefaultCalendarViewId = useAgendaSettingsStore((s) => s.setDefaultCalendarViewId)
const removeCalendarView = useAgendaSettingsStore((s) => s.removeCalendarView)
const [externalDialogOpen, setExternalDialogOpen] = useState(false)
const [editingExternal, setEditingExternal] = useState<AgendaExternalCalendar | null>(
null,
)
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const [editingView, setEditingView] = useState<AgendaCalendarView | null>(null)
const allCalendarIds = useMemo(
() => calendars.map((calendar) => calendar.id),
[calendars],
)
const calendarOptions = useMemo(
() =>
calendars.map((calendar) => ({
id: calendar.id,
label: calendar.display_name,
color: calendarColor(calendar),
})),
[calendars],
)
const labelOptions = useMemo(
() => labels.map((label) => ({ id: label.id, label: label.name })),
[labels],
)
const isPage = variant === "page"
const sectionShell = (position: "first" | "rest") =>
cn(
"space-y-3",
isPage
? MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS
: cn("px-4 py-3", position === "rest" && "border-t border-border"),
)
return (
<>
<section className={sectionShell("first")}>
<div>
<h2 className="text-sm font-medium text-foreground">Agendas par compte mail</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Choisissez les agendas visibles pour chaque compte Ultimail.
</p>
</div>
{accounts.length === 0 ? (
<p className="text-xs text-muted-foreground">
Aucun compte mail connecté.{" "}
<a href="/mail/settings/accounts" className="text-[#1a73e8] hover:underline">
Ajouter un compte
</a>
</p>
) : (
<div className="divide-y divide-border overflow-hidden rounded-lg border border-border">
{accounts.map((account) => (
<div key={account.id} className="p-3">
<div className="mb-2 flex items-center gap-2">
<AccountAvatar account={account} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium">{account.name}</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
<div className="space-y-0.5">
{calendars.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucun agenda.</p>
) : (
calendars.map((calendar) => (
<CalendarCheckboxRow
key={`${account.id}-${calendar.id}`}
calendarId={calendar.id}
label={calendar.display_name}
color={calendarColor(calendar)}
checked={isCalendarEnabledForAccount(
calendar.id,
account.id,
accountEnabledCalendarIds,
)}
onToggle={() =>
toggleAccountCalendar(account.id, calendar.id, allCalendarIds)
}
/>
))
)}
</div>
</div>
))}
</div>
)}
</section>
<section className={sectionShell("rest")}>
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-medium text-foreground">Calendriers externes</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Abonnez un flux iCal (Google, Outlook, etc.).
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full"
onClick={() => {
setEditingExternal(null)
setExternalDialogOpen(true)
}}
>
<Plus className="size-3.5" />
Ajouter
</Button>
</div>
{externalCalendars.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucun calendrier externe.</p>
) : (
<ul className="space-y-2">
{externalCalendars.map((calendar) => (
<li
key={calendar.id}
className="flex items-center gap-2 rounded-lg border border-border px-2 py-2"
>
<Link2 className="size-4 shrink-0 text-muted-foreground" />
<span
aria-hidden
className="size-3 shrink-0 rounded-full"
style={{ backgroundColor: calendar.color }}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{calendar.display_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{calendar.url}</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={`Modifier ${calendar.display_name}`}
onClick={() => {
setEditingExternal(calendar)
setExternalDialogOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive"
aria-label={`Supprimer ${calendar.display_name}`}
onClick={() => removeExternalCalendar(calendar.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
<section className={sectionShell("rest")}>
<div className="flex items-start justify-between gap-2">
<div>
<h2 className="text-sm font-medium text-foreground">Vues d&apos;agendas</h2>
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">
Regroupez des agendas et libellés, puis ouvrez une vue depuis la barre latérale.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 gap-1 rounded-full"
onClick={() => {
setEditingView(null)
setViewDialogOpen(true)
}}
>
<Plus className="size-3.5" />
Nouvelle vue
</Button>
</div>
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(9.75rem,1.18fr)] items-center gap-2 py-1">
<Label className="text-xs font-normal text-muted-foreground">Vue par défaut</Label>
<Select
value={defaultCalendarViewId ?? "none"}
onValueChange={(value) =>
setDefaultCalendarViewId(value === "none" ? null : value)
}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Aucune vue" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune vue</SelectItem>
{calendarViews.map((view) => (
<SelectItem key={view.id} value={view.id}>
{view.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{calendarViews.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune vue enregistrée.</p>
) : (
<ul className="space-y-2">
{calendarViews.map((view) => (
<li
key={view.id}
className="flex items-center gap-2 rounded-lg border border-border px-2 py-2"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{view.name}</p>
<p className="text-[11px] text-muted-foreground">
{view.calendar_ids.length} agenda
{view.calendar_ids.length > 1 ? "s" : ""}
{view.label_ids.length > 0
? ` · ${view.label_ids.length} libellé${view.label_ids.length > 1 ? "s" : ""}`
: ""}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
aria-label={`Modifier ${view.name}`}
onClick={() => {
setEditingView(view)
setViewDialogOpen(true)
}}
>
<Pencil className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-destructive"
aria-label={`Supprimer ${view.name}`}
onClick={() => removeCalendarView(view.id)}
>
<Trash2 className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
<ExternalCalendarDialog
open={externalDialogOpen}
onOpenChange={setExternalDialogOpen}
calendar={editingExternal}
accounts={accounts}
/>
<CalendarViewDialog
open={viewDialogOpen}
onOpenChange={setViewDialogOpen}
view={editingView}
calendarOptions={calendarOptions}
labelOptions={labelOptions}
/>
</>
)
}

View File

@ -10,18 +10,24 @@ import { cn } from "@/lib/utils"
export function AgendaEventChip({
event,
filled,
pending,
onClick,
className,
}: {
event: AgendaEvent
/** Fond plein (journée entière / multi-jours) vs point coloré + heure. */
filled?: boolean
/** Brouillon en cours d'édition — style fantôme. */
pending?: boolean
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
className?: string
}) {
const solid = filled ?? event.allDay
const style: CSSProperties = solid
? { backgroundColor: event.color, color: readableTextColor(event.color) }
? {
backgroundColor: pending ? `${event.color}99` : event.color,
color: readableTextColor(event.color),
}
: {}
const declined = isDeclinedForAll(event)
@ -29,9 +35,11 @@ export function AgendaEventChip({
<button
type="button"
onClick={onClick}
disabled={pending}
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",
!solid && !pending && "hover:bg-mail-nav-hover",
pending && "pointer-events-none ring-2 ring-dashed ring-primary/60",
declined && "opacity-50 line-through",
className,
)}

View File

@ -1,17 +1,16 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { addDays, addHours, format, parse } from "date-fns"
import { useEffect, useMemo, useRef, useState } from "react"
import { addDays, addHours } from "date-fns"
import { toast } from "sonner"
import { Icon } from "@iconify/react"
import {
AlignLeft,
CalendarDays,
Clock,
MapPin,
Repeat,
Trash2,
Users,
Video,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
@ -29,11 +28,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { AgendaEventScheduleFields, agendaScheduleFieldCount } from "@/components/agenda/agenda-event-schedule-fields"
import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker"
import { AgendaVideoToggle } from "@/components/agenda/agenda-video-toggle"
import {
createAgendaEventWithVideo,
saveAgendaEventEdit,
} from "@/lib/agenda/agenda-save-with-video"
import {
draftToApiEvent,
useCreateAgendaEvent,
useCreateAgendaMeetLink,
useDeleteAgendaEvent,
@ -41,16 +44,14 @@ import {
} 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 { describeRRule, recurrenceOptionsFor } from "@/lib/agenda/agenda-recurrence"
import type {
AgendaCalendar,
AgendaEvent,
AgendaEventAttendee,
AgendaEventDraft,
} from "@/lib/agenda/agenda-types"
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import { cn } from "@/lib/utils"
export interface AgendaEventDialogState {
@ -60,47 +61,38 @@ export interface AgendaEventDialogState {
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,
onDraftChange,
}: {
state: AgendaEventDialogState | null
onClose: () => void
calendars: AgendaCalendar[]
userEmail?: string
onDraftChange?: (draft: AgendaEventDraft) => void
}) {
const createMutation = useCreateAgendaEvent()
const updateMutation = useUpdateAgendaEvent()
const deleteMutation = useDeleteAgendaEvent()
const meetLinkMutation = useCreateAgendaMeetLink()
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
const [title, setTitle] = useState("")
const [allDay, setAllDay] = useState(false)
const [startDate, setStartDate] = useState("")
const [startTime, setStartTime] = useState("09:00")
const [endDate, setEndDate] = useState("")
const [endTime, setEndTime] = useState("10:00")
const [start, setStart] = useState(() => new Date())
const [end, setEnd] = useState(() => new Date())
const [calendarId, setCalendarId] = useState("")
const [rrule, setRRule] = useState("")
const [color, setColor] = useState("")
const [location, setLocation] = useState("")
const [description, setDescription] = useState("")
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
const [includeVideo, setIncludeVideo] = useState(false)
const [meetUrl, setMeetUrl] = useState("")
const titleRef = useRef<HTMLInputElement>(null)
const open = state !== null
const isEdit = state?.mode === "edit"
@ -110,48 +102,50 @@ export function AgendaEventDialog({
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))
setStart(d.start)
setEnd(d.allDay ? addDays(d.end, -1) : d.end)
setCalendarId(d.calendarId || calendars[0]?.id || "")
setRRule(d.rrule ?? "")
setColor(d.color ?? "")
setLocation(d.location ?? "")
setDescription(d.description ?? "")
setAttendees(d.attendees ?? [])
setIncludeVideo(Boolean(d.includeVideo || state.event?.meetUrl))
setMeetUrl(state.event?.meetUrl ?? "")
if (state.mode === "create") {
const timer = window.setTimeout(() => titleRef.current?.focus(), 0)
return () => window.clearTimeout(timer)
}
}, [state, calendars])
useEffect(() => {
if (allDay) setIncludeVideo(false)
}, [allDay])
const recurrenceOptions = useMemo(() => {
const 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])
}, [start, rrule])
const pending =
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending ||
meetLinkMutation.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)
}
if (!calendarId) return null
let eventStart = start
let eventEnd = allDay ? addDays(end, 1) : end
if (allDay && eventEnd <= eventStart) eventEnd = addDays(eventStart, 1)
if (!allDay && eventEnd <= eventStart) eventEnd = addHours(eventStart, 1)
return {
title,
start,
end,
start: eventStart,
end: eventEnd,
allDay,
calendarId,
description,
@ -159,25 +153,60 @@ export function AgendaEventDialog({
attendees,
rrule,
color: color || undefined,
includeVideo: includeVideo && !allDay,
}
}
useEffect(() => {
if (!open || isEdit || !onDraftChange) return
const draft = buildDraft()
if (draft) onDraftChange(draft)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
open,
isEdit,
title,
allDay,
start,
end,
calendarId,
color,
includeVideo,
onDraftChange,
])
const calendar = calendars.find((c) => c.id === calendarId) ?? calendars[0]
const submit = async () => {
const draft = buildDraft()
if (!draft || !calendarId) return
if (!draft || !calendar) return
try {
if (isEdit && state?.event) {
await updateMutation.mutateAsync({
await saveAgendaEventEdit({
draft,
path: state.event.path,
etag: state.event.etag,
event: draftToApiEvent(draft, state.event.master),
master: state.event.master,
includeVideo: includeVideo && !allDay,
meetUrl,
videoProvider: defaultVideoProvider,
updateMutation,
meetLinkMutation,
})
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éé")
await createAgendaEventWithVideo({
draft,
calendar,
userEmail,
includeVideo: includeVideo && !allDay,
videoProvider: defaultVideoProvider,
createMutation,
meetLinkMutation,
})
toast.success(
includeVideo && !allDay ? "Événement et visio créés" : "Événement créé",
)
}
onClose()
} catch {
@ -196,22 +225,17 @@ export function AgendaEventDialog({
}
}
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 handleVideoChange = (enabled: boolean) => {
setIncludeVideo(enabled)
if (!enabled) setMeetUrl("")
}
const meetUrl = state?.event?.meetUrl
const scheduleTabCount = agendaScheduleFieldCount({
allDay,
showAllDayToggle: true,
compact: false,
})
const tab = (offset: number) => 2 + scheduleTabCount + offset
return (
<Dialog
@ -220,87 +244,58 @@ export function AgendaEventDialog({
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">
<DialogContent
className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
<DialogHeader className="sr-only">
<DialogTitle>
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-5 pb-4 pt-4">
<Input
ref={titleRef}
value={title}
tabIndex={1}
autoFocus={!isEdit}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ajouter un titre"
className="h-11 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"
className="h-11 rounded-none border-0 border-b-2 border-border/60 !bg-transparent px-1 !text-xl shadow-none focus-visible:border-primary focus-visible:ring-0"
/>
{/* 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>
<AgendaEventScheduleFields
start={start}
end={end}
allDay={allDay}
stepMinutes={buttonSnapMinutes}
tabIndexBase={2}
showRowLabels
showAllDayToggle
onAllDayChange={setAllDay}
onChange={(nextStart, nextEnd) => {
setStart(nextStart)
setEnd(allDay ? nextEnd : nextEnd)
}}
/>
<div className="flex items-center gap-3">
<Repeat className="size-5 shrink-0 text-muted-foreground" />
<Select value={rrule || "none"} onValueChange={(v) => setRRule(v === "none" ? "" : v)}>
<SelectTrigger className="h-9 w-fit min-w-52 border-0 bg-muted/60 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{recurrenceOptions.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "none"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Repeat className="size-5 shrink-0 text-muted-foreground" />
<Select value={rrule || "none"} onValueChange={(v) => setRRule(v === "none" ? "" : v)}>
<SelectTrigger tabIndex={tab(0)} className="h-9 w-fit min-w-52 border-0 bg-muted/60 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
{recurrenceOptions.map((o) => (
<SelectItem key={o.value || "none"} value={o.value || "none"}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 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">
@ -308,70 +303,51 @@ export function AgendaEventDialog({
attendees={attendees}
onChange={setAttendees}
organizerEmail={userEmail}
tabIndex={tab(1)}
/>
</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>
{!allDay && defaultVideoProvider !== "none" ? (
<div className="flex items-start gap-3">
<Video className="mt-2 size-5 shrink-0 text-muted-foreground" aria-hidden />
<AgendaVideoToggle
provider={defaultVideoProvider}
enabled={includeVideo}
meetUrl={includeVideo ? meetUrl : undefined}
onEnabledChange={handleVideoChange}
pending={pending}
tabIndex={tab(2)}
/>
</div>
) : null}
{/* Lieu */}
<div className="flex items-center gap-3">
<MapPin className="size-5 shrink-0 text-muted-foreground" />
<Input
value={location}
tabIndex={tab(3)}
onChange={(e) => setLocation(e.target.value)}
placeholder="Ajouter un lieu"
className="h-9 flex-1"
/>
</div>
{/* Description */}
<div className="flex items-start gap-3">
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
<Textarea
value={description}
tabIndex={tab(4)}
onChange={(e) => setDescription(e.target.value)}
placeholder="Ajouter une description"
className="min-h-20 flex-1"
/>
</div>
{/* 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">
<Select value={calendarId} onValueChange={setCalendarId} disabled={isEdit}>
<SelectTrigger tabIndex={tab(5)} className="h-9 w-fit min-w-40 border-0 bg-muted/60 shadow-none">
<SelectValue placeholder="Agenda" />
</SelectTrigger>
<SelectContent>
@ -389,10 +365,11 @@ export function AgendaEventDialog({
</SelectContent>
</Select>
<div className="flex items-center gap-1.5">
{AGENDA_COLOR_PALETTE.slice(0, 8).map((c) => (
{AGENDA_COLOR_PALETTE.slice(0, 8).map((c, i) => (
<button
key={c.value}
type="button"
tabIndex={tab(6 + i)}
title={c.label}
aria-label={`Couleur ${c.label}`}
onClick={() => setColor(color === c.value ? "" : c.value)}
@ -412,6 +389,7 @@ export function AgendaEventDialog({
{isEdit && (
<Button
variant="ghost"
tabIndex={tab(14)}
className="mr-auto gap-2 text-destructive hover:text-destructive"
disabled={pending}
onClick={() => void remove()}
@ -419,12 +397,13 @@ export function AgendaEventDialog({
<Trash2 className="size-4" /> Supprimer
</Button>
)}
<Button variant="ghost" onClick={onClose} disabled={pending}>
<Button variant="ghost" tabIndex={tab(15)} onClick={onClose} disabled={pending}>
Annuler
</Button>
<Button
tabIndex={tab(16)}
onClick={() => void submit()}
disabled={pending || !calendarId || !startDate || !endDate}
disabled={pending || !calendarId}
className="rounded-full px-6"
>
Enregistrer

View File

@ -1,8 +1,8 @@
"use client"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner"
import { Icon } from "@iconify/react"
import {
Check,
CircleHelp,
@ -15,6 +15,7 @@ import {
X,
} from "lucide-react"
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
import { AgendaVideoProviderIcon } from "@/components/agenda/agenda-video-provider-icon"
import { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@ -23,6 +24,7 @@ import {
useRespondAgendaInvitation,
} from "@/lib/api/hooks/use-calendar-mutations"
import { formatEventRange } from "@/lib/agenda/agenda-date"
import { isUltiMeetUrl, meetJoinPath } from "@/lib/meet/meet-url"
import { describeRRule } from "@/lib/agenda/agenda-recurrence"
import { stashPendingCompose } from "@/lib/agenda/agenda-mail-compose"
import type { AgendaCalendar, AgendaEvent } from "@/lib/agenda/agenda-types"
@ -100,75 +102,15 @@ export function AgendaEventPopover({
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">
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-4 pb-5">
{/* Titre + actions */}
<div className="flex items-start gap-2">
<span
aria-hidden
className="mt-1.5 size-4 shrink-0 rounded-[5px]"
style={{ backgroundColor: event.color }}
/>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<h2 className="text-[1.3rem] leading-snug font-normal break-words text-foreground">
{event.title}
</h2>
@ -176,6 +118,64 @@ export function AgendaEventPopover({
{formatEventRange(event.start, event.end, event.allDay)}
</p>
</div>
<div className="flex shrink-0 items-center gap-0.5">
<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>
{event.recurring && (
@ -186,14 +186,16 @@ export function AgendaEventPopover({
{event.meetUrl && (
<Row
icon={
<Icon icon="simple-icons:jitsi" className="size-4.5" aria-hidden />
}
icon={<AgendaVideoProviderIcon provider="ultimeet" className="size-4.5" />}
>
<Button asChild className="h-9 rounded-full">
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
Rejoindre la visio
</a>
{isUltiMeetUrl(event.meetUrl) ? (
<Link href={meetJoinPath(event.meetUrl)}>Rejoindre la visio</Link>
) : (
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
Rejoindre la visio
</a>
)}
</Button>
</Row>
)}

View File

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

View File

@ -22,10 +22,12 @@ export function AgendaGuestPicker({
attendees,
onChange,
organizerEmail,
tabIndex,
}: {
attendees: AgendaEventAttendee[]
onChange: (attendees: AgendaEventAttendee[]) => void
organizerEmail?: string
tabIndex?: number
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
@ -80,6 +82,7 @@ export function AgendaGuestPicker({
<Input
value={query}
placeholder="Ajouter des invités"
tabIndex={tabIndex}
onChange={(e) => {
setQuery(e.target.value)
setActiveIndex(0)

View File

@ -1,6 +1,7 @@
"use client"
import Link from "next/link"
import { AgendaMark } from "@/components/suite/agenda-mark"
import { ChevronDown, ChevronLeft, ChevronRight, Menu } from "lucide-react"
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
import { Button } from "@/components/ui/button"
@ -12,14 +13,20 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { viewTitle } from "@/lib/agenda/agenda-date"
import { viewTitle, type WeekStartsOn } from "@/lib/agenda/agenda-date"
import type { AgendaWeekStart } from "@/lib/agenda/agenda-settings-types"
import {
AGENDA_VIEW_LABELS,
AGENDA_VIEWS,
type AgendaView,
} from "@/lib/agenda/agenda-url"
import { useAgendaUIStore } from "@/lib/agenda/agenda-store"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import { useIsMobile } from "@/hooks/use-mobile"
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
import { cn } from "@/lib/utils"
const VIEW_SHORTCUTS: Record<AgendaView, string> = {
@ -31,12 +38,16 @@ const VIEW_SHORTCUTS: Record<AgendaView, string> = {
export function AgendaHeader({
view,
date,
weekStart = "auto",
weekStartsOn,
onToday,
onStep,
onViewChange,
}: {
view: AgendaView
date: Date
weekStart?: AgendaWeekStart
weekStartsOn?: WeekStartsOn
onToday: () => void
onStep: (delta: 1 | -1) => void
onViewChange: (view: AgendaView) => void
@ -44,9 +55,10 @@ export function AgendaHeader({
const isMobile = useIsMobile()
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
const openQuickSettings = useAgendaSettingsStore((s) => s.setQuickSettingsOpen)
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">
<header className="flex h-16 shrink-0 items-center gap-2 bg-app-canvas px-2 sm:px-4">
<Button
variant="ghost"
size="icon"
@ -57,11 +69,12 @@ export function AgendaHeader({
<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
href="/agenda"
className={cn("mr-1 hidden sm:flex", SUITE_APP_LOGO_LOCKUP_CLASS)}
>
<AgendaMark className={SUITE_APP_LOGO_MARK_CLASS} />
<span className={cn("hidden md:block", SUITE_APP_LOGO_TEXT_CLASS)}>Agenda</span>
</Link>
<Button
@ -108,7 +121,7 @@ export function AgendaHeader({
"min-w-0 truncate text-[1.05rem] font-normal text-foreground/90 sm:text-[1.35rem]",
)}
>
{viewTitle(view, date)}
{viewTitle(view, date, weekStart, weekStartsOn)}
</h1>
<div className="ml-auto flex items-center gap-1">
@ -133,7 +146,10 @@ export function AgendaHeader({
))}
</DropdownMenuContent>
</DropdownMenu>
<HeaderAccountActions className="pl-1" settingsHref="/mail/settings" />
<HeaderAccountActions
className="pl-1"
onSettingsClick={() => openQuickSettings(true)}
/>
</div>
</header>
)

View File

@ -12,26 +12,30 @@ import {
} from "date-fns"
import { fr } from "date-fns/locale"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { WEEK_OPTS } from "@/lib/agenda/agenda-date"
import { getWeekOptionsFor, weekdayLabelsFor } from "@/lib/agenda/agenda-date"
import type { AgendaWeekStart } from "@/lib/agenda/agenda-settings-types"
import { useResolvedWeekStartsOn } from "@/lib/agenda/use-resolved-week-start"
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,
weekStart = "auto",
}: {
selected: Date
onSelect: (date: Date) => void
weekStart?: AgendaWeekStart
}) {
const weekStartsOn = useResolvedWeekStartsOn(weekStart)
const [cursor, setCursor] = useState(() => startOfMonth(selected))
const weekLabels = weekdayLabelsFor(weekStartsOn)
useEffect(() => {
setCursor(startOfMonth(selected))
}, [selected])
const gridStart = startOfWeek(cursor, WEEK_OPTS)
const gridStart = startOfWeek(cursor, getWeekOptionsFor(weekStartsOn))
const today = new Date()
const cells: Date[] = []
for (let i = 0; i < 42; i++) cells.push(addDays(gridStart, i))
@ -66,7 +70,7 @@ export function AgendaMiniMonth({
</div>
</div>
<div className="grid grid-cols-7 text-center">
{WEEKDAYS.map((d, i) => (
{weekLabels.map((d, i) => (
<span
key={`${d}-${i}`}
className="py-1 text-[0.65rem] font-medium text-muted-foreground"

View File

@ -0,0 +1,28 @@
"use client"
import { useEffect, useRef } from "react"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { MailThemeMode } from "@/lib/mail-settings/types"
/** Applique le thème organisationnel imposé sur le store mail partagé. */
export function AgendaOrgPolicySync() {
const { data: user } = useCurrentUser()
const appliedRef = useRef<MailThemeMode | null>(null)
const enforceOrgTheme = user?.org_agenda?.enforce_org_theme ?? false
const orgThemeMode = user?.org_agenda?.default_theme_mode
useEffect(() => {
if (!enforceOrgTheme || !orgThemeMode) return
if (appliedRef.current === orgThemeMode) return
const current = useMailSettingsStore.getState().themeMode
if (current !== orgThemeMode) {
useMailSettingsStore.getState().setThemeMode(orgThemeMode)
}
appliedRef.current = orgThemeMode
}, [enforceOrgTheme, orgThemeMode])
return null
}

View File

@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useParams, useRouter, useSearchParams } from "next/navigation"
import { addDays, addHours, addMonths, startOfHour } from "date-fns"
import { addDays, addHours, addMinutes, 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"
@ -12,10 +12,17 @@ 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 { useAgendaEvents } from "@/lib/api/hooks/use-calendar-queries"
import { isExternalCalendarId } from "@/lib/agenda/agenda-calendar-visibility"
import { parseICSDate, viewDays, viewRange } from "@/lib/agenda/agenda-date"
import { useExternalAgendaEvents } from "@/lib/agenda/use-external-agenda-events"
import { useResolvedWeekStartsOn } from "@/lib/agenda/use-resolved-week-start"
import { draftToPendingEvent } from "@/lib/agenda/agenda-pending-event"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import { useAgendaEventMove } from "@/lib/agenda/use-agenda-event-move"
import { useVisibleAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
import { useAgendaRouteRoot } from "@/lib/agenda/agenda-route-context"
import {
buildAgendaPath,
parseAgendaSegments,
@ -23,11 +30,14 @@ import {
} from "@/lib/agenda/agenda-url"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useIsMobile } from "@/hooks/use-mobile"
import { AGENDA_CALENDAR_CARD_CLASS, AGENDA_MAIN_INSET_X } from "@/lib/agenda/agenda-chrome-classes"
import { cn } from "@/lib/utils"
export function AgendaPage() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const agendaRouteRoot = useAgendaRouteRoot()
const isMobile = useIsMobile()
const identity = useChromeIdentity()
@ -37,15 +47,21 @@ export function AgendaPage() {
)
const lastView = useAgendaSettingsStore((s) => s.lastView)
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const externalCalendars = useAgendaSettingsStore((s) => s.externalCalendars)
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
const weekStartsOn = useResolvedWeekStartsOn(weekStart)
const defaultQuickDurationMinutes = useAgendaSettingsStore(
(s) => s.defaultQuickDurationMinutes,
)
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)
router.replace(
buildAgendaPath(view, date, agendaRouteRoot) + window.location.search
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [route.view])
@ -56,9 +72,9 @@ export function AgendaPage() {
const navigate = useCallback(
(nextView: AgendaView, nextDate: Date) => {
router.push(buildAgendaPath(nextView, nextDate))
router.push(buildAgendaPath(nextView, nextDate, agendaRouteRoot))
},
[router],
[router, agendaRouteRoot],
)
const step = useCallback(
@ -72,46 +88,96 @@ export function AgendaPage() {
[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)
const {
calendars,
visibleCalendars,
isLoading: calendarsLoading,
} = useVisibleAgendaCalendars()
const visibleApiCalendars = useMemo(
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[visibleCalendars],
)
const visibleExternalCalendars = useMemo(
() => externalCalendars.filter((calendar) =>
visibleCalendars.some((visible) => visible.id === calendar.id),
),
[externalCalendars, visibleCalendars],
)
const writableCalendars = useMemo(
() => calendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[calendars],
)
const writableVisibleCalendars = useMemo(
() => visibleCalendars.filter((calendar) => !isExternalCalendarId(calendar.id)),
[visibleCalendars],
)
const fetchRange = useMemo(
() => viewRange("month", date, weekStart, weekStartsOn),
[date, weekStart, weekStartsOn],
)
const { events: apiEvents } = useAgendaEvents(
visibleApiCalendars,
fetchRange.start,
fetchRange.end,
)
const { events: externalEvents } = useExternalAgendaEvents(
visibleExternalCalendars,
fetchRange.start,
fetchRange.end,
)
const events = useMemo(
() =>
[...apiEvents, ...externalEvents].sort(
(a, b) => a.start.getTime() - b.start.getTime(),
),
[apiEvents, externalEvents],
)
// 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)
/** Brouillon affiché sur la grille tant que la modale est ouverte. */
const [pendingDraft, setPendingDraft] = useState<AgendaEventDraft | null>(null)
const defaultCalendarId = visibleCalendars[0]?.id ?? calendars[0]?.id ?? ""
const defaultCalendarId =
writableVisibleCalendars[0]?.id ?? writableCalendars[0]?.id ?? ""
const userEmail = identity?.email
const clearPending = useCallback(() => setPendingDraft(null), [])
const closeOverlays = useCallback(() => {
setQuickCreate(null)
setPopover(null)
clearPending()
}, [clearPending])
const showPendingDraft = useCallback((draft: AgendaEventDraft) => {
setPendingDraft(draft)
}, [])
const openCreateDialog = useCallback(
(base?: Partial<AgendaEventDraft>) => {
closeOverlays()
(base?: Partial<AgendaEventDraft>, opts?: { keepPending?: boolean }) => {
setPopover(null)
setQuickCreate(null)
if (!opts?.keepPending) clearPending()
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,
},
})
const end = base?.end ?? addMinutes(start, defaultQuickDurationMinutes)
const draft: AgendaEventDraft = {
title: "",
start,
end,
allDay: false,
calendarId: defaultCalendarId,
...base,
}
showPendingDraft(draft)
setDialogState({ mode: "create", draft })
},
[closeOverlays, defaultCalendarId],
[clearPending, defaultCalendarId, defaultQuickDurationMinutes, showPendingDraft],
)
const openEditDialog = useCallback(
@ -139,7 +205,6 @@ export function AgendaPage() {
[closeOverlays],
)
// Interop : /agenda?new=1&guest=…&title=…
const handledNewParam = useRef(false)
useEffect(() => {
if (handledNewParam.current || !route.view) return
@ -157,7 +222,6 @@ export function AgendaPage() {
// 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
@ -197,24 +261,58 @@ export function AgendaPage() {
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
setQuickCreate(null)
clearPending()
setPopover({ event, anchor })
}, [])
}, [clearPending])
const handleCreateRange = useCallback(
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => {
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect, viaDrag: boolean) => {
setPopover(null)
setQuickCreate({ start, end, allDay, anchor })
const draft: AgendaEventDraft = {
title: "",
start,
end,
allDay,
calendarId: defaultCalendarId,
}
showPendingDraft(draft)
if (viaDrag) {
openCreateDialog(draft, { keepPending: true })
} else {
setDialogState(null)
setQuickCreate({ start, end, allDay, anchor })
}
},
[],
[defaultCalendarId, openCreateDialog, showPendingDraft],
)
const days = useMemo(() => viewDays(view, date), [view, date])
const { moveEvent } = useAgendaEventMove()
const handleEventMove = useCallback(
(event: AgendaEvent, targetStart: Date) => {
void moveEvent(event, targetStart)
},
[moveEvent],
)
const pendingEvent = useMemo(() => {
if (!pendingDraft) return null
return draftToPendingEvent(pendingDraft, visibleCalendars.length > 0 ? visibleCalendars : calendars)
}, [pendingDraft, visibleCalendars, calendars])
const days = useMemo(
() => viewDays(view, date, weekStart, weekStartsOn),
[view, date, weekStart, weekStartsOn],
)
return (
<>
<AgendaHeader
view={view}
date={date}
weekStart={weekStart}
weekStartsOn={weekStartsOn}
onToday={() => navigate(view, new Date())}
onStep={step}
onViewChange={(v) => navigate(v, date)}
@ -225,7 +323,8 @@ export function AgendaPage() {
onSelectDate={(d) => navigate(view, d)}
onCreateEvent={() => openCreateDialog()}
/>
<main className="flex min-w-0 flex-1 flex-col">
<main className={cn("flex min-h-0 min-w-0 flex-1 flex-col pb-1 max-sm:pb-0", AGENDA_MAIN_INSET_X)}>
<div className={AGENDA_CALENDAR_CARD_CLASS} data-agenda-calendar-card>
{calendarsLoading ? (
<div className="flex h-full flex-col gap-3 p-6">
<Skeleton className="h-8 w-64" />
@ -234,33 +333,42 @@ export function AgendaPage() {
) : view === "month" ? (
<AgendaViewMonth
date={date}
weekStart={weekStart}
weekStartsOn={weekStartsOn}
events={events}
onCreateAt={(day, anchor) =>
handleCreateRange(day, addDays(day, 1), true, anchor)
}
pendingEvent={pendingEvent}
onCreateRange={handleCreateRange}
onEventClick={handleEventClick}
onEventMove={handleEventMove}
onOpenDay={(day) => navigate("day", day)}
/>
) : (
<AgendaViewWeek
days={days}
events={events}
pendingEvent={pendingEvent}
onCreateRange={handleCreateRange}
onEventClick={handleEventClick}
onEventMove={handleEventMove}
onOpenDay={(day) => navigate("day", day)}
/>
)}
</div>
</main>
</div>
<AgendaQuickCreate
state={quickCreate}
calendars={visibleCalendars.length > 0 ? visibleCalendars : calendars}
calendars={writableVisibleCalendars.length > 0 ? writableVisibleCalendars : writableCalendars}
defaultCalendarId={defaultCalendarId}
userEmail={userEmail}
onClose={() => setQuickCreate(null)}
onClose={() => {
setQuickCreate(null)
clearPending()
}}
onMoreOptions={(draft) => {
setQuickCreate(null)
showPendingDraft(draft)
setDialogState({ mode: "create", draft })
}}
/>
@ -275,9 +383,13 @@ export function AgendaPage() {
<AgendaEventDialog
state={dialogState}
onClose={() => setDialogState(null)}
calendars={calendars}
onClose={() => {
setDialogState(null)
clearPending()
}}
calendars={writableCalendars}
userEmail={userEmail}
onDraftChange={showPendingDraft}
/>
</>
)

View File

@ -1,10 +1,11 @@
"use client"
import { useEffect, useState } from "react"
import { format } from "date-fns"
import { fr } from "date-fns/locale"
import { useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { X } from "lucide-react"
import { Users, Video, X } from "lucide-react"
import { AgendaEventScheduleFields } from "@/components/agenda/agenda-event-schedule-fields"
import { AgendaGuestPicker } from "@/components/agenda/agenda-guest-picker"
import { AgendaVideoToggle } from "@/components/agenda/agenda-video-toggle"
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@ -15,14 +16,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
draftToApiEvent,
useCreateAgendaEvent,
} from "@/lib/api/hooks/use-calendar-mutations"
import { formatEventTime } from "@/lib/agenda/agenda-date"
import { useCreateAgendaEvent, useCreateAgendaMeetLink } from "@/lib/api/hooks/use-calendar-mutations"
import { createAgendaEventWithVideo } from "@/lib/agenda/agenda-save-with-video"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import type {
AgendaCalendar,
AgendaEventAttendee,
AgendaEventDraft,
} from "@/lib/agenda/agenda-types"
@ -50,75 +50,153 @@ export function AgendaQuickCreate({
}) {
const [title, setTitle] = useState("")
const [calendarId, setCalendarId] = useState(defaultCalendarId)
const [start, setStart] = useState(() => new Date())
const [end, setEnd] = useState(() => new Date())
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
const [includeVideo, setIncludeVideo] = useState(false)
const titleRef = useRef<HTMLInputElement>(null)
const createMutation = useCreateAgendaEvent()
const meetLinkMutation = useCreateAgendaMeetLink()
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
useEffect(() => {
if (state) {
setTitle("")
setCalendarId(defaultCalendarId)
}
if (!state) return
setTitle("")
setCalendarId(defaultCalendarId)
setStart(state.start)
setEnd(state.end)
setAttendees([])
setIncludeVideo(false)
const timer = window.setTimeout(() => titleRef.current?.focus(), 0)
return () => window.clearTimeout(timer)
}, [state, defaultCalendarId])
if (!state) return null
const calendar = calendars.find((c) => c.id === calendarId) ?? calendars[0]
const draft: AgendaEventDraft = {
title,
start: state.start,
end: state.end,
start,
end,
allDay: state.allDay,
calendarId,
attendees,
includeVideo,
}
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 () => {
if (!calendarId || !calendar) return
try {
const apiEvent = draftToApiEvent(draft)
if (userEmail) apiEvent.organizer = userEmail
await createMutation.mutateAsync({ calendarId, event: apiEvent })
toast.success("Événement créé")
await createAgendaEventWithVideo({
draft,
calendar,
userEmail,
includeVideo: includeVideo && !state.allDay,
videoProvider: defaultVideoProvider,
createMutation,
meetLinkMutation,
})
toast.success(includeVideo && !state.allDay ? "Événement et visio créés" : "Événement créé")
onClose()
} catch {
toast.error("Impossible de créer l'événement")
}
}
const showVideo = !state.allDay && defaultVideoProvider !== "none"
const guestTab = state.allDay ? 3 : 5
const videoTab = state.allDay ? 4 : 6
const calendarTab = state.allDay ? (showVideo ? 5 : 4) : showVideo ? 7 : 6
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>
<AgendaFloatingCard anchor={state.anchor} onClose={onClose} width={420}>
<div className="flex flex-col gap-4 px-5 pb-5 pt-3">
<div className="flex items-start gap-1">
<Input
ref={titleRef}
value={title}
tabIndex={1}
autoFocus
placeholder="Ajouter un titre"
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save()
}}
className="h-11 min-w-0 flex-1 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"
/>
<Button
variant="ghost"
size="icon"
tabIndex={9}
className="size-8 shrink-0 rounded-full text-muted-foreground"
aria-label="Fermer"
onClick={onClose}
>
<X className="size-4" />
</Button>
</div>
<div className="flex flex-col gap-2">
{state.allDay ? (
<AgendaEventScheduleFields
compact
start={start}
end={end}
allDay
stepMinutes={buttonSnapMinutes}
tabIndexBase={2}
onChange={(nextStart, nextEnd) => {
setStart(nextStart)
setEnd(nextEnd)
}}
/>
) : (
<AgendaEventScheduleFields
compact
start={start}
end={end}
allDay={false}
stepMinutes={buttonSnapMinutes}
tabIndexBase={2}
onChange={(nextStart, nextEnd) => {
setStart(nextStart)
setEnd(nextEnd)
}}
/>
)}
</div>
<div className="flex items-start gap-2">
<Users className="mt-2 size-4 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<AgendaGuestPicker
attendees={attendees}
onChange={setAttendees}
organizerEmail={userEmail}
tabIndex={guestTab}
/>
</div>
</div>
{showVideo ? (
<div className="flex items-start gap-2">
<Video className="mt-2 size-4 shrink-0 text-muted-foreground" aria-hidden />
<AgendaVideoToggle
compact
provider={defaultVideoProvider}
enabled={includeVideo}
onEnabledChange={setIncludeVideo}
pending={createMutation.isPending || meetLinkMutation.isPending}
tabIndex={videoTab}
/>
</div>
) : null}
<Select value={calendarId} onValueChange={setCalendarId}>
<SelectTrigger className="h-9 w-fit min-w-44 border-0 bg-muted/60 shadow-none">
<SelectTrigger
tabIndex={calendarTab}
className="h-9 w-fit min-w-44 border-0 bg-muted/60 shadow-none"
>
<SelectValue placeholder="Agenda" />
</SelectTrigger>
<SelectContent>
@ -135,17 +213,24 @@ export function AgendaQuickCreate({
))}
</SelectContent>
</Select>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
tabIndex={7}
className="rounded-full"
onClick={() => onMoreOptions(draft)}
onClick={() => onMoreOptions({ ...draft, includeVideo })}
>
Autres options
</Button>
<Button
tabIndex={8}
className="rounded-full px-5"
disabled={createMutation.isPending || !calendarId}
disabled={
createMutation.isPending ||
meetLinkMutation.isPending ||
!calendarId
}
onClick={() => void save()}
>
Enregistrer

View File

@ -0,0 +1,77 @@
"use client"
import Link from "next/link"
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { AgendaSettingsFields } from "@/components/agenda/agenda-settings-fields"
import { ThemeSettingsDialog } from "@/components/gmail/quick-settings/theme-settings-dialog"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { cn } from "@/lib/utils"
export function AgendaQuickSettingsPanel() {
const open = useAgendaSettingsStore((s) => s.quickSettingsOpen)
const setOpen = useAgendaSettingsStore((s) => s.setQuickSettingsOpen)
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
const setThemeDialogOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
return (
<Sheet open={open} onOpenChange={(next) => !next && setOpen(false)}>
<SheetContent
side="right"
hideClose
overlayClassName={cn("bg-black/20", themeDialogOpen && "hidden")}
aria-label="Configuration rapide Agenda"
aria-describedby={undefined}
className="w-full gap-0 border-border bg-mail-surface p-0 text-foreground sm:max-w-[360px]"
onInteractOutside={(e) => {
if (themeDialogOpen) e.preventDefault()
}}
onEscapeKeyDown={(e) => {
if (themeDialogOpen) e.preventDefault()
}}
>
<header className="flex shrink-0 items-center justify-between gap-2 px-4 pt-5 pb-3">
<SheetTitle className="text-base font-normal text-foreground dark:text-white">
Configuration rapide
</SheetTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 text-muted-foreground"
aria-label="Fermer"
onClick={() => setOpen(false)}
>
<X className="size-5" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto pb-6">
<div className="px-4 pb-4">
<Button
variant="outline"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50 dark:border-[#9aa0a6] dark:text-white dark:hover:bg-[#3c4043]/50"
asChild
>
<Link href="/mail/settings/agenda" onClick={() => setOpen(false)}>
Voir tous les paramètres
</Link>
</Button>
</div>
<AgendaSettingsFields onOpenThemeDialog={() => setThemeDialogOpen(true)} />
</div>
</SheetContent>
</Sheet>
)
}
export function AgendaQuickSettingsRoot() {
return (
<>
<AgendaQuickSettingsPanel />
<ThemeSettingsDialog />
</>
)
}

View File

@ -0,0 +1,172 @@
"use client"
import { useMemo, useRef, useState } from "react"
import { X } from "lucide-react"
import { Input } from "@/components/ui/input"
import type { AgendaInvitationExclusion } from "@/lib/agenda/agenda-settings-types"
import { exclusionKey } from "@/lib/agenda/agenda-settings-types"
import { useAgendaInvitationSuggestions } from "@/lib/agenda/use-agenda-invitation-suggestions"
import { cn } from "@/lib/utils"
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function AgendaSettingsChipPicker({
items,
onChange,
placeholder,
allowedTypes,
emptyHint,
}: {
items: AgendaInvitationExclusion[]
onChange: (items: AgendaInvitationExclusion[]) => void
placeholder: string
allowedTypes?: AgendaInvitationExclusion["type"][]
emptyHint?: string
}) {
const [query, setQuery] = useState("")
const [focused, setFocused] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const blurTimer = useRef<number | null>(null)
const taken = useMemo(() => new Set(items.map(exclusionKey)), [items])
const suggestions = useAgendaInvitationSuggestions(query, taken, {
types: allowedTypes,
})
const addItem = (item: AgendaInvitationExclusion) => {
if (taken.has(exclusionKey(item))) return
onChange([...items, item])
setQuery("")
setActiveIndex(0)
}
const removeItem = (id: string) => {
onChange(items.filter((item) => exclusionKey(item) !== id))
}
const tryAddEmail = () => {
const email = query.trim().replace(/[,;]$/, "")
if (!EMAIL_RE.test(email)) return false
addItem({ type: "email", value: email, label: email })
return true
}
const showSuggestions =
focused && (suggestions.length > 0 || EMAIL_RE.test(query.trim()))
const grouped = useMemo(() => {
const map = new Map<string, typeof suggestions>()
for (const s of suggestions) {
const list = map.get(s.group) ?? []
list.push(s)
map.set(s.group, list)
}
return [...map.entries()]
}, [suggestions])
let flatIndex = -1
return (
<div className="flex flex-col gap-1.5">
<div
className={cn(
"min-h-9 rounded-md border border-input bg-background px-2 py-1.5",
"focus-within:ring-2 focus-within:ring-ring/40",
)}
>
{items.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1">
{items.map((item) => (
<span
key={exclusionKey(item)}
className="inline-flex max-w-full items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-foreground"
>
<span className="truncate">{item.label}</span>
<button
type="button"
className="shrink-0 rounded-full p-0.5 hover:bg-background/80"
aria-label={`Retirer ${item.label}`}
onClick={() => removeItem(exclusionKey(item))}
>
<X className="size-3" />
</button>
</span>
))}
</div>
) : null}
<Input
value={query}
placeholder={items.length === 0 ? placeholder : "Ajouter…"}
className="h-7 border-0 bg-transparent px-0 text-xs shadow-none focus-visible:ring-0"
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), 120)
}}
onKeyDown={(e) => {
if (e.key === "Backspace" && !query && items.length > 0) {
removeItem(exclusionKey(items[items.length - 1]!))
return
}
if (e.key === "Enter" || e.key === ",") {
e.preventDefault()
if (suggestions[activeIndex]) addItem(suggestions[activeIndex]!)
else tryAddEmail()
return
}
if (!showSuggestions || suggestions.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIndex((i) => (i + 1) % suggestions.length)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length)
}
}}
/>
</div>
{showSuggestions ? (
<ul className="max-h-44 overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md">
{grouped.map(([group, groupItems]) => (
<li key={group}>
<p className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{group}
</p>
{groupItems.map((s) => {
flatIndex += 1
const idx = flatIndex
return (
<button
key={s.id}
type="button"
className={cn(
"block w-full px-2 py-1.5 text-left text-xs hover:bg-muted",
idx === activeIndex && "bg-muted",
)}
onMouseDown={(e) => {
e.preventDefault()
addItem(s)
}}
>
{s.label}
</button>
)
})}
</li>
))}
</ul>
) : null}
{emptyHint && items.length === 0 ? (
<p className="text-[11px] text-muted-foreground">{emptyHint}</p>
) : null}
</div>
)
}

View File

@ -0,0 +1,466 @@
"use client"
import { cn } from "@/lib/utils"
import { AgendaCalendarsSettingsFields } from "@/components/agenda/agenda-calendars-settings"
import { AgendaSettingsChipPicker } from "@/components/agenda/agenda-settings-chip-picker"
import { AgendaVideoProviderSelectLabel } from "@/components/agenda/agenda-video-provider-select-label"
import { ThemeModePreview } from "@/components/gmail/quick-settings/settings-preview-icons"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AGENDA_DURATION_STEPS,
AGENDA_QUICK_DURATION_OPTIONS,
AGENDA_VIDEO_PROVIDERS,
AGENDA_WEEK_START_OPTIONS,
type AgendaDurationStep,
type AgendaTimeFormat,
type AgendaVideoProvider,
type AgendaWeekStart,
} from "@/lib/agenda/agenda-settings-types"
import {
formatDurationStepLabel,
formatQuickDurationLabel,
formatTimeFormatLabel,
formatWeekStartLabel,
videoProviderLabel,
} from "@/lib/agenda/agenda-settings-labels"
import { useAgendaSettingsStore } from "@/lib/agenda/agenda-store"
import {
useAgendaSettingsDestinationOptions,
useAgendaSettingsIdentityOptions,
} from "@/lib/agenda/use-agenda-invitation-suggestions"
import {
normalizeAutoImportInvitationSources,
normalizeInvitationImportExclusions,
} from "@/lib/agenda/agenda-destination-identities"
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
import { useIsDemoApp } from "@/lib/demo/use-is-demo-app"
import { useThemeModeControls } from "@/lib/demo/use-theme-mode-controls"
import {
MAIL_SETTINGS_PAGE_MASONRY_CLASS,
MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
} from "@/lib/mail-chrome-classes"
import type { MailThemeMode } from "@/lib/mail-settings/types"
const THEME_OPTIONS: { id: MailThemeMode; label: string }[] = [
{ id: "light", label: "Clair" },
{ id: "dark", label: "Sombre" },
{ id: "system", label: "Système" },
]
function SettingsSection({
title,
description,
action,
children,
variant = "panel",
}: {
title: string
description?: string
action?: React.ReactNode
children: React.ReactNode
variant?: "panel" | "page"
}) {
return (
<section
className={cn(
"space-y-2 border-b border-border px-4 py-3",
variant === "page" && MAIL_SETTINGS_PAGE_MASONRY_SECTION_CLASS,
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{description ? (
<p className="mt-0.5 text-[11px] leading-snug text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</div>
{children}
</section>
)
}
function PickerRow({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="grid grid-cols-[minmax(0,0.72fr)_minmax(9.75rem,1.18fr)] items-center gap-2 py-1">
<Label className="min-w-0 text-xs font-normal text-muted-foreground">{label}</Label>
<div className="min-w-0">{children}</div>
</div>
)
}
function minutesToTimeValue(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
}
function timeValueToMinutes(value: string): number {
const [h, m] = value.split(":").map(Number)
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0
return h * 60 + m
}
export function AgendaSettingsFields({
variant = "panel",
onOpenThemeDialog,
}: {
variant?: "panel" | "page"
onOpenThemeDialog?: () => void
}) {
const effective = useEffectiveAgendaSettings()
const isDemo = useIsDemoApp()
const { themeMode, setThemeMode } = useThemeModeControls()
const defaultVideoProvider = useAgendaSettingsStore((s) => s.defaultVideoProvider)
const setDefaultVideoProvider = useAgendaSettingsStore((s) => s.setDefaultVideoProvider)
const videoProviderApiKeys = useAgendaSettingsStore((s) => s.videoProviderApiKeys)
const setVideoProviderApiKey = useAgendaSettingsStore((s) => s.setVideoProviderApiKey)
const defaultInvitationIdentityKey = useAgendaSettingsStore(
(s) => s.defaultInvitationIdentityKey,
)
const setDefaultInvitationIdentityKey = useAgendaSettingsStore(
(s) => s.setDefaultInvitationIdentityKey,
)
const autoImportInvitationSources = useAgendaSettingsStore(
(s) => s.autoImportInvitationSources,
)
const setAutoImportInvitationSources = useAgendaSettingsStore(
(s) => s.setAutoImportInvitationSources,
)
const invitationImportExclusions = useAgendaSettingsStore(
(s) => s.invitationImportExclusions,
)
const setInvitationImportExclusions = useAgendaSettingsStore(
(s) => s.setInvitationImportExclusions,
)
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
const setWeekStart = useAgendaSettingsStore((s) => s.setWeekStart)
const defaultQuickDurationMinutes = useAgendaSettingsStore(
(s) => s.defaultQuickDurationMinutes,
)
const setDefaultQuickDurationMinutes = useAgendaSettingsStore(
(s) => s.setDefaultQuickDurationMinutes,
)
const visibleHoursStart = useAgendaSettingsStore((s) => s.visibleHoursStart)
const visibleHoursEnd = useAgendaSettingsStore((s) => s.visibleHoursEnd)
const setVisibleHoursStart = useAgendaSettingsStore((s) => s.setVisibleHoursStart)
const setVisibleHoursEnd = useAgendaSettingsStore((s) => s.setVisibleHoursEnd)
const timeFormat = useAgendaSettingsStore((s) => s.timeFormat)
const setTimeFormat = useAgendaSettingsStore((s) => s.setTimeFormat)
const dragSnapMinutes = useAgendaSettingsStore((s) => s.dragSnapMinutes)
const setDragSnapMinutes = useAgendaSettingsStore((s) => s.setDragSnapMinutes)
const buttonSnapMinutes = useAgendaSettingsStore((s) => s.buttonSnapMinutes)
const setButtonSnapMinutes = useAgendaSettingsStore((s) => s.setButtonSnapMinutes)
const identityOptions = useAgendaSettingsIdentityOptions()
const destinationOptions = useAgendaSettingsDestinationOptions()
const activeTheme = effective.orgEnforcesTheme ? effective.themeMode : themeMode
const activeProvider = effective.orgEnforcesVideoProvider
? effective.defaultVideoProvider
: defaultVideoProvider
const needsUserApiKey =
!effective.orgEnforcesVideoProvider &&
activeProvider !== "ultimeet" &&
activeProvider !== "none"
const autoImportItems = normalizeAutoImportInvitationSources(
autoImportInvitationSources,
destinationOptions,
)
const exclusionItems = normalizeInvitationImportExclusions(
invitationImportExclusions,
destinationOptions,
)
const isPage = variant === "page"
const fields = (
<>
{!isPage && !isDemo ? (
<SettingsSection
title="Thème"
variant={variant}
action={
onOpenThemeDialog ? (
<button
type="button"
className="shrink-0 text-xs text-[#1a73e8] hover:underline disabled:opacity-50"
disabled={effective.orgEnforcesTheme}
onClick={onOpenThemeDialog}
>
Arrière-plan
</button>
) : null
}
>
{effective.orgEnforcesTheme ? (
<p className="text-[11px] text-muted-foreground">
Thème imposé par votre organisation.
</p>
) : null}
<div className="grid grid-cols-3 gap-1.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
disabled={effective.orgEnforcesTheme}
onClick={() => setThemeMode(opt.id)}
className={cn(
"rounded-lg border-2 p-1 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
activeTheme === opt.id
? "border-primary bg-accent/60"
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40",
)}
>
<ThemeModePreview mode={opt.id} className="h-9" />
<span className="mt-0.5 block text-center text-[11px] text-foreground">
{opt.label}
</span>
</button>
))}
</div>
</SettingsSection>
) : null}
<SettingsSection title="Affichage" variant={variant}>
<PickerRow label="Premier jour">
<Select
value={String(weekStart)}
onValueChange={(v) =>
setWeekStart(
v === "auto" ? "auto" : (Number(v) as AgendaWeekStart),
)
}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{formatWeekStartLabel(weekStart)}</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_WEEK_START_OPTIONS.map((opt) => (
<SelectItem key={String(opt.value)} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
<PickerRow label="Format horaire">
<Select
value={timeFormat}
onValueChange={(v) => setTimeFormat(v as AgendaTimeFormat)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{formatTimeFormatLabel(timeFormat)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="24h">24 h</SelectItem>
<SelectItem value="12h">AM / PM</SelectItem>
</SelectContent>
</Select>
</PickerRow>
<PickerRow label="Heures visibles">
<div className="grid grid-cols-2 gap-1.5">
<Input
type="time"
className="h-8 text-xs [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
value={minutesToTimeValue(visibleHoursStart)}
onChange={(e) => setVisibleHoursStart(timeValueToMinutes(e.target.value))}
/>
<Input
type="time"
className="h-8 text-xs [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
value={minutesToTimeValue(visibleHoursEnd)}
onChange={(e) => setVisibleHoursEnd(timeValueToMinutes(e.target.value))}
/>
</div>
</PickerRow>
</SettingsSection>
<SettingsSection title="Création rapide" variant={variant}>
<PickerRow label="Durée par défaut">
<Select
value={String(defaultQuickDurationMinutes)}
onValueChange={(v) => setDefaultQuickDurationMinutes(Number(v))}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{formatQuickDurationLabel(defaultQuickDurationMinutes)}</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_QUICK_DURATION_OPTIONS.map((opt) => (
<SelectItem key={opt.minutes} value={String(opt.minutes)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
<PickerRow label="Arrondi glisser">
<Select
value={String(dragSnapMinutes)}
onValueChange={(v) => setDragSnapMinutes(Number(v) as AgendaDurationStep)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{formatDurationStepLabel(dragSnapMinutes)}</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_DURATION_STEPS.map((step) => (
<SelectItem key={step} value={String(step)}>
{formatDurationStepLabel(step)}
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
<PickerRow label="Arrondi boutons +/-">
<Select
value={String(buttonSnapMinutes)}
onValueChange={(v) => setButtonSnapMinutes(Number(v) as AgendaDurationStep)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>{formatDurationStepLabel(buttonSnapMinutes)}</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_DURATION_STEPS.map((step) => (
<SelectItem key={step} value={String(step)}>
{formatDurationStepLabel(step)}
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
</SettingsSection>
<SettingsSection
title="Visioconférence"
variant={variant}
description={
effective.orgEnforcesVideoProvider
? "Fournisseur imposé par votre organisation."
: undefined
}
>
<PickerRow label="Fournisseur">
<Select
value={activeProvider}
disabled={effective.orgEnforcesVideoProvider}
onValueChange={(v) => setDefaultVideoProvider(v as AgendaVideoProvider)}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue>
<AgendaVideoProviderSelectLabel provider={activeProvider} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{AGENDA_VIDEO_PROVIDERS.map((provider) => (
<SelectItem key={provider} value={provider}>
<AgendaVideoProviderSelectLabel provider={provider} />
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
{needsUserApiKey ? (
<div className="pt-1">
<Label htmlFor="agenda-video-api-key" className="text-[11px] text-muted-foreground">
Clé API {videoProviderLabel(activeProvider)}
</Label>
<Input
id="agenda-video-api-key"
type="password"
autoComplete="off"
className="mt-1 h-8 text-xs"
placeholder="Optionnel si configurée par l'organisation"
value={videoProviderApiKeys[activeProvider] ?? ""}
onChange={(e) => setVideoProviderApiKey(activeProvider, e.target.value)}
/>
</div>
) : null}
</SettingsSection>
<SettingsSection
title="Invitations par mail"
variant={variant}
description="Import automatique des fichiers .ics reçus et envoi des réponses."
>
<PickerRow label="Envoi par défaut">
<Select
value={defaultInvitationIdentityKey ?? "auto"}
onValueChange={(v) =>
setDefaultInvitationIdentityKey(v === "auto" ? null : v)
}
>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Identité par défaut" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Identité mail par défaut</SelectItem>
{identityOptions.map((identity) => (
<SelectItem key={identity.key} value={identity.key}>
{identity.label}
</SelectItem>
))}
</SelectContent>
</Select>
</PickerRow>
<div className="space-y-1 pt-1">
<Label className="text-xs font-normal text-muted-foreground">
Import automatique depuis
</Label>
<AgendaSettingsChipPicker
items={autoImportItems}
allowedTypes={["identity", "contact"]}
placeholder="Mails de destination ou contacts…"
emptyHint="Boîtes qui reçoivent les .ics, ou expéditeurs à importer."
onChange={(items) =>
setAutoImportInvitationSources(
normalizeAutoImportInvitationSources(items, destinationOptions),
)
}
/>
</div>
<div className="space-y-1 pt-2">
<Label className="text-xs font-normal text-muted-foreground">
Ne pas importer automatiquement
</Label>
<AgendaSettingsChipPicker
items={exclusionItems}
placeholder="Contact, adresse, dossier, libellé…"
emptyHint="Excluez expéditeurs, dossiers ou règles contacts."
onChange={(items) =>
setInvitationImportExclusions(
normalizeInvitationImportExclusions(items, destinationOptions),
)
}
/>
</div>
</SettingsSection>
<AgendaCalendarsSettingsFields variant={variant} />
</>
)
if (isPage) {
return <div className={MAIL_SETTINGS_PAGE_MASONRY_CLASS}>{fields}</div>
}
return fields
}

View File

@ -1,9 +1,13 @@
"use client"
import { useState } from "react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { MoreVertical, Pencil, Plus, Trash2 } from "lucide-react"
import { AgendaCalendarDialog } from "@/components/agenda/agenda-calendar-dialog"
import {
CalendarViewDialog,
ExternalCalendarDialog,
} from "@/components/agenda/agenda-calendars-settings"
import { AgendaMiniMonth } from "@/components/agenda/agenda-mini-month"
import {
AlertDialog,
@ -23,11 +27,14 @@ import {
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 { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { ALL_AGENDAS_VIEW_LABEL, isExternalCalendarId, isReservedAgendaViewName } from "@/lib/agenda/agenda-calendar-visibility"
import { calendarColor } from "@/lib/agenda/agenda-events"
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
@ -44,13 +51,36 @@ export function AgendaSidebar({
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
const weekStart = useAgendaSettingsStore((s) => s.weekStart)
const calendarViews = useAgendaSettingsStore((s) => s.calendarViews)
const activeCalendarViewId = useAgendaSettingsStore((s) => s.activeCalendarViewId)
const setActiveCalendarViewId = useAgendaSettingsStore((s) => s.setActiveCalendarViewId)
const toggleCalendar = useAgendaSettingsStore((s) => s.toggleCalendarVisible)
const { data: calendars, isLoading } = useAgendaCalendars()
const { data: accounts = [] } = useMailAccounts()
const { data: labels = [] } = useLabels()
const { calendars, isLoading } = useMergedAgendaCalendars()
const deleteMutation = useDeleteAgendaCalendar()
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
const [editingCalendar, setEditingCalendar] = useState<AgendaCalendar | null>(null)
const [deletingCalendar, setDeletingCalendar] = useState<AgendaCalendar | null>(null)
const [externalDialogOpen, setExternalDialogOpen] = useState(false)
const [viewDialogOpen, setViewDialogOpen] = useState(false)
const calendarOptions = useMemo(
() =>
(calendars ?? []).map((calendar) => ({
id: calendar.id,
label: calendar.display_name,
color: calendarColor(calendar),
})),
[calendars],
)
const labelOptions = useMemo(
() => labels.map((label) => ({ id: label.id, label: label.name })),
[labels],
)
const open = !sidebarCollapsed
@ -89,11 +119,12 @@ export function AgendaSidebar({
}}
>
<Plus className="size-6 text-primary" />
Créer
Nouvel événement
</Button>
<AgendaMiniMonth
selected={selectedDate}
weekStart={weekStart}
onSelect={(d) => {
onSelectDate(d)
if (isMobile) setSidebarCollapsed(true)
@ -102,7 +133,53 @@ export function AgendaSidebar({
<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">
<span className="py-1 text-sm font-medium text-foreground/90">Vues</span>
<Button
variant="ghost"
size="icon"
className="size-7 rounded-full text-muted-foreground"
aria-label="Créer une vue"
onClick={() => setViewDialogOpen(true)}
>
<Plus className="size-4" />
</Button>
</div>
{calendarViews.length > 0 ? (
<>
<button
type="button"
onClick={() => setActiveCalendarViewId(null)}
className={cn(
"rounded-lg px-2 py-1 text-left text-sm transition-colors hover:bg-mail-nav-hover",
activeCalendarViewId === null
? "bg-mail-nav-selected font-medium text-foreground"
: "text-foreground/85",
)}
>
{ALL_AGENDAS_VIEW_LABEL}
</button>
{calendarViews
.filter((view) => !isReservedAgendaViewName(view.name))
.map((view) => (
<button
key={view.id}
type="button"
onClick={() => setActiveCalendarViewId(view.id)}
className={cn(
"rounded-lg px-2 py-1 text-left text-sm transition-colors hover:bg-mail-nav-hover",
activeCalendarViewId === view.id
? "bg-mail-nav-selected font-medium text-foreground"
: "text-foreground/85",
)}
>
{view.name}
</button>
))}
</>
) : null}
<div className="mt-2 flex items-center justify-between border-t border-border/60 pr-1 pl-2 pt-2">
<span className="py-0.5 text-sm font-medium text-foreground/90">
Mes agendas
</span>
<Button
@ -120,7 +197,7 @@ export function AgendaSidebar({
</div>
{isLoading && (
<div className="flex flex-col gap-2 px-2 py-1">
<div className="flex flex-col gap-1.5 px-2 py-0.5">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-5 w-32" />
</div>
@ -129,10 +206,11 @@ export function AgendaSidebar({
{(calendars ?? []).map((cal) => {
const color = calendarColor(cal)
const visible = !hiddenIds.includes(cal.id)
const isExternal = isExternalCalendarId(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"
className="group flex items-center gap-2 rounded-lg px-2 py-0.5 hover:bg-mail-nav-hover"
>
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5">
<input
@ -165,8 +243,12 @@ export function AgendaSidebar({
</span>
<span className="truncate text-sm text-foreground/85">
{cal.display_name}
{isExternal ? (
<span className="ml-1 text-[10px] text-muted-foreground">(iCal)</span>
) : null}
</span>
</label>
{!isExternal ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -195,6 +277,7 @@ export function AgendaSidebar({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
})}
@ -205,6 +288,29 @@ export function AgendaSidebar({
open={calendarDialogOpen}
onOpenChange={setCalendarDialogOpen}
calendar={editingCalendar}
onAddExternalICal={
editingCalendar
? undefined
: () => {
setCalendarDialogOpen(false)
setExternalDialogOpen(true)
}
}
/>
<ExternalCalendarDialog
open={externalDialogOpen}
onOpenChange={setExternalDialogOpen}
calendar={null}
accounts={accounts}
/>
<CalendarViewDialog
open={viewDialogOpen}
onOpenChange={setViewDialogOpen}
view={null}
calendarOptions={calendarOptions}
labelOptions={labelOptions}
/>
<AlertDialog

View File

@ -0,0 +1,217 @@
"use client"
import type { KeyboardEvent, ReactNode } from "react"
import { ChevronLeft, ChevronRight, Minus, Plus } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export function handleStepAdjustKeyDown(
e: KeyboardEvent,
onDecrease: () => void,
onIncrease: () => void,
) {
if (e.key === "ArrowUp" || e.key === "+" || (e.key === "=" && !e.shiftKey)) {
e.preventDefault()
onIncrease()
} else if (e.key === "ArrowDown" || e.key === "-") {
e.preventDefault()
onDecrease()
}
}
export function StepAdjustDecreaseButton({
onClick,
disabled,
label,
className,
}: {
onClick: () => void
disabled?: boolean
label: string
className?: string
}) {
return (
<Button
type="button"
tabIndex={-1}
variant="ghost"
size="icon"
className={cn("size-7 rounded-full", className)}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<Minus className="size-4" />
</Button>
)
}
export function StepAdjustIncreaseButton({
onClick,
disabled,
label,
className,
}: {
onClick: () => void
disabled?: boolean
label: string
className?: string
}) {
return (
<Button
type="button"
tabIndex={-1}
variant="ghost"
size="icon"
className={cn("size-7 rounded-full", className)}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<Plus className="size-4" />
</Button>
)
}
/** Reculer l'heure — chevron gauche (réservé aux champs horaires). */
export function StepAdjustTimeDecreaseButton({
onClick,
disabled,
label,
className,
}: {
onClick: () => void
disabled?: boolean
label: string
className?: string
}) {
return (
<Button
type="button"
tabIndex={-1}
variant="ghost"
size="icon"
className={cn("size-7 rounded-full", className)}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<ChevronLeft className="size-4" />
</Button>
)
}
/** Avancer l'heure — chevron droit (réservé aux champs horaires). */
export function StepAdjustTimeIncreaseButton({
onClick,
disabled,
label,
className,
}: {
onClick: () => void
disabled?: boolean
label: string
className?: string
}) {
return (
<Button
type="button"
tabIndex={-1}
variant="ghost"
size="icon"
className={cn("size-7 rounded-full", className)}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<ChevronRight className="size-4" />
</Button>
)
}
export function StepAdjustGroup({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div
className={cn(
"inline-flex items-center gap-1 rounded-full border border-border/70 bg-muted/40 px-1 py-0.5",
className,
)}
>
{children}
</div>
)
}
export function FocusableStepValue({
tabIndex,
value,
ariaLabel,
onDecrease,
onIncrease,
decreaseDisabled,
increaseDisabled,
decreaseLabel,
increaseLabel,
className,
buttonClassName,
valueWrapperClassName,
valueClassName,
}: {
tabIndex?: number
value: string
ariaLabel: string
onDecrease: () => void
onIncrease: () => void
decreaseDisabled?: boolean
increaseDisabled?: boolean
decreaseLabel: string
increaseLabel: string
className?: string
buttonClassName?: string
valueWrapperClassName?: string
valueClassName?: string
}) {
const valueNode = (
<span
tabIndex={tabIndex}
role="spinbutton"
aria-label={ariaLabel}
aria-valuetext={value}
onKeyDown={(e) => handleStepAdjustKeyDown(e, onDecrease, onIncrease)}
className={cn(
"cursor-default outline-none focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-ring",
valueClassName,
)}
>
{value}
</span>
)
return (
<StepAdjustGroup className={className}>
<StepAdjustDecreaseButton
onClick={onDecrease}
disabled={decreaseDisabled}
label={decreaseLabel}
className={buttonClassName}
/>
{valueWrapperClassName ? (
<div className={valueWrapperClassName}>{valueNode}</div>
) : (
valueNode
)}
<StepAdjustIncreaseButton
onClick={onIncrease}
disabled={increaseDisabled}
label={increaseLabel}
className={buttonClassName}
/>
</StepAdjustGroup>
)
}

View File

@ -0,0 +1,42 @@
"use client"
import { Icon } from "@iconify/react"
import {
AGENDA_VIDEO_PROVIDER_ICONS,
type AgendaVideoIcon,
} from "@/lib/agenda/agenda-video-conference"
import type { AgendaVideoProvider } from "@/lib/agenda/agenda-settings-types"
import { cn } from "@/lib/utils"
export function AgendaVideoProviderIcon({
provider,
className,
}: {
provider: AgendaVideoProvider
className?: string
}) {
if (provider === "none") {
return (
<Icon
icon="mdi:video-off-outline"
className={cn("size-5 shrink-0 text-muted-foreground", className)}
aria-hidden
/>
)
}
const spec: AgendaVideoIcon = AGENDA_VIDEO_PROVIDER_ICONS[provider]
if (spec.kind === "image") {
return (
<img
src={spec.src}
alt=""
className={cn("size-5 shrink-0 object-contain", className)}
/>
)
}
return (
<Icon icon={spec.icon} className={cn("size-5 shrink-0", className)} aria-hidden />
)
}

View File

@ -0,0 +1,26 @@
"use client"
import { AgendaVideoProviderIcon } from "@/components/agenda/agenda-video-provider-icon"
import { videoProviderLabel } from "@/lib/agenda/agenda-settings-labels"
import type { AgendaVideoProvider } from "@/lib/agenda/agenda-settings-types"
import { cn } from "@/lib/utils"
export function AgendaVideoProviderSelectLabel({
provider,
className,
iconClassName,
}: {
provider: AgendaVideoProvider
className?: string
iconClassName?: string
}) {
return (
<span className={cn("inline-flex min-w-0 items-center gap-2", className)}>
<AgendaVideoProviderIcon
provider={provider}
className={cn("size-4 shrink-0", iconClassName)}
/>
<span className="truncate">{videoProviderLabel(provider)}</span>
</span>
)
}

View File

@ -0,0 +1,100 @@
"use client"
import Link from "next/link"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { AgendaVideoProviderIcon } from "@/components/agenda/agenda-video-provider-icon"
import {
canAutoGenerateVideoLink,
videoJoinLabel,
videoToggleLabel,
} from "@/lib/agenda/agenda-video-conference"
import type { AgendaVideoProvider } from "@/lib/agenda/agenda-settings-types"
import { isUltiMeetUrl, meetJoinPath } from "@/lib/meet/meet-url"
import { cn } from "@/lib/utils"
export function AgendaVideoToggle({
provider,
enabled,
onEnabledChange,
meetUrl,
disabled = false,
pending = false,
compact = false,
tabIndex,
}: {
provider: AgendaVideoProvider
enabled: boolean
onEnabledChange: (enabled: boolean) => void
meetUrl?: string
disabled?: boolean
pending?: boolean
compact?: boolean
tabIndex?: number
}) {
if (provider === "none") return null
if (meetUrl) {
const inAppJoin = provider === "ultimeet" && isUltiMeetUrl(meetUrl)
const joinHref = inAppJoin ? meetJoinPath(meetUrl) : meetUrl
return (
<div className="flex flex-wrap items-center gap-2">
<Button
asChild
variant="outline"
className={cn("h-9 rounded-full gap-2", compact && "h-8 text-xs")}
>
{inAppJoin ? (
<Link href={joinHref}>
<AgendaVideoProviderIcon provider={provider} className="size-4" />
{videoJoinLabel(provider)}
</Link>
) : (
<a href={joinHref} target="_blank" rel="noopener noreferrer">
<AgendaVideoProviderIcon provider={provider} className="size-4" />
{videoJoinLabel(provider)}
<ExternalLink className="size-3.5 opacity-60" aria-hidden />
</a>
)}
</Button>
<Button
type="button"
variant="ghost"
className={cn("h-9 rounded-full text-muted-foreground", compact && "h-8 text-xs")}
disabled={pending}
onClick={() => onEnabledChange(false)}
>
Retirer
</Button>
</div>
)
}
const auto = canAutoGenerateVideoLink(provider)
return (
<div className="flex flex-col gap-1">
<Button
type="button"
variant={enabled ? "default" : "outline"}
className={cn(
"h-9 w-fit rounded-full gap-2",
compact && "h-8 text-xs",
enabled && "shadow-sm",
)}
tabIndex={tabIndex}
disabled={disabled || pending}
onClick={() => onEnabledChange(!enabled)}
>
<AgendaVideoProviderIcon provider={provider} className="size-4" />
{videoToggleLabel(provider, enabled)}
</Button>
{enabled && !auto ? (
<p className="text-[11px] text-muted-foreground">
Lien généré automatiquement bientôt ajoutez-le dans le lieu en attendant.
</p>
) : null}
</div>
)
}

View File

@ -1,12 +1,21 @@
"use client"
import type { MouseEvent } from "react"
import { format, isSameDay, isSameMonth } from "date-fns"
import {
useEffect,
useRef,
useState,
type MouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react"
import { addDays, format, isSameDay, isSameMonth, startOfDay } from "date-fns"
import { fr } from "date-fns/locale"
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
import { viewDays } from "@/lib/agenda/agenda-date"
import { bindAgendaEventDragSession } from "@/lib/agenda/agenda-event-drag-session"
import { viewDays, type WeekStartsOn } from "@/lib/agenda/agenda-date"
import type { AgendaWeekStart } from "@/lib/agenda/agenda-settings-types"
import { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
import { isPendingEvent } from "@/lib/agenda/agenda-pending-event"
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
import { cn } from "@/lib/utils"
@ -17,26 +26,217 @@ function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
}
function dayIndex(days: Date[], day: Date): number {
return days.findIndex((d) => isSameDay(d, day))
}
interface MonthDragState {
startIndex: number
endIndex: number
moved: boolean
}
interface MonthEventMoveState {
event: AgendaEvent
originIndex: number
currentIndex: number
positionChanged: boolean
}
export function AgendaViewMonth({
date,
weekStart = "auto",
weekStartsOn,
events,
onCreateAt,
pendingEvent,
onCreateRange,
onEventClick,
onEventMove,
onOpenDay,
}: {
date: Date
weekStart?: AgendaWeekStart
weekStartsOn?: WeekStartsOn
events: AgendaEvent[]
onCreateAt: (day: Date, anchor: AnchorRect) => void
pendingEvent?: AgendaEvent | null
onCreateRange: (
start: Date,
end: Date,
allDay: boolean,
anchor: AnchorRect,
viaDrag: boolean,
) => void
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
onEventMove?: (event: AgendaEvent, targetStart: Date) => void
onOpenDay: (day: Date) => void
}) {
const days = viewDays("month", date)
const days = viewDays("month", date, weekStart, weekStartsOn)
const weeks: Date[][] = []
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
const today = new Date()
const cellRefs = useRef<(HTMLDivElement | null)[]>([])
const dragRef = useRef<MonthDragState | null>(null)
const eventMoveRef = useRef<MonthEventMoveState | null>(null)
const dragSessionCleanupRef = useRef<(() => void) | null>(null)
const suppressClickRef = useRef(false)
const [drag, setDrag] = useState<MonthDragState | null>(null)
const [eventMove, setEventMove] = useState<MonthEventMoveState | null>(null)
const merged = pendingEvent ? [...events, pendingEvent] : events
useEffect(
() => () => {
dragSessionCleanupRef.current?.()
dragSessionCleanupRef.current = null
},
[],
)
const indexFromElement = (el: HTMLElement | null): number | null => {
if (!el) return null
const idx = cellRefs.current.findIndex((cell) => cell && cell.contains(el))
return idx >= 0 ? idx : null
}
const indexFromClient = (clientX: number, clientY: number): number | null => {
const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null
const cell = el?.closest("[data-agenda-month-cell]") as HTMLElement | null
return indexFromElement(cell)
}
const finishDrag = (anchorEl: HTMLElement | null) => {
const d = dragRef.current
dragRef.current = null
setDrag(null)
if (!d) return
const minIdx = Math.min(d.startIndex, d.endIndex)
const maxIdx = Math.max(d.startIndex, d.endIndex)
const start = startOfDay(days[minIdx])
const end = addDays(startOfDay(days[maxIdx]), 1)
const anchor = anchorEl
? anchorFromEvent({ currentTarget: anchorEl } as MouseEvent<HTMLElement>)
: { left: 0, top: 0, width: 0, height: 0 }
if (d.moved) {
onCreateRange(start, end, true, anchor, true)
} else {
onCreateRange(start, end, true, anchor, false)
}
}
const handlePointerDown = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0 || eventMoveRef.current) return
if ((e.target as HTMLElement).closest("[data-agenda-event]")) return
const next: MonthDragState = { startIndex: dayIndex, endIndex: dayIndex, moved: false }
dragRef.current = next
setDrag(next)
e.currentTarget.setPointerCapture(e.pointerId)
}
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const d = dragRef.current
if (!d) return
const idx = indexFromClient(e.clientX, e.clientY)
if (idx === null || idx === d.endIndex) return
const next: MonthDragState = { ...d, endIndex: idx, moved: true }
dragRef.current = next
setDrag(next)
}
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
finishDrag(cellRefs.current[dayIndex])
}
const handlePointerCancel = () => {
dragRef.current = null
setDrag(null)
}
const finishEventMove = (didDrag: boolean) => {
const move = eventMoveRef.current
eventMoveRef.current = null
setEventMove(null)
if (!move || !onEventMove) return
const positionChanged = move.currentIndex !== move.originIndex
if (didDrag) {
suppressClickRef.current = true
}
if (!didDrag || !positionChanged) return
const delta = move.currentIndex - move.originIndex
const targetStart = addDays(move.event.start, delta)
onEventMove(move.event, targetStart)
}
const updateEventMove = (clientX: number, clientY: number) => {
const move = eventMoveRef.current
if (!move) return
const idx = indexFromClient(clientX, clientY)
if (idx === null) return
const positionChanged = idx !== move.originIndex
const next: MonthEventMoveState = {
...move,
currentIndex: idx,
positionChanged,
}
eventMoveRef.current = next
setEventMove(next)
}
const shouldSuppressEventClick = () => {
if (!suppressClickRef.current) return false
suppressClickRef.current = false
return true
}
const startEventMove =
(event: AgendaEvent, dayIdx: number) =>
(e: ReactPointerEvent<HTMLElement>) => {
if (e.button !== 0 || isPendingEvent(event) || !onEventMove) return
e.stopPropagation()
e.preventDefault()
const originIndex = dayIndex(days, event.start)
const resolvedOrigin = originIndex >= 0 ? originIndex : dayIdx
const next: MonthEventMoveState = {
event,
originIndex: resolvedOrigin,
currentIndex: resolvedOrigin,
positionChanged: false,
}
eventMoveRef.current = next
setEventMove(next)
dragSessionCleanupRef.current?.()
dragSessionCleanupRef.current = bindAgendaEventDragSession({
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
onMove: updateEventMove,
onFinish: (clientX, clientY, didDrag) => {
dragSessionCleanupRef.current = null
if (didDrag) {
updateEventMove(clientX, clientY)
}
finishEventMove(didDrag)
},
})
}
const dragRange =
drag && drag.moved
? {
min: Math.min(drag.startIndex, drag.endIndex),
max: Math.max(drag.startIndex, drag.endIndex),
}
: null
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="flex h-full min-h-0 flex-col overflow-hidden bg-mail-surface">
<div className="grid shrink-0 grid-cols-7 border-b border-border/60">
{days.slice(0, 7).map((d) => (
<div
@ -53,9 +253,12 @@ export function AgendaViewMonth({
>
{weeks.map((week) =>
week.map((day) => {
const dayEvents = eventsOnDay(events, day).sort((a, b) => {
const idx = dayIndex(days, day)
const dayEvents = eventsOnDay(merged, day).sort((a, b) => {
const aBanner = a.allDay || isMultiDay(a)
const bBanner = b.allDay || isMultiDay(b)
if (isPendingEvent(a)) return 1
if (isPendingEvent(b)) return -1
if (aBanner !== bBanner) return aBanner ? -1 : 1
return a.start.getTime() - b.start.getTime()
})
@ -63,15 +266,28 @@ export function AgendaViewMonth({
const hidden = dayEvents.length - visible.length
const isToday = isSameDay(day, today)
const inMonth = isSameMonth(day, date)
const inDragRange = dragRange && idx >= dragRange.min && idx <= dragRange.max
const inEventMoveTarget =
eventMove?.positionChanged && eventMove.currentIndex === idx
return (
<div
key={day.getTime()}
ref={(el) => {
cellRefs.current[idx] = el
}}
role="gridcell"
data-agenda-month-cell
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",
"relative flex min-h-0 cursor-pointer touch-none flex-col gap-0.5 overflow-hidden border-r border-b border-border/40 px-1 pb-1",
!inMonth && "bg-muted/30",
inDragRange && "bg-primary/10",
inEventMoveTarget && "bg-primary/10 ring-1 ring-inset ring-primary/40",
)}
onClick={(e) => onCreateAt(day, anchorFromEvent(e))}
onPointerDown={handlePointerDown(idx)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp(idx)}
onPointerCancel={handlePointerCancel}
>
<div className="flex justify-center pt-1">
<button
@ -81,6 +297,7 @@ export function AgendaViewMonth({
isToday && "bg-primary font-semibold text-primary-foreground hover:bg-primary",
!inMonth && "text-muted-foreground/60",
)}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onOpenDay(day)
@ -91,21 +308,44 @@ export function AgendaViewMonth({
: 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))
}}
/>
))}
{visible.map((event) => {
const isDragging =
eventMove?.event.key === event.key && eventMove.positionChanged
return (
<div
key={event.key}
data-agenda-event
className={cn(
onEventMove &&
!isPendingEvent(event) &&
"touch-none cursor-grab active:cursor-grabbing",
isDragging && "opacity-40",
)}
onPointerDown={startEventMove(event, idx)}
>
<AgendaEventChip
event={event}
filled={event.allDay || isMultiDay(event)}
pending={isPendingEvent(event)}
onClick={(e) => {
if (shouldSuppressEventClick()) {
e.preventDefault()
return
}
e.stopPropagation()
if (!isPendingEvent(event)) {
onEventClick(event, anchorFromEvent(e))
}
}}
/>
</div>
)
})}
{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"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
onOpenDay(day)

View File

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

View File

@ -13,17 +13,13 @@ import {
isSessionExpired,
useSessionGuardStore,
} from "@/lib/auth/session-guard-store"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/", "/demo/"]
const REFRESH_LEAD_MS = 5 * 60 * 1000
const REFRESH_CHECK_MS = 60 * 1000
function isPublicPath(pathname: string) {
if (pathname === "/") return true
if (pathname.startsWith("/drive/s/")) return true
return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix)
)
return isAuthPublicPath(pathname)
}
export function AuthProvider({ children }: { children: ReactNode }) {

View File

@ -1,69 +1,16 @@
"use client"
import { useEffect } from "react"
import { mailBackgroundStyle } from "@/lib/mail-settings/constants"
import { clearMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
type HtmlBgState = {
mailBackground?: string
layer: string
fallback: string
}
function readHtmlBgState(): HtmlBgState {
const html = document.documentElement
return {
mailBackground: html.dataset.mailBackground,
layer: html.style.getPropertyValue("--mail-bg-layer"),
fallback: html.style.getPropertyValue("--mail-bg-fallback"),
}
}
function applyHtmlBgState(state: HtmlBgState) {
const html = document.documentElement
if (state.mailBackground) {
html.dataset.mailBackground = state.mailBackground
} else {
delete html.dataset.mailBackground
}
if (state.layer) {
html.style.setProperty("--mail-bg-layer", state.layer)
} else {
html.style.removeProperty("--mail-bg-layer")
}
if (state.fallback) {
html.style.setProperty("--mail-bg-fallback", state.fallback)
} else {
html.style.removeProperty("--mail-bg-fallback")
}
}
function clearHtmlBg() {
const html = document.documentElement
delete html.dataset.mailBackground
html.style.removeProperty("--mail-bg-layer")
html.style.removeProperty("--mail-bg-fallback")
}
/** Login shell: fixed Aurore bg (sm+), no user mail background, canvas on xs. */
/** Login shell: plain canvas, no mail wallpaper. */
export function LoginChrome({ children }: { children: React.ReactNode }) {
useEffect(() => {
const saved = readHtmlBgState()
clearHtmlBg()
return () => applyHtmlBgState(saved)
clearMailBackgroundDom()
}, [])
const aurora = mailBackgroundStyle("gradient-aurora")
return (
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas sm:bg-transparent">
<div
className="pointer-events-none fixed inset-0 -z-10 hidden sm:block"
style={{
background: aurora.background,
backgroundColor: aurora.fallbackColor,
}}
aria-hidden
/>
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas">
{children}
</div>
)

View File

@ -15,15 +15,11 @@ import {
import { Button } from "@/components/ui/button"
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
import { tryRefreshSession } from "@/lib/auth/session-sync"
import { isAuthPublicPath } from "@/lib/auth/public-paths"
import { cn } from "@/lib/utils"
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
function isPublicPath(pathname: string) {
if (pathname.startsWith("/drive/s/")) return true
return PUBLIC_PREFIXES.some(
(prefix) => pathname === prefix || pathname.startsWith(prefix)
)
return isAuthPublicPath(pathname)
}
export function SessionGuard() {

View File

@ -0,0 +1,97 @@
"use client"
import { useMemo } from "react"
import { useTheme } from "next-themes"
import { ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
import {
buildAuthentikUrl,
resolveAuthentikTheme,
type AuthentikUserSettingsTab,
} from "@/lib/auth/authentik-user-url"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
type CompteAuthentikPanelProps = {
title: string
description: string
tab?: AuthentikUserSettingsTab
flowSlug?: string
actionLabel: string
icon?: React.ReactNode
}
export function CompteAuthentikPanel({
title,
description,
tab,
flowSlug,
actionLabel,
icon,
}: CompteAuthentikPanelProps) {
const themeMode = useMailSettingsStore((s) => s.themeMode)
const { resolvedTheme } = useTheme()
const authentikTheme = resolveAuthentikTheme(themeMode, resolvedTheme)
const url = useMemo(
() => buildAuthentikUrl({ tab, flowSlug, theme: authentikTheme }),
[tab, flowSlug, authentikTheme]
)
if (!url) {
return (
<CompteSettingsCard>
<PanelHeader icon={icon} title={title} description={description} />
<p className="mt-3 text-xs text-muted-foreground">
Portail d&apos;identité non configuré.
</p>
</CompteSettingsCard>
)
}
return (
<CompteSettingsCard>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<PanelHeader icon={icon} title={title} description={description} />
<Button
type="button"
variant="outline"
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
asChild
>
<a href={url} target="_blank" rel="noreferrer">
{actionLabel}
<ExternalLink className="size-3.5" aria-hidden />
</a>
</Button>
</div>
<p className="mt-3 text-xs text-muted-foreground">
Ouverture du portail d&apos;identité Authentik dans un nouvel onglet.
</p>
</CompteSettingsCard>
)
}
function PanelHeader({
icon,
title,
description,
}: {
icon?: React.ReactNode
title: string
description: string
}) {
return (
<div className="flex min-w-0 flex-1 gap-3">
{icon ? (
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-accent text-muted-foreground">
{icon}
</span>
) : null}
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
<p className="mt-0.5 text-sm text-muted-foreground">{description}</p>
</div>
</div>
)
}

View File

@ -0,0 +1,59 @@
"use client"
import { toast } from "sonner"
import { ContactAvatarPicker } from "@/components/gmail/contacts/contact-avatar-picker"
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
import {
useDeleteUserAvatar,
useUpdateUserAvatar,
} from "@/lib/api/hooks/use-user-avatar-mutations"
export function CompteAvatarField({
avatarUrl,
name,
email,
}: {
avatarUrl?: string
name: string
email: string
}) {
const updateAvatar = useUpdateUserAvatar()
const deleteAvatar = useDeleteUserAvatar()
const pending = updateAvatar.isPending || deleteAvatar.isPending
async function handleChange(next: string | undefined) {
try {
if (next) {
await updateAvatar.mutateAsync(next)
toast.success("Photo de profil mise à jour.")
} else {
await deleteAvatar.mutateAsync()
toast.success("Photo de profil supprimée.")
}
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Impossible de mettre à jour la photo."
)
}
}
return (
<CompteSettingsCard className="flex flex-col items-center gap-3 sm:flex-row sm:items-center sm:gap-6">
<ContactAvatarPicker
avatarUrl={avatarUrl}
displayName={name}
email={email}
variant="page"
className={pending ? "pointer-events-none opacity-60" : undefined}
onChange={(next) => void handleChange(next)}
/>
<div className="min-w-0 text-center sm:text-left">
<h3 className="text-sm font-medium text-foreground">Photo de profil</h3>
<p className="mt-1 text-sm text-muted-foreground">
Visible dans l&apos;en-tête de la suite et sur votre page compte. JPEG, PNG, GIF
ou WebP 512 Ko max.
</p>
</div>
</CompteSettingsCard>
)
}

View File

@ -0,0 +1,14 @@
import { cn } from "@/lib/utils"
import { MAIL_SETTINGS_CARD_CLASS } from "@/lib/mail-chrome-classes"
export function CompteSettingsCard({
className,
children,
}: {
className?: string
children: React.ReactNode
}) {
return (
<div className={cn(MAIL_SETTINGS_CARD_CLASS, "p-5", className)}>{children}</div>
)
}

View File

@ -4,6 +4,12 @@ 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"
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
import { cn } from "@/lib/utils"
const COMPTE_HREF = "/compte"
@ -14,18 +20,16 @@ export function CompteSettingsHeader() {
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">
<Link href={COMPTE_HREF} className={cn("inline-flex", SUITE_APP_LOGO_LOCKUP_CLASS)}>
<Image
src={suitePublicAsset("/compte-mark.svg")}
alt=""
width={32}
height={32}
className="h-8 w-8"
className={SUITE_APP_LOGO_MARK_CLASS}
priority
/>
<span className="text-sm font-medium text-muted-foreground">
Compte Ulti
</span>
<span className={SUITE_APP_LOGO_TEXT_CLASS}>Compte Ulti</span>
</Link>
</div>
@ -36,7 +40,7 @@ export function CompteSettingsHeader() {
alt="Compte Ulti"
width={32}
height={32}
className="h-8 w-8"
className={SUITE_APP_LOGO_MARK_CLASS}
priority
/>
</Link>

View File

@ -3,7 +3,9 @@
import Link from "next/link"
import { ChevronRight, ShieldCheck, UserRound } from "lucide-react"
import { AccountAvatar } from "@/components/suite/account-avatar"
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { cn } from "@/lib/utils"
const CARDS = [
{
@ -30,7 +32,11 @@ export function CompteHomeSection() {
<header className="mb-8 flex flex-col items-center text-center">
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
account={{
name: identity.name,
email: identity.email,
avatarUrl: identity.avatarUrl,
}}
size="lg"
/>
) : (
@ -52,22 +58,25 @@ export function CompteHomeSection() {
{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 key={card.href} href={card.href} className="group block">
<CompteSettingsCard
className={cn(
"h-full transition-colors hover:bg-accent/40",
"group-focus-visible:ring-2 group-focus-visible:ring-ring"
)}
>
<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 block text-sm text-muted-foreground">
{card.description}
</span>
</CompteSettingsCard>
</Link>
)
})}

View File

@ -1,11 +1,13 @@
"use client"
import { ExternalLink } from "lucide-react"
import { UserRound } from "lucide-react"
import { CompteAvatarField } from "@/components/compte/compte-avatar-field"
import { CompteAuthentikPanel } from "@/components/compte/compte-authentik-panel"
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
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",
@ -17,7 +19,6 @@ const ROLE_LABELS: Record<string, string> = {
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 || "—"
@ -34,35 +35,29 @@ export function ComptePersonalInfoSection() {
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>
<div className="space-y-4">
<CompteAvatarField avatarUrl={user?.avatar_url} name={name} email={email} />
<p className="mt-4 text-sm text-muted-foreground">
Votre identité est gérée par le fournisseur d&apos;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&apos;identité
<ExternalLink className="size-3.5" aria-hidden />
</a>
) : null}
<CompteSettingsCard className="overflow-hidden p-0">
<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}
</CompteSettingsCard>
<CompteAuthentikPanel
icon={<UserRound className="size-5" aria-hidden />}
title="Modifier le profil"
description="Nom, adresse e-mail et locale selon les droits définis par votre organisation."
tab="details"
actionLabel="Modifier le profil"
/>
</div>
</>
)
}

View File

@ -1,114 +1,77 @@
"use client"
import { ExternalLink, KeyRound, LogOut, Smartphone } from "lucide-react"
import { KeyRound, LogOut, MonitorSmartphone, ShieldCheck, Smartphone } from "lucide-react"
import { CompteAuthentikPanel } from "@/components/compte/compte-authentik-panel"
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"
import {
AUTHENTIK_SELF_SERVICE_FLOWS,
} 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."
description="Mot de passe, validation en deux étapes, sessions actives et déconnexion."
/>
<div className="space-y-4">
<SecurityCard
<CompteAuthentikPanel
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 />
)
}
description="Changez le mot de passe de votre compte Ulti."
flowSlug={AUTHENTIK_SELF_SERVICE_FLOWS.passwordChange}
actionLabel="Changer le mot de passe"
/>
<SecurityCard
<CompteAuthentikPanel
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 />
)
}
description="Ajoutez ou retirez des appareils TOTP, clés de sécurité WebAuthn ou codes de secours."
tab="mfa"
actionLabel="Gérer la validation"
/>
<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>
}
<CompteAuthentikPanel
icon={<MonitorSmartphone className="size-5" aria-hidden />}
title="Sessions et appareils"
description="Consultez les sessions actives et déconnectez un appareil distant."
tab="sessions"
actionLabel="Gérer les sessions"
/>
<CompteAuthentikPanel
icon={<ShieldCheck className="size-5" aria-hidden />}
title="Services connectés"
description="Liez ou déliez des comptes externes (Google, GitHub, etc.) si votre organisation les propose."
tab="sources"
actionLabel="Gérer les connexions"
/>
<div className="flex flex-col gap-3 rounded-lg border border-mail-border bg-mail-surface p-5 shadow-sm dark:bg-mail-surface-elevated sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h3 className="text-sm font-medium text-foreground">
Session sur cet appareil
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
Met fin à votre session Ulti sur ce navigateur. Vous devrez vous reconnecter.
</p>
</div>
<Button
type="button"
variant="outline"
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
onClick={() => void signOut()}
>
<LogOut className="size-4" aria-hidden />
Se déconnecter
</Button>
</div>
</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&apos;identité non configuré
</span>
)
}

View File

@ -0,0 +1,300 @@
import { addDays, addHours, addMinutes, setHours, setMinutes, startOfDay } from "date-fns"
import type { AgendaApiEvent, AgendaCalendar } from "@/lib/agenda/agenda-types"
import { formatICSDateOnly, formatICSDateTimeUTC } from "@/lib/agenda/agenda-date"
export const DEMO_AGENDA_CALENDARS: AgendaCalendar[] = [
{
id: "personal",
display_name: "Camille Visiteur",
color: "#039be5",
path: "/calendars/personal",
},
{
id: "work",
display_name: "Produit",
color: "#4f6df5",
path: "/calendars/work",
},
]
const WORK_COLOR = "#4f6df5"
const PERSONAL_COLOR = "#039be5"
function at(dayOffset: number, hour: number, minute = 0): Date {
const base = startOfDay(new Date())
return setMinutes(setHours(addDays(base, dayOffset), hour), minute)
}
export function createInitialDemoAgendaEvents(): AgendaApiEvent[] {
const standupStart = at(0, 9, 30)
const oneOnOneStart = at(0, 10, 45)
const lunchStart = at(0, 12, 30)
const clientCallStart = at(0, 15, 0)
const sprintReviewStart = at(1, 16, 30)
const doctorStart = at(2, 8, 30)
const roadmapStart = at(2, 14, 0)
const workshopStart = at(3, 10, 0)
const infraStart = at(3, 14, 0)
const internalDemoStart = at(4, 11, 0)
const focusDay = at(4, 0, 0)
const runStart = at(5, 7, 0)
const betaDeadlineStart = at(5, 17, 0)
const offsiteDay = at(6, 0, 0)
const retroStart = at(6, 14, 0)
return [
{
uid: "demo-standup",
summary: "Stand-up équipe produit",
description: "Point quotidien — avancement sprint, blocages, démo interactive.",
location: "UltiMeet",
start: formatICSDateTimeUTC(standupStart),
end: formatICSDateTimeUTC(addHours(standupStart, 1)),
all_day: false,
path: "/calendars/work/demo-standup.ics",
etag: '"demo-standup"',
organizer: "lea.fontaine@atelier-nord.fr",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "ACCEPTED" },
{ email: "julien.carpentier@proton.me", name: "Julien Carpentier", status: "TENTATIVE" },
],
meet_url: "https://meet.demo.ulti/standup",
color: WORK_COLOR,
},
{
uid: "demo-one-on-one",
summary: "1-on-1 — Léa Fontaine",
description: "Suivi carrière, feedback sur la refonte agenda.",
location: "Salle Boreal",
start: formatICSDateTimeUTC(oneOnOneStart),
end: formatICSDateTimeUTC(addMinutes(oneOnOneStart, 30)),
all_day: false,
path: "/calendars/work/demo-one-on-one.ics",
etag: '"demo-one-on-one"',
organizer: "lea.fontaine@atelier-nord.fr",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
],
color: WORK_COLOR,
},
{
uid: "demo-lunch",
summary: "Déjeuner équipe design",
description: "Célébrer la livraison du nouveau parcours onboarding.",
location: "Le Comptoir, 12 rue de la Paix, Paris 2e",
start: formatICSDateTimeUTC(lunchStart),
end: formatICSDateTimeUTC(addHours(lunchStart, 1)),
all_day: false,
path: "/calendars/personal/demo-lunch.ics",
etag: '"demo-lunch"',
organizer: "camille@demo.ulti",
attendees: [
{ email: "marie.deschamps@yahoo.fr", name: "Marie Deschamps", status: "ACCEPTED" },
{ email: "damien.girard@gmail.com", name: "Damien Girard", status: "NEEDS-ACTION" },
],
color: PERSONAL_COLOR,
},
{
uid: "demo-client-call",
summary: "Appel client — Atelier Nord",
description: "Présentation Ultimail Agenda + intégration UltiMeet pour leur équipe.",
location: "UltiMeet",
start: formatICSDateTimeUTC(clientCallStart),
end: formatICSDateTimeUTC(addHours(clientCallStart, 1)),
all_day: false,
path: "/calendars/work/demo-client-call.ics",
etag: '"demo-client-call"',
organizer: "camille@demo.ulti",
attendees: [
{ email: "lea.fontaine@atelier-nord.fr", name: "Léa Fontaine", status: "ACCEPTED" },
{ email: "vincent.morel@gmail.com", name: "Vincent Morel", status: "TENTATIVE" },
],
meet_url: "https://meet.demo.ulti/atelier-nord",
color: WORK_COLOR,
},
{
uid: "demo-sprint-review",
summary: "Revue de sprint",
description: "Démo des stories livrées — focus calendrier partagé et invitations mail.",
location: "Salle Atlas",
start: formatICSDateTimeUTC(sprintReviewStart),
end: formatICSDateTimeUTC(addHours(sprintReviewStart, 1)),
all_day: false,
path: "/calendars/work/demo-sprint-review.ics",
etag: '"demo-sprint-review"',
organizer: "lea.fontaine@atelier-nord.fr",
attendees: [
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "ACCEPTED" },
{ email: "julien.carpentier@proton.me", name: "Julien Carpentier", status: "NEEDS-ACTION" },
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
],
meet_url: "https://meet.demo.ulti/sprint-review",
color: WORK_COLOR,
},
{
uid: "demo-doctor",
summary: "RDV médecin",
description: "Visite annuelle — bloquer la matinée en conséquence.",
location: "Cabinet Dr. Martin, 8 av. de l'Opéra, Paris 1er",
start: formatICSDateTimeUTC(doctorStart),
end: formatICSDateTimeUTC(addMinutes(doctorStart, 30)),
all_day: false,
path: "/calendars/personal/demo-doctor.ics",
etag: '"demo-doctor"',
color: PERSONAL_COLOR,
},
{
uid: "demo-review",
summary: "Revue roadmap Q3",
description: "Prioriser les chantiers agenda, drive et meet avant le comité.",
location: "Salle Atlas",
start: formatICSDateTimeUTC(roadmapStart),
end: formatICSDateTimeUTC(addHours(roadmapStart, 1)),
all_day: false,
path: "/calendars/work/demo-review.ics",
etag: '"demo-review"',
organizer: "vincent.morel@gmail.com",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "lea.fontaine@atelier-nord.fr", name: "Léa Fontaine", status: "TENTATIVE" },
],
color: WORK_COLOR,
},
{
uid: "demo-workshop",
summary: "Atelier design system — composants agenda",
description: "Session collaborative : chips événements, drag & drop, popover invités.",
location: "Open space Produit",
start: formatICSDateTimeUTC(workshopStart),
end: formatICSDateTimeUTC(addHours(workshopStart, 2)),
all_day: false,
path: "/calendars/work/demo-workshop.ics",
etag: '"demo-workshop"',
organizer: "damien.girard@gmail.com",
attendees: [
{ email: "marie.deschamps@yahoo.fr", name: "Marie Deschamps", status: "ACCEPTED" },
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "ACCEPTED" },
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "NEEDS-ACTION" },
],
meet_url: "https://meet.demo.ulti/design-system",
color: WORK_COLOR,
},
{
uid: "demo-infra",
summary: "Point infra & sécurité",
description: "Revue des accès CalDAV, tokens API agenda et webhooks.",
location: "UltiMeet",
start: formatICSDateTimeUTC(infraStart),
end: formatICSDateTimeUTC(addHours(infraStart, 1)),
all_day: false,
path: "/calendars/work/demo-infra.ics",
etag: '"demo-infra"',
organizer: "guillaume.perrot@proton.me",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "julien.carpentier@proton.me", name: "Julien Carpentier", status: "TENTATIVE" },
],
meet_url: "https://meet.demo.ulti/infra",
color: WORK_COLOR,
},
{
uid: "demo-internal-demo",
summary: "Démo interne Ultimail",
description: "Présentation landing page + parcours démo mail, agenda et drive.",
location: "Salle Polaris",
start: formatICSDateTimeUTC(internalDemoStart),
end: formatICSDateTimeUTC(addHours(internalDemoStart, 1)),
all_day: false,
path: "/calendars/work/demo-internal-demo.ics",
etag: '"demo-internal-demo"',
organizer: "camille@demo.ulti",
attendees: [
{ email: "lea.fontaine@atelier-nord.fr", name: "Léa Fontaine", status: "ACCEPTED" },
{ email: "vincent.morel@gmail.com", name: "Vincent Morel", status: "ACCEPTED" },
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "NEEDS-ACTION" },
],
meet_url: "https://meet.demo.ulti/demo-interne",
color: WORK_COLOR,
},
{
uid: "demo-focus",
summary: "Focus — zéro interruption",
description: "Journée sans réunion pour finaliser les specs agenda.",
location: "",
start: formatICSDateOnly(focusDay),
end: formatICSDateOnly(addDays(focusDay, 1)),
all_day: true,
path: "/calendars/personal/demo-focus.ics",
etag: '"demo-focus"',
color: PERSONAL_COLOR,
},
{
uid: "demo-run",
summary: "Course — running club",
description: "Sortie matinale avant la deadline beta.",
location: "Bois de Vincennes, porte Dorée",
start: formatICSDateTimeUTC(runStart),
end: formatICSDateTimeUTC(addMinutes(runStart, 30)),
all_day: false,
path: "/calendars/personal/demo-run.ics",
etag: '"demo-run"',
color: PERSONAL_COLOR,
},
{
uid: "demo-beta-deadline",
summary: "Deadline — livraison beta agenda",
description: "Gel des features, QA finale et préparation release notes.",
location: "Salle Polaris",
start: formatICSDateTimeUTC(betaDeadlineStart),
end: formatICSDateTimeUTC(addHours(betaDeadlineStart, 1)),
all_day: false,
path: "/calendars/work/demo-beta-deadline.ics",
etag: '"demo-beta-deadline"',
organizer: "lea.fontaine@atelier-nord.fr",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "ACCEPTED" },
],
color: WORK_COLOR,
},
{
uid: "demo-offsite",
summary: "Offsite équipe produit",
description: "Journée stratégie Q3 — vision suite souveraine et roadmap intégrée.",
location: "Château de Chantilly, salle des Gardes",
start: formatICSDateOnly(offsiteDay),
end: formatICSDateOnly(addDays(offsiteDay, 1)),
all_day: true,
path: "/calendars/work/demo-offsite.ics",
etag: '"demo-offsite"',
organizer: "vincent.morel@gmail.com",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "lea.fontaine@atelier-nord.fr", name: "Léa Fontaine", status: "ACCEPTED" },
{ email: "thomas.giraud@proton.me", name: "Thomas Giraud", status: "TENTATIVE" },
{ email: "julien.carpentier@proton.me", name: "Julien Carpentier", status: "NEEDS-ACTION" },
],
color: WORK_COLOR,
},
{
uid: "demo-retro",
summary: "Rétrospective sprint",
description: "Bilan du cycle — ce qui a bien fonctionné sur l'agenda démo.",
location: "Salle Atlas",
start: formatICSDateTimeUTC(retroStart),
end: formatICSDateTimeUTC(addHours(retroStart, 2)),
all_day: false,
path: "/calendars/work/demo-retro.ics",
etag: '"demo-retro"',
organizer: "lea.fontaine@atelier-nord.fr",
attendees: [
{ email: "camille@demo.ulti", name: "Camille Visiteur", status: "ACCEPTED" },
{ email: "marie.deschamps@yahoo.fr", name: "Marie Deschamps", status: "ACCEPTED" },
{ email: "damien.girard@gmail.com", name: "Damien Girard", status: "NEEDS-ACTION" },
],
meet_url: "https://meet.demo.ulti/retro",
color: WORK_COLOR,
},
]
}

View File

@ -0,0 +1,20 @@
"use client"
import type { ReactNode } from "react"
import { AgendaAppShell } from "@/components/agenda/agenda-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { DemoAgendaProvider } from "@/lib/demo/demo-agenda-context"
import { DEMO_AGENDA_ROUTE_ROOT } from "@/lib/demo/demo-agenda-context"
import { DemoAgendaBootstrap } from "@/lib/demo/demo-agenda-bootstrap"
import { useDemoAgendaStore } from "@/lib/demo/demo-agenda-store"
export function DemoAgendaShell({ children }: { children: ReactNode }) {
return (
<DemoAgendaProvider onReset={() => useDemoAgendaStore.getState().reset()}>
<DemoAgendaBootstrap />
<DemoChrome>
<AgendaAppShell routeRoot={DEMO_AGENDA_ROUTE_ROOT}>{children}</AgendaAppShell>
</DemoChrome>
</DemoAgendaProvider>
)
}

View File

@ -0,0 +1,35 @@
"use client"
import type { ReactNode } from "react"
import { DemoNavigationGuard } from "@/components/demo/demo-navigation-guard"
import { cn } from "@/lib/utils"
export function DemoChrome({
children,
className,
}: {
children: ReactNode
className?: string
}) {
return (
<div
className={cn(
"relative flex h-dvh max-h-dvh flex-col overflow-hidden",
className
)}
>
<DemoNavigationGuard />
<div
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center pt-2",
"max-sm:pt-1"
)}
>
<span className="rounded-full border border-[var(--mail-border)] bg-[var(--mail-surface-elevated)]/95 px-3 py-1 text-[11px] font-semibold text-[var(--mail-text-muted)] shadow-sm backdrop-blur-sm">
Démo interactive zéro rétention
</span>
</div>
{children}
</div>
)
}

View File

@ -0,0 +1,761 @@
import type { FullContact } from "@/lib/contacts/types"
let _id = 0
function nextId(): string {
return `demo-contact-${String(++_id).padStart(3, "0")}`
}
const NOW = 1716000000000
function c(
partial: Omit<FullContact, "id" | "createdAt" | "updatedAt"> & {
createdAt?: number
updatedAt?: number
}
): FullContact {
return {
id: nextId(),
createdAt: NOW,
updatedAt: NOW,
...partial,
} as FullContact
}
const DEMO_CONTACT_AVATARS = Array.from({ length: 14 }, (_, index) =>
`/demo/contacts/avatars/avatar-${String(index + 1).padStart(2, "0")}.jpg`
)
function applyDemoAvatars(contacts: FullContact[]): FullContact[] {
let avatarIndex = 0
return contacts.map((contact, index) => {
if (index % 5 >= 2) return contact
const avatarUrl = DEMO_CONTACT_AVATARS[avatarIndex % DEMO_CONTACT_AVATARS.length]
avatarIndex += 1
return { ...contact, avatarUrl }
})
}
const RAW_DEMO_CONTACTS: FullContact[] = [
c({
firstName: "",
lastName: "",
phones: [{ value: "+33 6 47 82 19 03", label: "mobile" }],
emails: [],
notes: "Numéro reçu par SMS — identité inconnue.",
}),
c({
firstName: "Atelier",
lastName: "Nord",
emails: [{ value: "contact@atelier-nord.fr", label: "work" }],
phones: [{ value: "+33 1 76 54 32 10", label: "work" }],
company: "Atelier Nord",
website: "https://atelier-nord.fr",
addresses: [
{
street: "18 rue de la Fonderie",
city: "Lille",
region: "Hauts-de-France",
postalCode: "59000",
country: "France",
label: "Siège",
},
],
}),
c({
firstName: "Camille",
lastName: "Visiteur",
emails: [
{ value: "camille@demo.ulti", label: "work" },
{ value: "camille.visiteur@gmail.com", label: "personal" },
],
phones: [{ value: "+33 6 12 00 45 78", label: "mobile" }],
company: "Ultimail",
department: "Produit",
jobTitle: "Product Manager",
nicknames: ["Cam"],
labels: ["Équipe", "Favori"],
birthday: { day: 14, month: 6, year: 1991 },
notes: "Compte démo — calendrier personnel et boîte mail de démonstration.",
addresses: [
{
street: "42 avenue Parmentier",
city: "Paris",
region: "Île-de-France",
postalCode: "75011",
country: "France",
label: "home",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/camille-visiteur", label: "LinkedIn" },
{ value: "https://x.com/camille_visiteur", label: "X" },
],
interactionCount: 128,
}),
c({
firstName: "Léa",
lastName: "Fontaine",
emails: [
{ value: "lea.fontaine@atelier-nord.fr", label: "work" },
{ value: "lea.fontaine.perso@gmail.com", label: "personal" },
],
phones: [
{ value: "+33 6 78 45 12 90", label: "mobile" },
{ value: "+33 1 76 54 32 11", label: "work" },
],
company: "Atelier Nord",
department: "Produit",
jobTitle: "Head of Product",
nicknames: ["Léa F."],
labels: ["Client", "Produit"],
birthday: { day: 3, month: 11, year: 1988 },
notes: "Organisatrice du stand-up et du comité produit. Préfère les CR dans UltiDrive.",
addresses: [
{
street: "18 rue de la Fonderie",
city: "Lille",
region: "Hauts-de-France",
postalCode: "59000",
country: "France",
label: "work",
},
{
street: "7 quai de la Loire",
city: "Paris",
region: "Île-de-France",
postalCode: "75019",
country: "France",
label: "home",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/lea-fontaine", label: "LinkedIn" },
],
interactionCount: 54,
}),
c({
firstName: "Marc",
lastName: "Delcourt",
emails: [{ value: "marc@delcourt-conseil.com", label: "work" }],
phones: [{ value: "+33 6 55 88 22 41", label: "mobile" }],
company: "Delcourt Conseil",
jobTitle: "Consultant senior",
department: "Transformation digitale",
addresses: [
{
street: "3 place de la Bourse",
city: "Bordeaux",
region: "Nouvelle-Aquitaine",
postalCode: "33000",
country: "France",
label: "work",
},
],
interactionCount: 19,
}),
c({
firstName: "Anaïs",
lastName: "Rivet",
emails: [
{ value: "anais.rivet@coop-numerique.org", label: "work" },
{ value: "anais.rivet@gmail.com", label: "personal" },
],
phones: [{ value: "+33 6 33 77 44 12", label: "mobile" }],
company: "Coop Numérique",
department: "Communauté",
jobTitle: "Community Lead",
labels: ["Réseau"],
addresses: [
{
street: "12 cours Julien",
city: "Marseille",
region: "Provence-Alpes-Côte d'Azur",
postalCode: "13006",
country: "France",
label: "work",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/anais-rivet", label: "LinkedIn" },
{ value: "https://x.com/anais_rivet", label: "X" },
],
interactionCount: 31,
}),
c({
firstName: "Sophie",
lastName: "Morel",
emails: [{ value: "sophie@startup-io.fr", label: "work" }],
phones: [
{ value: "+33 6 90 11 22 33", label: "mobile" },
{ value: "+33 1 84 88 12 00", label: "work" },
],
company: "Startup IO",
jobTitle: "CEO",
department: "Direction",
nicknames: ["Soph"],
birthday: { day: 22, month: 4, year: 1985 },
notes: "Rencontrée au salon VivaTech — intéressée par l'intégration webhooks.",
addresses: [
{
street: "55 rue du Faubourg Saint-Honoré",
city: "Paris",
region: "Île-de-France",
postalCode: "75008",
country: "France",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/sophie-morel", label: "LinkedIn" }],
interactionCount: 12,
}),
c({
firstName: "Amadou",
lastName: "Diop",
emails: [{ value: "amadou.diop@outlook.com", label: "personal" }],
phones: [{ value: "+221 77 456 78 90", label: "mobile" }],
company: "Dakar Startup Hub",
jobTitle: "Program Manager",
addresses: [
{
street: "15 Avenue Cheikh Anta Diop",
city: "Dakar",
region: "Dakar",
postalCode: "18524",
country: "Sénégal",
label: "work",
},
],
isOtherContact: true,
interactionCount: 7,
}),
c({
firstName: "Hélène",
lastName: "Marchand",
emails: [
{ value: "helene.marchand@lyon-tech.fr", label: "work" },
{ value: "helene.m@gmail.com", label: "personal" },
],
phones: [{ value: "+33 6 44 55 66 77", label: "mobile" }],
company: "Lyon Tech",
department: "Ingénierie",
jobTitle: "Lead Backend",
nicknames: ["Hélène M."],
labels: ["Tech", "Partenaire"],
birthday: { day: 8, month: 2, year: 1990 },
addresses: [
{
street: "24 rue de la République",
city: "Lyon",
region: "Auvergne-Rhône-Alpes",
postalCode: "69002",
country: "France",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/helene-marchand", label: "LinkedIn" }],
interactionCount: 38,
}),
c({
firstName: "Thomas",
lastName: "Giraud",
emails: [{ value: "thomas.giraud@toulouse-data.fr", label: "work" }],
phones: [{ value: "+33 6 21 43 65 87", label: "mobile" }],
company: "Toulouse Data",
jobTitle: "Data Engineer",
department: "Analytics",
addresses: [
{
street: "8 allées Jean Jaurès",
city: "Toulouse",
region: "Occitanie",
postalCode: "31000",
country: "France",
label: "work",
},
],
notes: "Spécialiste pipelines ETL — contact pour le projet BI.",
}),
c({
firstName: "Émilie",
lastName: "Rousseau",
emails: [{ value: "emilie.rousseau@gmail.com", label: "personal" }],
phones: [{ value: "+33 6 88 77 66 55", label: "mobile" }],
nicknames: ["Mimi"],
birthday: { day: 17, month: 9, year: 1993 },
labels: ["Amis"],
addresses: [
{
street: "14 rue Sainte-Catherine",
city: "Bordeaux",
region: "Nouvelle-Aquitaine",
postalCode: "33000",
country: "France",
label: "home",
},
],
interactionCount: 22,
}),
c({
firstName: "Florian",
lastName: "Meyer",
emails: [
{ value: "florian.meyer@gmx.de", label: "personal" },
{ value: "f.meyer@berlin-dev.io", label: "work" },
],
phones: [{ value: "+49 170 1234567", label: "mobile" }],
company: "Berlin Dev",
jobTitle: "DevOps Engineer",
department: "Infrastructure",
addresses: [
{
street: "Torstraße 102",
city: "Berlin",
region: "Berlin",
postalCode: "10119",
country: "Germany",
label: "work",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/florian-meyer", label: "LinkedIn" },
{ value: "https://x.com/florian_meyer", label: "X" },
],
interactionCount: 15,
}),
c({
firstName: "Javier",
lastName: "Muñoz",
emails: [{ value: "javier.munoz@gmail.com", label: "personal" }],
phones: [{ value: "+34 612 345 678", label: "mobile" }],
company: "Telefónica",
jobTitle: "Data Scientist",
addresses: [
{
street: "Calle de Alcalá 48",
city: "Madrid",
region: "Comunidad de Madrid",
postalCode: "28014",
country: "Spain",
label: "work",
},
],
labels: ["Conférence"],
isOtherContact: true,
}),
c({
firstName: "Chloé",
lastName: "Tremblay",
emails: [
{ value: "chloe.tremblay@ultimail.ca", label: "work" },
{ value: "chloe.t@gmail.com", label: "personal" },
],
phones: [
{ value: "+1 514 555 0198", label: "mobile" },
{ value: "+1 514 555 0100", label: "work" },
],
company: "Ultimail Canada",
department: "Support",
jobTitle: "Customer Success",
nicknames: ["Chlo"],
birthday: { day: 5, month: 12, year: 1994 },
addresses: [
{
street: "1010 Rue Sainte-Catherine Ouest",
city: "Montréal",
region: "Québec",
postalCode: "H3B 1G1",
country: "Canada",
label: "work",
},
{
street: "4525 Avenue du Parc",
city: "Montréal",
region: "Québec",
postalCode: "H2V 4E7",
country: "Canada",
label: "home",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/chloe-tremblay", label: "LinkedIn" }],
interactionCount: 41,
}),
c({
firstName: "Hana",
lastName: "Yamamoto",
emails: [{ value: "hana.yamamoto@gmail.com", label: "personal" }],
phones: [{ value: "+81 90 1234 5678", label: "mobile" }],
company: "Nippon Design Co.",
jobTitle: "UX Researcher",
addresses: [
{
street: "2-8-1 Nishi-Shinjuku",
city: "Tokyo",
region: "Tokyo",
postalCode: "163-8001",
country: "Japan",
label: "work",
},
],
notes: "Collaboration design system — fuseau UTC+9.",
labels: ["Design"],
}),
c({
firstName: "Priya",
lastName: "Sharma",
emails: [
{ value: "priya.sharma@outlook.com", label: "personal" },
{ value: "priya@infosys-partner.in", label: "work" },
],
phones: [{ value: "+91 98765 43210", label: "mobile" }],
company: "Infosys",
department: "Delivery",
jobTitle: "Team Lead",
addresses: [
{
street: "44 MG Road",
city: "Bengaluru",
region: "Karnataka",
postalCode: "560001",
country: "India",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/priya-sharma", label: "LinkedIn" }],
interactionCount: 26,
}),
c({
firstName: "Augustin",
lastName: "Ferrand",
emails: [{ value: "augustin.ferrand@proton.me", label: "personal" }],
phones: [],
isOtherContact: true,
notes: "Ajouté automatiquement depuis un mail reçu.",
}),
c({
firstName: "Nadia",
lastName: "Haddad",
emails: [{ value: "nadia.haddad@gmail.com", label: "personal" }],
phones: [{ value: "+33 6 43 21 09 87", label: "mobile" }],
company: "Mistral AI",
jobTitle: "Research Engineer",
department: "R&D",
labels: ["IA"],
addresses: [
{
street: "15 rue des Halles",
city: "Paris",
region: "Île-de-France",
postalCode: "75001",
country: "France",
label: "work",
},
],
socialProfiles: [{ value: "https://x.com/nadia_haddad", label: "X" }],
interactionCount: 9,
}),
c({
firstName: "Vincent",
lastName: "Morel",
emails: [
{ value: "vincent.morel@gmail.com", label: "personal" },
{ value: "vincent.morel@blablacar.com", label: "work" },
],
phones: [{ value: "+33 6 65 78 90 12", label: "mobile" }],
company: "BlaBlaCar",
jobTitle: "VP Engineering",
department: "Platform",
nicknames: ["Vince"],
birthday: { day: 12, month: 4, year: 1987 },
addresses: [
{
street: "84 avenue de la République",
city: "Paris",
region: "Île-de-France",
postalCode: "75011",
country: "France",
label: "work",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/vincent-morel", label: "LinkedIn" },
],
interactionCount: 42,
}),
c({
firstName: "Fatou",
lastName: "Sow",
emails: [{ value: "fatou.sow@orange.sn", label: "work" }],
phones: [{ value: "+221 70 123 45 67", label: "mobile" }],
company: "Orange Sénégal",
jobTitle: "Account Manager",
addresses: [
{
street: "Km 8 Route de Ouakam",
city: "Dakar",
region: "Dakar",
postalCode: "18524",
country: "Sénégal",
label: "work",
},
],
labels: ["Partenaire"],
interactionCount: 14,
}),
c({
firstName: "Lucie",
lastName: "Simon",
emails: [{ value: "lucie.simon@proton.me", label: "personal" }],
phones: [{ value: "+33 6 67 89 01 23", label: "mobile" }],
nicknames: ["Lu"],
birthday: { day: 28, month: 2, year: 1997 },
labels: ["Famille"],
addresses: [
{
street: "6 place du Capitole",
city: "Toulouse",
region: "Occitanie",
postalCode: "31000",
country: "France",
label: "home",
},
],
}),
c({
firstName: "Kevin",
lastName: "Park",
emails: [{ value: "kevin.park@gmail.com", label: "personal" }],
phones: [{ value: "+82 10 1234 5678", label: "mobile" }],
company: "Samsung",
jobTitle: "Frontend Engineer",
department: "Mobile",
addresses: [
{
street: "129 Samsung-ro",
city: "Suwon",
region: "Gyeonggi",
postalCode: "16677",
country: "South Korea",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/kevin-park", label: "LinkedIn" }],
}),
c({
firstName: "Manon",
lastName: "Leroy",
emails: [
{ value: "manon.leroy@outlook.fr", label: "personal" },
{ value: "manon.leroy@ovhcloud.com", label: "work" },
],
phones: [{ value: "+33 6 32 10 98 76", label: "mobile" }],
company: "OVHcloud",
jobTitle: "SRE",
department: "Cloud Ops",
notes: "Contact infra — escalade incidents P1.",
addresses: [
{
street: "2 rue Kellermann",
city: "Roubaix",
region: "Hauts-de-France",
postalCode: "59100",
country: "France",
label: "work",
},
],
interactionCount: 33,
}),
c({
firstName: "Rachid",
lastName: "Ouali",
emails: [{ value: "rachid.ouali@yahoo.fr", label: "personal" }],
phones: [{ value: "+33 6 87 65 43 21", label: "mobile" }],
company: "Freelance",
jobTitle: "Photographe",
addresses: [
{
street: "23 cours Mirabeau",
city: "Aix-en-Provence",
region: "Provence-Alpes-Côte d'Azur",
postalCode: "13100",
country: "France",
label: "home",
},
],
labels: ["Créatif"],
}),
c({
firstName: "Elena",
lastName: "Kuznetsova",
emails: [{ value: "elena.kuznetsova@mail.ru", label: "personal" }],
phones: [{ value: "+7 916 123 45 67", label: "mobile" }],
company: "Yandex",
jobTitle: "ML Engineer",
addresses: [
{
street: "16 Leo Tolstoy Street",
city: "Moscow",
region: "Moscow",
postalCode: "119021",
country: "Russia",
label: "work",
},
],
interactionCount: 6,
}),
c({
firstName: "Youssef",
lastName: "Alaoui",
emails: [
{ value: "youssef.alaoui@outlook.com", label: "personal" },
{ value: "y.alaoui@ram.ma", label: "work" },
],
phones: [{ value: "+212 6 45 67 89 01", label: "mobile" }],
company: "Royal Air Maroc",
jobTitle: "IT Manager",
department: "Systèmes",
addresses: [
{
street: "Aéroport Mohammed V",
city: "Casablanca",
region: "Casablanca-Settat",
postalCode: "20000",
country: "Morocco",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/youssef-alaoui", label: "LinkedIn" }],
interactionCount: 11,
}),
c({
firstName: "Charlotte",
lastName: "Martin",
emails: [{ value: "charlotte.martin@gmail.com", label: "personal" }],
phones: [{ value: "+33 6 22 11 00 99", label: "mobile" }],
birthday: { day: 5, month: 11, year: 1995 },
nicknames: ["Cha"],
addresses: [
{
street: "9 rue de la République",
city: "Lyon",
region: "Auvergne-Rhône-Alpes",
postalCode: "69001",
country: "France",
label: "home",
},
],
labels: ["Amis"],
interactionCount: 18,
}),
c({
firstName: "Gabriel",
lastName: "Santos",
emails: [{ value: "gabriel.santos@outlook.com", label: "personal" }],
phones: [{ value: "+55 11 98765 4321", label: "mobile" }],
company: "Nubank",
jobTitle: "Product Designer",
addresses: [
{
street: "Rua Capote Valente 39",
city: "São Paulo",
region: "SP",
postalCode: "05409-000",
country: "Brazil",
label: "work",
},
],
socialProfiles: [
{ value: "https://linkedin.com/in/gabriel-santos", label: "LinkedIn" },
{ value: "https://x.com/gabriel_santos", label: "X" },
],
}),
c({
firstName: "Inès",
lastName: "Belhadj",
emails: [{ value: "ines.belhadj@gmail.com", label: "personal" }],
phones: [],
company: "Doctolib",
jobTitle: "iOS Developer",
department: "Mobile",
notes: "Ancienne collègue — recommandation stage.",
addresses: [
{
street: "54 quai de la Rapée",
city: "Paris",
region: "Île-de-France",
postalCode: "75012",
country: "France",
label: "work",
},
],
}),
c({
firstName: "William",
lastName: "Hartmann",
emails: [{ value: "william.hartmann@gmx.de", label: "personal" }],
phones: [{ value: "+49 160 9876543", label: "mobile" }],
company: "Bosch",
jobTitle: "Embedded Engineer",
addresses: [
{
street: "Robert-Bosch-Platz 1",
city: "Stuttgart",
region: "Baden-Württemberg",
postalCode: "70839",
country: "Germany",
label: "work",
},
],
interactionCount: 4,
}),
c({
firstName: "Sandra",
lastName: "Oliveira",
emails: [{ value: "sandra.oliveira@outlook.com", label: "personal" }],
phones: [{ value: "+351 912 345 678", label: "mobile" }],
company: "Farfetch",
jobTitle: "QA Lead",
addresses: [
{
street: "Rua do Ouro 240",
city: "Lisbon",
region: "Lisboa",
postalCode: "1100-063",
country: "Portugal",
label: "work",
},
],
labels: ["QA"],
birthday: { day: 30, month: 6, year: 1990 },
}),
c({
firstName: "Mehdi",
lastName: "Bouaziz",
emails: [{ value: "mehdi.bouaziz@outlook.com", label: "personal" }],
phones: [{ value: "+216 55 123 456", label: "mobile" }],
company: "Sofrecom",
jobTitle: "Architecte solutions",
department: "Consulting",
addresses: [
{
street: "Les Berges du Lac",
city: "Tunis",
region: "Tunis",
postalCode: "1053",
country: "Tunisia",
label: "work",
},
],
socialProfiles: [{ value: "https://linkedin.com/in/mehdi-bouaziz", label: "LinkedIn" }],
interactionCount: 8,
}),
].sort((a, b) => {
const nameA = `${a.firstName} ${a.lastName}`.toLowerCase()
const nameB = `${b.firstName} ${b.lastName}`.toLowerCase()
return nameA.localeCompare(nameB)
})
export const DEMO_FULL_CONTACTS: FullContact[] = applyDemoAvatars(RAW_DEMO_CONTACTS)
export function createInitialDemoContacts(): FullContact[] {
return [...DEMO_FULL_CONTACTS]
}

View File

@ -0,0 +1,23 @@
"use client"
import type { ReactNode } from "react"
import { ContactsAppShell } from "@/components/gmail/contacts-page/contacts-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { DemoContactsProvider } from "@/lib/demo/demo-contacts-context"
import { DemoContactsBootstrap } from "@/lib/demo/demo-contacts-bootstrap"
import { useDemoContactsStore } from "@/lib/demo/demo-contacts-store"
export function DemoContactsShell({ children }: { children: ReactNode }) {
return (
<DemoContactsProvider onReset={() => useDemoContactsStore.getState().reset()}>
<DemoContactsBootstrap />
<SuiteThemeShell>
<DemoChrome>
<ContactsAppShell />
{children}
</DemoChrome>
</SuiteThemeShell>
</DemoContactsProvider>
)
}

View File

@ -1,7 +1,9 @@
"use client"
import { useEffect, useMemo } from "react"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
const DEMO_SESSION: RichTextSessionResponse = {
@ -38,14 +40,18 @@ export function DemoDocsEditor() {
)
return (
<div className="flex h-dvh flex-col overflow-hidden">
<RichTextDocumentEditor
session={DEMO_SESSION}
mode="edit"
userName="Visiteur"
userColor="#4f6df5"
chrome={chrome}
/>
</div>
<SuiteThemeShell>
<DemoChrome>
<div className="flex h-dvh flex-col overflow-hidden">
<RichTextDocumentEditor
session={DEMO_SESSION}
mode="edit"
userName="Visiteur"
userColor="#4f6df5"
chrome={chrome}
/>
</div>
</DemoChrome>
</SuiteThemeShell>
)
}

View File

@ -0,0 +1,387 @@
import type { DriveFileInfo } from "@/lib/api/types"
function demoNow(): string {
const d = new Date()
d.setHours(14, 30, 0, 0)
return d.toISOString().replace(/\.\d{3}Z$/, "+00:00")
}
function demoDaysAgo(days: number, hours = 14, minutes = 30): string {
const d = new Date()
d.setDate(d.getDate() - days)
d.setHours(hours, minutes, 0, 0)
return d.toISOString().replace(/\.\d{3}Z$/, "+00:00")
}
function file(
partial: Omit<
DriveFileInfo,
"etag" | "last_modified" | "size" | "mime_type" | "type" | "is_favorite" | "is_shared"
> &
Partial<
Pick<
DriveFileInfo,
"etag" | "last_modified" | "size" | "mime_type" | "type" | "is_favorite" | "is_shared"
>
>
): DriveFileInfo {
return {
...partial,
etag: partial.etag ?? `"demo-${partial.path}"`,
last_modified: partial.last_modified ?? demoNow(),
size: partial.size ?? 0,
mime_type: partial.mime_type ?? "application/octet-stream",
type: partial.type ?? "file",
is_favorite: partial.is_favorite ?? false,
is_shared: partial.is_shared ?? false,
}
}
/** Flat demo file tree (paths like Nextcloud). */
export const DEMO_DRIVE_FILES: DriveFileInfo[] = [
// — Dossiers —
file({
path: "/Produit",
name: "Produit",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 101,
last_modified: demoDaysAgo(14, 9, 0),
}),
file({
path: "/Produit/Comités",
name: "Comités",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 102,
last_modified: demoDaysAgo(3, 16, 45),
}),
file({
path: "/Marketing",
name: "Marketing",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 103,
last_modified: demoDaysAgo(10, 11, 20),
}),
file({
path: "/Marketing/Campagne lancement",
name: "Campagne lancement",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 104,
last_modified: demoDaysAgo(6, 10, 0),
}),
file({
path: "/RH",
name: "RH",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 105,
last_modified: demoDaysAgo(12, 8, 30),
}),
file({
path: "/RH/Onboarding",
name: "Onboarding",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 106,
last_modified: demoDaysAgo(7, 14, 0),
}),
file({
path: "/Perso",
name: "Perso",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 107,
last_modified: demoDaysAgo(11, 18, 15),
}),
file({
path: "/Archives",
name: "Archives",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 108,
last_modified: demoDaysAgo(13, 9, 0),
}),
file({
path: "/Archives/2025",
name: "2025",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 109,
last_modified: demoDaysAgo(13, 9, 5),
}),
file({
path: "/Partagé",
name: "Partagé",
type: "directory",
mime_type: "httpd/unix-directory",
file_id: 110,
last_modified: demoDaysAgo(4, 11, 30),
}),
// — Produit —
file({
path: "/Produit/Comité produit — CR 9 juin.ultidoc",
name: "Comité produit — CR 9 juin.ultidoc",
size: 287_400,
mime_type: "application/vnd.ultimail.document",
file_id: 111,
source: "ultimail",
is_favorite: true,
last_modified: demoDaysAgo(0, 10, 15),
}),
file({
path: "/Produit/Roadmap Q3.pdf",
name: "Roadmap Q3.pdf",
size: 1_842_000,
mime_type: "application/pdf",
file_id: 112,
last_modified: demoDaysAgo(1, 15, 20),
}),
file({
path: "/Produit/Specs fonctionnelles v2.4.ultidoc",
name: "Specs fonctionnelles v2.4.ultidoc",
size: 412_800,
mime_type: "application/vnd.ultimail.document",
file_id: 113,
source: "ultimail",
is_shared: true,
last_modified: demoDaysAgo(2, 9, 40),
}),
file({
path: "/Produit/Comités/Retro sprint 24 — notes.ultidoc",
name: "Retro sprint 24 — notes.ultidoc",
size: 156_200,
mime_type: "application/vnd.ultimail.document",
file_id: 114,
source: "ultimail",
last_modified: demoDaysAgo(3, 17, 10),
}),
// — Marketing —
file({
path: "/Marketing/Présentation lancement Q3.pptx",
name: "Présentation lancement Q3.pptx",
size: 5_640_000,
mime_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
file_id: 115,
last_modified: demoDaysAgo(1, 11, 0),
}),
file({
path: "/Marketing/Bannière site — hero.png",
name: "Bannière site — hero.png",
size: 2_180_000,
mime_type: "image/png",
file_id: 116,
last_modified: demoDaysAgo(4, 14, 30),
}),
file({
path: "/Marketing/Charte graphique 2026.pdf",
name: "Charte graphique 2026.pdf",
size: 3_210_000,
mime_type: "application/pdf",
file_id: 117,
is_favorite: true,
last_modified: demoDaysAgo(5, 10, 45),
}),
file({
path: "/Marketing/Campagne lancement/Brief créatif.docx",
name: "Brief créatif.docx",
size: 67_500,
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_id: 118,
is_shared: true,
last_modified: demoDaysAgo(6, 16, 20),
}),
// — RH —
file({
path: "/RH/Contrat type CDI.docx",
name: "Contrat type CDI.docx",
size: 48_300,
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_id: 119,
last_modified: demoDaysAgo(8, 9, 15),
}),
file({
path: "/RH/Guide télétravail.pdf",
name: "Guide télétravail.pdf",
size: 892_000,
mime_type: "application/pdf",
file_id: 120,
last_modified: demoDaysAgo(9, 13, 0),
}),
file({
path: "/RH/Onboarding/Checklist jour 1.ultidoc",
name: "Checklist jour 1.ultidoc",
size: 94_600,
mime_type: "application/vnd.ultimail.document",
file_id: 121,
source: "ultimail",
last_modified: demoDaysAgo(7, 8, 50),
}),
// — Perso —
file({
path: "/Perso/Notes réunion.txt",
name: "Notes réunion.txt",
size: 4_200,
mime_type: "text/plain",
file_id: 122,
last_modified: demoDaysAgo(2, 18, 30),
}),
file({
path: "/Perso/Idées side project.ultidoc",
name: "Idées side project.ultidoc",
size: 38_900,
mime_type: "application/vnd.ultimail.document",
file_id: 123,
source: "ultimail",
last_modified: demoDaysAgo(10, 20, 0),
}),
file({
path: "/Perso/Photo équipe offsite.jpg",
name: "Photo équipe offsite.jpg",
size: 3_450_000,
mime_type: "image/jpeg",
file_id: 124,
last_modified: demoDaysAgo(11, 19, 45),
}),
// — Archives —
file({
path: "/Archives/CR comité — mai.ultidoc",
name: "CR comité — mai.ultidoc",
size: 221_000,
mime_type: "application/vnd.ultimail.document",
file_id: 125,
source: "ultimail",
last_modified: demoDaysAgo(12, 10, 0),
}),
file({
path: "/Archives/2025/Budget 2025.xlsx",
name: "Budget 2025.xlsx",
size: 76_800,
mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file_id: 126,
last_modified: demoDaysAgo(13, 9, 30),
}),
// — Partagé (dossier local) —
file({
path: "/Partagé/Analyse concurrence.xlsx",
name: "Analyse concurrence.xlsx",
size: 112_400,
mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file_id: 127,
is_shared: true,
last_modified: demoDaysAgo(3, 11, 15),
}),
// — Racine —
file({
path: "/Budget 2026.xlsx",
name: "Budget 2026.xlsx",
size: 89_000,
mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file_id: 128,
is_favorite: true,
last_modified: demoDaysAgo(0, 8, 0),
}),
file({
path: "/Logo Ultimail.svg",
name: "Logo Ultimail.svg",
size: 12_400,
mime_type: "image/svg+xml",
file_id: 129,
last_modified: demoDaysAgo(14, 10, 0),
}),
file({
path: "/Architecture système — schéma.svg",
name: "Architecture système — schéma.svg",
size: 18_700,
mime_type: "image/svg+xml",
file_id: 130,
last_modified: demoDaysAgo(5, 15, 30),
}),
file({
path: "/Release notes v2.3.txt",
name: "Release notes v2.3.txt",
size: 8_900,
mime_type: "text/plain",
file_id: 131,
last_modified: demoDaysAgo(1, 9, 30),
}),
]
export const DEMO_DRIVE_RECENT_IDS = [111, 128, 131, 112, 115, 113, 122, 127]
export const DEMO_DRIVE_STARRED_IDS = [111, 117, 128]
export const DEMO_DRIVE_TRASH: DriveFileInfo[] = [
file({
path: "/Ancien brief.docx",
name: "Ancien brief.docx",
size: 34_000,
mime_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
file_id: 201,
last_modified: demoDaysAgo(5, 16, 0),
}),
file({
path: "/Export contacts — test.csv",
name: "Export contacts — test.csv",
size: 12_800,
mime_type: "text/csv",
file_id: 202,
last_modified: demoDaysAgo(8, 11, 45),
}),
file({
path: "/Maquette homepage v1.pptx",
name: "Maquette homepage v1.pptx",
size: 4_120_000,
mime_type: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
file_id: 203,
last_modified: demoDaysAgo(11, 14, 20),
}),
]
export const DEMO_DRIVE_SHARED: DriveFileInfo[] = [
file({
path: "/Partagé/Specs API v2.pdf",
name: "Specs API v2.pdf",
size: 512_000,
mime_type: "application/pdf",
file_id: 301,
is_shared: true,
last_modified: demoDaysAgo(0, 14, 0),
}),
file({
path: "/Partagé/Benchmark SaaS européens.xlsx",
name: "Benchmark SaaS européens.xlsx",
size: 98_600,
mime_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file_id: 302,
is_shared: true,
last_modified: demoDaysAgo(2, 10, 30),
}),
file({
path: "/Partagé/CR réunion client — Delcourt.pdf",
name: "CR réunion client — Delcourt.pdf",
size: 245_000,
mime_type: "application/pdf",
file_id: 303,
is_shared: true,
last_modified: demoDaysAgo(4, 17, 45),
}),
file({
path: "/Partagé/Wireframes flux auth.ultidoc",
name: "Wireframes flux auth.ultidoc",
size: 178_300,
mime_type: "application/vnd.ultimail.document",
file_id: 304,
source: "ultimail",
is_shared: true,
last_modified: demoDaysAgo(1, 13, 15),
}),
]

View File

@ -0,0 +1,20 @@
"use client"
import type { ReactNode } from "react"
import { DriveAppShell } from "@/components/drive/drive-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { DemoDriveProvider } from "@/lib/demo/demo-drive-context"
import { DemoDriveBootstrap } from "@/lib/demo/demo-drive-bootstrap"
import { DEMO_DRIVE_ROUTE_ROOT } from "@/lib/demo/demo-drive-context"
import { useDemoDriveStore } from "@/lib/demo/demo-drive-store"
export function DemoDriveShell({ children }: { children: ReactNode }) {
return (
<DemoDriveProvider onReset={() => useDemoDriveStore.getState().reset()}>
<DemoDriveBootstrap />
<DemoChrome>
<DriveAppShell routeRoot={DEMO_DRIVE_ROUTE_ROOT}>{children}</DriveAppShell>
</DemoChrome>
</DemoDriveProvider>
)
}

View File

@ -1,496 +0,0 @@
"use client"
import { useMemo, useState } from "react"
import {
Archive,
ArrowLeft,
Inbox,
Pencil,
RotateCcw,
Search,
Send,
Star,
Trash2,
X,
} from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
DEMO_EMAILS,
DEMO_USER,
type DemoEmail,
type DemoFolder,
} from "@/components/demo/demo-mail-data"
import { cn } from "@/lib/utils"
const FOLDERS: { id: DemoFolder; label: string; icon: typeof Inbox }[] = [
{ id: "inbox", label: "Boîte de réception", icon: Inbox },
{ id: "starred", label: "Favoris", icon: Star },
{ id: "sent", label: "Envoyés", icon: Send },
{ id: "archive", label: "Archive", icon: Archive },
{ id: "trash", label: "Corbeille", icon: Trash2 },
]
function demoToast(message: string) {
toast.message(message, {
description: "Mode démo : rien n'est envoyé ni conservé.",
})
}
function Avatar({ name, className }: { name: string; className?: string }) {
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-full text-sm font-medium text-white",
className ?? "size-9"
)}
style={{ backgroundColor: avatarColor(name) }}
aria-hidden
>
{senderInitial(name)}
</span>
)
}
function ComposeModal({
onClose,
onSend,
}: {
onClose: () => void
onSend: (email: { to: string; subject: string; body: string }) => void
}) {
const [to, setTo] = useState("")
const [subject, setSubject] = useState("")
const [body, setBody] = useState("")
return (
<div className="absolute bottom-0 right-0 z-30 flex w-full max-w-md flex-col overflow-hidden rounded-t-xl border border-[var(--mail-border)] bg-[var(--mail-surface-elevated)] shadow-2xl sm:bottom-4 sm:right-4 sm:rounded-xl">
<div className="flex items-center justify-between bg-[var(--mail-surface-muted)] px-4 py-2.5">
<span className="text-sm font-medium text-[var(--mail-text-strong)]">
Nouveau message
</span>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-[var(--mail-text-muted)] hover:bg-[var(--mail-hover)]"
aria-label="Fermer"
>
<X className="size-4" />
</button>
</div>
<div className="flex flex-col divide-y divide-[var(--mail-border-subtle)] px-4">
<input
value={to}
onChange={(e) => setTo(e.target.value)}
placeholder="À"
className="bg-transparent py-2.5 text-sm text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Objet"
className="bg-transparent py-2.5 text-sm text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Votre message…"
rows={7}
className="resize-none bg-transparent py-2.5 text-sm leading-relaxed text-[var(--mail-text)] outline-none placeholder:text-[var(--mail-text-muted)]"
/>
</div>
<div className="flex items-center justify-between px-4 py-3">
<Button
size="sm"
className="rounded-full px-5"
onClick={() => {
onSend({ to, subject, body })
onClose()
}}
>
Envoyer
</Button>
<span className="text-[11px] text-[var(--mail-text-muted)]">
Démo aucun envoi réel
</span>
</div>
</div>
)
}
export function DemoMailApp() {
const [emails, setEmails] = useState<DemoEmail[]>(DEMO_EMAILS)
const [folder, setFolder] = useState<DemoFolder>("inbox")
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState("")
const [composeOpen, setComposeOpen] = useState(false)
const [replyDraft, setReplyDraft] = useState("")
const visible = useMemo(() => {
const inFolder =
folder === "starred"
? emails.filter((e) => e.starred && e.folder !== "trash")
: emails.filter((e) => e.folder === folder)
const q = query.trim().toLowerCase()
if (!q) return inFolder
return inFolder.filter((e) =>
[e.fromName, e.fromEmail, e.subject, e.preview].some((field) =>
field.toLowerCase().includes(q)
)
)
}, [emails, folder, query])
const selected = emails.find((e) => e.id === selectedId) ?? null
const unreadCount = emails.filter(
(e) => e.folder === "inbox" && e.unread
).length
const patchEmail = (id: string, patch: Partial<DemoEmail>) =>
setEmails((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e)))
const openEmail = (email: DemoEmail) => {
setSelectedId(email.id)
setReplyDraft("")
if (email.unread) patchEmail(email.id, { unread: false })
}
const moveEmail = (id: string, dest: DemoEmail["folder"], message: string) => {
patchEmail(id, { folder: dest })
if (selectedId === id) setSelectedId(null)
demoToast(message)
}
const sendCompose = (draft: { to: string; subject: string; body: string }) => {
setEmails((prev) => [
{
id: `sent-${Date.now()}`,
fromName: DEMO_USER.name,
fromEmail: DEMO_USER.email,
subject: draft.subject || "(sans objet)",
preview: draft.body.slice(0, 110) || "(message vide)",
body: draft.body ? draft.body.split("\n\n") : ["(message vide)"],
time: "À l'instant",
unread: false,
starred: false,
folder: "sent",
},
...prev,
])
demoToast("Message « envoyé »")
}
return (
<div className="relative flex h-dvh flex-col overflow-hidden bg-[var(--app-canvas)] text-[var(--mail-text)]">
{/* Barre supérieure */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-[var(--mail-border-subtle)] bg-[var(--mail-surface)] px-3 sm:px-4">
<img
src="/brand/ultimail-header-icon.png"
alt=""
className="h-7 w-7 shrink-0 object-contain"
aria-hidden
/>
<span className="hidden text-lg font-semibold text-[var(--mail-text-strong)] sm:block">
Ultimail
</span>
<span className="rounded-full bg-[var(--mail-active)] px-2.5 py-0.5 text-[11px] font-semibold text-[var(--mail-nav-selected-fg)]">
Démo
</span>
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-[var(--mail-surface-muted)] px-3.5 py-2 sm:mx-4">
<Search className="size-4 shrink-0 text-[var(--mail-text-muted)]" aria-hidden />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rechercher dans les messages"
className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-[var(--mail-text-muted)]"
aria-label="Rechercher"
/>
</div>
<span
className="hidden items-center gap-1.5 rounded-full border border-[var(--mail-border)] px-2.5 py-1 text-[11px] font-medium text-[var(--mail-text-muted)] md:inline-flex"
title="Vos actions restent dans cet onglet et disparaissent au rechargement."
>
<RotateCcw className="size-3" aria-hidden />
Zéro rétention
</span>
<Avatar name={DEMO_USER.name} className="size-8 text-xs" />
</header>
<div className="flex min-h-0 flex-1">
{/* Barre latérale */}
<aside className="hidden w-56 shrink-0 flex-col gap-1 px-3 py-4 sm:flex">
<Button
className="mb-3 h-12 w-fit rounded-2xl px-5 shadow-sm"
onClick={() => setComposeOpen(true)}
>
<Pencil className="size-4" aria-hidden />
Nouveau message
</Button>
{FOLDERS.map((f) => {
const FolderIcon = f.icon
const active = folder === f.id
return (
<button
key={f.id}
type="button"
onClick={() => {
setFolder(f.id)
setSelectedId(null)
}}
className={cn(
"flex items-center justify-between rounded-full px-4 py-1.5 text-sm transition-colors",
active
? "bg-[var(--mail-nav-selected)] font-semibold text-[var(--mail-nav-selected-fg)]"
: "hover:bg-[var(--mail-nav-hover)]"
)}
>
<span className="flex items-center gap-3">
<FolderIcon className="size-4" aria-hidden />
{f.label}
</span>
{f.id === "inbox" && unreadCount > 0 ? (
<span className="text-xs font-semibold">{unreadCount}</span>
) : null}
</button>
)
})}
</aside>
{/* Contenu */}
<main className="m-0 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[var(--mail-surface)] sm:mb-3 sm:mr-3 sm:rounded-2xl sm:border sm:border-[var(--mail-border-subtle)]">
{selected ? (
<article className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-1 border-b border-[var(--mail-border-subtle)] px-2 py-2 sm:px-4">
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedId(null)}
aria-label="Retour à la liste"
>
<ArrowLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
moveEmail(selected.id, "archive", "Message archivé")
}
aria-label="Archiver"
>
<Archive className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
moveEmail(selected.id, "trash", "Message placé dans la corbeille")
}
aria-label="Supprimer"
>
<Trash2 className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => patchEmail(selected.id, { starred: !selected.starred })}
aria-label={selected.starred ? "Retirer des favoris" : "Ajouter aux favoris"}
>
<Star
className={cn(
"size-4",
selected.starred && "fill-amber-400 text-amber-400"
)}
/>
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
<h1 className="text-xl font-normal text-[var(--mail-text-strong)] sm:text-2xl">
{selected.subject}
</h1>
<div className="mt-5 flex items-center gap-3">
<Avatar name={selected.fromName} />
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-[var(--mail-text-strong)]">
{selected.fromName}
<span className="ml-2 font-normal text-[var(--mail-text-muted)]">
&lt;{selected.fromEmail}&gt;
</span>
</p>
<p className="text-xs text-[var(--mail-text-muted)]">
À moi · {selected.time}
</p>
</div>
</div>
<div className="mt-6 max-w-2xl space-y-4 text-[15px] leading-relaxed">
{selected.body.map((paragraph, i) => (
<p key={i} className="whitespace-pre-line">
{paragraph}
</p>
))}
</div>
<div className="mt-8 max-w-2xl rounded-2xl border border-[var(--mail-border)] p-4">
<textarea
value={replyDraft}
onChange={(e) => setReplyDraft(e.target.value)}
placeholder={`Répondre à ${selected.fromName}`}
rows={3}
className="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-[var(--mail-text-muted)]"
/>
<div className="mt-2 flex items-center justify-between">
<Button
size="sm"
className="rounded-full px-5"
onClick={() => {
setReplyDraft("")
demoToast("Réponse « envoyée »")
}}
>
Envoyer
</Button>
<span className="text-[11px] text-[var(--mail-text-muted)]">
Démo aucun envoi réel
</span>
</div>
</div>
</div>
</article>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
{visible.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
<Inbox className="size-8 text-[var(--mail-text-muted)]" aria-hidden />
<p className="text-sm text-[var(--mail-text-muted)]">
{query
? "Aucun message ne correspond à votre recherche."
: "Ce dossier est vide."}
</p>
</div>
) : (
<ul className="divide-y divide-[var(--mail-list-divider)]">
{visible.map((email) => (
<li key={email.id}>
<div
role="button"
tabIndex={0}
onClick={() => openEmail(email)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openEmail(email)
}}
className={cn(
"group flex w-full cursor-pointer items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-[var(--mail-hover)] sm:px-4",
email.unread
? "bg-[var(--mail-row-unread)]"
: "bg-[var(--mail-row-read)]"
)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
patchEmail(email.id, { starred: !email.starred })
}}
className="shrink-0 rounded p-1 text-[var(--mail-text-muted)] hover:text-amber-500"
aria-label={
email.starred ? "Retirer des favoris" : "Ajouter aux favoris"
}
>
<Star
className={cn(
"size-4",
email.starred && "fill-amber-400 text-amber-400"
)}
/>
</button>
<Avatar name={email.fromName} className="hidden size-8 text-xs sm:flex" />
<span
className={cn(
"w-32 shrink-0 truncate text-sm sm:w-44",
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text)]"
)}
>
{email.fromName}
</span>
<span className="min-w-0 flex-1 truncate text-sm">
{email.label ? (
<span
className="mr-2 rounded px-1.5 py-px text-[10px] font-semibold text-white"
style={{ backgroundColor: email.label.color }}
>
{email.label.text}
</span>
) : null}
<span
className={cn(
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text)]"
)}
>
{email.subject}
</span>
<span className="text-[var(--mail-text-muted)]">
{" "}
{email.preview}
</span>
</span>
<span className="hidden shrink-0 items-center gap-1 group-hover:flex">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveEmail(email.id, "archive", "Message archivé")
}}
className="rounded p-1.5 text-[var(--mail-text-muted)] hover:bg-[var(--mail-surface-muted)]"
aria-label="Archiver"
>
<Archive className="size-4" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
moveEmail(email.id, "trash", "Message placé dans la corbeille")
}}
className="rounded p-1.5 text-[var(--mail-text-muted)] hover:bg-[var(--mail-surface-muted)]"
aria-label="Supprimer"
>
<Trash2 className="size-4" />
</button>
</span>
<span
className={cn(
"shrink-0 text-xs group-hover:hidden",
email.unread
? "font-semibold text-[var(--mail-text-strong)]"
: "text-[var(--mail-text-muted)]"
)}
>
{email.time}
</span>
</div>
</li>
))}
</ul>
)}
</div>
)}
</main>
</div>
{/* Bouton composer (mobile) */}
<Button
className="absolute bottom-5 right-5 z-20 size-14 rounded-2xl shadow-lg sm:hidden"
onClick={() => setComposeOpen(true)}
aria-label="Nouveau message"
>
<Pencil className="size-5" />
</Button>
{composeOpen ? (
<ComposeModal onClose={() => setComposeOpen(false)} onSend={sendCompose} />
) : null}
</div>
)
}

View File

@ -1,4 +1,10 @@
export type DemoFolder = "inbox" | "starred" | "sent" | "archive" | "trash"
export type DemoFolder =
| "inbox"
| "starred"
| "sent"
| "archive"
| "trash"
| "drafts"
export type DemoEmail = {
id: string
@ -11,8 +17,13 @@ export type DemoEmail = {
time: string
unread: boolean
starred: boolean
important?: boolean
hasAttachment?: boolean
folder: Exclude<DemoFolder, "starred">
/** Pastille principale en liste (couleur explicite). */
label?: { text: string; color: string }
/** Libellés de tri et dossiers (texte nav exact). */
tags?: string[]
}
export const DEMO_USER = {
@ -39,6 +50,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
starred: false,
folder: "inbox",
label: { text: "Produit", color: "#4f6df5" },
tags: ["Produit"],
},
{
id: "m2",
@ -58,6 +70,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
starred: true,
folder: "inbox",
label: { text: "IA", color: "#9a5cf0" },
tags: ["IA"],
},
{
id: "m3",
@ -75,7 +88,9 @@ export const DEMO_EMAILS: DemoEmail[] = [
time: "Hier",
unread: false,
starred: true,
important: true,
folder: "inbox",
tags: ["Partenaires"],
},
{
id: "m4",
@ -94,6 +109,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
starred: false,
folder: "inbox",
label: { text: "Drive", color: "#1fb6c9" },
tags: ["Drive", "Comptabilité"],
},
{
id: "m5",
@ -112,6 +128,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
unread: false,
starred: false,
folder: "inbox",
tags: ["Clients"],
},
{
id: "m6",
@ -147,5 +164,337 @@ export const DEMO_EMAILS: DemoEmail[] = [
unread: false,
starred: false,
folder: "archive",
tags: ["Mises à jour"],
},
{
id: "m8",
fromName: "Open Source Weekly",
fromEmail: "newsletter@opensource-weekly.io",
subject: "Cette semaine : PostgreSQL 17, Rust 1.88, et 3 outils souverains",
preview:
"Bonjour Camille, voici la sélection de la semaine : nouveautés bases de données, écosystème Rust…",
body: [
"Bonjour Camille,",
"Voici la sélection de la semaine dans l'écosystème open source :",
"• PostgreSQL 17 : améliorations du partitionnement\n• Rust 1.88 : async en stabilisation\n• 3 suites souveraines à surveiller pour remplacer Google Workspace",
"Bonne lecture !",
],
time: "07:30",
unread: true,
starred: false,
folder: "inbox",
tags: ["Newsletters"],
},
{
id: "m9",
fromName: "Ultimail",
fromEmail: "promo@demo.ulti",
subject: "20 % sur Ultimail Pro — offre de lancement",
preview:
"Passez à Ultimail Pro avant le 30 juin : stockage illimité, UltiMeet intégré, agents IA avancés…",
body: [
"Bonjour Camille,",
"Pour fêter le lancement public, profitez de 20 % sur Ultimail Pro jusqu'au 30 juin.",
"Inclus : stockage Drive illimité, salles UltiMeet persistantes, règles de tri IA illimitées et support prioritaire.",
"Code : LAUNCH20",
],
time: "06:55",
unread: true,
starred: false,
folder: "inbox",
tags: ["Promotions"],
},
{
id: "m10",
fromName: "LinkedIn",
fromEmail: "notifications@linkedin.com",
subject: "Thomas Martin a commenté votre publication",
preview:
"« Belle initiative sur la souveraineté numérique — on en parle au prochain meetup ? »…",
body: [
"Thomas Martin a commenté votre publication :",
"« Belle initiative sur la souveraineté numérique — on en parle au prochain meetup ? »",
"Voir la conversation sur LinkedIn.",
],
time: "Hier",
unread: false,
starred: false,
folder: "inbox",
tags: ["Réseaux sociaux"],
},
{
id: "m11",
fromName: "GitHub",
fromEmail: "noreply@github.com",
subject: "[ulti-backend] Dependabot : 2 mises à jour de sécurité",
preview:
"Dependabot a ouvert 2 PR sur ulti-backend : golang.org/x/crypto et github.com/lib/pq…",
body: [
"Dependabot a détecté 2 vulnérabilités corrigeables sur ulti-backend :",
"• golang.org/x/crypto — CVE-2026-0142\n• github.com/lib/pq — mise à jour mineure recommandée",
"Consultez les pull requests ou activez l'auto-merge si vos checks CI passent.",
],
time: "Mar.",
unread: false,
starred: false,
folder: "inbox",
tags: ["Mises à jour", "Produit"],
label: { text: "Produit", color: "#4f6df5" },
},
{
id: "m12",
fromName: "Communauté Ultimail",
fromEmail: "forum@demo.ulti",
subject: "Nouvelle réponse : « Webhooks agenda — bonnes pratiques »",
preview:
"Julien a répondu à votre fil : « Nous utilisons des templates JSON avec variables $event.title… »",
body: [
"Julien a répondu à votre fil sur le forum :",
"« Nous utilisons des templates JSON avec variables $event.title et $event.attendees pour brancher Slack et n8n sans code custom. »",
"Voir la discussion complète sur forum.demo.ulti.",
],
time: "Mar.",
unread: true,
starred: false,
folder: "inbox",
tags: ["Forums"],
},
{
id: "m13",
fromName: "Amazon Business",
fromEmail: "order-update@amazon.fr",
subject: "Commande n° 402-8819203-7720156 — expédiée",
preview:
"Votre commande (clavier Keychron K3, hub USB-C) a été expédiée. Livraison prévue demain…",
body: [
"Bonjour Camille,",
"Votre commande a été expédiée.",
"Articles : Keychron K3 (AZERTY), hub USB-C 7 ports\nLivraison estimée : demain avant 20h",
],
time: "Mer.",
unread: false,
starred: false,
folder: "inbox",
tags: ["Achats"],
},
{
id: "m14",
fromName: "SNCF Connect",
fromEmail: "billetterie@sncf.com",
subject: "Votre billet Paris → Lyon — vendredi 14 juin",
preview:
"Trajet TGV 6641, départ 08:12 gare de Lyon, place 42 voiture 7. Billet disponible dans l'app…",
body: [
"Bonjour Camille,",
"Voici le récapitulatif de votre trajet :",
"TGV 6641 — Paris Gare de Lyon → Lyon Part-Dieu\nVendredi 14 juin, départ 08:12 — arrivée 10:04\nPlace 42, voiture 7",
"Votre billet est disponible dans l'application SNCF Connect.",
],
time: "Mer.",
unread: false,
starred: false,
hasAttachment: true,
folder: "inbox",
tags: ["Déplacements", "Voyages"],
},
{
id: "m15",
fromName: "Stripe",
fromEmail: "billing@stripe.com",
subject: "Facture Ultimail Pro — juin 2026",
preview:
"Montant : 79,00 € TTC. Paiement par carte se terminant par 4242. PDF joint…",
body: [
"Bonjour,",
"Votre facture Ultimail Pro pour juin 2026 est disponible.",
"Montant : 79,00 € TTC\nPériode : 130 juin 2026\nMoyen de paiement : carte •••• 4242",
"Le PDF est joint à ce message.",
],
time: "Jeu.",
unread: false,
starred: false,
hasAttachment: true,
folder: "inbox",
tags: ["Finance", "Factures", "Comptabilité"],
},
{
id: "m16",
fromName: "RH Ultimail",
fromEmail: "rh@demo.ulti",
subject: "Documents d'onboarding — à compléter avant lundi",
preview:
"Bonjour Camille, merci de signer le règlement intérieur et la fiche mutuelle dans UltiSign…",
body: [
"Bonjour Camille,",
"Pour finaliser votre dossier administratif, merci de compléter dans UltiSign :",
"• Règlement intérieur\n• Fiche mutuelle\n• Attestation de télétravail",
"Deadline : lundi 17 juin.",
],
time: "Jeu.",
unread: true,
starred: false,
folder: "inbox",
tags: ["RH"],
},
{
id: "m17",
fromName: "Maman",
fromEmail: "maman.visiteur@gmail.com",
subject: "Repas de famille dimanche — qui amène le dessert ?",
preview:
"Salut ma chérie, on confirme dimanche 12h chez tata Claire. Tu peux amener la tarte aux fraises ?",
body: [
"Salut ma chérie,",
"On confirme le repas dimanche 12h chez tata Claire. Tu peux amener la tarte aux fraises comme l'an dernier ?",
"Gros bisous,\nMaman",
],
time: "Ven.",
unread: false,
starred: false,
folder: "inbox",
tags: ["Famille"],
},
{
id: "m18",
fromName: "Airbnb",
fromEmail: "automated@airbnb.com",
subject: "Rappel : séjour à Annecy dans 10 jours",
preview:
"Check-in le 22 juin à partir de 16h. Code d'accès et instructions dans l'application…",
body: [
"Bonjour Camille,",
"Votre séjour à Annecy approche (2229 juin).",
"Check-in : 16h\nAdresse et code d'accès disponibles dans l'application 24 h avant l'arrivée.",
],
time: "Sam.",
unread: false,
starred: false,
folder: "inbox",
tags: ["Voyages", "Déplacements"],
},
{
id: "m19",
fromName: "Support ACME Corp",
fromEmail: "support@acme-corp.fr",
subject: "Ticket #8842 — déploiement Ultimail en production",
preview:
"Bonjour, nous avons validé le cutover DNS pour lundi 6h. Merci de confirmer la fenêtre de maintenance…",
body: [
"Bonjour Camille,",
"Nous avons validé le cutover DNS pour lundi 6h00 (TTL réduit à 300 s).",
"Merci de confirmer la fenêtre de maintenance et la personne de contact sur site.",
"Support ACME — ticket #8842",
],
time: "09:10",
unread: true,
starred: false,
important: true,
folder: "inbox",
tags: ["Clients"],
},
{
id: "m20",
fromName: "Camille Visiteur",
fromEmail: "camille@demo.ulti",
subject: "Re: Roadmap Q3 — points à clarifier",
preview:
"Merci Léa, je reviens vers toi demain avec une proposition sur UltiMeet et les webhooks agenda…",
body: [
"Merci Léa pour le CR.",
"Je reviens vers toi demain avec une proposition consolidée sur UltiMeet et les webhooks agenda.",
],
time: "10:05",
unread: false,
starred: false,
folder: "drafts",
},
{
id: "m21",
fromName: "OldPromo",
fromEmail: "deals@spammy-shop.net",
subject: "Dernière chance — 90 % sur tout le site",
preview: "Ne manquez pas cette offre exceptionnelle…",
body: [
"Ne manquez pas cette offre exceptionnelle valable 24 h seulement.",
"Cliquez ici pour en profiter.",
],
time: "Dim.",
unread: false,
starred: false,
folder: "trash",
},
{
id: "m22",
fromName: "Camille Visiteur",
fromEmail: "camille@demo.ulti",
subject: "Compte-rendu migration ACME — version client",
preview:
"Bonjour Marc, comme convenu voici la version client du CR de migration. N'hésitez pas à annoter…",
body: [
"Bonjour Marc,",
"Comme convenu, voici la version client du compte-rendu de migration.",
"Le document est sur UltiDrive (dossier Clients > ACME). N'hésitez pas à annoter directement.",
"Camille",
],
time: "Mar.",
unread: false,
starred: false,
folder: "sent",
},
{
id: "m23",
fromName: "Sophie Bernard",
fromEmail: "sophie@startup-io.fr",
subject: "Intro investisseurs — suite à UltiMeet",
preview:
"Merci pour la démo hier. Nos partners seraient intéressés par un call la semaine prochaine…",
body: [
"Bonjour Camille,",
"Merci pour la démo UltiMeet hier — l'interface a vraiment bluffé l'équipe.",
"Nos partners seraient intéressés par un call la semaine prochaine. Tu es dispo mardi ou jeudi ?",
"Sophie",
],
time: "11:20",
unread: true,
starred: true,
folder: "inbox",
tags: ["Partenaires"],
},
{
id: "m24",
fromName: "OVHcloud",
fromEmail: "facturation@ovh.com",
subject: "Facture hébergement — mai 2026",
preview: "Montant : 142,80 € HT. Services : 2 instances, backup object storage…",
body: [
"Bonjour,",
"Votre facture OVHcloud de mai 2026 est disponible.",
"Montant : 142,80 € HT\nServices : 2 instances VPS, backup object storage",
],
time: "Lun.",
unread: false,
starred: false,
hasAttachment: true,
folder: "archive",
tags: ["Factures", "Finance", "Comptabilité"],
},
{
id: "m25",
fromName: "Notion",
fromEmail: "team@mail.notion.so",
subject: "Camille vous a mentionné dans « Sprint board Q2 »",
preview:
"@Camille peux-tu valider les specs webhooks avant la review de vendredi ?",
body: [
"Camille vous a mentionné dans « Sprint board Q2 » :",
"@Camille peux-tu valider les specs webhooks avant la review de vendredi ?",
"Ouvrir dans Notion",
],
time: "08:48",
unread: true,
starred: false,
folder: "inbox",
tags: ["Produit"],
label: { text: "Produit", color: "#4f6df5" },
},
]

View File

@ -0,0 +1,19 @@
"use client"
import type { ReactNode } from "react"
import { MailAppShell } from "@/app/mail/mail-app-shell"
import { DemoChrome } from "@/components/demo/demo-chrome"
import { DemoMailProvider } from "@/lib/demo/demo-mail-context"
import { DemoMailBootstrap } from "@/lib/demo/demo-mail-bootstrap"
import { useDemoMailStore } from "@/lib/demo/demo-mail-store"
export function DemoMailShell({ children }: { children: ReactNode }) {
return (
<DemoMailProvider onReset={() => useDemoMailStore.getState().reset()}>
<DemoMailBootstrap />
<DemoChrome>
<MailAppShell>{children}</MailAppShell>
</DemoChrome>
</DemoMailProvider>
)
}

View File

@ -0,0 +1,85 @@
"use client"
import { useEffect } from "react"
import { usePathname } from "next/navigation"
import { toast } from "sonner"
import {
getDemoRouteRoot,
isHrefWithinDemoScope,
} from "@/lib/demo/demo-navigation"
const DEMO_NAV_TOAST_ID = "demo-nav-blocked"
function showDemoNavBlockedToast() {
toast.message("Navigation hors démo", {
id: DEMO_NAV_TOAST_ID,
description:
"Créez un compte pour accéder à toute la suite — la démo reste sur cette application.",
})
}
function urlArgToHref(url: string | URL | null | undefined): string | null {
if (url == null || url === "") return null
return typeof url === "string" ? url : url.toString()
}
/** Bloque liens et router client hors du scope `/demo/<app>/…`. */
export function DemoNavigationGuard() {
const pathname = usePathname()
const demoRouteRoot = getDemoRouteRoot(pathname)
useEffect(() => {
if (!demoRouteRoot) return
const origin = window.location.origin
const isAllowed = (href: string | null) => {
if (!href) return true
return isHrefWithinDemoScope(href, demoRouteRoot, origin)
}
const originalPushState = history.pushState.bind(history)
const originalReplaceState = history.replaceState.bind(history)
history.pushState = (state, title, url) => {
const href = urlArgToHref(url)
if (!isAllowed(href)) {
showDemoNavBlockedToast()
return
}
return originalPushState(state, title, url)
}
history.replaceState = (state, title, url) => {
const href = urlArgToHref(url)
if (!isAllowed(href)) {
showDemoNavBlockedToast()
return
}
return originalReplaceState(state, title, url)
}
const handleClick = (event: MouseEvent) => {
const anchor = (event.target as Element).closest("a[href]")
if (!anchor) return
if (anchor.getAttribute("target") === "_blank") return
const href = anchor.getAttribute("href")
if (!href || isAllowed(href)) return
event.preventDefault()
event.stopPropagation()
showDemoNavBlockedToast()
}
document.addEventListener("click", handleClick, true)
return () => {
history.pushState = originalPushState
history.replaceState = originalReplaceState
document.removeEventListener("click", handleClick, true)
}
}, [demoRouteRoot])
return null
}

View File

@ -17,6 +17,7 @@ import { DRIVE_MENU_SURFACE_CLASS } from "@/components/drive/drive-file-context-
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo } from "@/lib/api/types"
import type { DriveView } from "@/lib/drive/drive-url"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import { buildDriveFolderHref } from "@/lib/drive/drive-url"
import { displayFileName } from "@/lib/drive/display-file-name"
import { resolveRenameName } from "@/lib/drive/drive-default-name"
@ -49,7 +50,7 @@ export function BreadcrumbFolderMenu({
onRenameOpenChange,
className,
}: {
view: Extract<DriveView, "files" | "shared">
view: Extract<DriveView, "files" | "shared" | "org" | "mount">
segments: string[]
folderPath: string
writable?: boolean
@ -60,6 +61,7 @@ export function BreadcrumbFolderMenu({
}) {
const isMobile = useIsMobile()
const router = useRouter()
const routeRoot = useDriveRouteRoot()
const [dropdownOpen, setDropdownOpen] = useState(false)
const [sheetOpen, setSheetOpen] = useState(false)
const [internalRenameOpen, setInternalRenameOpen] = useState(false)
@ -86,7 +88,9 @@ export function BreadcrumbFolderMenu({
await mutations.rename.mutateAsync({ path: folder.path, new_name: newName })
toast.success("Dossier renommé")
const parentSegments = segments.slice(0, -1)
router.push(buildDriveFolderHref(view, [...parentSegments, newName]))
router.push(
buildDriveFolderHref(view, [...parentSegments, newName], undefined, routeRoot)
)
} catch {
toast.error("Impossible de renommer ce dossier")
throw new Error("rename failed")

View File

@ -5,7 +5,8 @@ import Link from "next/link"
import { ChevronRight } from "lucide-react"
import { BreadcrumbFolderMenu } from "@/components/drive/breadcrumb-folder-menu"
import type { DriveView } from "@/lib/drive/drive-url"
import { buildDriveFolderHref, folderPathFromSegments } from "@/lib/drive/drive-url"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import { buildDriveFolderHref, driveRouteBase, folderPathFromSegments } from "@/lib/drive/drive-url"
import { displayFileName } from "@/lib/drive/display-file-name"
import { cn } from "@/lib/utils"
@ -19,17 +20,35 @@ const CRUMB_LINE_CLASS = "leading-6 md:leading-7"
export function BreadcrumbNav({
view,
segments,
rootId,
writable = true,
}: {
view: Extract<DriveView, "files" | "shared">
view: Extract<DriveView, "files" | "shared" | "org" | "mount">
segments: string[]
rootId?: string | null
/** When false, double-click rename is disabled (e.g. read-only share). */
writable?: boolean
}) {
const [renameOpen, setRenameOpen] = useState(false)
const routeRoot = useDriveRouteRoot()
const base = driveRouteBase(routeRoot)
const rootLabel = view === "shared" ? "Partagés avec moi" : "Mon Drive"
const rootHref = view === "shared" ? "/drive/shared" : "/drive"
const rootLabel =
view === "shared"
? "Partagés avec moi"
: view === "org"
? "Dossier d'organisation"
: view === "mount"
? "Volume monté"
: "Mon Drive"
const rootHref =
view === "shared"
? `${base}/shared`
: view === "org" && rootId
? `${base}/org/${encodeURIComponent(rootId)}`
: view === "mount" && rootId
? `${base}/mounts/${encodeURIComponent(rootId)}`
: base
const folderPath = folderPathFromSegments(segments)
const canRenameCurrent = writable && segments.length > 0
@ -38,7 +57,7 @@ export function BreadcrumbNav({
const slice = segments.slice(0, i + 1)
crumbs.push({
label: displayFileName(segments[i]),
href: buildDriveFolderHref(view, slice),
href: buildDriveFolderHref(view, slice, rootId ?? undefined, routeRoot),
segments: slice,
})
}

View File

@ -0,0 +1,209 @@
"use client"
import { useMemo, useState } from "react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useDriveMountMutations } from "@/lib/api/hooks/use-drive-queries"
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
import { driveMountOAuthProvider, buildDriveMountOAuthRedirectURI, isDriveMountOAuthConfigured, openDriveMountOAuthPopup } from "@/lib/drive/drive-mount-oauth"
type BackendChoice = "webdav" | "googledrive" | "dropbox" | "onedrive"
const OAUTH_BACKENDS: BackendChoice[] = ["googledrive", "dropbox", "onedrive"]
export function DriveAddMountDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { createMount, fetchOAuthURL } = useDriveMountMutations()
const { data: user } = useCurrentUser()
const configuredProviders = user?.org_drive?.configured_mount_oauth_providers ?? []
const redirectUri = buildDriveMountOAuthRedirectURI()
const [displayName, setDisplayName] = useState("")
const [backend, setBackend] = useState<BackendChoice>("webdav")
const [host, setHost] = useState("")
const [root, setRoot] = useState("/")
const [userName, setUserName] = useState("")
const [password, setPassword] = useState("")
const [secure, setSecure] = useState(true)
const [error, setError] = useState<string | null>(null)
const oauthConfigured = useMemo(
() => isDriveMountOAuthConfigured(backend, configuredProviders),
[backend, configuredProviders]
)
const reset = () => {
setDisplayName("")
setBackend("webdav")
setHost("")
setRoot("/")
setUserName("")
setPassword("")
setSecure(true)
setError(null)
}
const startOAuthConnect = async (mountId: string) => {
const { oauth_url: oauthUrl } = await fetchOAuthURL(mountId, redirectUri)
if (!oauthUrl) {
throw new Error("URL OAuth indisponible")
}
openDriveMountOAuthPopup(oauthUrl, mountId)
}
const handleSubmit = async () => {
setError(null)
if (!displayName.trim()) return
if (OAUTH_BACKENDS.includes(backend) && !oauthConfigured) {
setError("Ce fournisseur cloud n'est pas configuré par l'administration.")
return
}
try {
if (backend === "webdav") {
if (!host.trim() || !userName.trim()) return
await createMount.mutateAsync({
scope: "user",
display_name: displayName.trim(),
backend_type: "webdav",
webdav: {
host: host.trim(),
root: root.trim() || "/",
user: userName.trim(),
password,
secure,
},
})
reset()
onOpenChange(false)
return
}
const mount = await createMount.mutateAsync({
scope: "user",
display_name: displayName.trim(),
backend_type: backend,
oauth_backend: backend,
})
reset()
onOpenChange(false)
if (mount.needs_oauth || mount.status === "pending_oauth") {
await startOAuthConnect(mount.id)
}
} catch (err) {
const message = err instanceof Error ? err.message : "Impossible d'ajouter le volume"
setError(message)
}
}
const providerLabel = driveMountOAuthProvider(backend)
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) reset()
onOpenChange(next)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ajouter un volume monté</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-1.5">
<Label htmlFor="mount-name">Nom affiché</Label>
<Input
id="mount-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Mon NAS"
/>
</div>
<div className="grid gap-1.5">
<Label>Type</Label>
<Select value={backend} onValueChange={(v) => setBackend(v as BackendChoice)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="webdav">WebDAV / NAS / Nextcloud</SelectItem>
<SelectItem value="googledrive" disabled={!isDriveMountOAuthConfigured("googledrive", configuredProviders)}>
Google Drive{!isDriveMountOAuthConfigured("googledrive", configuredProviders) ? " (non configuré)" : ""}
</SelectItem>
<SelectItem value="dropbox" disabled={!isDriveMountOAuthConfigured("dropbox", configuredProviders)}>
Dropbox{!isDriveMountOAuthConfigured("dropbox", configuredProviders) ? " (non configuré)" : ""}
</SelectItem>
<SelectItem value="onedrive" disabled={!isDriveMountOAuthConfigured("onedrive", configuredProviders)}>
Microsoft OneDrive{!isDriveMountOAuthConfigured("onedrive", configuredProviders) ? " (non configuré)" : ""}
</SelectItem>
</SelectContent>
</Select>
</div>
{backend === "webdav" ? (
<>
<div className="grid gap-1.5">
<Label htmlFor="mount-host">Hôte</Label>
<Input id="mount-host" value={host} onChange={(e) => setHost(e.target.value)} placeholder="nas.example.com" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mount-root">Chemin racine</Label>
<Input id="mount-root" value={root} onChange={(e) => setRoot(e.target.value)} placeholder="/remote.php/dav/files/user" />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mount-user">Utilisateur</Label>
<Input id="mount-user" value={userName} onChange={(e) => setUserName(e.target.value)} />
</div>
<div className="grid gap-1.5">
<Label htmlFor="mount-pass">Mot de passe</Label>
<Input id="mount-pass" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} />
HTTPS
</label>
</>
) : (
<div className="space-y-2 text-sm text-muted-foreground">
<p>
Après création, vous serez invité à vous connecter avec{" "}
{providerLabel === "google" ? "Google" : providerLabel === "dropbox" ? "Dropbox" : "Microsoft"}.
</p>
{redirectUri ? (
<p className="font-mono text-xs break-all">Redirect URI : {redirectUri}</p>
) : null}
</div>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={() => void handleSubmit()} disabled={createMount.isPending || (OAUTH_BACKENDS.includes(backend) && !oauthConfigured)}>
{createMount.isPending ? "Ajout…" : OAUTH_BACKENDS.includes(backend) ? "Ajouter et connecter" : "Ajouter"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -7,9 +7,16 @@ import { FilePreviewDialog } from "@/components/drive/file-preview-dialog"
import { ShareDialog } from "@/components/drive/share-dialog"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { useIsMobile } from "@/hooks/use-mobile"
import { DriveRouteRootProvider } from "@/lib/drive/drive-route-context"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
export function DriveAppShell({ children }: { children: ReactNode }) {
export function DriveAppShell({
children,
routeRoot,
}: {
children: ReactNode
routeRoot?: string
}) {
const isMobile = useIsMobile()
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
@ -24,6 +31,7 @@ export function DriveAppShell({ children }: { children: ReactNode }) {
}, [isMobile, setSidebarCollapsed])
return (
<DriveRouteRootProvider routeRoot={routeRoot}>
<SuiteThemeShell>
<div className="ultimail-app relative flex h-dvh overflow-hidden bg-app-canvas" data-drive-app>
{isMobile && sidebarOpen && (
@ -41,5 +49,6 @@ export function DriveAppShell({ children }: { children: ReactNode }) {
<AiChatPanel />
</div>
</SuiteThemeShell>
</DriveRouteRootProvider>
)
}

View File

@ -20,17 +20,21 @@ const VIEW_TITLES: Partial<Record<DriveView, string>> = {
recent: "Récents",
starred: "Favoris",
trash: "Corbeille",
org: "Dossier d'organisation",
mount: "Volume monté",
}
export function DriveBrowserChrome({
view,
segments,
rootId,
isTrash,
items,
searchState,
}: {
view: DriveView
segments: string[]
rootId?: string | null
isTrash?: boolean
items: DriveFileInfo[]
searchState?: DriveSearchState | null
@ -43,7 +47,7 @@ export function DriveBrowserChrome({
[items, selectedPaths]
)
const showBulk = selectedTargets.length > 0
const showBreadcrumb = view === "files" || view === "shared"
const showBreadcrumb = view === "files" || view === "shared" || view === "org" || view === "mount"
const showSearchBreadcrumb = view === "search" && searchState
const title = VIEW_TITLES[view]
const allowShare = view !== "shared"
@ -70,7 +74,8 @@ export function DriveBrowserChrome({
<BreadcrumbNav
view={view}
segments={segments}
writable={view === "files"}
rootId={rootId}
writable={view === "files" || view === "org" || view === "mount"}
/>
) : title ? (
<div className="flex min-w-0 items-center gap-3">

View File

@ -48,7 +48,7 @@ const SOURCE_OPTIONS: { id: DriveSourceId; label: string; iconSrc: string }[] =
{
id: "ultimail",
label: "Ultimail",
iconSrc: suitePublicAsset("/brand/ultimail-header-icon.png"),
iconSrc: suitePublicAsset("/ultimail-mark.svg"),
},
{
id: "ultimeet",

View File

@ -1,5 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { formatDriveListDate } from "@/lib/drive/drive-date"
import { cn } from "@/lib/utils"
@ -10,22 +11,40 @@ export function DriveListModified({
iso: string
className?: string
}) {
const { short, full, dateTime } = formatDriveListDate(iso)
const [formatted, setFormatted] = useState<{
short: string
full: string
dateTime?: string
} | null>(null)
if (!full) {
useEffect(() => {
if (!iso?.trim()) {
setFormatted({ short: "—", full: "" })
return
}
setFormatted(formatDriveListDate(iso))
}, [iso])
if (!formatted?.full) {
return (
<span className={cn("text-sm text-muted-foreground", className)}></span>
<span
className={cn("text-sm text-muted-foreground", className)}
suppressHydrationWarning
>
{formatted?.short ?? "\u00a0"}
</span>
)
}
return (
<time
dateTime={dateTime}
title={full}
aria-label={full}
dateTime={formatted.dateTime}
title={formatted.full}
aria-label={formatted.full}
className={cn("text-sm text-muted-foreground", className)}
suppressHydrationWarning
>
{short}
{formatted.short}
</time>
)
}

View File

@ -14,6 +14,7 @@ import {
defaultDriveSearchScope,
fileBrowserViewForSearchScope,
} from "@/lib/drive/drive-search"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import type { DriveView } from "@/lib/drive/drive-url"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
@ -40,6 +41,7 @@ export function DriveMobileBottomBar({
parentPath: string
}) {
const router = useRouter()
const routeRoot = useDriveRouteRoot()
const openPreview = useDriveUIStore((s) => s.openPreview)
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
@ -57,11 +59,14 @@ export function DriveMobileBottomBar({
const q = search.trim()
if (!q) return
router.push(
buildDriveSearchUrl({
query: q,
scope: effectiveScope,
folderPath: effectiveScope === "folder" ? folderPath : "/",
})
buildDriveSearchUrl(
{
query: q,
scope: effectiveScope,
folderPath: effectiveScope === "folder" ? folderPath : "/",
},
routeRoot
)
)
}

View File

@ -3,12 +3,11 @@
import { useEffect } from "react"
import { clearMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
/** Marks document as Drive scope: no mail wallpaper, no first-launch splash. */
/** Marks document as Drive scope: no mail wallpaper. */
export function DriveRouteScope() {
useEffect(() => {
const html = document.documentElement
html.dataset.routeScope = "drive"
html.dataset.splashSeen = "1"
clearMailBackgroundDom(html)
return () => {

View File

@ -14,6 +14,7 @@ import {
defaultDriveSearchScope,
fileBrowserViewForSearchScope,
} from "@/lib/drive/drive-search"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import type { DriveView } from "@/lib/drive/drive-url"
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
@ -45,6 +46,7 @@ export function DriveSearchBar({
autoFocus = false,
}: DriveSearchBarProps) {
const router = useRouter()
const routeRoot = useDriveRouteRoot()
const openPreview = useDriveUIStore((s) => s.openPreview)
const inputRef = useRef<HTMLInputElement>(null)
const [focused, setFocused] = useState(false)
@ -71,11 +73,14 @@ export function DriveSearchBar({
const q = (query ?? value).trim()
if (!q) return
router.push(
buildDriveSearchUrl({
query: q,
scope: effectiveScope,
folderPath: effectiveScope === "folder" ? folderPath : "/",
})
buildDriveSearchUrl(
{
query: q,
scope: effectiveScope,
folderPath: effectiveScope === "folder" ? folderPath : "/",
},
routeRoot
)
)
inputRef.current?.blur()
}

View File

@ -14,6 +14,7 @@ import {
} from "@/lib/drive/drive-search"
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
import { displayFileName } from "@/lib/drive/display-file-name"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
@ -59,9 +60,10 @@ function SuggestionRow({
onPick: (item: DriveFileInfo) => void
}) {
const router = useRouter()
const routeRoot = useDriveRouteRoot()
const view = scope === "shared" ? "shared" : "files"
const parentPath = itemParentFolderPath(item.path, item.type)
const parentHref = driveFolderHref(view, parentPath)
const parentHref = driveFolderHref(view, parentPath, undefined, routeRoot)
const location = itemLocationLabel(item.path, item.type)
return (

View File

@ -0,0 +1,266 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ChevronRight, Link2 } from "lucide-react"
import { Icon } from "@iconify/react"
import { DriveAddMountDialog } from "@/components/drive/drive-add-mount-dialog"
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
import {
DRIVE_ICON_BTN,
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
DRIVE_SIDEBAR_ROW_BODY_CLASS,
DRIVE_SIDEBAR_ROW_CLASS,
} from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { displayFileName } from "@/lib/drive/display-file-name"
import { driveFolderHref, mountRootKey } from "@/lib/drive/drive-sidebar-tree"
import { useDriveMountList, useDriveMountMutations, useDriveMounts } from "@/lib/api/hooks/use-drive-queries"
import type { DriveMount } from "@/lib/api/types"
import { useIsMobile } from "@/hooks/use-mobile"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import { openDriveMountOAuthPopup, buildDriveMountOAuthRedirectURI } from "@/lib/drive/drive-mount-oauth"
const INDENT_PX = 16
function mountIcon(backendType: string) {
switch (backendType) {
case "googledrive":
case "google":
return "logos:google-drive"
case "dropbox":
return "logos:dropbox"
case "onedrive":
case "microsoft":
return "logos:microsoft-onedrive"
case "webdav":
case "dav":
return "mdi:cloud-sync"
default:
return "mdi:harddisk"
}
}
function MountConnectButton({ mount }: { mount: DriveMount }) {
const { fetchOAuthURL } = useDriveMountMutations()
const [pending, setPending] = useState(false)
if (!mount.needs_oauth && mount.status !== "pending_oauth") {
return null
}
return (
<button
type="button"
className={cn(
"mr-1 flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded-md px-2 text-[11px] text-primary",
DRIVE_ICON_BTN
)}
disabled={pending}
onClick={() => {
void (async () => {
setPending(true)
try {
const { oauth_url: oauthUrl } = await fetchOAuthURL(mount.id, buildDriveMountOAuthRedirectURI())
openDriveMountOAuthPopup(oauthUrl, mount.id)
} finally {
setPending(false)
}
})()
}}
>
<Link2 className="h-3.5 w-3.5" />
{pending ? "…" : "Connecter"}
</button>
)
}
function MountTree({
mount,
active,
pathSegments,
}: {
mount: DriveMount
active: boolean
pathSegments: string[]
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const rootKey = mountRootKey(mount.id)
const isExpanded = expandedPaths.has(rootKey)
const isRootSelected = active && pathSegments.length === 0
const rootHref = driveFolderHref("mount", "/", mount.id)
const mountReady = mount.status === "active"
const list = useDriveMountList(mount.id, "/", 1, isExpanded && mountReady)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
return (
<div className="min-w-0">
<div className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected: isRootSelected }))}>
<button
type="button"
aria-label={isExpanded ? "Replier" : "Déplier"}
className={cn(DRIVE_SIDEBAR_CARET_SLOT_CLASS, "cursor-pointer rounded-md", DRIVE_ICON_BTN)}
onClick={() => toggleSidebarPath(rootKey)}
disabled={!mountReady}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90", !mountReady && "opacity-40")} />
</button>
<Link
href={rootHref}
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer", !mountReady && "pointer-events-none opacity-70")}
onClick={(event) => {
if (!mountReady) {
event.preventDefault()
return
}
event.preventDefault()
router.push(rootHref)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<Icon icon={mountIcon(mount.backend_type)} className="h-4 w-4 shrink-0" aria-hidden />
<span className="truncate">{mount.display_name}</span>
{mount.status === "error" ? (
<span className="ml-auto shrink-0 text-[10px] text-destructive">!</span>
) : null}
</Link>
<MountConnectButton mount={mount} />
</div>
{isExpanded && mountReady
? directories.map((child) => (
<MountFolderNode
key={child.path}
mount={mount}
folderPath={child.path}
depth={1}
active={active}
currentPath={pathSegments.length ? `/${pathSegments.join("/")}` : "/"}
/>
))
: null}
</div>
)
}
function MountFolderNode({
mount,
folderPath,
depth,
active,
currentPath,
}: {
mount: DriveMount
folderPath: string
depth: number
active: boolean
currentPath: string
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const isExpanded = expandedPaths.has(folderPath)
const isSelected = active && currentPath === folderPath
const href = driveFolderHref("mount", folderPath, mount.id)
const list = useDriveMountList(mount.id, folderPath, 1, isExpanded)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
return (
<div className="min-w-0">
<div
className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected }))}
style={{ paddingLeft: depth * INDENT_PX }}
>
<button
type="button"
className={cn(
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
"cursor-pointer rounded-md",
directories.length > 0 ? DRIVE_ICON_BTN : "invisible"
)}
onClick={() => toggleSidebarPath(folderPath)}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
</button>
<Link href={href} className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")} onClick={(e) => { e.preventDefault(); router.push(href); if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true) }}>
<DriveFolderIcon file={{ path: folderPath, name: displayFileName(folderPath.split("/").pop() ?? ""), type: "directory", size: 0, mime_type: "", last_modified: "", etag: "", is_favorite: false }} size="sm" />
<span className="truncate">{displayFileName(folderPath.split("/").pop() ?? folderPath)}</span>
</Link>
</div>
{isExpanded ? directories.map((child) => (
<MountFolderNode key={child.path} mount={mount} folderPath={child.path} depth={depth + 1} active={active} currentPath={currentPath} />
)) : null}
</div>
)
}
export function DriveSidebarMounts({
active,
pathSegments,
rootId,
}: {
active: boolean
pathSegments: string[]
rootId: string | null
}) {
const mounts = useDriveMounts()
const items = mounts.data ?? []
if (items.length === 0) {
return null
}
return (
<>
{items.map((mount) => (
<MountTree
key={mount.id}
mount={mount}
active={active && rootId === mount.id}
pathSegments={rootId === mount.id ? pathSegments : []}
/>
))}
</>
)
}
export function DriveConnectMountAction() {
const [addOpen, setAddOpen] = useState(false)
const { invalidate } = useDriveMountMutations()
useEffect(() => {
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data?.type === "drive-mount-oauth-complete") {
invalidate()
}
}
window.addEventListener("message", onMessage)
return () => window.removeEventListener("message", onMessage)
}, [invalidate])
return (
<>
<button
type="button"
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-mail-nav-hover hover:text-foreground"
)}
onClick={() => setAddOpen(true)}
>
<span className="flex shrink-0 items-center gap-0.5" aria-hidden>
<Icon icon="logos:google-drive" className="size-3.5" />
<Icon icon="logos:dropbox" className="size-3.5" />
<Icon icon="logos:microsoft-onedrive" className="size-3.5" />
</span>
<span className="min-w-0 truncate">Monter un volume</span>
</button>
<DriveAddMountDialog open={addOpen} onOpenChange={setAddOpen} />
</>
)
}

View File

@ -0,0 +1,230 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { Building2, ChevronRight } from "lucide-react"
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
import {
DRIVE_ICON_BTN,
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
DRIVE_SIDEBAR_ROW_BODY_CLASS,
DRIVE_SIDEBAR_ROW_CLASS,
} from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { folderPathFromSegments } from "@/lib/drive/drive-url"
import { displayFileName } from "@/lib/drive/display-file-name"
import {
ancestorFolderPaths,
driveFolderHref,
orgRootKey,
selectedFolderPath,
} from "@/lib/drive/drive-sidebar-tree"
import { useDriveOrgFolders, useDriveOrgList } from "@/lib/api/hooks/use-drive-queries"
import type { DriveOrgFolder } from "@/lib/api/types"
import { useIsMobile } from "@/hooks/use-mobile"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
const INDENT_PX = 16
function OrgFolderTree({
folder,
active,
pathSegments,
}: {
folder: DriveOrgFolder
active: boolean
pathSegments: string[]
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const ensureSidebarPathsExpanded = useDriveUIStore((s) => s.ensureSidebarPathsExpanded)
const rootKey = orgRootKey(folder.id)
const isExpanded = expandedPaths.has(rootKey)
const currentPath = active ? selectedFolderPath("org", pathSegments) : ""
const isRootSelected = active && pathSegments.length === 0
const rootHref = driveFolderHref("org", "/", folder.id)
const list = useDriveOrgList(folder.id, "/", 1, isExpanded)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
useEffect(() => {
if (!active) return
ensureSidebarPathsExpanded(ancestorFolderPaths(folderPathFromSegments(pathSegments)))
ensureSidebarPathsExpanded([rootKey])
}, [active, ensureSidebarPathsExpanded, pathSegments, rootKey])
return (
<div className="min-w-0">
<div className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected: isRootSelected }))}>
<button
type="button"
aria-label={isExpanded ? "Replier" : "Déplier"}
className={cn(DRIVE_SIDEBAR_CARET_SLOT_CLASS, "cursor-pointer rounded-md", DRIVE_ICON_BTN)}
onClick={() => toggleSidebarPath(rootKey)}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
</button>
<Link
href={rootHref}
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")}
onClick={(event) => {
event.preventDefault()
router.push(rootHref)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<Building2 className="h-4 w-4 shrink-0" />
<span className="truncate">{folder.mount_point}</span>
</Link>
</div>
{isExpanded
? directories.map((child) => (
<OrgFolderNode
key={child.path}
orgFolder={folder}
folderPath={child.path}
depth={1}
active={active}
currentPath={currentPath}
/>
))
: null}
</div>
)
}
function OrgFolderNode({
orgFolder,
folderPath,
depth,
active,
currentPath,
}: {
orgFolder: DriveOrgFolder
folderPath: string
depth: number
active: boolean
currentPath: string
}) {
const router = useRouter()
const isMobile = useIsMobile()
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const isExpanded = expandedPaths.has(folderPath)
const isSelected = active && currentPath === folderPath
const href = driveFolderHref("org", folderPath, orgFolder.id)
const list = useDriveOrgList(orgFolder.id, folderPath, 1, isExpanded)
const directories = list.data?.files.filter((f) => f.type === "directory") ?? []
return (
<div className="min-w-0">
<div
className={cn(DRIVE_SIDEBAR_ROW_CLASS, mailNavRowClass({ isSelected }))}
style={{ paddingLeft: depth * INDENT_PX }}
>
<button
type="button"
aria-label={isExpanded ? "Replier" : "Déplier"}
className={cn(
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
"cursor-pointer rounded-md",
directories.length > 0 ? DRIVE_ICON_BTN : "invisible"
)}
onClick={() => toggleSidebarPath(folderPath)}
>
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
</button>
<Link
href={href}
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")}
onClick={(event) => {
event.preventDefault()
router.push(href)
if (isMobile) useDriveUIStore.getState().setSidebarCollapsed(true)
}}
>
<DriveFolderIcon file={{ path: folderPath, name: displayFileName(folderPath.split("/").pop() ?? ""), type: "directory", size: 0, mime_type: "", last_modified: "", etag: "", is_favorite: false }} size="sm" />
<span className="truncate">{displayFileName(folderPath.split("/").pop() ?? folderPath)}</span>
</Link>
</div>
{isExpanded
? directories.map((child) => (
<OrgFolderNode
key={child.path}
orgFolder={orgFolder}
folderPath={child.path}
depth={depth + 1}
active={active}
currentPath={currentPath}
/>
))
: null}
</div>
)
}
export function DriveSidebarOrgFolders({
active,
pathSegments,
rootId,
}: {
active: boolean
pathSegments: string[]
rootId: string | null
}) {
const orgFolders = useDriveOrgFolders()
const folders = orgFolders.data ?? []
const expandedPaths = useDriveUIStore((s) => s.expandedSidebarPaths)
const toggleSidebarPath = useDriveUIStore((s) => s.toggleSidebarPath)
const sectionKey = "/__org_section__"
const isSectionExpanded = expandedPaths.has(sectionKey)
if (orgFolders.isLoading) {
return (
<div className="px-3 py-1.5 text-xs text-muted-foreground">Dossiers d&apos;organisation</div>
)
}
if (folders.length === 0) {
return null
}
return (
<div className="min-w-0 pb-1">
<button
type="button"
className={cn(
DRIVE_SIDEBAR_ROW_CLASS,
"w-full cursor-pointer",
mailNavRowClass({ isSelected: false })
)}
onClick={() => toggleSidebarPath(sectionKey)}
>
<span className={DRIVE_SIDEBAR_CARET_SLOT_CLASS}>
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform",
isSectionExpanded && "rotate-90"
)}
/>
</span>
<span className={DRIVE_SIDEBAR_ROW_BODY_CLASS}>
<Building2 className="h-4 w-4 shrink-0" />
<span className="truncate">Dossiers d&apos;organisation</span>
</span>
</button>
{isSectionExpanded
? folders.map((folder) => (
<OrgFolderTree
key={folder.id}
folder={folder}
active={active && rootId === folder.id}
pathSegments={rootId === folder.id ? pathSegments : []}
/>
))
: null}
</div>
)
}

View File

@ -6,23 +6,30 @@ import { useParams, usePathname } from "next/navigation"
import { Icon } from "@iconify/react"
import { Clock, Star, Trash2 } from "lucide-react"
import { cn } from "@/lib/utils"
import {
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
DRIVE_SIDEBAR_ROW_BODY_CLASS,
DRIVE_SIDEBAR_ROW_CLASS,
} from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { DriveQuotaBar } from "@/components/drive/quota-bar"
import { DriveNewMenu } from "@/components/drive/new-menu"
import { DriveSidebarFolderTree } from "@/components/drive/sidebar-folder-tree"
import { DriveSidebarOrgFolders } from "@/components/drive/drive-sidebar-org-folders"
import { DriveSidebarMounts, DriveConnectMountAction } from "@/components/drive/drive-sidebar-mounts"
import { AccountAvatar } from "@/components/suite/account-avatar"
import { AccountSwitcherSheet } from "@/components/suite/account-switcher-sheet"
import { Button } from "@/components/ui/button"
import { useIsXs } from "@/hooks/use-xs"
import { folderPathFromSegments, parseDriveSegments } from "@/lib/drive/drive-url"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import { driveRouteBase, folderPathFromSegments, parseDriveSegments } from "@/lib/drive/drive-url"
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
const OTHER_NAV = [
{ href: "/drive/recent", label: "Récents", icon: Clock },
{ href: "/drive/starred", label: "Favoris", icon: Star },
{ href: "/drive/trash", label: "Corbeille", icon: Trash2 },
]
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
export function DriveSidebar({
overlay = false,
@ -33,6 +40,13 @@ export function DriveSidebar({
}) {
const pathname = usePathname()
const params = useParams()
const routeRoot = useDriveRouteRoot()
const driveBase = driveRouteBase(routeRoot)
const otherNav = [
{ href: `${driveBase}/recent`, label: "Récents", icon: Clock },
{ href: `${driveBase}/starred`, label: "Favoris", icon: Star },
{ href: `${driveBase}/trash`, label: "Corbeille", icon: Trash2 },
]
const isXs = useIsXs()
const identity = useChromeIdentity()
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
@ -44,6 +58,8 @@ export function DriveSidebar({
const parentPath = folderPathFromSegments(route.pathSegments)
const filesSegments = route.view === "files" ? route.pathSegments : []
const sharedSegments = route.view === "shared" ? route.pathSegments : []
const orgSegments = route.view === "org" ? route.pathSegments : []
const mountSegments = route.view === "mount" ? route.pathSegments : []
const closeSidebar = () => setSidebarCollapsed(true)
const displayName = identity?.name ?? "Utilisateur"
@ -62,16 +78,16 @@ export function DriveSidebar({
aria-hidden={overlay && !open}
>
<div className="flex shrink-0 items-center justify-between gap-2 px-4 py-4">
<div className="flex min-w-0 items-center gap-2">
<div className={cn(SUITE_APP_LOGO_LOCKUP_CLASS, "min-w-0")}>
<img
src="/drive/ultidrive-mark.svg"
alt=""
className="h-8 w-8 shrink-0"
className={SUITE_APP_LOGO_MARK_CLASS}
onError={(e) => {
;(e.target as HTMLImageElement).style.display = "none"
}}
/>
<span className="truncate text-lg font-medium">UltiDrive</span>
<span className={SUITE_APP_LOGO_TEXT_CLASS}>UltiDrive</span>
</div>
{isXs ? (
<Button
@ -102,8 +118,18 @@ export function DriveSidebar({
pathSegments={sharedSegments}
active={route.view === "shared"}
/>
<DriveSidebarMounts
active={route.view === "mount"}
pathSegments={mountSegments}
rootId={route.rootId}
/>
</div>
{OTHER_NAV.map(({ href, label, icon: Icon }) => {
<DriveSidebarOrgFolders
active={route.view === "org"}
pathSegments={orgSegments}
rootId={route.rootId}
/>
{otherNav.map(({ href, label, icon: Icon }) => {
const active = pathname.startsWith(href)
return (
<Link
@ -113,12 +139,16 @@ export function DriveSidebar({
if (overlay) closeSidebar()
}}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm",
DRIVE_SIDEBAR_ROW_CLASS,
"cursor-pointer",
mailNavRowClass({ isSelected: active })
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
<span className={DRIVE_SIDEBAR_CARET_SLOT_CLASS} aria-hidden />
<span className={DRIVE_SIDEBAR_ROW_BODY_CLASS}>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{label}</span>
</span>
</Link>
)
})}
@ -129,7 +159,10 @@ export function DriveSidebar({
isXs && "pb-[calc(4rem+env(safe-area-inset-bottom))]",
)}
>
<div className={cn(isXs ? "px-3 pt-1.5 pb-0" : "p-3")}>
<div className={cn(isXs ? "px-3 pt-2 pb-0" : "px-3 pt-2")}>
<DriveConnectMountAction />
</div>
<div className={cn(isXs ? "px-3 pt-1.5 pb-0" : "p-3 pt-2")}>
<DriveQuotaBar />
</div>
{isXs ? (
@ -144,7 +177,11 @@ export function DriveSidebar({
>
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
account={{
name: identity.name,
email: identity.email,
avatarUrl: identity.avatarUrl,
}}
size="sm"
/>
) : (

View File

@ -24,7 +24,11 @@ export function EditorAccountButton() {
>
{identity ? (
<AccountAvatar
account={{ name: identity.name, email: identity.email }}
account={{
name: identity.name,
email: identity.email,
avatarUrl: identity.avatarUrl,
}}
size="sm"
/>
) : (

View File

@ -9,6 +9,8 @@ import type { DriveFileInfo } from "@/lib/api/types"
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
import { openDriveItem } from "@/lib/drive/drive-open-item"
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import { pathRefFromRoute, type DrivePathRef } from "@/lib/api/drive-roots"
import type { DriveView } from "@/lib/drive/drive-url"
import type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb"
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
import {
@ -17,6 +19,7 @@ import {
} from "@/lib/drive/display-file-name"
import { useDriveGridSelection } from "@/lib/hooks/use-drive-grid-selection"
import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import { cn } from "@/lib/utils"
function formatSize(n: number) {
@ -28,6 +31,8 @@ function formatSize(n: number) {
export function FileBrowser({
items,
view = "files",
rootId,
pathRef: pathRefProp,
isTrash,
onOpenItem,
mutations: mutationsProp,
@ -40,7 +45,9 @@ export function FileBrowser({
publicShare,
}: {
items: DriveFileInfo[]
view?: "files" | "shared"
view?: DriveView
rootId?: string | null
pathRef?: DrivePathRef
isTrash?: boolean
onOpenItem?: (file: DriveFileInfo) => void
mutations?: ReturnType<typeof useDriveMutations>
@ -53,9 +60,11 @@ export function FileBrowser({
publicShare?: PublicShareThumbContext
}) {
const router = useRouter()
const routeRoot = useDriveRouteRoot()
const viewMode = useDriveSettingsStore((s) => s.viewMode)
const openPreview = useDriveUIStore((s) => s.openPreview)
const mutationsDefault = useDriveMutations()
const pathRef = pathRefProp ?? pathRefFromRoute(view, rootId ?? null, "/")
const mutationsDefault = useDriveMutations(pathRef)
const mutations = mutationsProp ?? mutationsDefault
const openItem = (file: DriveFileInfo) => {
@ -69,6 +78,7 @@ export function FileBrowser({
view,
contextItems: items,
isTrash: Boolean(isTrash),
routeRoot,
})
}

View File

@ -29,6 +29,8 @@ import { Button } from "@/components/ui/button"
import { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog"
import { downloadDriveFile, fetchDrivePreviewBlob } from "@/lib/api/drive-download"
import { fetchPublicShareBlob } from "@/lib/api/public-share"
import { resolveDemoDrivePreview } from "@/lib/demo/demo-drive-preview"
import { useIsDemoDrive } from "@/lib/demo/demo-drive-context"
import { downloadPublicShareFile } from "@/lib/drive/open-public-share-item"
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
import { ApiRequestError } from "@/lib/api/client"
@ -179,6 +181,7 @@ export function FilePreviewDialog() {
const mailSource = previewContext?.mailSource ?? false
const mailMessageId = previewContext?.mailMessageId ?? file?.mailMessageId ?? ""
const router = useRouter()
const isDemoDrive = useIsDemoDrive()
const saveToDrive = useSaveAttachmentToDrive(mailMessageId)
const isMailAttachment = mailSource && Boolean(file?.mailAttachmentId)
const showWriteActions = !isTrash && !publicShare && !isMailAttachment
@ -189,6 +192,7 @@ export function FilePreviewDialog() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [imgFailed, setImgFailed] = useState(false)
const [demoPreviewAsImage, setDemoPreviewAsImage] = useState(false)
const blobUrlRef = useRef<string | null>(null)
const kind = file ? drivePreviewKind(file) : null
@ -222,11 +226,44 @@ export function FilePreviewDialog() {
setLoading(true)
setError(null)
setImgFailed(false)
setDemoPreviewAsImage(false)
setTextContent(null)
setSvgMarkup(null)
;(async () => {
try {
if (isDemoDrive && !publicShare && !file.mailAttachmentId) {
const resolved = resolveDemoDrivePreview(file, { width: 1600, height: 1200 })
if (!resolved) {
throw new Error("Aperçu indisponible en mode démo.")
}
if (cancelled) return
if (resolved.type === "text") {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(null)
setTextContent(resolved.content)
return
}
if (resolved.type === "svg") {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current)
blobUrlRef.current = null
}
setBlobUrl(null)
setSvgMarkup(resolved.markup)
return
}
setTextContent(null)
setSvgMarkup(null)
setBlobUrl(resolved.url)
setDemoPreviewAsImage(true)
return
}
const blob = file.mailAttachmentId
? await apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`)
: publicShare
@ -291,6 +328,7 @@ export function FilePreviewDialog() {
isSvg,
publicShare?.token,
publicShare?.password,
isDemoDrive,
])
const previewReady =
@ -593,7 +631,7 @@ export function FilePreviewDialog() {
) : null}
{!loading && !error && !imgFailed && kind && file && previewReady ? (
<PreviewBody
kind={kind}
kind={demoPreviewAsImage ? "image" : kind}
blobUrl={blobUrl ?? ""}
name={file.name}
textContent={textContent}

View File

@ -32,6 +32,11 @@ import {
} from "@/lib/drive/drive-share-permissions"
import { buildPublicShareEditHref, persistPublicShareRootType } from "@/lib/drive/public-share-url"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import {
SUITE_APP_LOGO_LOCKUP_CLASS,
SUITE_APP_LOGO_MARK_CLASS,
SUITE_APP_LOGO_TEXT_CLASS,
} from "@/lib/suite/suite-chrome-classes"
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
import { cn } from "@/lib/utils"
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
@ -327,9 +332,15 @@ export function PublicShareChrome({ children }: { children: ReactNode }) {
<SuiteThemeShell>
<div className="ultimail-app flex h-dvh flex-col overflow-hidden bg-app-canvas" data-drive-app>
<header className="flex h-16 shrink-0 items-center border-b border-border bg-mail-surface px-4 sm:px-6">
<Link href="/drive" className="flex items-center gap-2.5">
<img src={suitePublicAsset("/ultidrive-mark.svg")} alt="" className="h-8 w-8" />
<span className="text-lg font-medium text-[#3c4043] dark:text-[#e8eaed]">UltiDrive</span>
<Link href="/drive" className={SUITE_APP_LOGO_LOCKUP_CLASS}>
<img
src={suitePublicAsset("/ultidrive-mark.svg")}
alt=""
className={SUITE_APP_LOGO_MARK_CLASS}
draggable={false}
aria-hidden
/>
<span className={SUITE_APP_LOGO_TEXT_CLASS}>UltiDrive</span>
</Link>
<span className="ml-3 hidden text-sm text-muted-foreground sm:inline">
<FolderOpen className="mr-1 inline h-4 w-4 align-[-2px]" aria-hidden />

View File

@ -1,5 +1,6 @@
"use client"
import "@/styles/excalidraw.css"
import dynamic from "next/dynamic"
import { memo, useCallback, useState } from "react"
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
@ -12,7 +13,6 @@ export function docsExcalidrawEditorKey(drawScene: string | null): string {
const ExcalidrawEditor = dynamic(
async () => {
await import("@excalidraw/excalidraw/index.css")
const { Excalidraw, restoreElements, restoreAppState } = await import(
"@excalidraw/excalidraw"
)

View File

@ -7,11 +7,17 @@ import type { LucideIcon } from "lucide-react"
import { ChevronRight, HardDrive, Users } from "lucide-react"
import { DRIVE_DROP_TARGET_CLASS } from "@/components/drive/drive-file-context-menu"
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
import { DRIVE_ICON_BTN } from "@/lib/drive/drive-chrome-classes"
import {
DRIVE_ICON_BTN,
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
DRIVE_SIDEBAR_ROW_BODY_CLASS,
DRIVE_SIDEBAR_ROW_CLASS,
} from "@/lib/drive/drive-chrome-classes"
import { mailNavRowClass } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
import type { DriveView } from "@/lib/drive/drive-url"
import { folderPathFromSegments } from "@/lib/drive/drive-url"
import { driveRouteBase, folderPathFromSegments } from "@/lib/drive/drive-url"
import { useDriveList, useDriveSharedWithMe } from "@/lib/api/hooks/use-drive-queries"
import type { DriveFileInfo } from "@/lib/api/types"
import { displayFileName } from "@/lib/drive/display-file-name"
@ -49,14 +55,15 @@ function SidebarTreeCaret({
label: string
}) {
if (!visible) {
return <span className="w-6 shrink-0" aria-hidden="true" />
return <span className={DRIVE_SIDEBAR_CARET_SLOT_CLASS} aria-hidden="true" />
}
return (
<button
type="button"
aria-label={label}
className={cn(
"flex h-7 w-6 shrink-0 cursor-pointer items-center justify-center rounded-md",
DRIVE_SIDEBAR_CARET_SLOT_CLASS,
"cursor-pointer rounded-md",
DRIVE_ICON_BTN
)}
onClick={(event) => {
@ -81,12 +88,14 @@ function SidebarFolderNode({
view,
currentPath,
active,
routeRoot,
}: {
folder: DriveFileInfo
depth: number
view: DriveView
currentPath: string
active: boolean
routeRoot: string
}) {
const router = useRouter()
const isMobile = useIsMobile()
@ -98,7 +107,7 @@ function SidebarFolderNode({
const isSelected = active && currentPath === folderPath
const { directories } = useFolderChildren(folderPath, true, false)
const hasChildFolders = directories.length > 0
const href = driveFolderHref(view, folderPath)
const href = driveFolderHref(view, folderPath, undefined, routeRoot)
const label = displayFileName(folder.name)
const { dropProps, canDrop, isOver } = useDriveDropTarget({
folderPath,
@ -113,7 +122,7 @@ function SidebarFolderNode({
<div className="min-w-0">
<div
className={cn(
"group flex min-w-0 items-center rounded-lg text-sm",
DRIVE_SIDEBAR_ROW_CLASS,
mailNavRowClass({ isSelected }),
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
)}
@ -128,7 +137,7 @@ function SidebarFolderNode({
/>
<Link
href={href}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1.5 pr-2"
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")}
onClick={(event) => {
event.preventDefault()
router.push(href)
@ -148,6 +157,7 @@ function SidebarFolderNode({
view={view}
currentPath={currentPath}
active={active}
routeRoot={routeRoot}
/>
))
: null}
@ -163,6 +173,7 @@ function SidebarRootBranch({
rootKey,
pathSegments,
active,
routeRoot,
}: {
view: DriveView
rootHref: string
@ -171,6 +182,7 @@ function SidebarRootBranch({
rootKey: string
pathSegments: string[]
active: boolean
routeRoot: string
}) {
const router = useRouter()
const isMobile = useIsMobile()
@ -204,7 +216,7 @@ function SidebarRootBranch({
<div className="min-w-0">
<div
className={cn(
"group flex min-w-0 items-center rounded-lg text-sm",
DRIVE_SIDEBAR_ROW_CLASS,
mailNavRowClass({ isSelected: isRootSelected }),
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
)}
@ -218,7 +230,7 @@ function SidebarRootBranch({
/>
<Link
href={rootHref}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 py-1.5 pr-2"
className={cn(DRIVE_SIDEBAR_ROW_BODY_CLASS, "cursor-pointer")}
onClick={(event) => {
event.preventDefault()
router.push(rootHref)
@ -238,6 +250,7 @@ function SidebarRootBranch({
view={view}
currentPath={currentPath}
active={active}
routeRoot={routeRoot}
/>
))
: null}
@ -254,16 +267,20 @@ export function DriveSidebarFolderTree({
pathSegments: string[]
active: boolean
}) {
const routeRoot = useDriveRouteRoot()
const driveBase = driveRouteBase(routeRoot)
if (view === "files") {
return (
<SidebarRootBranch
view="files"
rootHref="/drive"
rootHref={driveBase}
rootLabel="Mon Drive"
rootIcon={HardDrive}
rootKey="/"
pathSegments={pathSegments}
active={active}
routeRoot={routeRoot}
/>
)
}
@ -271,12 +288,13 @@ export function DriveSidebarFolderTree({
return (
<SidebarRootBranch
view="shared"
rootHref="/drive/shared"
rootHref={`${driveBase}/shared`}
rootLabel="Partagés avec moi"
rootIcon={Users}
rootKey="/__shared_root__"
pathSegments={pathSegments}
active={active}
routeRoot={routeRoot}
/>
)
}

View File

@ -1,5 +1,6 @@
"use client"
import "@/styles/excalidraw.css"
import dynamic from "next/dynamic"
import { useCallback, useEffect, useMemo, useState } from "react"
import { HocuspocusProvider } from "@hocuspocus/provider"
@ -48,7 +49,6 @@ function seedYdocIfEmpty(ydoc: Y.Doc, parsed: ParsedDrawFile): void {
const ExcalidrawCanvas = dynamic(
async () => {
await import("@excalidraw/excalidraw/index.css")
const { Excalidraw } = await import("@excalidraw/excalidraw")
return Excalidraw
},

View File

@ -1,12 +1,18 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useLayoutEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { UltiMailLogo } from "@/components/ultimail-logo"
import { isDriveAppPath } from "@/lib/suite/drive-route"
import {
markSuiteSplashSeen,
shouldShowSuiteSplash,
suiteSplashAppFromPath,
SUITE_SPLASH_CONFIG,
type SuiteSplashApp,
} from "@/lib/suite/suite-app-splash"
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
import { cn } from "@/lib/utils"
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
const SPLASH_VISIBLE_MS = 1750
const SPLASH_EXIT_MS = 500
@ -16,35 +22,35 @@ export function FirstLaunchSplash({
children: React.ReactNode
}) {
const pathname = usePathname()
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(() =>
typeof window === "undefined"
? null
: shouldShowSuiteSplash(window.location.pathname)
)
const [isHiding, setIsHiding] = useState(false)
const [isComplete, setIsComplete] = useState(false)
const [isComplete, setIsComplete] = useState(() => activeApp === null)
useLayoutEffect(() => {
const nextApp = shouldShowSuiteSplash(pathname)
const root = document.documentElement
root.dataset.splashApp = suiteSplashAppFromPath(pathname) ?? ""
root.dataset.splashSeen = nextApp ? "0" : "1"
setActiveApp(nextApp)
setIsComplete(nextApp === null)
setIsHiding(false)
}, [pathname])
useEffect(() => {
const root = document.documentElement
const skipForDrive =
pathname === "/" ||
pathname.startsWith("/demo/") ||
isDriveAppPath(pathname) ||
root.dataset.routeScope === "drive" ||
root.dataset.splashSeen === "1"
if (skipForDrive) {
if (isDriveAppPath(pathname)) root.dataset.splashSeen = "1"
setIsComplete(true)
return
}
if (!activeApp) return
const hideTimer = window.setTimeout(() => {
setIsHiding(true)
}, SPLASH_VISIBLE_MS)
const completeTimer = window.setTimeout(() => {
try {
localStorage.setItem(SPLASH_SEEN_KEY, "1")
} catch {
// Ignore storage failures (private mode / disabled storage).
}
root.dataset.splashSeen = "1"
markSuiteSplashSeen(activeApp)
document.documentElement.dataset.splashSeen = "1"
setActiveApp(null)
setIsComplete(true)
}, SPLASH_VISIBLE_MS + SPLASH_EXIT_MS)
@ -52,26 +58,69 @@ export function FirstLaunchSplash({
window.clearTimeout(hideTimer)
window.clearTimeout(completeTimer)
}
}, [pathname])
}, [activeApp])
const config = activeApp ? SUITE_SPLASH_CONFIG[activeApp] : null
return (
<>
{children}
{!isComplete ? (
{!isComplete && config ? (
<div
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
role="status"
aria-live="polite"
aria-label="Chargement d'Ultimail"
aria-label={config.ariaLabel}
data-suite-splash={activeApp}
>
<div className="app-first-launch-splash__aurora" aria-hidden />
<div className="app-first-launch-splash__grain" aria-hidden />
<div className="app-first-launch-splash__content">
<div className="app-first-launch-splash__pill">ULTIMAIL</div>
<UltiMailLogo href={null} className="app-first-launch-splash__logo" />
<p className="app-first-launch-splash__subtitle">
Synchronisation de votre boite de reception...
</p>
<div className="app-first-launch-splash__pill">{config.pill}</div>
{activeApp === "mail" ? (
<UltiMailLogo href={null} className="app-first-launch-splash__logo" />
) : config.markDark ? (
<>
<img
src={suitePublicAsset(config.mark)}
alt=""
className={cn(
"app-first-launch-splash__mark dark:hidden",
config.spinMark && "app-first-launch-splash__mark--spin"
)}
width={56}
height={56}
decoding="async"
draggable={false}
/>
<img
src={suitePublicAsset(config.markDark)}
alt=""
className={cn(
"app-first-launch-splash__mark hidden dark:block",
config.spinMark && "app-first-launch-splash__mark--spin"
)}
width={56}
height={56}
decoding="async"
draggable={false}
/>
</>
) : (
<img
src={suitePublicAsset(config.mark)}
alt=""
className={cn(
"app-first-launch-splash__mark",
config.spinMark && "app-first-launch-splash__mark--spin"
)}
width={56}
height={56}
decoding="async"
draggable={false}
/>
)}
<p className="app-first-launch-splash__subtitle">{config.subtitle}</p>
<div className="app-first-launch-splash__loader" aria-hidden>
<span />
</div>

View File

@ -3,6 +3,7 @@
import { useMemo, useState } from "react"
import { format } from "date-fns"
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
import { AgendaMark } from "@/components/suite/agenda-mark"
import { Icon } from "@iconify/react"
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
import {
@ -94,12 +95,7 @@ export function CalendarInvitationPreview({
<div className="flex shrink-0 flex-row items-start gap-3 md:flex-col md:items-end">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border bg-mail-surface shadow-sm">
<img
src="/agenda-mark.svg"
alt=""
className="size-9 object-contain"
aria-hidden
/>
<AgendaMark className="size-9 object-contain" />
</div>
<div className="min-w-0 text-right text-sm leading-snug text-muted-foreground">
<p className="font-medium text-foreground">Dans votre agenda</p>

View File

@ -3,11 +3,12 @@
import { useEffect, useMemo } from "react"
import { useQueries } from "@tanstack/react-query"
import { useAuthReady } from "@/lib/api/use-auth-ready"
import { useIsDemoMail } from "@/lib/demo/demo-mail-context"
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
import { apiClient } from "@/lib/api/client"
import type { ApiIdentity } from "@/lib/api/types"
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
import { apiIdentityToCompose } from "@/lib/compose/identity-map"
import { apiIdentityToCompose, dedupeComposeIdentities } from "@/lib/compose/identity-map"
import type { Identity } from "@/lib/compose-context"
import { useComposeIdentitiesStore } from "@/lib/stores/compose-identities-store"
@ -20,6 +21,7 @@ async function fetchIdentities(accountId: string) {
/** Hydrate compose From identities from server for all mail accounts. */
export function ComposeIdentitiesSync() {
const isDemoMail = useIsDemoMail()
const { ready, authenticated } = useAuthReady()
const { data: accounts = [], isSuccess: accountsReady } = useMailAccounts()
const { data: signatures = [], isSuccess: signaturesReady } = useMailSignatures()
@ -45,8 +47,10 @@ export function ComposeIdentitiesSync() {
if (identityQueries.some((q) => q.isPending && q.fetchStatus !== "idle")) {
return null
}
return identityQueries.flatMap((q) =>
(q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById))
return dedupeComposeIdentities(
identityQueries.flatMap((q) =>
(q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById)),
),
)
}, [
ready,
@ -60,13 +64,14 @@ export function ComposeIdentitiesSync() {
])
useEffect(() => {
if (isDemoMail) return
if (!ready || !authenticated) {
useComposeIdentitiesStore.getState().clear()
return
}
if (merged === null) return
useComposeIdentitiesStore.getState().hydrateFromApi(merged)
}, [ready, authenticated, merged])
}, [isDemoMail, ready, authenticated, merged])
return null
}

Some files were not shown because too many files have changed in this diff Show More