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 { CompteSettingsLayout } from "@/components/compte/compte-settings-layout"
|
||||||
|
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
@ -11,5 +12,9 @@ export default function CompteRootLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
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 type { Metadata } from "next"
|
||||||
import { DemoMailApp } from "@/components/demo/demo-mail-app"
|
|
||||||
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
import { suitePageMetadata } from "@/lib/suite/page-metadata"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@ -13,6 +13,10 @@ export const metadata: Metadata = {
|
|||||||
robots: { index: false },
|
robots: { index: false },
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DemoMailPage() {
|
export default function DemoMailLayout({
|
||||||
return <DemoMailApp />
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return <DemoMailShell>{children}</DemoMailShell>
|
||||||
}
|
}
|
||||||
@ -27,12 +27,15 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
useDriveList,
|
useDriveList,
|
||||||
|
useDriveMountList,
|
||||||
|
useDriveOrgList,
|
||||||
useDriveRecent,
|
useDriveRecent,
|
||||||
useDriveSearch,
|
useDriveSearch,
|
||||||
useDriveSharedWithMe,
|
useDriveSharedWithMe,
|
||||||
useDriveStarred,
|
useDriveStarred,
|
||||||
useDriveTrash,
|
useDriveTrash,
|
||||||
} from "@/lib/api/hooks/use-drive-queries"
|
} from "@/lib/api/hooks/use-drive-queries"
|
||||||
|
import { pathRefFromRoute } from "@/lib/api/drive-roots"
|
||||||
|
|
||||||
export default function DriveBrowserPage() {
|
export default function DriveBrowserPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@ -42,7 +45,13 @@ export default function DriveBrowserPage() {
|
|||||||
|
|
||||||
const folderPath = folderPathFromSegments(route.pathSegments)
|
const folderPath = folderPathFromSegments(route.pathSegments)
|
||||||
const contextView =
|
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(
|
const fallbackScope = defaultDriveSearchScope(
|
||||||
route.view === "shared" ? "shared" : "files",
|
route.view === "shared" ? "shared" : "files",
|
||||||
folderPath
|
folderPath
|
||||||
@ -78,6 +87,8 @@ export default function DriveBrowserPage() {
|
|||||||
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
|
const folderPlacement = useDriveSettingsStore((s) => s.folderPlacement)
|
||||||
|
|
||||||
const list = useDriveList(folderPath, route.page, "", route.view === "files")
|
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(
|
const shared = useDriveSharedWithMe(
|
||||||
route.page,
|
route.page,
|
||||||
"",
|
"",
|
||||||
@ -113,6 +124,10 @@ export default function DriveBrowserPage() {
|
|||||||
? route.pathSegments.length === 0
|
? route.pathSegments.length === 0
|
||||||
? shared
|
? shared
|
||||||
: sharedFolder
|
: sharedFolder
|
||||||
|
: route.view === "org"
|
||||||
|
? orgList
|
||||||
|
: route.view === "mount"
|
||||||
|
? mountList
|
||||||
: list
|
: list
|
||||||
|
|
||||||
const files = active.data?.files ?? []
|
const files = active.data?.files ?? []
|
||||||
@ -186,6 +201,7 @@ export default function DriveBrowserPage() {
|
|||||||
<DriveBrowserChrome
|
<DriveBrowserChrome
|
||||||
view={route.view}
|
view={route.view}
|
||||||
segments={route.pathSegments}
|
segments={route.pathSegments}
|
||||||
|
rootId={route.rootId}
|
||||||
isTrash={isTrash}
|
isTrash={isTrash}
|
||||||
items={filteredFiles}
|
items={filteredFiles}
|
||||||
searchState={committedSearch}
|
searchState={committedSearch}
|
||||||
@ -247,7 +263,14 @@ export default function DriveBrowserPage() {
|
|||||||
{filteredFiles.length > 0 ? (
|
{filteredFiles.length > 0 ? (
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
items={filteredFiles}
|
items={filteredFiles}
|
||||||
view={isSearchView ? searchBrowserView : route.view === "shared" ? "shared" : "files"}
|
view={
|
||||||
|
isSearchView
|
||||||
|
? searchBrowserView
|
||||||
|
: route.view === "shared"
|
||||||
|
? "shared"
|
||||||
|
: route.view
|
||||||
|
}
|
||||||
|
rootId={route.rootId}
|
||||||
isTrash={isTrash}
|
isTrash={isTrash}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 {
|
@theme inline {
|
||||||
--font-sans: 'Geist', 'Geist Fallback';
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
|
--font-mono: var(--font-geist-mono);
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--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;
|
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 {
|
.app-first-launch-splash__loader {
|
||||||
width: min(58vw, 230px);
|
width: min(58vw, 230px);
|
||||||
height: 4px;
|
height: 4px;
|
||||||
@ -595,6 +603,7 @@ html[data-splash-seen='1'] .app-first-launch-splash {
|
|||||||
.app-first-launch-splash__grain,
|
.app-first-launch-splash__grain,
|
||||||
.app-first-launch-splash__content,
|
.app-first-launch-splash__content,
|
||||||
.app-first-launch-splash__logo,
|
.app-first-launch-splash__logo,
|
||||||
|
.app-first-launch-splash__mark--spin,
|
||||||
.app-first-launch-splash__loader > span {
|
.app-first-launch-splash__loader > span {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
@ -609,7 +618,7 @@ html:has(.ultimail-login) body {
|
|||||||
background-color: transparent !important;
|
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[data-route-scope='drive']::before,
|
||||||
html:has([data-drive-app])::before {
|
html:has([data-drive-app])::before {
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
@ -623,12 +632,6 @@ html[data-route-scope='drive'] body {
|
|||||||
background-color: var(--app-canvas) !important;
|
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) {
|
@media (min-width: 640px) {
|
||||||
.ultimail-login-card-frame {
|
.ultimail-login-card-frame {
|
||||||
padding: 3px;
|
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;
|
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-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;
|
background-color: var(--app-canvas) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -738,7 +763,10 @@ html[data-mail-background]:not([data-mail-background='none'])
|
|||||||
[data-mail-settings-sidebar],
|
[data-mail-settings-sidebar],
|
||||||
html[data-mail-background]:not([data-mail-background='none'])
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
[data-admin-settings-app]
|
[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;
|
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]),
|
:where([data-mail-settings-main]),
|
||||||
html[data-mail-background]:not([data-mail-background='none'])
|
html[data-mail-background]:not([data-mail-background='none'])
|
||||||
[data-admin-settings-app]
|
[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;
|
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;
|
border-color: var(--border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Réglages mail : cartes cohérentes en dark mode (fond + bordure plus visible) */
|
/* Réglages mail / compte : cartes cohérentes en dark mode (fond + bordure plus visible) */
|
||||||
html.dark [data-mail-settings-main] {
|
html.dark [data-mail-settings-main],
|
||||||
|
html.dark [data-compte-settings-main] {
|
||||||
--border: var(--mail-border);
|
--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;
|
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 */
|
/* Settings / Drive : cartes et champs internes — gris mail, pas le noir shadcn */
|
||||||
html.dark .ultimail-app :where(.bg-background) {
|
html.dark .ultimail-app :where(.bg-background) {
|
||||||
background-color: var(--mail-surface-muted) !important;
|
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 { MailToaster } from '@/components/gmail/mail-toaster'
|
||||||
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
import { suiteRootMetadata } from '@/lib/suite/page-metadata'
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const geistSans = Geist({ subsets: ['latin'], variable: '--font-geist-sans' })
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const geistMono = Geist_Mono({ subsets: ['latin'], variable: '--font-geist-mono' })
|
||||||
|
|
||||||
export const metadata: Metadata = suiteRootMetadata()
|
export const metadata: Metadata = suiteRootMetadata()
|
||||||
|
|
||||||
@ -30,7 +30,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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">
|
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
|
||||||
<ThemeInitScript />
|
<ThemeInitScript />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
import { useIsXs } from "@/hooks/use-xs"
|
import { useIsXs } from "@/hooks/use-xs"
|
||||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
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 { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||||
import { useRouter, usePathname } from "next/navigation"
|
|
||||||
import { Sidebar } from "@/components/gmail/sidebar"
|
import { Sidebar } from "@/components/gmail/sidebar"
|
||||||
import { Header } from "@/components/gmail/header"
|
import { Header } from "@/components/gmail/header"
|
||||||
import { EmailList } from "@/components/gmail/email-list"
|
import { EmailList } from "@/components/gmail/email-list"
|
||||||
@ -55,7 +55,6 @@ function isMailSettingsPath(pathname: string | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MailAppInner() {
|
function MailAppInner() {
|
||||||
const router = useRouter()
|
|
||||||
const { route, navigateRoute, searchParams: currentSearchParams } =
|
const { route, navigateRoute, searchParams: currentSearchParams } =
|
||||||
useMailRoute()
|
useMailRoute()
|
||||||
const activeSearchQuery =
|
const activeSearchQuery =
|
||||||
@ -204,7 +203,14 @@ function MailAppInner() {
|
|||||||
xsViewChrome={xsViewChrome}
|
xsViewChrome={xsViewChrome}
|
||||||
onOpenSearch={() => setMobileSearchOpen(true)}
|
onOpenSearch={() => setMobileSearchOpen(true)}
|
||||||
searchQuery={activeSearchQuery}
|
searchQuery={activeSearchQuery}
|
||||||
onClearSearch={() => router.push("/mail/inbox")}
|
onClearSearch={() =>
|
||||||
|
navigateRoute({
|
||||||
|
folderId: "inbox",
|
||||||
|
inboxTab: DEFAULT_INBOX_TAB,
|
||||||
|
page: 1,
|
||||||
|
mailId: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<MobileSearchOverlay
|
<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"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useAuthStore } from "@/lib/api/auth-store"
|
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||||
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
import { usePlatformAdminAccess } from "@/lib/auth/use-platform-admin-access"
|
||||||
import { adminScopesFromToken, isPlatformAdminFromToken } from "@/lib/auth/admin"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
|
export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
|
||||||
const { ready, authenticated } = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
const token = useAuthStore((s) => s.accessToken)
|
const { isAdmin, adminReady } = usePlatformAdminAccess()
|
||||||
const { data: me, isFetching: meLoading } = useCurrentUser()
|
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
@ -29,16 +26,12 @@ export function AdminAccessGuard({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meLoading && !me) {
|
if (!adminReady) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">Vérification des droits administrateur…</p>
|
<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) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
<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 { AdminLogo } from "@/components/admin/admin-logo"
|
||||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
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"
|
const SETTINGS_HREF = "/admin/settings"
|
||||||
|
|
||||||
@ -11,13 +17,18 @@ export function AdminSettingsHeader() {
|
|||||||
data-admin-settings-chrome-header
|
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"
|
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">
|
<div
|
||||||
<AdminLogo className="min-h-8 shrink-0" />
|
className={cn(
|
||||||
<span className="text-sm font-medium text-muted-foreground">Administration</span>
|
"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>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center pl-2 md:hidden">
|
<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>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center px-1 sm:pl-1 sm:pr-1" />
|
<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 { OnlyofficeSection } from "@/components/admin/settings/sections/onlyoffice-section"
|
||||||
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
|
import { RichtextSection } from "@/components/admin/settings/sections/richtext-section"
|
||||||
import { AiAssistantSection } from "@/components/admin/settings/sections/ai-assistant-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"
|
import { AuditSection } from "@/components/admin/settings/sections/audit-section"
|
||||||
|
|
||||||
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
||||||
@ -36,6 +38,8 @@ const SECTIONS: Record<AdminSettingsSectionId, React.ComponentType> = {
|
|||||||
search: SearchSection,
|
search: SearchSection,
|
||||||
plugins: PluginsSection,
|
plugins: PluginsSection,
|
||||||
nextcloud: NextcloudSection,
|
nextcloud: NextcloudSection,
|
||||||
|
agenda: AgendaSection,
|
||||||
|
ultimeet: UltimeetSection,
|
||||||
mailing: MailingSection,
|
mailing: MailingSection,
|
||||||
onlyoffice: OnlyofficeSection,
|
onlyoffice: OnlyofficeSection,
|
||||||
richtext: RichtextSection,
|
richtext: RichtextSection,
|
||||||
|
|||||||
@ -28,9 +28,10 @@ export function OrgSettingsSection({
|
|||||||
}) {
|
}) {
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const { isFetching, isError, refetch } = useOrgSettings()
|
const { isFetching, isError, refetch, isFetched } = useOrgSettings()
|
||||||
const savePolicy = useSaveOrgPolicy()
|
const savePolicy = useSaveOrgPolicy()
|
||||||
const apiSynced = useOrgSettingsStore((s) => s.apiSynced)
|
const apiSynced = useOrgSettingsStore((s) => s.apiSynced)
|
||||||
|
const showPendingBanner = !apiSynced && !isError && (isFetching || !isFetched)
|
||||||
const hasSave = Boolean(policySection)
|
const hasSave = Boolean(policySection)
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
@ -51,7 +52,7 @@ export function OrgSettingsSection({
|
|||||||
<>
|
<>
|
||||||
<SettingsSectionHeader title={title} description={description} />
|
<SettingsSectionHeader title={title} description={description} />
|
||||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||||
{!apiSynced ? <AdminPendingApiBanner /> : null}
|
{!showPendingBanner ? null : <AdminPendingApiBanner />}
|
||||||
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
|
{showEffectiveBanner ? <AdminRuntimePanel /> : null}
|
||||||
<div className="space-y-6">{children}</div>
|
<div className="space-y-6">{children}</div>
|
||||||
{hasSave ? (
|
{hasSave ? (
|
||||||
|
|||||||
@ -14,13 +14,18 @@ export function OrgSettingsSync() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return
|
if (!data) return
|
||||||
|
try {
|
||||||
hydratingRef.current = true
|
hydratingRef.current = true
|
||||||
const mapped = apiOrgPolicyToStore(data.policy)
|
const mapped = apiOrgPolicyToStore(data.policy)
|
||||||
const meta = apiOrgSettingsMeta(data)
|
const meta = apiOrgSettingsMeta(data)
|
||||||
useOrgSettingsStore.getState().hydrateFromApi(mapped, meta)
|
useOrgSettingsStore.getState().hydrateFromApi(mapped, meta)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("org settings hydrate failed", err)
|
||||||
|
} finally {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
hydratingRef.current = false
|
hydratingRef.current = false
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
return null
|
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"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
import { OrgSettingsSection } from "@/components/admin/settings/org-settings-form"
|
||||||
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
import { useOrgSettingsStore } from "@/lib/admin-settings/org-settings-store"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@ -13,10 +14,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} 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() {
|
export function FilePoliciesSection() {
|
||||||
const filePolicies = useOrgSettingsStore((s) => s.filePolicies)
|
const filePolicies = useOrgSettingsStore((s) => s.filePolicies)
|
||||||
const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies)
|
const setFilePolicies = useOrgSettingsStore((s) => s.setFilePolicies)
|
||||||
|
const [mountOAuthDraft, setMountOAuthDraft] = useState(filePolicies.mount_oauth)
|
||||||
const vtKeyConfigured = useOrgSettingsStore(
|
const vtKeyConfigured = useOrgSettingsStore(
|
||||||
(s) => s.meta?.secrets?.virustotal_api_key?.configured ?? false
|
(s) => s.meta?.secrets?.virustotal_api_key?.configured ?? false
|
||||||
)
|
)
|
||||||
@ -25,11 +29,16 @@ export function FilePoliciesSection() {
|
|||||||
!vtKeyConfigured &&
|
!vtKeyConfigured &&
|
||||||
!(filePolicies.virustotal_api_key ?? "").trim()
|
!(filePolicies.virustotal_api_key ?? "").trim()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMountOAuthDraft(filePolicies.mount_oauth)
|
||||||
|
}, [filePolicies.mount_oauth])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OrgSettingsSection
|
<OrgSettingsSection
|
||||||
title="Politiques fichiers"
|
title="Politiques fichiers"
|
||||||
description="Règles d'upload, partage externe et rétention pour UltiDrive."
|
description="Règles d'upload, partage externe et rétention pour UltiDrive."
|
||||||
policySection="file_policies"
|
policySection="file_policies"
|
||||||
|
beforeSave={() => setFilePolicies({ mount_oauth: mountOAuthDraft })}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
@ -143,6 +152,8 @@ export function FilePoliciesSection() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<DriveMountOAuthSection draft={mountOAuthDraft} onChange={setMountOAuthDraft} />
|
||||||
|
<DriveOrgFoldersSection />
|
||||||
</OrgSettingsSection>
|
</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 { useEffect, useLayoutEffect, type ReactNode } from "react"
|
||||||
import { AiChatPanel } from "@/components/ai/ai-chat-panel"
|
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 { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
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 isMobile = useIsMobile()
|
||||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
||||||
@ -21,6 +31,7 @@ export function AgendaAppShell({ children }: { children: ReactNode }) {
|
|||||||
}, [isMobile, setSidebarCollapsed])
|
}, [isMobile, setSidebarCollapsed])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AgendaRouteRootProvider routeRoot={routeRoot}>
|
||||||
<SuiteThemeShell>
|
<SuiteThemeShell>
|
||||||
<TooltipProvider delayDuration={400}>
|
<TooltipProvider delayDuration={400}>
|
||||||
<div
|
<div
|
||||||
@ -37,8 +48,12 @@ export function AgendaAppShell({ children }: { children: ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
{children}
|
{children}
|
||||||
<AiChatPanel />
|
<AiChatPanel />
|
||||||
|
<ComposeIdentitiesSync />
|
||||||
|
<AgendaOrgPolicySync />
|
||||||
|
<AgendaQuickSettingsRoot />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SuiteThemeShell>
|
</SuiteThemeShell>
|
||||||
|
</AgendaRouteRootProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,11 +25,14 @@ export function AgendaCalendarDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
calendar,
|
calendar,
|
||||||
|
onAddExternalICal,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** Agenda à modifier — absent en création. */
|
/** Agenda à modifier — absent en création. */
|
||||||
calendar?: AgendaCalendar | null
|
calendar?: AgendaCalendar | null
|
||||||
|
/** Création uniquement — ouvre la modale iCal externe. */
|
||||||
|
onAddExternalICal?: () => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
|
const [color, setColor] = useState(AGENDA_COLOR_PALETTE[0].value)
|
||||||
@ -66,7 +69,7 @@ export function AgendaCalendarDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md" aria-describedby={undefined}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{calendar ? "Modifier l'agenda" : "Nouvel agenda"}
|
{calendar ? "Modifier l'agenda" : "Nouvel agenda"}
|
||||||
@ -107,13 +110,24 @@ export function AgendaCalendarDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<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)}>
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void submit()} disabled={!name.trim() || pending}>
|
<Button onClick={() => void submit()} disabled={!name.trim() || pending}>
|
||||||
{calendar ? "Enregistrer" : "Créer"}
|
{calendar ? "Enregistrer" : "Créer"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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({
|
export function AgendaEventChip({
|
||||||
event,
|
event,
|
||||||
filled,
|
filled,
|
||||||
|
pending,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
event: AgendaEvent
|
event: AgendaEvent
|
||||||
/** Fond plein (journée entière / multi-jours) vs point coloré + heure. */
|
/** Fond plein (journée entière / multi-jours) vs point coloré + heure. */
|
||||||
filled?: boolean
|
filled?: boolean
|
||||||
|
/** Brouillon en cours d'édition — style fantôme. */
|
||||||
|
pending?: boolean
|
||||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
const solid = filled ?? event.allDay
|
const solid = filled ?? event.allDay
|
||||||
const style: CSSProperties = solid
|
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)
|
const declined = isDeclinedForAll(event)
|
||||||
|
|
||||||
@ -29,9 +35,11 @@ export function AgendaEventChip({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
disabled={pending}
|
||||||
className={cn(
|
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",
|
"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",
|
declined && "opacity-50 line-through",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { addDays, addHours, format, parse } from "date-fns"
|
import { addDays, addHours } from "date-fns"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Icon } from "@iconify/react"
|
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Clock,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Repeat,
|
Repeat,
|
||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
|
Video,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
@ -29,11 +28,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
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 { 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 {
|
import {
|
||||||
draftToApiEvent,
|
|
||||||
useCreateAgendaEvent,
|
useCreateAgendaEvent,
|
||||||
useCreateAgendaMeetLink,
|
useCreateAgendaMeetLink,
|
||||||
useDeleteAgendaEvent,
|
useDeleteAgendaEvent,
|
||||||
@ -41,16 +44,14 @@ import {
|
|||||||
} from "@/lib/api/hooks/use-calendar-mutations"
|
} from "@/lib/api/hooks/use-calendar-mutations"
|
||||||
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
|
import { AGENDA_COLOR_PALETTE } from "@/lib/agenda/agenda-colors"
|
||||||
import { calendarColor } from "@/lib/agenda/agenda-events"
|
import { calendarColor } from "@/lib/agenda/agenda-events"
|
||||||
import {
|
import { describeRRule, recurrenceOptionsFor } from "@/lib/agenda/agenda-recurrence"
|
||||||
describeRRule,
|
|
||||||
recurrenceOptionsFor,
|
|
||||||
} from "@/lib/agenda/agenda-recurrence"
|
|
||||||
import type {
|
import type {
|
||||||
AgendaCalendar,
|
AgendaCalendar,
|
||||||
AgendaEvent,
|
AgendaEvent,
|
||||||
AgendaEventAttendee,
|
AgendaEventAttendee,
|
||||||
AgendaEventDraft,
|
AgendaEventDraft,
|
||||||
} from "@/lib/agenda/agenda-types"
|
} from "@/lib/agenda/agenda-types"
|
||||||
|
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface AgendaEventDialogState {
|
export interface AgendaEventDialogState {
|
||||||
@ -60,47 +61,38 @@ export interface AgendaEventDialogState {
|
|||||||
event?: AgendaEvent
|
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({
|
export function AgendaEventDialog({
|
||||||
state,
|
state,
|
||||||
onClose,
|
onClose,
|
||||||
calendars,
|
calendars,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
onDraftChange,
|
||||||
}: {
|
}: {
|
||||||
state: AgendaEventDialogState | null
|
state: AgendaEventDialogState | null
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
calendars: AgendaCalendar[]
|
calendars: AgendaCalendar[]
|
||||||
userEmail?: string
|
userEmail?: string
|
||||||
|
onDraftChange?: (draft: AgendaEventDraft) => void
|
||||||
}) {
|
}) {
|
||||||
const createMutation = useCreateAgendaEvent()
|
const createMutation = useCreateAgendaEvent()
|
||||||
const updateMutation = useUpdateAgendaEvent()
|
const updateMutation = useUpdateAgendaEvent()
|
||||||
const deleteMutation = useDeleteAgendaEvent()
|
const deleteMutation = useDeleteAgendaEvent()
|
||||||
const meetLinkMutation = useCreateAgendaMeetLink()
|
const meetLinkMutation = useCreateAgendaMeetLink()
|
||||||
|
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
|
||||||
|
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [allDay, setAllDay] = useState(false)
|
const [allDay, setAllDay] = useState(false)
|
||||||
const [startDate, setStartDate] = useState("")
|
const [start, setStart] = useState(() => new Date())
|
||||||
const [startTime, setStartTime] = useState("09:00")
|
const [end, setEnd] = useState(() => new Date())
|
||||||
const [endDate, setEndDate] = useState("")
|
|
||||||
const [endTime, setEndTime] = useState("10:00")
|
|
||||||
const [calendarId, setCalendarId] = useState("")
|
const [calendarId, setCalendarId] = useState("")
|
||||||
const [rrule, setRRule] = useState("")
|
const [rrule, setRRule] = useState("")
|
||||||
const [color, setColor] = useState("")
|
const [color, setColor] = useState("")
|
||||||
const [location, setLocation] = useState("")
|
const [location, setLocation] = useState("")
|
||||||
const [description, setDescription] = useState("")
|
const [description, setDescription] = useState("")
|
||||||
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
|
const [attendees, setAttendees] = useState<AgendaEventAttendee[]>([])
|
||||||
|
const [includeVideo, setIncludeVideo] = useState(false)
|
||||||
|
const [meetUrl, setMeetUrl] = useState("")
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const open = state !== null
|
const open = state !== null
|
||||||
const isEdit = state?.mode === "edit"
|
const isEdit = state?.mode === "edit"
|
||||||
@ -110,48 +102,50 @@ export function AgendaEventDialog({
|
|||||||
const d = state.draft
|
const d = state.draft
|
||||||
setTitle(d.title)
|
setTitle(d.title)
|
||||||
setAllDay(d.allDay)
|
setAllDay(d.allDay)
|
||||||
setStartDate(toDateInput(d.start))
|
setStart(d.start)
|
||||||
setStartTime(toTimeInput(d.start))
|
setEnd(d.allDay ? addDays(d.end, -1) : d.end)
|
||||||
// ICS : fin exclusive pour les journées entières → réaffiche la date incluse.
|
|
||||||
const displayEnd = d.allDay ? addDays(d.end, -1) : d.end
|
|
||||||
setEndDate(toDateInput(displayEnd < d.start ? d.start : displayEnd))
|
|
||||||
setEndTime(toTimeInput(d.end))
|
|
||||||
setCalendarId(d.calendarId || calendars[0]?.id || "")
|
setCalendarId(d.calendarId || calendars[0]?.id || "")
|
||||||
setRRule(d.rrule ?? "")
|
setRRule(d.rrule ?? "")
|
||||||
setColor(d.color ?? "")
|
setColor(d.color ?? "")
|
||||||
setLocation(d.location ?? "")
|
setLocation(d.location ?? "")
|
||||||
setDescription(d.description ?? "")
|
setDescription(d.description ?? "")
|
||||||
setAttendees(d.attendees ?? [])
|
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])
|
}, [state, calendars])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (allDay) setIncludeVideo(false)
|
||||||
|
}, [allDay])
|
||||||
|
|
||||||
const recurrenceOptions = useMemo(() => {
|
const recurrenceOptions = useMemo(() => {
|
||||||
const start = fromInputs(startDate, startTime) ?? new Date()
|
|
||||||
const options = recurrenceOptionsFor(start)
|
const options = recurrenceOptionsFor(start)
|
||||||
if (rrule && !options.some((o) => o.value === rrule)) {
|
if (rrule && !options.some((o) => o.value === rrule)) {
|
||||||
options.push({ value: rrule, label: describeRRule(rrule) })
|
options.push({ value: rrule, label: describeRRule(rrule) })
|
||||||
}
|
}
|
||||||
return options
|
return options
|
||||||
}, [startDate, startTime, rrule])
|
}, [start, rrule])
|
||||||
|
|
||||||
const pending =
|
const pending =
|
||||||
createMutation.isPending || updateMutation.isPending || deleteMutation.isPending
|
createMutation.isPending ||
|
||||||
|
updateMutation.isPending ||
|
||||||
|
deleteMutation.isPending ||
|
||||||
|
meetLinkMutation.isPending
|
||||||
|
|
||||||
const buildDraft = (): AgendaEventDraft | null => {
|
const buildDraft = (): AgendaEventDraft | null => {
|
||||||
const start = fromInputs(startDate, allDay ? "00:00" : startTime)
|
if (!calendarId) return null
|
||||||
if (!start) return null
|
let eventStart = start
|
||||||
let end = fromInputs(endDate, allDay ? "00:00" : endTime)
|
let eventEnd = allDay ? addDays(end, 1) : end
|
||||||
if (!end) return null
|
if (allDay && eventEnd <= eventStart) eventEnd = addDays(eventStart, 1)
|
||||||
if (allDay) {
|
if (!allDay && eventEnd <= eventStart) eventEnd = addHours(eventStart, 1)
|
||||||
// Fin exclusive : jour affiché inclus + 1.
|
|
||||||
end = addDays(end, 1)
|
|
||||||
if (end <= start) end = addDays(start, 1)
|
|
||||||
} else if (end <= start) {
|
|
||||||
end = addHours(start, 1)
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
start,
|
start: eventStart,
|
||||||
end,
|
end: eventEnd,
|
||||||
allDay,
|
allDay,
|
||||||
calendarId,
|
calendarId,
|
||||||
description,
|
description,
|
||||||
@ -159,25 +153,60 @@ export function AgendaEventDialog({
|
|||||||
attendees,
|
attendees,
|
||||||
rrule,
|
rrule,
|
||||||
color: color || undefined,
|
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 submit = async () => {
|
||||||
const draft = buildDraft()
|
const draft = buildDraft()
|
||||||
if (!draft || !calendarId) return
|
if (!draft || !calendar) return
|
||||||
try {
|
try {
|
||||||
if (isEdit && state?.event) {
|
if (isEdit && state?.event) {
|
||||||
await updateMutation.mutateAsync({
|
await saveAgendaEventEdit({
|
||||||
|
draft,
|
||||||
path: state.event.path,
|
path: state.event.path,
|
||||||
etag: state.event.etag,
|
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")
|
toast.success("Événement mis à jour")
|
||||||
} else {
|
} else {
|
||||||
const apiEvent = draftToApiEvent(draft)
|
await createAgendaEventWithVideo({
|
||||||
if (userEmail) apiEvent.organizer = userEmail
|
draft,
|
||||||
await createMutation.mutateAsync({ calendarId, event: apiEvent })
|
calendar,
|
||||||
toast.success("Événement créé")
|
userEmail,
|
||||||
|
includeVideo: includeVideo && !allDay,
|
||||||
|
videoProvider: defaultVideoProvider,
|
||||||
|
createMutation,
|
||||||
|
meetLinkMutation,
|
||||||
|
})
|
||||||
|
toast.success(
|
||||||
|
includeVideo && !allDay ? "Événement et visio créés" : "Événement créé",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@ -196,22 +225,17 @@ export function AgendaEventDialog({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addMeetLink = async () => {
|
const handleVideoChange = (enabled: boolean) => {
|
||||||
if (!state?.event) return
|
setIncludeVideo(enabled)
|
||||||
try {
|
if (!enabled) setMeetUrl("")
|
||||||
const res = await meetLinkMutation.mutateAsync({
|
|
||||||
path: state.event.path,
|
|
||||||
etag: state.event.etag,
|
|
||||||
})
|
|
||||||
toast.success("Visio UltiMeet ajoutée")
|
|
||||||
window.open(res.meet_url, "_blank", "noopener")
|
|
||||||
onClose()
|
|
||||||
} catch {
|
|
||||||
toast.error("Impossible de créer le lien UltiMeet")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const meetUrl = state?.event?.meetUrl
|
const scheduleTabCount = agendaScheduleFieldCount({
|
||||||
|
allDay,
|
||||||
|
showAllDayToggle: true,
|
||||||
|
compact: false,
|
||||||
|
})
|
||||||
|
const tab = (offset: number) => 2 + scheduleTabCount + offset
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -220,73 +244,46 @@ export function AgendaEventDialog({
|
|||||||
if (!o) onClose()
|
if (!o) onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
|
<DialogContent
|
||||||
<DialogHeader className="border-b border-border/60 px-5 py-3">
|
className="flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||||
<DialogTitle className="text-base font-medium">
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>
|
||||||
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
|
{isEdit ? "Modifier l'événement" : "Nouvel événement"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</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
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
value={title}
|
value={title}
|
||||||
|
tabIndex={1}
|
||||||
autoFocus={!isEdit}
|
autoFocus={!isEdit}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Ajouter un titre"
|
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 */}
|
<AgendaEventScheduleFields
|
||||||
<div className="flex flex-col gap-2">
|
start={start}
|
||||||
<div className="flex items-start gap-3">
|
end={end}
|
||||||
<Clock className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
allDay={allDay}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
stepMinutes={buttonSnapMinutes}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
tabIndexBase={2}
|
||||||
<Input
|
showRowLabels
|
||||||
type="date"
|
showAllDayToggle
|
||||||
value={startDate}
|
onAllDayChange={setAllDay}
|
||||||
onChange={(e) => {
|
onChange={(nextStart, nextEnd) => {
|
||||||
setStartDate(e.target.value)
|
setStart(nextStart)
|
||||||
if (e.target.value > endDate) setEndDate(e.target.value)
|
setEnd(allDay ? nextEnd : nextEnd)
|
||||||
}}
|
}}
|
||||||
className="h-9 w-fit"
|
|
||||||
/>
|
/>
|
||||||
{!allDay && (
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
|
||||||
className="h-9 w-fit"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="px-0.5 text-muted-foreground">–</span>
|
|
||||||
{!allDay && (
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
|
||||||
className="h-9 w-fit"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
min={startDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="h-9 w-fit"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-foreground/80">
|
|
||||||
<Switch checked={allDay} onCheckedChange={setAllDay} />
|
|
||||||
Toute la journée
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Repeat className="size-5 shrink-0 text-muted-foreground" />
|
<Repeat className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<Select value={rrule || "none"} onValueChange={(v) => setRRule(v === "none" ? "" : v)}>
|
<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">
|
<SelectTrigger tabIndex={tab(0)} className="h-9 w-fit min-w-52 border-0 bg-muted/60 shadow-none">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -298,9 +295,7 @@ export function AgendaEventDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Invités */}
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Users className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
<Users className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@ -308,70 +303,51 @@ export function AgendaEventDialog({
|
|||||||
attendees={attendees}
|
attendees={attendees}
|
||||||
onChange={setAttendees}
|
onChange={setAttendees}
|
||||||
organizerEmail={userEmail}
|
organizerEmail={userEmail}
|
||||||
|
tabIndex={tab(1)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visio */}
|
{!allDay && defaultVideoProvider !== "none" ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Icon
|
<Video className="mt-2 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
icon="simple-icons:jitsi"
|
<AgendaVideoToggle
|
||||||
className="size-5 shrink-0 text-muted-foreground"
|
provider={defaultVideoProvider}
|
||||||
aria-hidden
|
enabled={includeVideo}
|
||||||
|
meetUrl={includeVideo ? meetUrl : undefined}
|
||||||
|
onEnabledChange={handleVideoChange}
|
||||||
|
pending={pending}
|
||||||
|
tabIndex={tab(2)}
|
||||||
/>
|
/>
|
||||||
{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>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Lieu */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
<MapPin className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
value={location}
|
value={location}
|
||||||
|
tabIndex={tab(3)}
|
||||||
onChange={(e) => setLocation(e.target.value)}
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="Ajouter un lieu"
|
placeholder="Ajouter un lieu"
|
||||||
className="h-9 flex-1"
|
className="h-9 flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
<AlignLeft className="mt-2 size-5 shrink-0 text-muted-foreground" />
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={description}
|
||||||
|
tabIndex={tab(4)}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
placeholder="Ajouter une description"
|
placeholder="Ajouter une description"
|
||||||
className="min-h-20 flex-1"
|
className="min-h-20 flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agenda + couleur */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
<CalendarDays className="size-5 shrink-0 text-muted-foreground" />
|
||||||
<Select
|
<Select value={calendarId} onValueChange={setCalendarId} disabled={isEdit}>
|
||||||
value={calendarId}
|
<SelectTrigger tabIndex={tab(5)} className="h-9 w-fit min-w-40 border-0 bg-muted/60 shadow-none">
|
||||||
onValueChange={setCalendarId}
|
|
||||||
disabled={isEdit}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9 w-fit min-w-40 border-0 bg-muted/60 shadow-none">
|
|
||||||
<SelectValue placeholder="Agenda" />
|
<SelectValue placeholder="Agenda" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -389,10 +365,11 @@ export function AgendaEventDialog({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex items-center gap-1.5">
|
<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
|
<button
|
||||||
key={c.value}
|
key={c.value}
|
||||||
type="button"
|
type="button"
|
||||||
|
tabIndex={tab(6 + i)}
|
||||||
title={c.label}
|
title={c.label}
|
||||||
aria-label={`Couleur ${c.label}`}
|
aria-label={`Couleur ${c.label}`}
|
||||||
onClick={() => setColor(color === c.value ? "" : c.value)}
|
onClick={() => setColor(color === c.value ? "" : c.value)}
|
||||||
@ -412,6 +389,7 @@ export function AgendaEventDialog({
|
|||||||
{isEdit && (
|
{isEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
tabIndex={tab(14)}
|
||||||
className="mr-auto gap-2 text-destructive hover:text-destructive"
|
className="mr-auto gap-2 text-destructive hover:text-destructive"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void remove()}
|
onClick={() => void remove()}
|
||||||
@ -419,12 +397,13 @@ export function AgendaEventDialog({
|
|||||||
<Trash2 className="size-4" /> Supprimer
|
<Trash2 className="size-4" /> Supprimer
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" onClick={onClose} disabled={pending}>
|
<Button variant="ghost" tabIndex={tab(15)} onClick={onClose} disabled={pending}>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
tabIndex={tab(16)}
|
||||||
onClick={() => void submit()}
|
onClick={() => void submit()}
|
||||||
disabled={pending || !calendarId || !startDate || !endDate}
|
disabled={pending || !calendarId}
|
||||||
className="rounded-full px-6"
|
className="rounded-full px-6"
|
||||||
>
|
>
|
||||||
Enregistrer
|
Enregistrer
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Icon } from "@iconify/react"
|
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CircleHelp,
|
CircleHelp,
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
|
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 { ContactAvatar } from "@/components/gmail/contacts/contact-avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
useRespondAgendaInvitation,
|
useRespondAgendaInvitation,
|
||||||
} from "@/lib/api/hooks/use-calendar-mutations"
|
} from "@/lib/api/hooks/use-calendar-mutations"
|
||||||
import { formatEventRange } from "@/lib/agenda/agenda-date"
|
import { formatEventRange } from "@/lib/agenda/agenda-date"
|
||||||
|
import { isUltiMeetUrl, meetJoinPath } from "@/lib/meet/meet-url"
|
||||||
import { describeRRule } from "@/lib/agenda/agenda-recurrence"
|
import { describeRRule } from "@/lib/agenda/agenda-recurrence"
|
||||||
import { stashPendingCompose } from "@/lib/agenda/agenda-mail-compose"
|
import { stashPendingCompose } from "@/lib/agenda/agenda-mail-compose"
|
||||||
import type { AgendaCalendar, AgendaEvent } from "@/lib/agenda/agenda-types"
|
import type { AgendaCalendar, AgendaEvent } from "@/lib/agenda/agenda-types"
|
||||||
@ -100,8 +102,23 @@ export function AgendaEventPopover({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AgendaFloatingCard anchor={anchor} onClose={onClose} width={420}>
|
<AgendaFloatingCard anchor={anchor} onClose={onClose} width={420}>
|
||||||
{/* Barre d'actions */}
|
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-4 pb-5">
|
||||||
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
|
{/* 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 flex-1">
|
||||||
|
<h2 className="text-[1.3rem] leading-snug font-normal break-words text-foreground">
|
||||||
|
{event.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{formatEventRange(event.start, event.end, event.allDay)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -159,23 +176,6 @@ export function AgendaEventPopover({
|
|||||||
<X className="size-4" />
|
<X className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 overflow-y-auto px-5 pt-1 pb-5">
|
|
||||||
{/* Titre */}
|
|
||||||
<div className="flex items-start gap-3.5">
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="mt-1.5 size-4 shrink-0 rounded-[5px]"
|
|
||||||
style={{ backgroundColor: event.color }}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-[1.3rem] leading-snug font-normal break-words text-foreground">
|
|
||||||
{event.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatEventRange(event.start, event.end, event.allDay)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event.recurring && (
|
{event.recurring && (
|
||||||
@ -186,14 +186,16 @@ export function AgendaEventPopover({
|
|||||||
|
|
||||||
{event.meetUrl && (
|
{event.meetUrl && (
|
||||||
<Row
|
<Row
|
||||||
icon={
|
icon={<AgendaVideoProviderIcon provider="ultimeet" className="size-4.5" />}
|
||||||
<Icon icon="simple-icons:jitsi" className="size-4.5" aria-hidden />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Button asChild className="h-9 rounded-full">
|
<Button asChild className="h-9 rounded-full">
|
||||||
|
{isUltiMeetUrl(event.meetUrl) ? (
|
||||||
|
<Link href={meetJoinPath(event.meetUrl)}>Rejoindre la visio</Link>
|
||||||
|
) : (
|
||||||
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
|
<a href={event.meetUrl} target="_blank" rel="noopener noreferrer">
|
||||||
Rejoindre la visio
|
Rejoindre la visio
|
||||||
</a>
|
</a>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</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,
|
attendees,
|
||||||
onChange,
|
onChange,
|
||||||
organizerEmail,
|
organizerEmail,
|
||||||
|
tabIndex,
|
||||||
}: {
|
}: {
|
||||||
attendees: AgendaEventAttendee[]
|
attendees: AgendaEventAttendee[]
|
||||||
onChange: (attendees: AgendaEventAttendee[]) => void
|
onChange: (attendees: AgendaEventAttendee[]) => void
|
||||||
organizerEmail?: string
|
organizerEmail?: string
|
||||||
|
tabIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
@ -80,6 +82,7 @@ export function AgendaGuestPicker({
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
placeholder="Ajouter des invités"
|
placeholder="Ajouter des invités"
|
||||||
|
tabIndex={tabIndex}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
setActiveIndex(0)
|
setActiveIndex(0)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { AgendaMark } from "@/components/suite/agenda-mark"
|
||||||
import { ChevronDown, ChevronLeft, ChevronRight, Menu } from "lucide-react"
|
import { ChevronDown, ChevronLeft, ChevronRight, Menu } from "lucide-react"
|
||||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@ -12,14 +13,20 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
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 {
|
import {
|
||||||
AGENDA_VIEW_LABELS,
|
AGENDA_VIEW_LABELS,
|
||||||
AGENDA_VIEWS,
|
AGENDA_VIEWS,
|
||||||
type AgendaView,
|
type AgendaView,
|
||||||
} from "@/lib/agenda/agenda-url"
|
} 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 { 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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const VIEW_SHORTCUTS: Record<AgendaView, string> = {
|
const VIEW_SHORTCUTS: Record<AgendaView, string> = {
|
||||||
@ -31,12 +38,16 @@ const VIEW_SHORTCUTS: Record<AgendaView, string> = {
|
|||||||
export function AgendaHeader({
|
export function AgendaHeader({
|
||||||
view,
|
view,
|
||||||
date,
|
date,
|
||||||
|
weekStart = "auto",
|
||||||
|
weekStartsOn,
|
||||||
onToday,
|
onToday,
|
||||||
onStep,
|
onStep,
|
||||||
onViewChange,
|
onViewChange,
|
||||||
}: {
|
}: {
|
||||||
view: AgendaView
|
view: AgendaView
|
||||||
date: Date
|
date: Date
|
||||||
|
weekStart?: AgendaWeekStart
|
||||||
|
weekStartsOn?: WeekStartsOn
|
||||||
onToday: () => void
|
onToday: () => void
|
||||||
onStep: (delta: 1 | -1) => void
|
onStep: (delta: 1 | -1) => void
|
||||||
onViewChange: (view: AgendaView) => void
|
onViewChange: (view: AgendaView) => void
|
||||||
@ -44,9 +55,10 @@ export function AgendaHeader({
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
||||||
|
const openQuickSettings = useAgendaSettingsStore((s) => s.setQuickSettingsOpen)
|
||||||
|
|
||||||
return (
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@ -57,11 +69,12 @@ export function AgendaHeader({
|
|||||||
<Menu className="size-5" />
|
<Menu className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Link href="/agenda" className="mr-1 hidden min-w-0 items-center gap-2 sm:flex">
|
<Link
|
||||||
<img src="/agenda-mark.svg" alt="" className="size-9 shrink-0" />
|
href="/agenda"
|
||||||
<span className="hidden truncate text-[1.35rem] leading-none text-foreground/80 md:block">
|
className={cn("mr-1 hidden sm:flex", SUITE_APP_LOGO_LOCKUP_CLASS)}
|
||||||
Agenda
|
>
|
||||||
</span>
|
<AgendaMark className={SUITE_APP_LOGO_MARK_CLASS} />
|
||||||
|
<span className={cn("hidden md:block", SUITE_APP_LOGO_TEXT_CLASS)}>Agenda</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@ -108,7 +121,7 @@ export function AgendaHeader({
|
|||||||
"min-w-0 truncate text-[1.05rem] font-normal text-foreground/90 sm:text-[1.35rem]",
|
"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>
|
</h1>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
@ -133,7 +146,10 @@ export function AgendaHeader({
|
|||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<HeaderAccountActions className="pl-1" settingsHref="/mail/settings" />
|
<HeaderAccountActions
|
||||||
|
className="pl-1"
|
||||||
|
onSettingsClick={() => openQuickSettings(true)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -12,26 +12,30 @@ import {
|
|||||||
} from "date-fns"
|
} from "date-fns"
|
||||||
import { fr } from "date-fns/locale"
|
import { fr } from "date-fns/locale"
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const WEEKDAYS = ["L", "M", "M", "J", "V", "S", "D"]
|
|
||||||
|
|
||||||
export function AgendaMiniMonth({
|
export function AgendaMiniMonth({
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
weekStart = "auto",
|
||||||
}: {
|
}: {
|
||||||
selected: Date
|
selected: Date
|
||||||
onSelect: (date: Date) => void
|
onSelect: (date: Date) => void
|
||||||
|
weekStart?: AgendaWeekStart
|
||||||
}) {
|
}) {
|
||||||
|
const weekStartsOn = useResolvedWeekStartsOn(weekStart)
|
||||||
const [cursor, setCursor] = useState(() => startOfMonth(selected))
|
const [cursor, setCursor] = useState(() => startOfMonth(selected))
|
||||||
|
const weekLabels = weekdayLabelsFor(weekStartsOn)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCursor(startOfMonth(selected))
|
setCursor(startOfMonth(selected))
|
||||||
}, [selected])
|
}, [selected])
|
||||||
|
|
||||||
const gridStart = startOfWeek(cursor, WEEK_OPTS)
|
const gridStart = startOfWeek(cursor, getWeekOptionsFor(weekStartsOn))
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const cells: Date[] = []
|
const cells: Date[] = []
|
||||||
for (let i = 0; i < 42; i++) cells.push(addDays(gridStart, i))
|
for (let i = 0; i < 42; i++) cells.push(addDays(gridStart, i))
|
||||||
@ -66,7 +70,7 @@ export function AgendaMiniMonth({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-7 text-center">
|
<div className="grid grid-cols-7 text-center">
|
||||||
{WEEKDAYS.map((d, i) => (
|
{weekLabels.map((d, i) => (
|
||||||
<span
|
<span
|
||||||
key={`${d}-${i}`}
|
key={`${d}-${i}`}
|
||||||
className="py-1 text-[0.65rem] font-medium text-muted-foreground"
|
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 { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { useParams, useRouter, useSearchParams } from "next/navigation"
|
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 { AgendaEventDialog, type AgendaEventDialogState } from "@/components/agenda/agenda-event-dialog"
|
||||||
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
|
import { AgendaEventPopover, type AgendaEventPopoverState } from "@/components/agenda/agenda-event-popover"
|
||||||
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
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 { AgendaViewMonth } from "@/components/agenda/agenda-view-month"
|
||||||
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
|
import { AgendaViewWeek } from "@/components/agenda/agenda-view-week"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
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 { 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 { 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 type { AgendaEvent, AgendaEventDraft } from "@/lib/agenda/agenda-types"
|
||||||
|
import { useAgendaRouteRoot } from "@/lib/agenda/agenda-route-context"
|
||||||
import {
|
import {
|
||||||
buildAgendaPath,
|
buildAgendaPath,
|
||||||
parseAgendaSegments,
|
parseAgendaSegments,
|
||||||
@ -23,11 +30,14 @@ import {
|
|||||||
} from "@/lib/agenda/agenda-url"
|
} from "@/lib/agenda/agenda-url"
|
||||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
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() {
|
export function AgendaPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
const agendaRouteRoot = useAgendaRouteRoot()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const identity = useChromeIdentity()
|
const identity = useChromeIdentity()
|
||||||
|
|
||||||
@ -37,15 +47,21 @@ export function AgendaPage() {
|
|||||||
)
|
)
|
||||||
const lastView = useAgendaSettingsStore((s) => s.lastView)
|
const lastView = useAgendaSettingsStore((s) => s.lastView)
|
||||||
const setLastView = useAgendaSettingsStore((s) => s.setLastView)
|
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 view: AgendaView = route.view ?? (isMobile ? "day" : lastView)
|
||||||
const date = route.date
|
const date = route.date
|
||||||
|
|
||||||
// Normalise l'URL quand la vue est implicite.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!route.view) {
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [route.view])
|
}, [route.view])
|
||||||
@ -56,9 +72,9 @@ export function AgendaPage() {
|
|||||||
|
|
||||||
const navigate = useCallback(
|
const navigate = useCallback(
|
||||||
(nextView: AgendaView, nextDate: Date) => {
|
(nextView: AgendaView, nextDate: Date) => {
|
||||||
router.push(buildAgendaPath(nextView, nextDate))
|
router.push(buildAgendaPath(nextView, nextDate, agendaRouteRoot))
|
||||||
},
|
},
|
||||||
[router],
|
[router, agendaRouteRoot],
|
||||||
)
|
)
|
||||||
|
|
||||||
const step = useCallback(
|
const step = useCallback(
|
||||||
@ -72,46 +88,96 @@ export function AgendaPage() {
|
|||||||
[view, date, navigate],
|
[view, date, navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Données
|
const {
|
||||||
const { data: calendars = [], isLoading: calendarsLoading } = useAgendaCalendars()
|
calendars,
|
||||||
const visibleCalendars = useMemo(
|
visibleCalendars,
|
||||||
() => calendars.filter((c) => !hiddenIds.includes(c.id)),
|
isLoading: calendarsLoading,
|
||||||
[calendars, hiddenIds],
|
} = useVisibleAgendaCalendars()
|
||||||
)
|
|
||||||
const fetchRange = useMemo(() => viewRange("month", date), [date])
|
const visibleApiCalendars = useMemo(
|
||||||
const { events } = useAgendaEvents(visibleCalendars, fetchRange.start, fetchRange.end)
|
() => 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 [quickCreate, setQuickCreate] = useState<AgendaQuickCreateState | null>(null)
|
||||||
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
|
const [dialogState, setDialogState] = useState<AgendaEventDialogState | null>(null)
|
||||||
const [popover, setPopover] = useState<AgendaEventPopoverState | 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 userEmail = identity?.email
|
||||||
|
|
||||||
|
const clearPending = useCallback(() => setPendingDraft(null), [])
|
||||||
|
|
||||||
const closeOverlays = useCallback(() => {
|
const closeOverlays = useCallback(() => {
|
||||||
setQuickCreate(null)
|
setQuickCreate(null)
|
||||||
setPopover(null)
|
setPopover(null)
|
||||||
|
clearPending()
|
||||||
|
}, [clearPending])
|
||||||
|
|
||||||
|
const showPendingDraft = useCallback((draft: AgendaEventDraft) => {
|
||||||
|
setPendingDraft(draft)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const openCreateDialog = useCallback(
|
const openCreateDialog = useCallback(
|
||||||
(base?: Partial<AgendaEventDraft>) => {
|
(base?: Partial<AgendaEventDraft>, opts?: { keepPending?: boolean }) => {
|
||||||
closeOverlays()
|
setPopover(null)
|
||||||
|
setQuickCreate(null)
|
||||||
|
if (!opts?.keepPending) clearPending()
|
||||||
|
|
||||||
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
|
const start = base?.start ?? addHours(startOfHour(new Date()), 1)
|
||||||
const end = base?.end ?? addHours(start, 1)
|
const end = base?.end ?? addMinutes(start, defaultQuickDurationMinutes)
|
||||||
setDialogState({
|
const draft: AgendaEventDraft = {
|
||||||
mode: "create",
|
|
||||||
draft: {
|
|
||||||
title: "",
|
title: "",
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
allDay: false,
|
allDay: false,
|
||||||
calendarId: defaultCalendarId,
|
calendarId: defaultCalendarId,
|
||||||
...base,
|
...base,
|
||||||
|
}
|
||||||
|
showPendingDraft(draft)
|
||||||
|
setDialogState({ mode: "create", draft })
|
||||||
},
|
},
|
||||||
})
|
[clearPending, defaultCalendarId, defaultQuickDurationMinutes, showPendingDraft],
|
||||||
},
|
|
||||||
[closeOverlays, defaultCalendarId],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const openEditDialog = useCallback(
|
const openEditDialog = useCallback(
|
||||||
@ -139,7 +205,6 @@ export function AgendaPage() {
|
|||||||
[closeOverlays],
|
[closeOverlays],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Interop : /agenda?new=1&guest=…&title=…
|
|
||||||
const handledNewParam = useRef(false)
|
const handledNewParam = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (handledNewParam.current || !route.view) return
|
if (handledNewParam.current || !route.view) return
|
||||||
@ -157,7 +222,6 @@ export function AgendaPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [searchParams, route.view])
|
}, [searchParams, route.view])
|
||||||
|
|
||||||
// Raccourcis clavier façon Google Calendar.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
@ -197,24 +261,58 @@ export function AgendaPage() {
|
|||||||
|
|
||||||
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
|
const handleEventClick = useCallback((event: AgendaEvent, anchor: AnchorRect) => {
|
||||||
setQuickCreate(null)
|
setQuickCreate(null)
|
||||||
|
clearPending()
|
||||||
setPopover({ event, anchor })
|
setPopover({ event, anchor })
|
||||||
}, [])
|
}, [clearPending])
|
||||||
|
|
||||||
const handleCreateRange = useCallback(
|
const handleCreateRange = useCallback(
|
||||||
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect) => {
|
(start: Date, end: Date, allDay: boolean, anchor: AnchorRect, viaDrag: boolean) => {
|
||||||
setPopover(null)
|
setPopover(null)
|
||||||
|
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 })
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AgendaHeader
|
<AgendaHeader
|
||||||
view={view}
|
view={view}
|
||||||
date={date}
|
date={date}
|
||||||
|
weekStart={weekStart}
|
||||||
|
weekStartsOn={weekStartsOn}
|
||||||
onToday={() => navigate(view, new Date())}
|
onToday={() => navigate(view, new Date())}
|
||||||
onStep={step}
|
onStep={step}
|
||||||
onViewChange={(v) => navigate(v, date)}
|
onViewChange={(v) => navigate(v, date)}
|
||||||
@ -225,7 +323,8 @@ export function AgendaPage() {
|
|||||||
onSelectDate={(d) => navigate(view, d)}
|
onSelectDate={(d) => navigate(view, d)}
|
||||||
onCreateEvent={() => openCreateDialog()}
|
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 ? (
|
{calendarsLoading ? (
|
||||||
<div className="flex h-full flex-col gap-3 p-6">
|
<div className="flex h-full flex-col gap-3 p-6">
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-8 w-64" />
|
||||||
@ -234,33 +333,42 @@ export function AgendaPage() {
|
|||||||
) : view === "month" ? (
|
) : view === "month" ? (
|
||||||
<AgendaViewMonth
|
<AgendaViewMonth
|
||||||
date={date}
|
date={date}
|
||||||
|
weekStart={weekStart}
|
||||||
|
weekStartsOn={weekStartsOn}
|
||||||
events={events}
|
events={events}
|
||||||
onCreateAt={(day, anchor) =>
|
pendingEvent={pendingEvent}
|
||||||
handleCreateRange(day, addDays(day, 1), true, anchor)
|
onCreateRange={handleCreateRange}
|
||||||
}
|
|
||||||
onEventClick={handleEventClick}
|
onEventClick={handleEventClick}
|
||||||
|
onEventMove={handleEventMove}
|
||||||
onOpenDay={(day) => navigate("day", day)}
|
onOpenDay={(day) => navigate("day", day)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<AgendaViewWeek
|
<AgendaViewWeek
|
||||||
days={days}
|
days={days}
|
||||||
events={events}
|
events={events}
|
||||||
|
pendingEvent={pendingEvent}
|
||||||
onCreateRange={handleCreateRange}
|
onCreateRange={handleCreateRange}
|
||||||
onEventClick={handleEventClick}
|
onEventClick={handleEventClick}
|
||||||
|
onEventMove={handleEventMove}
|
||||||
onOpenDay={(day) => navigate("day", day)}
|
onOpenDay={(day) => navigate("day", day)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AgendaQuickCreate
|
<AgendaQuickCreate
|
||||||
state={quickCreate}
|
state={quickCreate}
|
||||||
calendars={visibleCalendars.length > 0 ? visibleCalendars : calendars}
|
calendars={writableVisibleCalendars.length > 0 ? writableVisibleCalendars : writableCalendars}
|
||||||
defaultCalendarId={defaultCalendarId}
|
defaultCalendarId={defaultCalendarId}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
onClose={() => setQuickCreate(null)}
|
onClose={() => {
|
||||||
|
setQuickCreate(null)
|
||||||
|
clearPending()
|
||||||
|
}}
|
||||||
onMoreOptions={(draft) => {
|
onMoreOptions={(draft) => {
|
||||||
setQuickCreate(null)
|
setQuickCreate(null)
|
||||||
|
showPendingDraft(draft)
|
||||||
setDialogState({ mode: "create", draft })
|
setDialogState({ mode: "create", draft })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -275,9 +383,13 @@ export function AgendaPage() {
|
|||||||
|
|
||||||
<AgendaEventDialog
|
<AgendaEventDialog
|
||||||
state={dialogState}
|
state={dialogState}
|
||||||
onClose={() => setDialogState(null)}
|
onClose={() => {
|
||||||
calendars={calendars}
|
setDialogState(null)
|
||||||
|
clearPending()
|
||||||
|
}}
|
||||||
|
calendars={writableCalendars}
|
||||||
userEmail={userEmail}
|
userEmail={userEmail}
|
||||||
|
onDraftChange={showPendingDraft}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { format } from "date-fns"
|
|
||||||
import { fr } from "date-fns/locale"
|
|
||||||
import { toast } from "sonner"
|
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 { AgendaFloatingCard, type AnchorRect } from "@/components/agenda/agenda-floating-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@ -15,14 +16,13 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import {
|
import { useCreateAgendaEvent, useCreateAgendaMeetLink } from "@/lib/api/hooks/use-calendar-mutations"
|
||||||
draftToApiEvent,
|
import { createAgendaEventWithVideo } from "@/lib/agenda/agenda-save-with-video"
|
||||||
useCreateAgendaEvent,
|
|
||||||
} from "@/lib/api/hooks/use-calendar-mutations"
|
|
||||||
import { formatEventTime } from "@/lib/agenda/agenda-date"
|
|
||||||
import { calendarColor } from "@/lib/agenda/agenda-events"
|
import { calendarColor } from "@/lib/agenda/agenda-events"
|
||||||
|
import { useEffectiveAgendaSettings } from "@/lib/agenda/use-effective-agenda-settings"
|
||||||
import type {
|
import type {
|
||||||
AgendaCalendar,
|
AgendaCalendar,
|
||||||
|
AgendaEventAttendee,
|
||||||
AgendaEventDraft,
|
AgendaEventDraft,
|
||||||
} from "@/lib/agenda/agenda-types"
|
} from "@/lib/agenda/agenda-types"
|
||||||
|
|
||||||
@ -50,75 +50,153 @@ export function AgendaQuickCreate({
|
|||||||
}) {
|
}) {
|
||||||
const [title, setTitle] = useState("")
|
const [title, setTitle] = useState("")
|
||||||
const [calendarId, setCalendarId] = useState(defaultCalendarId)
|
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 createMutation = useCreateAgendaEvent()
|
||||||
|
const meetLinkMutation = useCreateAgendaMeetLink()
|
||||||
|
const { buttonSnapMinutes, defaultVideoProvider } = useEffectiveAgendaSettings()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state) {
|
if (!state) return
|
||||||
setTitle("")
|
setTitle("")
|
||||||
setCalendarId(defaultCalendarId)
|
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])
|
}, [state, defaultCalendarId])
|
||||||
|
|
||||||
if (!state) return null
|
if (!state) return null
|
||||||
|
|
||||||
|
const calendar = calendars.find((c) => c.id === calendarId) ?? calendars[0]
|
||||||
const draft: AgendaEventDraft = {
|
const draft: AgendaEventDraft = {
|
||||||
title,
|
title,
|
||||||
start: state.start,
|
start,
|
||||||
end: state.end,
|
end,
|
||||||
allDay: state.allDay,
|
allDay: state.allDay,
|
||||||
calendarId,
|
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 () => {
|
const save = async () => {
|
||||||
|
if (!calendarId || !calendar) return
|
||||||
try {
|
try {
|
||||||
const apiEvent = draftToApiEvent(draft)
|
await createAgendaEventWithVideo({
|
||||||
if (userEmail) apiEvent.organizer = userEmail
|
draft,
|
||||||
await createMutation.mutateAsync({ calendarId, event: apiEvent })
|
calendar,
|
||||||
toast.success("Événement créé")
|
userEmail,
|
||||||
|
includeVideo: includeVideo && !state.allDay,
|
||||||
|
videoProvider: defaultVideoProvider,
|
||||||
|
createMutation,
|
||||||
|
meetLinkMutation,
|
||||||
|
})
|
||||||
|
toast.success(includeVideo && !state.allDay ? "Événement et visio créés" : "Événement créé")
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Impossible de créer l'événement")
|
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 (
|
return (
|
||||||
<AgendaFloatingCard anchor={state.anchor} onClose={onClose} width={400}>
|
<AgendaFloatingCard anchor={state.anchor} onClose={onClose} width={420}>
|
||||||
<div className="flex items-center justify-end px-2 pt-2">
|
<div className="flex flex-col gap-4 px-5 pb-5 pt-3">
|
||||||
<Button
|
<div className="flex items-start gap-1">
|
||||||
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
|
<Input
|
||||||
|
ref={titleRef}
|
||||||
value={title}
|
value={title}
|
||||||
|
tabIndex={1}
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="Ajouter un titre"
|
placeholder="Ajouter un titre"
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") void save()
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-1 text-sm text-foreground/85">
|
<Button
|
||||||
<span>{dateLabel}</span>
|
variant="ghost"
|
||||||
<span className="text-muted-foreground">{timeLabel}</span>
|
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>
|
||||||
|
|
||||||
|
<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}>
|
<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" />
|
<SelectValue placeholder="Agenda" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -135,17 +213,24 @@ export function AgendaQuickCreate({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
tabIndex={7}
|
||||||
className="rounded-full"
|
className="rounded-full"
|
||||||
onClick={() => onMoreOptions(draft)}
|
onClick={() => onMoreOptions({ ...draft, includeVideo })}
|
||||||
>
|
>
|
||||||
Autres options
|
Autres options
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
tabIndex={8}
|
||||||
className="rounded-full px-5"
|
className="rounded-full px-5"
|
||||||
disabled={createMutation.isPending || !calendarId}
|
disabled={
|
||||||
|
createMutation.isPending ||
|
||||||
|
meetLinkMutation.isPending ||
|
||||||
|
!calendarId
|
||||||
|
}
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
>
|
>
|
||||||
Enregistrer
|
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"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { MoreVertical, Pencil, Plus, Trash2 } from "lucide-react"
|
import { MoreVertical, Pencil, Plus, Trash2 } from "lucide-react"
|
||||||
import { AgendaCalendarDialog } from "@/components/agenda/agenda-calendar-dialog"
|
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 { AgendaMiniMonth } from "@/components/agenda/agenda-mini-month"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@ -23,11 +27,14 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
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 { 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 { calendarColor } from "@/lib/agenda/agenda-events"
|
||||||
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
import { useAgendaSettingsStore, useAgendaUIStore } from "@/lib/agenda/agenda-store"
|
||||||
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
import type { AgendaCalendar } from "@/lib/agenda/agenda-types"
|
||||||
|
import { useMergedAgendaCalendars } from "@/lib/agenda/use-visible-agenda-calendars"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -44,13 +51,36 @@ export function AgendaSidebar({
|
|||||||
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useAgendaUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
const setSidebarCollapsed = useAgendaUIStore((s) => s.setSidebarCollapsed)
|
||||||
const hiddenIds = useAgendaSettingsStore((s) => s.hiddenCalendarIds)
|
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 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 deleteMutation = useDeleteAgendaCalendar()
|
||||||
|
|
||||||
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
|
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
|
||||||
const [editingCalendar, setEditingCalendar] = useState<AgendaCalendar | null>(null)
|
const [editingCalendar, setEditingCalendar] = useState<AgendaCalendar | null>(null)
|
||||||
const [deletingCalendar, setDeletingCalendar] = 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
|
const open = !sidebarCollapsed
|
||||||
|
|
||||||
@ -89,11 +119,12 @@ export function AgendaSidebar({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-6 text-primary" />
|
<Plus className="size-6 text-primary" />
|
||||||
Créer
|
Nouvel événement
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<AgendaMiniMonth
|
<AgendaMiniMonth
|
||||||
selected={selectedDate}
|
selected={selectedDate}
|
||||||
|
weekStart={weekStart}
|
||||||
onSelect={(d) => {
|
onSelect={(d) => {
|
||||||
onSelectDate(d)
|
onSelectDate(d)
|
||||||
if (isMobile) setSidebarCollapsed(true)
|
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 min-h-0 flex-col gap-0.5">
|
||||||
<div className="flex items-center justify-between pr-1 pl-2">
|
<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
|
Mes agendas
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -120,7 +197,7 @@ export function AgendaSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{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-40" />
|
||||||
<Skeleton className="h-5 w-32" />
|
<Skeleton className="h-5 w-32" />
|
||||||
</div>
|
</div>
|
||||||
@ -129,10 +206,11 @@ export function AgendaSidebar({
|
|||||||
{(calendars ?? []).map((cal) => {
|
{(calendars ?? []).map((cal) => {
|
||||||
const color = calendarColor(cal)
|
const color = calendarColor(cal)
|
||||||
const visible = !hiddenIds.includes(cal.id)
|
const visible = !hiddenIds.includes(cal.id)
|
||||||
|
const isExternal = isExternalCalendarId(cal.id)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cal.id}
|
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">
|
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5">
|
||||||
<input
|
<input
|
||||||
@ -165,8 +243,12 @@ export function AgendaSidebar({
|
|||||||
</span>
|
</span>
|
||||||
<span className="truncate text-sm text-foreground/85">
|
<span className="truncate text-sm text-foreground/85">
|
||||||
{cal.display_name}
|
{cal.display_name}
|
||||||
|
{isExternal ? (
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground">(iCal)</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{!isExternal ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@ -195,6 +277,7 @@ export function AgendaSidebar({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -205,6 +288,29 @@ export function AgendaSidebar({
|
|||||||
open={calendarDialogOpen}
|
open={calendarDialogOpen}
|
||||||
onOpenChange={setCalendarDialogOpen}
|
onOpenChange={setCalendarDialogOpen}
|
||||||
calendar={editingCalendar}
|
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
|
<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"
|
"use client"
|
||||||
|
|
||||||
import type { MouseEvent } from "react"
|
import {
|
||||||
import { format, isSameDay, isSameMonth } from "date-fns"
|
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 { fr } from "date-fns/locale"
|
||||||
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
|
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
|
||||||
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
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 { eventsOnDay, isMultiDay } from "@/lib/agenda/agenda-events"
|
||||||
|
import { isPendingEvent } from "@/lib/agenda/agenda-pending-event"
|
||||||
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
|
import type { AgendaEvent } from "@/lib/agenda/agenda-types"
|
||||||
import { cn } from "@/lib/utils"
|
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 }
|
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({
|
export function AgendaViewMonth({
|
||||||
date,
|
date,
|
||||||
|
weekStart = "auto",
|
||||||
|
weekStartsOn,
|
||||||
events,
|
events,
|
||||||
onCreateAt,
|
pendingEvent,
|
||||||
|
onCreateRange,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
|
onEventMove,
|
||||||
onOpenDay,
|
onOpenDay,
|
||||||
}: {
|
}: {
|
||||||
date: Date
|
date: Date
|
||||||
|
weekStart?: AgendaWeekStart
|
||||||
|
weekStartsOn?: WeekStartsOn
|
||||||
events: AgendaEvent[]
|
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
|
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
|
||||||
|
onEventMove?: (event: AgendaEvent, targetStart: Date) => void
|
||||||
onOpenDay: (day: Date) => void
|
onOpenDay: (day: Date) => void
|
||||||
}) {
|
}) {
|
||||||
const days = viewDays("month", date)
|
const days = viewDays("month", date, weekStart, weekStartsOn)
|
||||||
const weeks: Date[][] = []
|
const weeks: Date[][] = []
|
||||||
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
|
for (let i = 0; i < days.length; i += 7) weeks.push(days.slice(i, i + 7))
|
||||||
const today = new Date()
|
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 (
|
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">
|
<div className="grid shrink-0 grid-cols-7 border-b border-border/60">
|
||||||
{days.slice(0, 7).map((d) => (
|
{days.slice(0, 7).map((d) => (
|
||||||
<div
|
<div
|
||||||
@ -53,9 +253,12 @@ export function AgendaViewMonth({
|
|||||||
>
|
>
|
||||||
{weeks.map((week) =>
|
{weeks.map((week) =>
|
||||||
week.map((day) => {
|
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 aBanner = a.allDay || isMultiDay(a)
|
||||||
const bBanner = b.allDay || isMultiDay(b)
|
const bBanner = b.allDay || isMultiDay(b)
|
||||||
|
if (isPendingEvent(a)) return 1
|
||||||
|
if (isPendingEvent(b)) return -1
|
||||||
if (aBanner !== bBanner) return aBanner ? -1 : 1
|
if (aBanner !== bBanner) return aBanner ? -1 : 1
|
||||||
return a.start.getTime() - b.start.getTime()
|
return a.start.getTime() - b.start.getTime()
|
||||||
})
|
})
|
||||||
@ -63,15 +266,28 @@ export function AgendaViewMonth({
|
|||||||
const hidden = dayEvents.length - visible.length
|
const hidden = dayEvents.length - visible.length
|
||||||
const isToday = isSameDay(day, today)
|
const isToday = isSameDay(day, today)
|
||||||
const inMonth = isSameMonth(day, date)
|
const inMonth = isSameMonth(day, date)
|
||||||
|
const inDragRange = dragRange && idx >= dragRange.min && idx <= dragRange.max
|
||||||
|
const inEventMoveTarget =
|
||||||
|
eventMove?.positionChanged && eventMove.currentIndex === idx
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.getTime()}
|
key={day.getTime()}
|
||||||
|
ref={(el) => {
|
||||||
|
cellRefs.current[idx] = el
|
||||||
|
}}
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
|
data-agenda-month-cell
|
||||||
className={cn(
|
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",
|
!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">
|
<div className="flex justify-center pt-1">
|
||||||
<button
|
<button
|
||||||
@ -81,6 +297,7 @@ export function AgendaViewMonth({
|
|||||||
isToday && "bg-primary font-semibold text-primary-foreground hover:bg-primary",
|
isToday && "bg-primary font-semibold text-primary-foreground hover:bg-primary",
|
||||||
!inMonth && "text-muted-foreground/60",
|
!inMonth && "text-muted-foreground/60",
|
||||||
)}
|
)}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onOpenDay(day)
|
onOpenDay(day)
|
||||||
@ -91,21 +308,44 @@ export function AgendaViewMonth({
|
|||||||
: day.getDate()}
|
: day.getDate()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{visible.map((event) => (
|
{visible.map((event) => {
|
||||||
<AgendaEventChip
|
const isDragging =
|
||||||
|
eventMove?.event.key === event.key && eventMove.positionChanged
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={event.key}
|
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}
|
event={event}
|
||||||
filled={event.allDay || isMultiDay(event)}
|
filled={event.allDay || isMultiDay(event)}
|
||||||
|
pending={isPendingEvent(event)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (shouldSuppressEventClick()) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (!isPendingEvent(event)) {
|
||||||
onEventClick(event, anchorFromEvent(e))
|
onEventClick(event, anchorFromEvent(e))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{hidden > 0 && (
|
{hidden > 0 && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onOpenDay(day)
|
onOpenDay(day)
|
||||||
|
|||||||
@ -8,14 +8,17 @@ import {
|
|||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { format, isSameDay } from "date-fns"
|
import { addDays, format, isSameDay, startOfDay } from "date-fns"
|
||||||
import { fr } from "date-fns/locale"
|
import { fr } from "date-fns/locale"
|
||||||
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
|
import { AgendaEventChip } from "@/components/agenda/agenda-event-chip"
|
||||||
import type { AnchorRect } from "@/components/agenda/agenda-floating-card"
|
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 { readableTextColor } from "@/lib/agenda/agenda-colors"
|
||||||
import { layoutDayEvents } from "@/lib/agenda/agenda-event-layout"
|
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 { 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 type { AgendaEvent } from "@/lib/agenda/agenda-types"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -23,117 +26,394 @@ const HOUR_PX = 48
|
|||||||
const GUTTER_PX = 56
|
const GUTTER_PX = 56
|
||||||
const MIN_EVENT_PX = 22
|
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 {
|
function anchorFromEvent(e: MouseEvent<HTMLElement>): AnchorRect {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
dayIndex: number
|
startDayIndex: number
|
||||||
|
endDayIndex: number
|
||||||
anchorMin: number
|
anchorMin: number
|
||||||
startMin: number
|
startMin: number
|
||||||
endMin: number
|
endMin: number
|
||||||
moved: boolean
|
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({
|
export function AgendaViewWeek({
|
||||||
days,
|
days,
|
||||||
events,
|
events,
|
||||||
|
pendingEvent,
|
||||||
onCreateRange,
|
onCreateRange,
|
||||||
onEventClick,
|
onEventClick,
|
||||||
|
onEventMove,
|
||||||
onOpenDay,
|
onOpenDay,
|
||||||
}: {
|
}: {
|
||||||
days: Date[]
|
days: Date[]
|
||||||
events: AgendaEvent[]
|
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
|
onEventClick: (event: AgendaEvent, anchor: AnchorRect) => void
|
||||||
|
onEventMove?: (event: AgendaEvent, targetStart: Date) => void
|
||||||
onOpenDay: (day: Date) => void
|
onOpenDay: (day: Date) => void
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
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 [drag, setDrag] = useState<DragState | null>(null)
|
||||||
|
const [eventMove, setEventMove] = useState<EventMoveState | null>(null)
|
||||||
const [now, setNow] = useState(() => new Date())
|
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(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => setNow(new Date()), 60_000)
|
const id = window.setInterval(() => setNow(new Date()), 60_000)
|
||||||
return () => window.clearInterval(id)
|
return () => window.clearInterval(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
dragSessionCleanupRef.current?.()
|
||||||
|
dragSessionCleanupRef.current = null
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
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
|
el.scrollTop = target
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [visibleHoursStart, visibleHoursEnd])
|
||||||
|
|
||||||
const perDay = useMemo(
|
const dayIndexFromClientX = (clientX: number): number | null => {
|
||||||
() =>
|
for (let i = 0; i < columnRefs.current.length; i++) {
|
||||||
days.map((day) => {
|
const el = columnRefs.current[i]
|
||||||
const dayEvents = eventsOnDay(events, day)
|
if (!el) continue
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (clientX >= rect.left && clientX <= rect.right) return i
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const perDay = useMemo(() => {
|
||||||
|
const merged = pendingEvent ? [...events, pendingEvent] : events
|
||||||
|
return days.map((day) => {
|
||||||
|
const dayEvents = eventsOnDay(merged, day)
|
||||||
const banners = dayEvents
|
const banners = dayEvents
|
||||||
.filter((e) => e.allDay || isMultiDay(e))
|
.filter((e) => e.allDay || isMultiDay(e))
|
||||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
.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))
|
const timed = dayEvents.filter((e) => !e.allDay && !isMultiDay(e))
|
||||||
return { day, banners, positioned: layoutDayEvents(timed, day) }
|
return { day, banners, positioned: layoutDayEvents(timed, day) }
|
||||||
}),
|
})
|
||||||
[days, events],
|
}, [days, events, pendingEvent])
|
||||||
)
|
|
||||||
|
|
||||||
const hasBanners = perDay.some((d) => d.banners.length > 0)
|
const hasBanners =
|
||||||
|
perDay.some((d) => d.banners.length > 0) || (drag?.multiDay ?? false)
|
||||||
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
|
const colTemplate = `${GUTTER_PX}px repeat(${days.length}, minmax(0, 1fr))`
|
||||||
|
|
||||||
const minuteFromPointer = (e: ReactPointerEvent<HTMLElement>): number => {
|
const minuteFromClientY = (clientY: number, dayIndex: number): number | null => {
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const el = columnRefs.current[dayIndex]
|
||||||
const minutes = ((e.clientY - rect.top) / HOUR_PX) * 60
|
if (!el) return null
|
||||||
return Math.min(24 * 60, Math.max(0, roundToStep(minutes, 15)))
|
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>) => {
|
const finishDrag = () => {
|
||||||
if (e.button !== 0 || (e.target as HTMLElement).closest("[data-agenda-event]")) return
|
const d = dragRef.current
|
||||||
const min = minuteFromPointer(e)
|
dragRef.current = null
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
setDrag(null)
|
||||||
setDrag({ dayIndex, anchorMin: min, startMin: min, endMin: min + 15, moved: false })
|
if (!d) return
|
||||||
|
|
||||||
|
const minDay = Math.min(d.startDayIndex, d.endDayIndex)
|
||||||
|
const maxDay = Math.max(d.startDayIndex, d.endDayIndex)
|
||||||
|
const viaDrag = d.moved
|
||||||
|
|
||||||
|
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 handlePointerMove = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
const day = days[d.startDayIndex]
|
||||||
setDrag((d) => {
|
const el = columnRefs.current[d.startDayIndex]
|
||||||
if (!d || d.dayIndex !== dayIndex) return d
|
if (!el) return
|
||||||
const min = minuteFromPointer(e)
|
|
||||||
if (min === d.anchorMin && !d.moved) return d
|
|
||||||
return {
|
|
||||||
...d,
|
|
||||||
moved: true,
|
|
||||||
startMin: Math.min(d.anchorMin, min),
|
|
||||||
endMin: Math.max(d.anchorMin, min, Math.min(d.anchorMin, min) + 15),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePointerUp = (dayIndex: number) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
const startMin = d.startMin
|
||||||
if (!drag || drag.dayIndex !== dayIndex) return
|
const endMin = viaDrag
|
||||||
const day = days[dayIndex]
|
? d.endMin
|
||||||
const startMin = drag.startMin
|
: Math.min(visibleHoursEnd, d.startMin + defaultQuickDurationMinutes)
|
||||||
const endMin = drag.moved ? drag.endMin : drag.startMin + 60
|
|
||||||
const start = new Date(day)
|
const start = new Date(day)
|
||||||
start.setHours(0, startMin, 0, 0)
|
start.setHours(0, startMin, 0, 0)
|
||||||
const end = new Date(day)
|
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 colRect = el.getBoundingClientRect()
|
||||||
const anchor: AnchorRect = {
|
onCreateRange(start, end, false, {
|
||||||
left: colRect.left,
|
left: colRect.left,
|
||||||
top: colRect.top + (startMin / 60) * HOUR_PX,
|
top: colRect.top + ((startMin - visibleHoursStart) / 60) * HOUR_PX,
|
||||||
width: colRect.width,
|
width: colRect.width,
|
||||||
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
|
height: Math.max(((endMin - startMin) / 60) * HOUR_PX, MIN_EVENT_PX),
|
||||||
}
|
}, viaDrag)
|
||||||
setDrag(null)
|
|
||||||
onCreateRange(start, end, false, anchor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
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 */}
|
{/* 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 className="grid" style={{ gridTemplateColumns: colTemplate }}>
|
||||||
<div />
|
<div />
|
||||||
{days.map((day) => {
|
{days.map((day) => {
|
||||||
@ -164,75 +444,136 @@ export function AgendaViewWeek({
|
|||||||
{hasBanners && (
|
{hasBanners && (
|
||||||
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
|
<div className="grid" style={{ gridTemplateColumns: colTemplate }}>
|
||||||
<div className="pt-0.5 pr-2 text-right text-[0.65rem] text-muted-foreground" />
|
<div className="pt-0.5 pr-2 text-right text-[0.65rem] text-muted-foreground" />
|
||||||
{perDay.map(({ day, banners }) => (
|
{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
|
<div
|
||||||
key={day.getTime()}
|
key={day.getTime()}
|
||||||
className="flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
|
className="relative flex min-h-6 flex-col gap-0.5 border-l border-border/40 px-0.5 pb-1"
|
||||||
>
|
>
|
||||||
{banners.map((event) => (
|
{inDragRange && (
|
||||||
<AgendaEventChip
|
<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}
|
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}
|
event={event}
|
||||||
filled
|
filled
|
||||||
|
pending={isPendingEvent(event)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
if (shouldSuppressEventClick()) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
if (!isPendingEvent(event)) {
|
||||||
onEventClick(event, anchorFromEvent(e))
|
onEventClick(event, anchorFromEvent(e))
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grille horaire */}
|
{/* Grille horaire */}
|
||||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
|
<div ref={scrollRef} className={SCROLL_CLASS}>
|
||||||
<div
|
<div
|
||||||
className="relative grid"
|
className="relative grid"
|
||||||
style={{ gridTemplateColumns: colTemplate, height: 24 * HOUR_PX }}
|
style={{ gridTemplateColumns: colTemplate, height: gridHeightPx }}
|
||||||
>
|
>
|
||||||
{/* Gouttière heures */}
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
|
{hourMarks.map((h) => (
|
||||||
<span
|
<span
|
||||||
key={h}
|
key={h}
|
||||||
className="absolute right-2 -translate-y-1/2 text-[0.65rem] text-muted-foreground"
|
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>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{perDay.map(({ day, positioned }, dayIndex) => {
|
{perDay.map(({ day, positioned }, dayIndex) => {
|
||||||
const isToday = isSameDay(day, now)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={day.getTime()}
|
key={day.getTime()}
|
||||||
|
ref={(el) => {
|
||||||
|
columnRefs.current[dayIndex] = el
|
||||||
|
}}
|
||||||
className="relative cursor-pointer touch-none border-l border-border/40"
|
className="relative cursor-pointer touch-none border-l border-border/40"
|
||||||
onPointerDown={handlePointerDown(dayIndex)}
|
onPointerDown={handlePointerDown(dayIndex)}
|
||||||
onPointerMove={handlePointerMove(dayIndex)}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerUp(dayIndex)}
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
>
|
>
|
||||||
{/* Lignes d'heures */}
|
{hourMarks.map((h) => (
|
||||||
{Array.from({ length: 23 }, (_, i) => i + 1).map((h) => (
|
|
||||||
<div
|
<div
|
||||||
key={h}
|
key={h}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute right-0 left-0 border-t border-border/40"
|
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 */}
|
{showTimedDrag && (
|
||||||
{drag && drag.dayIndex === dayIndex && drag.moved && (
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
||||||
style={{
|
style={{
|
||||||
top: (drag.startMin / 60) * HOUR_PX,
|
top: ((drag.startMin - visibleHoursStart) / 60) * HOUR_PX,
|
||||||
height: Math.max(
|
height: Math.max(
|
||||||
((drag.endMin - drag.startMin) / 60) * HOUR_PX,
|
((drag.endMin - drag.startMin) / 60) * HOUR_PX,
|
||||||
8,
|
8,
|
||||||
@ -240,33 +581,90 @@ export function AgendaViewWeek({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="px-1.5 text-[0.65rem] font-medium text-primary">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Événements positionnés */}
|
{showEventMove && eventMove && (
|
||||||
{positioned.map(({ event, top, duration, leftPct, widthPct }) => {
|
<div
|
||||||
const compact = (duration / 60) * HOUR_PX < 40
|
aria-hidden
|
||||||
return (
|
className="absolute right-1 left-0.5 z-10 rounded-md bg-primary/25 ring-1 ring-primary/50"
|
||||||
<button
|
|
||||||
key={event.key}
|
|
||||||
type="button"
|
|
||||||
data-agenda-event
|
|
||||||
className="absolute z-20 flex flex-col overflow-hidden rounded-md px-1.5 py-0.5 text-left shadow-sm ring-1 ring-black/5 transition-[filter] hover:z-30 hover:brightness-95 dark:ring-white/10 dark:hover:brightness-110"
|
|
||||||
style={{
|
style={{
|
||||||
top: (top / 60) * HOUR_PX,
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={event.key}
|
||||||
|
role={pending ? "presentation" : "button"}
|
||||||
|
tabIndex={pending ? undefined : 0}
|
||||||
|
data-agenda-event
|
||||||
|
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 - visibleHoursStart) / 60) * HOUR_PX,
|
||||||
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
|
height: Math.max((duration / 60) * HOUR_PX - 2, MIN_EVENT_PX),
|
||||||
left: `calc(${leftPct}% + 1px)`,
|
left: `calc(${leftPct}% + 1px)`,
|
||||||
width: `calc(${widthPct}% - 3px)`,
|
width: `calc(${widthPct}% - 3px)`,
|
||||||
backgroundColor: event.color,
|
backgroundColor: pending ? `${event.color}99` : event.color,
|
||||||
color: readableTextColor(event.color),
|
color: readableTextColor(event.color),
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={
|
||||||
|
pending
|
||||||
|
? undefined
|
||||||
|
: (e) => {
|
||||||
|
if (shouldSuppressEventClick()) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
onEventClick(event, anchorFromEvent(e))
|
onEventClick(event, anchorFromEvent(e as unknown as MouseEvent<HTMLElement>))
|
||||||
}}
|
}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
}
|
||||||
|
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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -278,21 +676,21 @@ export function AgendaViewWeek({
|
|||||||
{compact && (
|
{compact && (
|
||||||
<span className="font-normal opacity-90">
|
<span className="font-normal opacity-90">
|
||||||
{" "}
|
{" "}
|
||||||
⋅ {formatEventTime(event.start)}
|
⋅ {formatEventTime(event.start, timeFormat)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<span className="truncate text-[0.7rem] leading-tight opacity-90">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Indicateur maintenant */}
|
{isToday && showNowLine && (
|
||||||
{isToday && (
|
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="pointer-events-none absolute right-0 left-0 z-30"
|
className="pointer-events-none absolute right-0 left-0 z-30"
|
||||||
@ -312,8 +710,7 @@ export function AgendaViewWeek({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMinutes(min: number): string {
|
function formatMinutes(min: number, timeFormat: "24h" | "12h" = "24h"): string {
|
||||||
const h = Math.floor(min / 60)
|
const d = new Date(2000, 0, 1, Math.floor(min / 60), min % 60, 0)
|
||||||
const m = min % 60
|
return formatEventTime(d, timeFormat)
|
||||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,17 +13,13 @@ import {
|
|||||||
isSessionExpired,
|
isSessionExpired,
|
||||||
useSessionGuardStore,
|
useSessionGuardStore,
|
||||||
} from "@/lib/auth/session-guard-store"
|
} 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_LEAD_MS = 5 * 60 * 1000
|
||||||
const REFRESH_CHECK_MS = 60 * 1000
|
const REFRESH_CHECK_MS = 60 * 1000
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
if (pathname === "/") return true
|
return isAuthPublicPath(pathname)
|
||||||
if (pathname.startsWith("/drive/s/")) return true
|
|
||||||
return PUBLIC_PREFIXES.some(
|
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
@ -1,69 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { mailBackgroundStyle } from "@/lib/mail-settings/constants"
|
import { clearMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
|
||||||
|
|
||||||
type HtmlBgState = {
|
/** Login shell: plain canvas, no mail wallpaper. */
|
||||||
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. */
|
|
||||||
export function LoginChrome({ children }: { children: React.ReactNode }) {
|
export function LoginChrome({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = readHtmlBgState()
|
clearMailBackgroundDom()
|
||||||
clearHtmlBg()
|
|
||||||
return () => applyHtmlBgState(saved)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const aurora = mailBackgroundStyle("gradient-aurora")
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas sm:bg-transparent">
|
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas">
|
||||||
<div
|
|
||||||
className="pointer-events-none fixed inset-0 -z-10 hidden sm:block"
|
|
||||||
style={{
|
|
||||||
background: aurora.background,
|
|
||||||
backgroundColor: aurora.fallbackColor,
|
|
||||||
}}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -15,15 +15,11 @@ import {
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
|
import { useSessionGuardStore } from "@/lib/auth/session-guard-store"
|
||||||
import { tryRefreshSession } from "@/lib/auth/session-sync"
|
import { tryRefreshSession } from "@/lib/auth/session-sync"
|
||||||
|
import { isAuthPublicPath } from "@/lib/auth/public-paths"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
|
|
||||||
|
|
||||||
function isPublicPath(pathname: string) {
|
function isPublicPath(pathname: string) {
|
||||||
if (pathname.startsWith("/drive/s/")) return true
|
return isAuthPublicPath(pathname)
|
||||||
return PUBLIC_PREFIXES.some(
|
|
||||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionGuard() {
|
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 Link from "next/link"
|
||||||
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
import { HeaderAccountActions } from "@/components/suite/header-account-actions"
|
||||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
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"
|
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"
|
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">
|
<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
|
<Image
|
||||||
src={suitePublicAsset("/compte-mark.svg")}
|
src={suitePublicAsset("/compte-mark.svg")}
|
||||||
alt=""
|
alt=""
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className="h-8 w-8"
|
className={SUITE_APP_LOGO_MARK_CLASS}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className={SUITE_APP_LOGO_TEXT_CLASS}>Compte Ulti</span>
|
||||||
Compte Ulti
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -36,7 +40,7 @@ export function CompteSettingsHeader() {
|
|||||||
alt="Compte Ulti"
|
alt="Compte Ulti"
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
className="h-8 w-8"
|
className={SUITE_APP_LOGO_MARK_CLASS}
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -3,7 +3,9 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ChevronRight, ShieldCheck, UserRound } from "lucide-react"
|
import { ChevronRight, ShieldCheck, UserRound } from "lucide-react"
|
||||||
import { AccountAvatar } from "@/components/suite/account-avatar"
|
import { AccountAvatar } from "@/components/suite/account-avatar"
|
||||||
|
import { CompteSettingsCard } from "@/components/compte/compte-settings-card"
|
||||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const CARDS = [
|
const CARDS = [
|
||||||
{
|
{
|
||||||
@ -30,7 +32,11 @@ export function CompteHomeSection() {
|
|||||||
<header className="mb-8 flex flex-col items-center text-center">
|
<header className="mb-8 flex flex-col items-center text-center">
|
||||||
{identity ? (
|
{identity ? (
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
account={{ name: identity.name, email: identity.email }}
|
account={{
|
||||||
|
name: identity.name,
|
||||||
|
email: identity.email,
|
||||||
|
avatarUrl: identity.avatarUrl,
|
||||||
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -52,10 +58,12 @@ export function CompteHomeSection() {
|
|||||||
{CARDS.map((card) => {
|
{CARDS.map((card) => {
|
||||||
const Icon = card.icon
|
const Icon = card.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link key={card.href} href={card.href} className="group block">
|
||||||
key={card.href}
|
<CompteSettingsCard
|
||||||
href={card.href}
|
className={cn(
|
||||||
className="group flex flex-col rounded-2xl border border-border bg-background p-5 transition-colors hover:bg-accent"
|
"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 />
|
<Icon className="size-6 text-muted-foreground" aria-hidden />
|
||||||
<span className="mt-3 flex items-center gap-1 text-sm font-medium text-foreground">
|
<span className="mt-3 flex items-center gap-1 text-sm font-medium text-foreground">
|
||||||
@ -65,9 +73,10 @@ export function CompteHomeSection() {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-1 text-sm text-muted-foreground">
|
<span className="mt-1 block text-sm text-muted-foreground">
|
||||||
{card.description}
|
{card.description}
|
||||||
</span>
|
</span>
|
||||||
|
</CompteSettingsCard>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
"use client"
|
"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 { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||||
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
import { useCurrentUser } from "@/lib/api/hooks/use-current-user"
|
||||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||||
import { authentikUserSettingsUrl } from "@/lib/auth/authentik-user-url"
|
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
admin: "Administrateur",
|
admin: "Administrateur",
|
||||||
@ -17,7 +19,6 @@ const ROLE_LABELS: Record<string, string> = {
|
|||||||
export function ComptePersonalInfoSection() {
|
export function ComptePersonalInfoSection() {
|
||||||
const identity = useChromeIdentity()
|
const identity = useChromeIdentity()
|
||||||
const { data: user, isFetching, isError, refetch } = useCurrentUser()
|
const { data: user, isFetching, isError, refetch } = useCurrentUser()
|
||||||
const idpUrl = authentikUserSettingsUrl()
|
|
||||||
|
|
||||||
const name = user?.name || identity?.name || "—"
|
const name = user?.name || identity?.name || "—"
|
||||||
const email = user?.email || identity?.email || "—"
|
const email = user?.email || identity?.email || "—"
|
||||||
@ -34,7 +35,10 @@ export function ComptePersonalInfoSection() {
|
|||||||
onRetry={() => refetch()}
|
onRetry={() => refetch()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-border">
|
<div className="space-y-4">
|
||||||
|
<CompteAvatarField avatarUrl={user?.avatar_url} name={name} email={email} />
|
||||||
|
|
||||||
|
<CompteSettingsCard className="overflow-hidden p-0">
|
||||||
<InfoRow label="Nom" value={name} />
|
<InfoRow label="Nom" value={name} />
|
||||||
<InfoRow label="Adresse e-mail" value={email} />
|
<InfoRow label="Adresse e-mail" value={email} />
|
||||||
<InfoRow label="Identifiant" value={user?.sub ?? "—"} mono />
|
<InfoRow label="Identifiant" value={user?.sub ?? "—"} mono />
|
||||||
@ -44,25 +48,16 @@ export function ComptePersonalInfoSection() {
|
|||||||
{user?.groups?.length ? (
|
{user?.groups?.length ? (
|
||||||
<InfoRow label="Groupes" value={user.groups.join(", ")} />
|
<InfoRow label="Groupes" value={user.groups.join(", ")} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</CompteSettingsCard>
|
||||||
|
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
<CompteAuthentikPanel
|
||||||
Votre identité est gérée par le fournisseur d'identité de votre
|
icon={<UserRound className="size-5" aria-hidden />}
|
||||||
organisation. Pour modifier votre nom ou votre adresse e-mail,
|
title="Modifier le profil"
|
||||||
rapprochez-vous de votre administrateur
|
description="Nom, adresse e-mail et locale selon les droits définis par votre organisation."
|
||||||
{idpUrl ? " ou utilisez le portail d'identité" : ""}.
|
tab="details"
|
||||||
</p>
|
actionLabel="Modifier le profil"
|
||||||
{idpUrl ? (
|
/>
|
||||||
<a
|
</div>
|
||||||
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}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,114 +1,77 @@
|
|||||||
"use client"
|
"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 { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useAuthLogout } from "@/components/auth/auth-provider"
|
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() {
|
export function CompteSecuritySection() {
|
||||||
const signOut = useAuthLogout()
|
const signOut = useAuthLogout()
|
||||||
const idpUrl = authentikUserSettingsUrl()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionHeader
|
<SettingsSectionHeader
|
||||||
title="Sécurité"
|
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">
|
<div className="space-y-4">
|
||||||
<SecurityCard
|
<CompteAuthentikPanel
|
||||||
icon={<KeyRound className="size-5" aria-hidden />}
|
icon={<KeyRound className="size-5" aria-hidden />}
|
||||||
title="Mot de passe"
|
title="Mot de passe"
|
||||||
description="Modifiez votre mot de passe depuis le portail d'identité de votre organisation."
|
description="Changez le mot de passe de votre compte Ulti."
|
||||||
action={
|
flowSlug={AUTHENTIK_SELF_SERVICE_FLOWS.passwordChange}
|
||||||
idpUrl ? (
|
actionLabel="Changer le mot de passe"
|
||||||
<ExternalAction href={idpUrl} label="Changer le mot de passe" />
|
|
||||||
) : (
|
|
||||||
<UnavailableNote />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SecurityCard
|
<CompteAuthentikPanel
|
||||||
icon={<Smartphone className="size-5" aria-hidden />}
|
icon={<Smartphone className="size-5" aria-hidden />}
|
||||||
title="Validation en deux étapes"
|
title="Validation en deux étapes"
|
||||||
description="Ajoutez ou gérez vos appareils de validation (application TOTP, WebAuthn, clés de sécurité)."
|
description="Ajoutez ou retirez des appareils TOTP, clés de sécurité WebAuthn ou codes de secours."
|
||||||
action={
|
tab="mfa"
|
||||||
idpUrl ? (
|
actionLabel="Gérer la validation"
|
||||||
<ExternalAction href={idpUrl} label="Gérer la validation" />
|
|
||||||
) : (
|
|
||||||
<UnavailableNote />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SecurityCard
|
<CompteAuthentikPanel
|
||||||
icon={<LogOut className="size-5" aria-hidden />}
|
icon={<MonitorSmartphone className="size-5" aria-hidden />}
|
||||||
title="Session sur cet appareil"
|
title="Sessions et appareils"
|
||||||
description="Met fin à votre session Ulti sur ce navigateur. Vous devrez vous reconnecter."
|
description="Consultez les sessions actives et déconnectez un appareil distant."
|
||||||
action={
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-9 rounded-full px-4 text-sm font-medium"
|
className="h-9 shrink-0 rounded-full px-4 text-sm font-medium"
|
||||||
onClick={() => void signOut()}
|
onClick={() => void signOut()}
|
||||||
>
|
>
|
||||||
|
<LogOut className="size-4" aria-hidden />
|
||||||
Se déconnecter
|
Se déconnecter
|
||||||
</Button>
|
</Button>
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
</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"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react"
|
import { useEffect, useMemo } from "react"
|
||||||
|
import { DemoChrome } from "@/components/demo/demo-chrome"
|
||||||
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
|
import { RichTextDocumentEditor } from "@/components/drive/richtext-document"
|
||||||
|
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
import type { RichTextSessionResponse } from "@/lib/drive/richtext-types"
|
||||||
|
|
||||||
const DEMO_SESSION: RichTextSessionResponse = {
|
const DEMO_SESSION: RichTextSessionResponse = {
|
||||||
@ -38,6 +40,8 @@ export function DemoDocsEditor() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<SuiteThemeShell>
|
||||||
|
<DemoChrome>
|
||||||
<div className="flex h-dvh flex-col overflow-hidden">
|
<div className="flex h-dvh flex-col overflow-hidden">
|
||||||
<RichTextDocumentEditor
|
<RichTextDocumentEditor
|
||||||
session={DEMO_SESSION}
|
session={DEMO_SESSION}
|
||||||
@ -47,5 +51,7 @@ export function DemoDocsEditor() {
|
|||||||
chrome={chrome}
|
chrome={chrome}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 = {
|
export type DemoEmail = {
|
||||||
id: string
|
id: string
|
||||||
@ -11,8 +17,13 @@ export type DemoEmail = {
|
|||||||
time: string
|
time: string
|
||||||
unread: boolean
|
unread: boolean
|
||||||
starred: boolean
|
starred: boolean
|
||||||
|
important?: boolean
|
||||||
|
hasAttachment?: boolean
|
||||||
folder: Exclude<DemoFolder, "starred">
|
folder: Exclude<DemoFolder, "starred">
|
||||||
|
/** Pastille principale en liste (couleur explicite). */
|
||||||
label?: { text: string; color: string }
|
label?: { text: string; color: string }
|
||||||
|
/** Libellés de tri et dossiers (texte nav exact). */
|
||||||
|
tags?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEMO_USER = {
|
export const DEMO_USER = {
|
||||||
@ -39,6 +50,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
starred: false,
|
starred: false,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
label: { text: "Produit", color: "#4f6df5" },
|
label: { text: "Produit", color: "#4f6df5" },
|
||||||
|
tags: ["Produit"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "m2",
|
id: "m2",
|
||||||
@ -58,6 +70,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
starred: true,
|
starred: true,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
label: { text: "IA", color: "#9a5cf0" },
|
label: { text: "IA", color: "#9a5cf0" },
|
||||||
|
tags: ["IA"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "m3",
|
id: "m3",
|
||||||
@ -75,7 +88,9 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
time: "Hier",
|
time: "Hier",
|
||||||
unread: false,
|
unread: false,
|
||||||
starred: true,
|
starred: true,
|
||||||
|
important: true,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
|
tags: ["Partenaires"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "m4",
|
id: "m4",
|
||||||
@ -94,6 +109,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
starred: false,
|
starred: false,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
label: { text: "Drive", color: "#1fb6c9" },
|
label: { text: "Drive", color: "#1fb6c9" },
|
||||||
|
tags: ["Drive", "Comptabilité"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "m5",
|
id: "m5",
|
||||||
@ -112,6 +128,7 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
unread: false,
|
unread: false,
|
||||||
starred: false,
|
starred: false,
|
||||||
folder: "inbox",
|
folder: "inbox",
|
||||||
|
tags: ["Clients"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "m6",
|
id: "m6",
|
||||||
@ -147,5 +164,337 @@ export const DEMO_EMAILS: DemoEmail[] = [
|
|||||||
unread: false,
|
unread: false,
|
||||||
starred: false,
|
starred: false,
|
||||||
folder: "archive",
|
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 { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
||||||
import type { DriveFileInfo } from "@/lib/api/types"
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
import type { DriveView } from "@/lib/drive/drive-url"
|
||||||
|
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
|
||||||
import { buildDriveFolderHref } from "@/lib/drive/drive-url"
|
import { buildDriveFolderHref } from "@/lib/drive/drive-url"
|
||||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||||
import { resolveRenameName } from "@/lib/drive/drive-default-name"
|
import { resolveRenameName } from "@/lib/drive/drive-default-name"
|
||||||
@ -49,7 +50,7 @@ export function BreadcrumbFolderMenu({
|
|||||||
onRenameOpenChange,
|
onRenameOpenChange,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
view: Extract<DriveView, "files" | "shared">
|
view: Extract<DriveView, "files" | "shared" | "org" | "mount">
|
||||||
segments: string[]
|
segments: string[]
|
||||||
folderPath: string
|
folderPath: string
|
||||||
writable?: boolean
|
writable?: boolean
|
||||||
@ -60,6 +61,7 @@ export function BreadcrumbFolderMenu({
|
|||||||
}) {
|
}) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [internalRenameOpen, setInternalRenameOpen] = useState(false)
|
const [internalRenameOpen, setInternalRenameOpen] = useState(false)
|
||||||
@ -86,7 +88,9 @@ export function BreadcrumbFolderMenu({
|
|||||||
await mutations.rename.mutateAsync({ path: folder.path, new_name: newName })
|
await mutations.rename.mutateAsync({ path: folder.path, new_name: newName })
|
||||||
toast.success("Dossier renommé")
|
toast.success("Dossier renommé")
|
||||||
const parentSegments = segments.slice(0, -1)
|
const parentSegments = segments.slice(0, -1)
|
||||||
router.push(buildDriveFolderHref(view, [...parentSegments, newName]))
|
router.push(
|
||||||
|
buildDriveFolderHref(view, [...parentSegments, newName], undefined, routeRoot)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Impossible de renommer ce dossier")
|
toast.error("Impossible de renommer ce dossier")
|
||||||
throw new Error("rename failed")
|
throw new Error("rename failed")
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import Link from "next/link"
|
|||||||
import { ChevronRight } from "lucide-react"
|
import { ChevronRight } from "lucide-react"
|
||||||
import { BreadcrumbFolderMenu } from "@/components/drive/breadcrumb-folder-menu"
|
import { BreadcrumbFolderMenu } from "@/components/drive/breadcrumb-folder-menu"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
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 { displayFileName } from "@/lib/drive/display-file-name"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -19,17 +20,35 @@ const CRUMB_LINE_CLASS = "leading-6 md:leading-7"
|
|||||||
export function BreadcrumbNav({
|
export function BreadcrumbNav({
|
||||||
view,
|
view,
|
||||||
segments,
|
segments,
|
||||||
|
rootId,
|
||||||
writable = true,
|
writable = true,
|
||||||
}: {
|
}: {
|
||||||
view: Extract<DriveView, "files" | "shared">
|
view: Extract<DriveView, "files" | "shared" | "org" | "mount">
|
||||||
segments: string[]
|
segments: string[]
|
||||||
|
rootId?: string | null
|
||||||
/** When false, double-click rename is disabled (e.g. read-only share). */
|
/** When false, double-click rename is disabled (e.g. read-only share). */
|
||||||
writable?: boolean
|
writable?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [renameOpen, setRenameOpen] = useState(false)
|
const [renameOpen, setRenameOpen] = useState(false)
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
|
const base = driveRouteBase(routeRoot)
|
||||||
|
|
||||||
const rootLabel = view === "shared" ? "Partagés avec moi" : "Mon Drive"
|
const rootLabel =
|
||||||
const rootHref = view === "shared" ? "/drive/shared" : "/drive"
|
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 folderPath = folderPathFromSegments(segments)
|
||||||
const canRenameCurrent = writable && segments.length > 0
|
const canRenameCurrent = writable && segments.length > 0
|
||||||
|
|
||||||
@ -38,7 +57,7 @@ export function BreadcrumbNav({
|
|||||||
const slice = segments.slice(0, i + 1)
|
const slice = segments.slice(0, i + 1)
|
||||||
crumbs.push({
|
crumbs.push({
|
||||||
label: displayFileName(segments[i]),
|
label: displayFileName(segments[i]),
|
||||||
href: buildDriveFolderHref(view, slice),
|
href: buildDriveFolderHref(view, slice, rootId ?? undefined, routeRoot),
|
||||||
segments: slice,
|
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 { ShareDialog } from "@/components/drive/share-dialog"
|
||||||
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
import { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { DriveRouteRootProvider } from "@/lib/drive/drive-route-context"
|
||||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
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 isMobile = useIsMobile()
|
||||||
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
|
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
|
||||||
@ -24,6 +31,7 @@ export function DriveAppShell({ children }: { children: ReactNode }) {
|
|||||||
}, [isMobile, setSidebarCollapsed])
|
}, [isMobile, setSidebarCollapsed])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DriveRouteRootProvider routeRoot={routeRoot}>
|
||||||
<SuiteThemeShell>
|
<SuiteThemeShell>
|
||||||
<div className="ultimail-app relative flex h-dvh overflow-hidden bg-app-canvas" data-drive-app>
|
<div className="ultimail-app relative flex h-dvh overflow-hidden bg-app-canvas" data-drive-app>
|
||||||
{isMobile && sidebarOpen && (
|
{isMobile && sidebarOpen && (
|
||||||
@ -41,5 +49,6 @@ export function DriveAppShell({ children }: { children: ReactNode }) {
|
|||||||
<AiChatPanel />
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
</SuiteThemeShell>
|
</SuiteThemeShell>
|
||||||
|
</DriveRouteRootProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,17 +20,21 @@ const VIEW_TITLES: Partial<Record<DriveView, string>> = {
|
|||||||
recent: "Récents",
|
recent: "Récents",
|
||||||
starred: "Favoris",
|
starred: "Favoris",
|
||||||
trash: "Corbeille",
|
trash: "Corbeille",
|
||||||
|
org: "Dossier d'organisation",
|
||||||
|
mount: "Volume monté",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DriveBrowserChrome({
|
export function DriveBrowserChrome({
|
||||||
view,
|
view,
|
||||||
segments,
|
segments,
|
||||||
|
rootId,
|
||||||
isTrash,
|
isTrash,
|
||||||
items,
|
items,
|
||||||
searchState,
|
searchState,
|
||||||
}: {
|
}: {
|
||||||
view: DriveView
|
view: DriveView
|
||||||
segments: string[]
|
segments: string[]
|
||||||
|
rootId?: string | null
|
||||||
isTrash?: boolean
|
isTrash?: boolean
|
||||||
items: DriveFileInfo[]
|
items: DriveFileInfo[]
|
||||||
searchState?: DriveSearchState | null
|
searchState?: DriveSearchState | null
|
||||||
@ -43,7 +47,7 @@ export function DriveBrowserChrome({
|
|||||||
[items, selectedPaths]
|
[items, selectedPaths]
|
||||||
)
|
)
|
||||||
const showBulk = selectedTargets.length > 0
|
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 showSearchBreadcrumb = view === "search" && searchState
|
||||||
const title = VIEW_TITLES[view]
|
const title = VIEW_TITLES[view]
|
||||||
const allowShare = view !== "shared"
|
const allowShare = view !== "shared"
|
||||||
@ -70,7 +74,8 @@ export function DriveBrowserChrome({
|
|||||||
<BreadcrumbNav
|
<BreadcrumbNav
|
||||||
view={view}
|
view={view}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
writable={view === "files"}
|
rootId={rootId}
|
||||||
|
writable={view === "files" || view === "org" || view === "mount"}
|
||||||
/>
|
/>
|
||||||
) : title ? (
|
) : title ? (
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<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",
|
id: "ultimail",
|
||||||
label: "Ultimail",
|
label: "Ultimail",
|
||||||
iconSrc: suitePublicAsset("/brand/ultimail-header-icon.png"),
|
iconSrc: suitePublicAsset("/ultimail-mark.svg"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "ultimeet",
|
id: "ultimeet",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
import { formatDriveListDate } from "@/lib/drive/drive-date"
|
import { formatDriveListDate } from "@/lib/drive/drive-date"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -10,22 +11,40 @@ export function DriveListModified({
|
|||||||
iso: string
|
iso: string
|
||||||
className?: 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 (
|
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 (
|
return (
|
||||||
<time
|
<time
|
||||||
dateTime={dateTime}
|
dateTime={formatted.dateTime}
|
||||||
title={full}
|
title={formatted.full}
|
||||||
aria-label={full}
|
aria-label={formatted.full}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
{short}
|
{formatted.short}
|
||||||
</time>
|
</time>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
defaultDriveSearchScope,
|
defaultDriveSearchScope,
|
||||||
fileBrowserViewForSearchScope,
|
fileBrowserViewForSearchScope,
|
||||||
} from "@/lib/drive/drive-search"
|
} from "@/lib/drive/drive-search"
|
||||||
|
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
import type { DriveView } from "@/lib/drive/drive-url"
|
||||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||||
|
|
||||||
@ -40,6 +41,7 @@ export function DriveMobileBottomBar({
|
|||||||
parentPath: string
|
parentPath: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
const openPreview = useDriveUIStore((s) => s.openPreview)
|
const openPreview = useDriveUIStore((s) => s.openPreview)
|
||||||
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
|
const sidebarCollapsed = useDriveUIStore((s) => s.sidebarCollapsed)
|
||||||
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
|
const setSidebarCollapsed = useDriveUIStore((s) => s.setSidebarCollapsed)
|
||||||
@ -57,11 +59,14 @@ export function DriveMobileBottomBar({
|
|||||||
const q = search.trim()
|
const q = search.trim()
|
||||||
if (!q) return
|
if (!q) return
|
||||||
router.push(
|
router.push(
|
||||||
buildDriveSearchUrl({
|
buildDriveSearchUrl(
|
||||||
|
{
|
||||||
query: q,
|
query: q,
|
||||||
scope: effectiveScope,
|
scope: effectiveScope,
|
||||||
folderPath: effectiveScope === "folder" ? folderPath : "/",
|
folderPath: effectiveScope === "folder" ? folderPath : "/",
|
||||||
})
|
},
|
||||||
|
routeRoot
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,12 +3,11 @@
|
|||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { clearMailBackgroundDom } from "@/lib/mail-settings/mail-background-dom"
|
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() {
|
export function DriveRouteScope() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const html = document.documentElement
|
const html = document.documentElement
|
||||||
html.dataset.routeScope = "drive"
|
html.dataset.routeScope = "drive"
|
||||||
html.dataset.splashSeen = "1"
|
|
||||||
clearMailBackgroundDom(html)
|
clearMailBackgroundDom(html)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
defaultDriveSearchScope,
|
defaultDriveSearchScope,
|
||||||
fileBrowserViewForSearchScope,
|
fileBrowserViewForSearchScope,
|
||||||
} from "@/lib/drive/drive-search"
|
} from "@/lib/drive/drive-search"
|
||||||
|
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
import type { DriveView } from "@/lib/drive/drive-url"
|
||||||
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
|
import { useDebouncedValue } from "@/lib/hooks/use-debounced-value"
|
||||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||||
@ -45,6 +46,7 @@ export function DriveSearchBar({
|
|||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
}: DriveSearchBarProps) {
|
}: DriveSearchBarProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
const openPreview = useDriveUIStore((s) => s.openPreview)
|
const openPreview = useDriveUIStore((s) => s.openPreview)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
@ -71,11 +73,14 @@ export function DriveSearchBar({
|
|||||||
const q = (query ?? value).trim()
|
const q = (query ?? value).trim()
|
||||||
if (!q) return
|
if (!q) return
|
||||||
router.push(
|
router.push(
|
||||||
buildDriveSearchUrl({
|
buildDriveSearchUrl(
|
||||||
|
{
|
||||||
query: q,
|
query: q,
|
||||||
scope: effectiveScope,
|
scope: effectiveScope,
|
||||||
folderPath: effectiveScope === "folder" ? folderPath : "/",
|
folderPath: effectiveScope === "folder" ? folderPath : "/",
|
||||||
})
|
},
|
||||||
|
routeRoot
|
||||||
|
)
|
||||||
)
|
)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/lib/drive/drive-search"
|
} from "@/lib/drive/drive-search"
|
||||||
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
|
import { driveFolderHref } from "@/lib/drive/drive-sidebar-tree"
|
||||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
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 { MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS } from "@/lib/mail-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -59,9 +60,10 @@ function SuggestionRow({
|
|||||||
onPick: (item: DriveFileInfo) => void
|
onPick: (item: DriveFileInfo) => void
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
const view = scope === "shared" ? "shared" : "files"
|
const view = scope === "shared" ? "shared" : "files"
|
||||||
const parentPath = itemParentFolderPath(item.path, item.type)
|
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)
|
const location = itemLocationLabel(item.path, item.type)
|
||||||
|
|
||||||
return (
|
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 { Icon } from "@iconify/react"
|
||||||
import { Clock, Star, Trash2 } from "lucide-react"
|
import { Clock, Star, Trash2 } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 { mailNavRowClass } from "@/lib/mail-chrome-classes"
|
||||||
import { DriveQuotaBar } from "@/components/drive/quota-bar"
|
import { DriveQuotaBar } from "@/components/drive/quota-bar"
|
||||||
import { DriveNewMenu } from "@/components/drive/new-menu"
|
import { DriveNewMenu } from "@/components/drive/new-menu"
|
||||||
import { DriveSidebarFolderTree } from "@/components/drive/sidebar-folder-tree"
|
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 { AccountAvatar } from "@/components/suite/account-avatar"
|
||||||
import { AccountSwitcherSheet } from "@/components/suite/account-switcher-sheet"
|
import { AccountSwitcherSheet } from "@/components/suite/account-switcher-sheet"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useIsXs } from "@/hooks/use-xs"
|
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 { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||||
|
import {
|
||||||
const OTHER_NAV = [
|
SUITE_APP_LOGO_LOCKUP_CLASS,
|
||||||
{ href: "/drive/recent", label: "Récents", icon: Clock },
|
SUITE_APP_LOGO_MARK_CLASS,
|
||||||
{ href: "/drive/starred", label: "Favoris", icon: Star },
|
SUITE_APP_LOGO_TEXT_CLASS,
|
||||||
{ href: "/drive/trash", label: "Corbeille", icon: Trash2 },
|
} from "@/lib/suite/suite-chrome-classes"
|
||||||
]
|
|
||||||
|
|
||||||
export function DriveSidebar({
|
export function DriveSidebar({
|
||||||
overlay = false,
|
overlay = false,
|
||||||
@ -33,6 +40,13 @@ export function DriveSidebar({
|
|||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const params = useParams()
|
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 isXs = useIsXs()
|
||||||
const identity = useChromeIdentity()
|
const identity = useChromeIdentity()
|
||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
||||||
@ -44,6 +58,8 @@ export function DriveSidebar({
|
|||||||
const parentPath = folderPathFromSegments(route.pathSegments)
|
const parentPath = folderPathFromSegments(route.pathSegments)
|
||||||
const filesSegments = route.view === "files" ? route.pathSegments : []
|
const filesSegments = route.view === "files" ? route.pathSegments : []
|
||||||
const sharedSegments = route.view === "shared" ? 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 closeSidebar = () => setSidebarCollapsed(true)
|
||||||
const displayName = identity?.name ?? "Utilisateur"
|
const displayName = identity?.name ?? "Utilisateur"
|
||||||
@ -62,16 +78,16 @@ export function DriveSidebar({
|
|||||||
aria-hidden={overlay && !open}
|
aria-hidden={overlay && !open}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center justify-between gap-2 px-4 py-4">
|
<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
|
<img
|
||||||
src="/drive/ultidrive-mark.svg"
|
src="/drive/ultidrive-mark.svg"
|
||||||
alt=""
|
alt=""
|
||||||
className="h-8 w-8 shrink-0"
|
className={SUITE_APP_LOGO_MARK_CLASS}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
;(e.target as HTMLImageElement).style.display = "none"
|
;(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>
|
</div>
|
||||||
{isXs ? (
|
{isXs ? (
|
||||||
<Button
|
<Button
|
||||||
@ -102,8 +118,18 @@ export function DriveSidebar({
|
|||||||
pathSegments={sharedSegments}
|
pathSegments={sharedSegments}
|
||||||
active={route.view === "shared"}
|
active={route.view === "shared"}
|
||||||
/>
|
/>
|
||||||
|
<DriveSidebarMounts
|
||||||
|
active={route.view === "mount"}
|
||||||
|
pathSegments={mountSegments}
|
||||||
|
rootId={route.rootId}
|
||||||
|
/>
|
||||||
</div>
|
</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)
|
const active = pathname.startsWith(href)
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@ -113,12 +139,16 @@ export function DriveSidebar({
|
|||||||
if (overlay) closeSidebar()
|
if (overlay) closeSidebar()
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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 })
|
mailNavRowClass({ isSelected: active })
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className={DRIVE_SIDEBAR_CARET_SLOT_CLASS} aria-hidden />
|
||||||
|
<span className={DRIVE_SIDEBAR_ROW_BODY_CLASS}>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
{label}
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -129,7 +159,10 @@ export function DriveSidebar({
|
|||||||
isXs && "pb-[calc(4rem+env(safe-area-inset-bottom))]",
|
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 />
|
<DriveQuotaBar />
|
||||||
</div>
|
</div>
|
||||||
{isXs ? (
|
{isXs ? (
|
||||||
@ -144,7 +177,11 @@ export function DriveSidebar({
|
|||||||
>
|
>
|
||||||
{identity ? (
|
{identity ? (
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
account={{ name: identity.name, email: identity.email }}
|
account={{
|
||||||
|
name: identity.name,
|
||||||
|
email: identity.email,
|
||||||
|
avatarUrl: identity.avatarUrl,
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -24,7 +24,11 @@ export function EditorAccountButton() {
|
|||||||
>
|
>
|
||||||
{identity ? (
|
{identity ? (
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
account={{ name: identity.name, email: identity.email }}
|
account={{
|
||||||
|
name: identity.name,
|
||||||
|
email: identity.email,
|
||||||
|
avatarUrl: identity.avatarUrl,
|
||||||
|
}}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import type { DriveFileInfo } from "@/lib/api/types"
|
|||||||
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
|
import { useDriveSettingsStore } from "@/lib/stores/drive-settings-store"
|
||||||
import { openDriveItem } from "@/lib/drive/drive-open-item"
|
import { openDriveItem } from "@/lib/drive/drive-open-item"
|
||||||
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
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 type { PublicShareThumbContext } from "@/lib/api/hooks/use-public-share-preview-thumb"
|
||||||
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
import { useDriveUIStore } from "@/lib/stores/drive-ui-store"
|
||||||
import {
|
import {
|
||||||
@ -17,6 +19,7 @@ import {
|
|||||||
} from "@/lib/drive/display-file-name"
|
} from "@/lib/drive/display-file-name"
|
||||||
import { useDriveGridSelection } from "@/lib/hooks/use-drive-grid-selection"
|
import { useDriveGridSelection } from "@/lib/hooks/use-drive-grid-selection"
|
||||||
import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes"
|
import { DRIVE_CARD_PAD_X } from "@/lib/drive/drive-chrome-classes"
|
||||||
|
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function formatSize(n: number) {
|
function formatSize(n: number) {
|
||||||
@ -28,6 +31,8 @@ function formatSize(n: number) {
|
|||||||
export function FileBrowser({
|
export function FileBrowser({
|
||||||
items,
|
items,
|
||||||
view = "files",
|
view = "files",
|
||||||
|
rootId,
|
||||||
|
pathRef: pathRefProp,
|
||||||
isTrash,
|
isTrash,
|
||||||
onOpenItem,
|
onOpenItem,
|
||||||
mutations: mutationsProp,
|
mutations: mutationsProp,
|
||||||
@ -40,7 +45,9 @@ export function FileBrowser({
|
|||||||
publicShare,
|
publicShare,
|
||||||
}: {
|
}: {
|
||||||
items: DriveFileInfo[]
|
items: DriveFileInfo[]
|
||||||
view?: "files" | "shared"
|
view?: DriveView
|
||||||
|
rootId?: string | null
|
||||||
|
pathRef?: DrivePathRef
|
||||||
isTrash?: boolean
|
isTrash?: boolean
|
||||||
onOpenItem?: (file: DriveFileInfo) => void
|
onOpenItem?: (file: DriveFileInfo) => void
|
||||||
mutations?: ReturnType<typeof useDriveMutations>
|
mutations?: ReturnType<typeof useDriveMutations>
|
||||||
@ -53,9 +60,11 @@ export function FileBrowser({
|
|||||||
publicShare?: PublicShareThumbContext
|
publicShare?: PublicShareThumbContext
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
const viewMode = useDriveSettingsStore((s) => s.viewMode)
|
const viewMode = useDriveSettingsStore((s) => s.viewMode)
|
||||||
const openPreview = useDriveUIStore((s) => s.openPreview)
|
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 mutations = mutationsProp ?? mutationsDefault
|
||||||
|
|
||||||
const openItem = (file: DriveFileInfo) => {
|
const openItem = (file: DriveFileInfo) => {
|
||||||
@ -69,6 +78,7 @@ export function FileBrowser({
|
|||||||
view,
|
view,
|
||||||
contextItems: items,
|
contextItems: items,
|
||||||
isTrash: Boolean(isTrash),
|
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 { DriveMoveDialog, type DriveFolderPickerMode } from "@/components/drive/drive-move-dialog"
|
||||||
import { downloadDriveFile, fetchDrivePreviewBlob } from "@/lib/api/drive-download"
|
import { downloadDriveFile, fetchDrivePreviewBlob } from "@/lib/api/drive-download"
|
||||||
import { fetchPublicShareBlob } from "@/lib/api/public-share"
|
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 { downloadPublicShareFile } from "@/lib/drive/open-public-share-item"
|
||||||
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
import { useDriveMutations } from "@/lib/api/hooks/use-drive-queries"
|
||||||
import { ApiRequestError } from "@/lib/api/client"
|
import { ApiRequestError } from "@/lib/api/client"
|
||||||
@ -179,6 +181,7 @@ export function FilePreviewDialog() {
|
|||||||
const mailSource = previewContext?.mailSource ?? false
|
const mailSource = previewContext?.mailSource ?? false
|
||||||
const mailMessageId = previewContext?.mailMessageId ?? file?.mailMessageId ?? ""
|
const mailMessageId = previewContext?.mailMessageId ?? file?.mailMessageId ?? ""
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const isDemoDrive = useIsDemoDrive()
|
||||||
const saveToDrive = useSaveAttachmentToDrive(mailMessageId)
|
const saveToDrive = useSaveAttachmentToDrive(mailMessageId)
|
||||||
const isMailAttachment = mailSource && Boolean(file?.mailAttachmentId)
|
const isMailAttachment = mailSource && Boolean(file?.mailAttachmentId)
|
||||||
const showWriteActions = !isTrash && !publicShare && !isMailAttachment
|
const showWriteActions = !isTrash && !publicShare && !isMailAttachment
|
||||||
@ -189,6 +192,7 @@ export function FilePreviewDialog() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [imgFailed, setImgFailed] = useState(false)
|
const [imgFailed, setImgFailed] = useState(false)
|
||||||
|
const [demoPreviewAsImage, setDemoPreviewAsImage] = useState(false)
|
||||||
const blobUrlRef = useRef<string | null>(null)
|
const blobUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const kind = file ? drivePreviewKind(file) : null
|
const kind = file ? drivePreviewKind(file) : null
|
||||||
@ -222,11 +226,44 @@ export function FilePreviewDialog() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setImgFailed(false)
|
setImgFailed(false)
|
||||||
|
setDemoPreviewAsImage(false)
|
||||||
setTextContent(null)
|
setTextContent(null)
|
||||||
setSvgMarkup(null)
|
setSvgMarkup(null)
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
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
|
const blob = file.mailAttachmentId
|
||||||
? await apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`)
|
? await apiClient.getBlob(`/mail/attachments/${file.mailAttachmentId}`)
|
||||||
: publicShare
|
: publicShare
|
||||||
@ -291,6 +328,7 @@ export function FilePreviewDialog() {
|
|||||||
isSvg,
|
isSvg,
|
||||||
publicShare?.token,
|
publicShare?.token,
|
||||||
publicShare?.password,
|
publicShare?.password,
|
||||||
|
isDemoDrive,
|
||||||
])
|
])
|
||||||
|
|
||||||
const previewReady =
|
const previewReady =
|
||||||
@ -593,7 +631,7 @@ export function FilePreviewDialog() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!loading && !error && !imgFailed && kind && file && previewReady ? (
|
{!loading && !error && !imgFailed && kind && file && previewReady ? (
|
||||||
<PreviewBody
|
<PreviewBody
|
||||||
kind={kind}
|
kind={demoPreviewAsImage ? "image" : kind}
|
||||||
blobUrl={blobUrl ?? ""}
|
blobUrl={blobUrl ?? ""}
|
||||||
name={file.name}
|
name={file.name}
|
||||||
textContent={textContent}
|
textContent={textContent}
|
||||||
|
|||||||
@ -32,6 +32,11 @@ import {
|
|||||||
} from "@/lib/drive/drive-share-permissions"
|
} from "@/lib/drive/drive-share-permissions"
|
||||||
import { buildPublicShareEditHref, persistPublicShareRootType } from "@/lib/drive/public-share-url"
|
import { buildPublicShareEditHref, persistPublicShareRootType } from "@/lib/drive/public-share-url"
|
||||||
import { suitePublicAsset } from "@/lib/suite/suite-public-asset"
|
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 { SuiteThemeShell } from "@/components/suite/suite-theme-shell"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
|
import { filterHiddenDriveSidecars } from "@/lib/drive/drive-hidden-files"
|
||||||
@ -327,9 +332,15 @@ export function PublicShareChrome({ children }: { children: ReactNode }) {
|
|||||||
<SuiteThemeShell>
|
<SuiteThemeShell>
|
||||||
<div className="ultimail-app flex h-dvh flex-col overflow-hidden bg-app-canvas" data-drive-app>
|
<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">
|
<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">
|
<Link href="/drive" className={SUITE_APP_LOGO_LOCKUP_CLASS}>
|
||||||
<img src={suitePublicAsset("/ultidrive-mark.svg")} alt="" className="h-8 w-8" />
|
<img
|
||||||
<span className="text-lg font-medium text-[#3c4043] dark:text-[#e8eaed]">UltiDrive</span>
|
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>
|
</Link>
|
||||||
<span className="ml-3 hidden text-sm text-muted-foreground sm:inline">
|
<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 />
|
<FolderOpen className="mr-1 inline h-4 w-4 align-[-2px]" aria-hidden />
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import "@/styles/excalidraw.css"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { memo, useCallback, useState } from "react"
|
import { memo, useCallback, useState } from "react"
|
||||||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
|
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"
|
||||||
@ -12,7 +13,6 @@ export function docsExcalidrawEditorKey(drawScene: string | null): string {
|
|||||||
|
|
||||||
const ExcalidrawEditor = dynamic(
|
const ExcalidrawEditor = dynamic(
|
||||||
async () => {
|
async () => {
|
||||||
await import("@excalidraw/excalidraw/index.css")
|
|
||||||
const { Excalidraw, restoreElements, restoreAppState } = await import(
|
const { Excalidraw, restoreElements, restoreAppState } = await import(
|
||||||
"@excalidraw/excalidraw"
|
"@excalidraw/excalidraw"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,11 +7,17 @@ import type { LucideIcon } from "lucide-react"
|
|||||||
import { ChevronRight, HardDrive, Users } from "lucide-react"
|
import { ChevronRight, HardDrive, Users } from "lucide-react"
|
||||||
import { DRIVE_DROP_TARGET_CLASS } from "@/components/drive/drive-file-context-menu"
|
import { DRIVE_DROP_TARGET_CLASS } from "@/components/drive/drive-file-context-menu"
|
||||||
import { DriveFolderIcon } from "@/lib/drive/drive-file-icon"
|
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 { mailNavRowClass } from "@/lib/mail-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useDriveRouteRoot } from "@/lib/drive/drive-route-context"
|
||||||
import type { DriveView } from "@/lib/drive/drive-url"
|
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 { useDriveList, useDriveSharedWithMe } from "@/lib/api/hooks/use-drive-queries"
|
||||||
import type { DriveFileInfo } from "@/lib/api/types"
|
import type { DriveFileInfo } from "@/lib/api/types"
|
||||||
import { displayFileName } from "@/lib/drive/display-file-name"
|
import { displayFileName } from "@/lib/drive/display-file-name"
|
||||||
@ -49,14 +55,15 @@ function SidebarTreeCaret({
|
|||||||
label: string
|
label: string
|
||||||
}) {
|
}) {
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
return <span className="w-6 shrink-0" aria-hidden="true" />
|
return <span className={DRIVE_SIDEBAR_CARET_SLOT_CLASS} aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={cn(
|
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
|
DRIVE_ICON_BTN
|
||||||
)}
|
)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
@ -81,12 +88,14 @@ function SidebarFolderNode({
|
|||||||
view,
|
view,
|
||||||
currentPath,
|
currentPath,
|
||||||
active,
|
active,
|
||||||
|
routeRoot,
|
||||||
}: {
|
}: {
|
||||||
folder: DriveFileInfo
|
folder: DriveFileInfo
|
||||||
depth: number
|
depth: number
|
||||||
view: DriveView
|
view: DriveView
|
||||||
currentPath: string
|
currentPath: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
routeRoot: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
@ -98,7 +107,7 @@ function SidebarFolderNode({
|
|||||||
const isSelected = active && currentPath === folderPath
|
const isSelected = active && currentPath === folderPath
|
||||||
const { directories } = useFolderChildren(folderPath, true, false)
|
const { directories } = useFolderChildren(folderPath, true, false)
|
||||||
const hasChildFolders = directories.length > 0
|
const hasChildFolders = directories.length > 0
|
||||||
const href = driveFolderHref(view, folderPath)
|
const href = driveFolderHref(view, folderPath, undefined, routeRoot)
|
||||||
const label = displayFileName(folder.name)
|
const label = displayFileName(folder.name)
|
||||||
const { dropProps, canDrop, isOver } = useDriveDropTarget({
|
const { dropProps, canDrop, isOver } = useDriveDropTarget({
|
||||||
folderPath,
|
folderPath,
|
||||||
@ -113,7 +122,7 @@ function SidebarFolderNode({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-w-0 items-center rounded-lg text-sm",
|
DRIVE_SIDEBAR_ROW_CLASS,
|
||||||
mailNavRowClass({ isSelected }),
|
mailNavRowClass({ isSelected }),
|
||||||
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
|
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
|
||||||
)}
|
)}
|
||||||
@ -128,7 +137,7 @@ function SidebarFolderNode({
|
|||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
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) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
router.push(href)
|
router.push(href)
|
||||||
@ -148,6 +157,7 @@ function SidebarFolderNode({
|
|||||||
view={view}
|
view={view}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
active={active}
|
active={active}
|
||||||
|
routeRoot={routeRoot}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
@ -163,6 +173,7 @@ function SidebarRootBranch({
|
|||||||
rootKey,
|
rootKey,
|
||||||
pathSegments,
|
pathSegments,
|
||||||
active,
|
active,
|
||||||
|
routeRoot,
|
||||||
}: {
|
}: {
|
||||||
view: DriveView
|
view: DriveView
|
||||||
rootHref: string
|
rootHref: string
|
||||||
@ -171,6 +182,7 @@ function SidebarRootBranch({
|
|||||||
rootKey: string
|
rootKey: string
|
||||||
pathSegments: string[]
|
pathSegments: string[]
|
||||||
active: boolean
|
active: boolean
|
||||||
|
routeRoot: string
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
@ -204,7 +216,7 @@ function SidebarRootBranch({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-w-0 items-center rounded-lg text-sm",
|
DRIVE_SIDEBAR_ROW_CLASS,
|
||||||
mailNavRowClass({ isSelected: isRootSelected }),
|
mailNavRowClass({ isSelected: isRootSelected }),
|
||||||
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
|
isOver && canDrop && DRIVE_DROP_TARGET_CLASS
|
||||||
)}
|
)}
|
||||||
@ -218,7 +230,7 @@ function SidebarRootBranch({
|
|||||||
/>
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={rootHref}
|
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) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
router.push(rootHref)
|
router.push(rootHref)
|
||||||
@ -238,6 +250,7 @@ function SidebarRootBranch({
|
|||||||
view={view}
|
view={view}
|
||||||
currentPath={currentPath}
|
currentPath={currentPath}
|
||||||
active={active}
|
active={active}
|
||||||
|
routeRoot={routeRoot}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
: null}
|
: null}
|
||||||
@ -254,16 +267,20 @@ export function DriveSidebarFolderTree({
|
|||||||
pathSegments: string[]
|
pathSegments: string[]
|
||||||
active: boolean
|
active: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const routeRoot = useDriveRouteRoot()
|
||||||
|
const driveBase = driveRouteBase(routeRoot)
|
||||||
|
|
||||||
if (view === "files") {
|
if (view === "files") {
|
||||||
return (
|
return (
|
||||||
<SidebarRootBranch
|
<SidebarRootBranch
|
||||||
view="files"
|
view="files"
|
||||||
rootHref="/drive"
|
rootHref={driveBase}
|
||||||
rootLabel="Mon Drive"
|
rootLabel="Mon Drive"
|
||||||
rootIcon={HardDrive}
|
rootIcon={HardDrive}
|
||||||
rootKey="/"
|
rootKey="/"
|
||||||
pathSegments={pathSegments}
|
pathSegments={pathSegments}
|
||||||
active={active}
|
active={active}
|
||||||
|
routeRoot={routeRoot}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -271,12 +288,13 @@ export function DriveSidebarFolderTree({
|
|||||||
return (
|
return (
|
||||||
<SidebarRootBranch
|
<SidebarRootBranch
|
||||||
view="shared"
|
view="shared"
|
||||||
rootHref="/drive/shared"
|
rootHref={`${driveBase}/shared`}
|
||||||
rootLabel="Partagés avec moi"
|
rootLabel="Partagés avec moi"
|
||||||
rootIcon={Users}
|
rootIcon={Users}
|
||||||
rootKey="/__shared_root__"
|
rootKey="/__shared_root__"
|
||||||
pathSegments={pathSegments}
|
pathSegments={pathSegments}
|
||||||
active={active}
|
active={active}
|
||||||
|
routeRoot={routeRoot}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import "@/styles/excalidraw.css"
|
||||||
import dynamic from "next/dynamic"
|
import dynamic from "next/dynamic"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider"
|
import { HocuspocusProvider } from "@hocuspocus/provider"
|
||||||
@ -48,7 +49,6 @@ function seedYdocIfEmpty(ydoc: Y.Doc, parsed: ParsedDrawFile): void {
|
|||||||
|
|
||||||
const ExcalidrawCanvas = dynamic(
|
const ExcalidrawCanvas = dynamic(
|
||||||
async () => {
|
async () => {
|
||||||
await import("@excalidraw/excalidraw/index.css")
|
|
||||||
const { Excalidraw } = await import("@excalidraw/excalidraw")
|
const { Excalidraw } = await import("@excalidraw/excalidraw")
|
||||||
return Excalidraw
|
return Excalidraw
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useLayoutEffect, useState } from "react"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
|
|
||||||
const SPLASH_VISIBLE_MS = 1750
|
const SPLASH_VISIBLE_MS = 1750
|
||||||
const SPLASH_EXIT_MS = 500
|
const SPLASH_EXIT_MS = 500
|
||||||
|
|
||||||
@ -16,35 +22,35 @@ export function FirstLaunchSplash({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [activeApp, setActiveApp] = useState<SuiteSplashApp | null>(() =>
|
||||||
|
typeof window === "undefined"
|
||||||
|
? null
|
||||||
|
: shouldShowSuiteSplash(window.location.pathname)
|
||||||
|
)
|
||||||
const [isHiding, setIsHiding] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
if (!activeApp) return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideTimer = window.setTimeout(() => {
|
const hideTimer = window.setTimeout(() => {
|
||||||
setIsHiding(true)
|
setIsHiding(true)
|
||||||
}, SPLASH_VISIBLE_MS)
|
}, SPLASH_VISIBLE_MS)
|
||||||
|
|
||||||
const completeTimer = window.setTimeout(() => {
|
const completeTimer = window.setTimeout(() => {
|
||||||
try {
|
markSuiteSplashSeen(activeApp)
|
||||||
localStorage.setItem(SPLASH_SEEN_KEY, "1")
|
document.documentElement.dataset.splashSeen = "1"
|
||||||
} catch {
|
setActiveApp(null)
|
||||||
// Ignore storage failures (private mode / disabled storage).
|
|
||||||
}
|
|
||||||
root.dataset.splashSeen = "1"
|
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
}, SPLASH_VISIBLE_MS + SPLASH_EXIT_MS)
|
}, SPLASH_VISIBLE_MS + SPLASH_EXIT_MS)
|
||||||
|
|
||||||
@ -52,26 +58,69 @@ export function FirstLaunchSplash({
|
|||||||
window.clearTimeout(hideTimer)
|
window.clearTimeout(hideTimer)
|
||||||
window.clearTimeout(completeTimer)
|
window.clearTimeout(completeTimer)
|
||||||
}
|
}
|
||||||
}, [pathname])
|
}, [activeApp])
|
||||||
|
|
||||||
|
const config = activeApp ? SUITE_SPLASH_CONFIG[activeApp] : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
{!isComplete ? (
|
{!isComplete && config ? (
|
||||||
<div
|
<div
|
||||||
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
|
className={cn("app-first-launch-splash", isHiding && "app-first-launch-splash--hide")}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
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__aurora" aria-hidden />
|
||||||
<div className="app-first-launch-splash__grain" aria-hidden />
|
<div className="app-first-launch-splash__grain" aria-hidden />
|
||||||
<div className="app-first-launch-splash__content">
|
<div className="app-first-launch-splash__content">
|
||||||
<div className="app-first-launch-splash__pill">ULTIMAIL</div>
|
<div className="app-first-launch-splash__pill">{config.pill}</div>
|
||||||
|
{activeApp === "mail" ? (
|
||||||
<UltiMailLogo href={null} className="app-first-launch-splash__logo" />
|
<UltiMailLogo href={null} className="app-first-launch-splash__logo" />
|
||||||
<p className="app-first-launch-splash__subtitle">
|
) : config.markDark ? (
|
||||||
Synchronisation de votre boite de reception...
|
<>
|
||||||
</p>
|
<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>
|
<div className="app-first-launch-splash__loader" aria-hidden>
|
||||||
<span />
|
<span />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
|
import { InvitationTimeChipText } from "@/components/gmail/invitation-time-chip-text"
|
||||||
|
import { AgendaMark } from "@/components/suite/agenda-mark"
|
||||||
import { Icon } from "@iconify/react"
|
import { Icon } from "@iconify/react"
|
||||||
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
|
import { ThumbsDown, ThumbsUp, Users, MoreVertical } from "lucide-react"
|
||||||
import {
|
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 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">
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border bg-mail-surface shadow-sm">
|
||||||
<img
|
<AgendaMark className="size-9 object-contain" />
|
||||||
src="/agenda-mark.svg"
|
|
||||||
alt=""
|
|
||||||
className="size-9 object-contain"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 text-right text-sm leading-snug text-muted-foreground">
|
<div className="min-w-0 text-right text-sm leading-snug text-muted-foreground">
|
||||||
<p className="font-medium text-foreground">Dans votre agenda</p>
|
<p className="font-medium text-foreground">Dans votre agenda</p>
|
||||||
|
|||||||
@ -3,11 +3,12 @@
|
|||||||
import { useEffect, useMemo } from "react"
|
import { useEffect, useMemo } from "react"
|
||||||
import { useQueries } from "@tanstack/react-query"
|
import { useQueries } from "@tanstack/react-query"
|
||||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
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 { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import { apiClient } from "@/lib/api/client"
|
import { apiClient } from "@/lib/api/client"
|
||||||
import type { ApiIdentity } from "@/lib/api/types"
|
import type { ApiIdentity } from "@/lib/api/types"
|
||||||
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
|
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 type { Identity } from "@/lib/compose-context"
|
||||||
import { useComposeIdentitiesStore } from "@/lib/stores/compose-identities-store"
|
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. */
|
/** Hydrate compose From identities from server for all mail accounts. */
|
||||||
export function ComposeIdentitiesSync() {
|
export function ComposeIdentitiesSync() {
|
||||||
|
const isDemoMail = useIsDemoMail()
|
||||||
const { ready, authenticated } = useAuthReady()
|
const { ready, authenticated } = useAuthReady()
|
||||||
const { data: accounts = [], isSuccess: accountsReady } = useMailAccounts()
|
const { data: accounts = [], isSuccess: accountsReady } = useMailAccounts()
|
||||||
const { data: signatures = [], isSuccess: signaturesReady } = useMailSignatures()
|
const { data: signatures = [], isSuccess: signaturesReady } = useMailSignatures()
|
||||||
@ -45,8 +47,10 @@ export function ComposeIdentitiesSync() {
|
|||||||
if (identityQueries.some((q) => q.isPending && q.fetchStatus !== "idle")) {
|
if (identityQueries.some((q) => q.isPending && q.fetchStatus !== "idle")) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return identityQueries.flatMap((q) =>
|
return dedupeComposeIdentities(
|
||||||
(q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById))
|
identityQueries.flatMap((q) =>
|
||||||
|
(q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}, [
|
}, [
|
||||||
ready,
|
ready,
|
||||||
@ -60,13 +64,14 @@ export function ComposeIdentitiesSync() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDemoMail) return
|
||||||
if (!ready || !authenticated) {
|
if (!ready || !authenticated) {
|
||||||
useComposeIdentitiesStore.getState().clear()
|
useComposeIdentitiesStore.getState().clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (merged === null) return
|
if (merged === null) return
|
||||||
useComposeIdentitiesStore.getState().hydrateFromApi(merged)
|
useComposeIdentitiesStore.getState().hydrateFromApi(merged)
|
||||||
}, [ready, authenticated, merged])
|
}, [isDemoMail, ready, authenticated, merged])
|
||||||
|
|
||||||
return null
|
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