feat: enhance configuration and add new demo layouts
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
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:
parent
3bbf3691b0
commit
ad1370ea7e
60
app/api/agenda/ical/route.ts
Normal file
60
app/api/agenda/ical/route.ts
Normal 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 |
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
12
app/demo/agenda/[[...segments]]/page.tsx
Normal file
12
app/demo/agenda/[[...segments]]/page.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense } from "react"
|
||||
import { AgendaPage } from "@/components/agenda/agenda-page"
|
||||
|
||||
export default function DemoAgendaRoutePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AgendaPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
22
app/demo/agenda/layout.tsx
Normal file
22
app/demo/agenda/layout.tsx
Normal 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>
|
||||
}
|
||||
22
app/demo/contacts/layout.tsx
Normal file
22
app/demo/contacts/layout.tsx
Normal 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>
|
||||
}
|
||||
4
app/demo/contacts/page.tsx
Normal file
4
app/demo/contacts/page.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
/** Route racine : l'interface contacts est rendue par `app/demo/contacts/layout.tsx`. */
|
||||
export default function DemoContactsPage() {
|
||||
return null
|
||||
}
|
||||
1
app/demo/drive/[[...segments]]/page.tsx
Normal file
1
app/demo/drive/[[...segments]]/page.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { default } from "@/app/drive/(browser)/[[...segments]]/page"
|
||||
28
app/demo/drive/layout.tsx
Normal file
28
app/demo/drive/layout.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
4
app/demo/mail/[[...segments]]/page.tsx
Normal file
4
app/demo/mail/[[...segments]]/page.tsx
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
}
|
||||
@ -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}
|
||||
|
||||
57
app/drive/mounts/oauth/callback/page.tsx
Normal file
57
app/drive/mounts/oauth/callback/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
|
||||
BIN
app/icon.png
BIN
app/icon.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 956 B |
@ -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>
|
||||
|
||||
@ -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
24
app/meet/[room]/page.tsx
Normal 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
18
app/meet/join/page.tsx
Normal 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
9
app/meet/layout.tsx
Normal 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
5
app/meet/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { MeetLobby } from "@/components/meet/meet-lobby"
|
||||
|
||||
export default function MeetPage() {
|
||||
return <MeetLobby />
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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
|
||||
|
||||
158
components/admin/settings/sections/agenda-section.tsx
Normal file
158
components/admin/settings/sections/agenda-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
154
components/admin/settings/sections/drive-mount-oauth-section.tsx
Normal file
154
components/admin/settings/sections/drive-mount-oauth-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
94
components/admin/settings/sections/drive-org-section.tsx
Normal file
94
components/admin/settings/sections/drive-org-section.tsx
Normal 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'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'organisation</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
347
components/admin/settings/sections/ultimeet-section.tsx
Normal file
347
components/admin/settings/sections/ultimeet-section.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
629
components/agenda/agenda-calendars-settings.tsx
Normal file
629
components/agenda/agenda-calendars-settings.tsx
Normal 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'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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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,
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
403
components/agenda/agenda-event-schedule-fields.tsx
Normal file
403
components/agenda/agenda-event-schedule-fields.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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"
|
||||
|
||||
28
components/agenda/agenda-org-policy-sync.tsx
Normal file
28
components/agenda/agenda-org-policy-sync.tsx
Normal 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
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
77
components/agenda/agenda-quick-settings-panel.tsx
Normal file
77
components/agenda/agenda-quick-settings-panel.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
172
components/agenda/agenda-settings-chip-picker.tsx
Normal file
172
components/agenda/agenda-settings-chip-picker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
466
components/agenda/agenda-settings-fields.tsx
Normal file
466
components/agenda/agenda-settings-fields.tsx
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
217
components/agenda/agenda-step-adjust.tsx
Normal file
217
components/agenda/agenda-step-adjust.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
components/agenda/agenda-video-provider-icon.tsx
Normal file
42
components/agenda/agenda-video-provider-icon.tsx
Normal 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 />
|
||||
)
|
||||
}
|
||||
26
components/agenda/agenda-video-provider-select-label.tsx
Normal file
26
components/agenda/agenda-video-provider-select-label.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
components/agenda/agenda-video-toggle.tsx
Normal file
100
components/agenda/agenda-video-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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() {
|
||||
|
||||
97
components/compte/compte-authentik-panel.tsx
Normal file
97
components/compte/compte-authentik-panel.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
59
components/compte/compte-avatar-field.tsx
Normal file
59
components/compte/compte-avatar-field.tsx
Normal 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'en-tête de la suite et sur votre page compte. JPEG, PNG, GIF
|
||||
ou WebP — 512 Ko max.
|
||||
</p>
|
||||
</div>
|
||||
</CompteSettingsCard>
|
||||
)
|
||||
}
|
||||
14
components/compte/compte-settings-card.tsx
Normal file
14
components/compte/compte-settings-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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'identité de votre
|
||||
organisation. Pour modifier votre nom ou votre adresse e-mail,
|
||||
rapprochez-vous de votre administrateur
|
||||
{idpUrl ? " ou utilisez le portail d'identité" : ""}.
|
||||
</p>
|
||||
{idpUrl ? (
|
||||
<a
|
||||
href={idpUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Ouvrir le portail d'identité
|
||||
<ExternalLink className="size-3.5" aria-hidden />
|
||||
</a>
|
||||
) : null}
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'identité non configuré
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
300
components/demo/demo-agenda-data.ts
Normal file
300
components/demo/demo-agenda-data.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
}
|
||||
20
components/demo/demo-agenda-shell.tsx
Normal file
20
components/demo/demo-agenda-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/demo/demo-chrome.tsx
Normal file
35
components/demo/demo-chrome.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
761
components/demo/demo-contacts-data.ts
Normal file
761
components/demo/demo-contacts-data.ts
Normal 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]
|
||||
}
|
||||
23
components/demo/demo-contacts-shell.tsx
Normal file
23
components/demo/demo-contacts-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
387
components/demo/demo-drive-data.ts
Normal file
387
components/demo/demo-drive-data.ts
Normal 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),
|
||||
}),
|
||||
]
|
||||
20
components/demo/demo-drive-shell.tsx
Normal file
20
components/demo/demo-drive-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)]">
|
||||
<{selected.fromEmail}>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -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 : 1–30 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 (22–29 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" },
|
||||
},
|
||||
]
|
||||
|
||||
19
components/demo/demo-mail-shell.tsx
Normal file
19
components/demo/demo-mail-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
85
components/demo/demo-navigation-guard.tsx
Normal file
85
components/demo/demo-navigation-guard.tsx
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
209
components/drive/drive-add-mount-dialog.tsx
Normal file
209
components/drive/drive-add-mount-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
266
components/drive/drive-sidebar-mounts.tsx
Normal file
266
components/drive/drive-sidebar-mounts.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
230
components/drive/drive-sidebar-org-folders.tsx
Normal file
230
components/drive/drive-sidebar-org-folders.tsx
Normal 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'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'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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user