huhuhuhu
This commit is contained in:
parent
c87670e90f
commit
5567e2f0c1
14
.env.example
Normal file
14
.env.example
Normal file
@ -0,0 +1,14 @@
|
||||
# API backend — URL relative : Next.js proxy vers nginx (:80), pas de CORS en dev
|
||||
NEXT_PUBLIC_API_URL=/api/v1
|
||||
NEXT_PUBLIC_WS_URL=ws://localhost/ws
|
||||
# Cible du proxy Next (optionnel, défaut 127.0.0.1:80)
|
||||
# ULTI_PROXY_ORIGIN=http://127.0.0.1
|
||||
|
||||
# OIDC Authentik (blueprints deploy/authentik dans ulti-backend)
|
||||
NEXT_PUBLIC_OIDC_ISSUER=http://localhost/auth/application/o/ulti/
|
||||
NEXT_PUBLIC_OIDC_CLIENT_ID=ulti-backend
|
||||
# URL publique affichée dans les redirects OIDC (navigateur) — utiliser localhost, pas 0.0.0.0
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Secret serveur uniquement — doit matcher ULTID_OIDC_CLIENT_SECRET / blueprint
|
||||
OIDC_CLIENT_SECRET=changeme
|
||||
128
app/api/auth/callback/route.ts
Normal file
128
app/api/auth/callback/route.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import { resolveServerOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
|
||||
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
||||
import {
|
||||
applySessionCookies,
|
||||
type TokenResponse,
|
||||
} from "@/lib/auth/session"
|
||||
|
||||
const PKCE_COOKIE = "ulti_pkce_verifier"
|
||||
const STATE_COOKIE = "ulti_oauth_state"
|
||||
const INTENT_COOKIE = "ulti_auth_intent"
|
||||
const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const appOrigin = getAppOrigin()
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const oauthError = url.searchParams.get("error")
|
||||
|
||||
if (oauthError) {
|
||||
const desc = url.searchParams.get("error_description") ?? oauthError
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(desc)}`, appOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=missing_code", appOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
const jar = await cookies()
|
||||
const expectedState = jar.get(STATE_COOKIE)?.value
|
||||
const verifier = jar.get(PKCE_COOKIE)?.value
|
||||
const returnTo = jar.get("ulti_auth_return")?.value ?? "/mail/inbox"
|
||||
const authIntent = jar.get(INTENT_COOKIE)?.value
|
||||
const previousSub = jar.get(PREVIOUS_SUB_COOKIE)?.value
|
||||
|
||||
if (!expectedState || state !== expectedState || !verifier) {
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/login?error=${encodeURIComponent(
|
||||
!expectedState || !verifier
|
||||
? "invalid_state:missing_oauth_cookies"
|
||||
: "invalid_state:state_mismatch"
|
||||
)}`,
|
||||
appOrigin
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let cfg
|
||||
try {
|
||||
cfg = await resolveServerOidcConfig()
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "oidc_discovery_failed"
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
|
||||
)
|
||||
}
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: cfg.clientId,
|
||||
client_secret: cfg.clientSecret,
|
||||
code,
|
||||
redirect_uri: cfg.redirectUri,
|
||||
code_verifier: verifier,
|
||||
})
|
||||
|
||||
let tokens: TokenResponse
|
||||
try {
|
||||
const res = await fetch(cfg.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/login?error=${encodeURIComponent(`token_exchange_failed:${text.slice(0, 120)}`)}`,
|
||||
appOrigin
|
||||
)
|
||||
)
|
||||
}
|
||||
tokens = (await res.json()) as TokenResponse
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "token_exchange_failed"
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?error=${encodeURIComponent(message)}`, appOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
if (!tokens.id_token) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/login?error=no_id_token", appOrigin)
|
||||
)
|
||||
}
|
||||
|
||||
const bearer = tokens.id_token
|
||||
|
||||
const newUser = platformUserFromToken(bearer)
|
||||
const completeUrl = new URL("/auth/complete", appOrigin)
|
||||
completeUrl.searchParams.set("returnTo", returnTo)
|
||||
if (
|
||||
authIntent === "add_account" &&
|
||||
previousSub &&
|
||||
newUser?.sub === previousSub
|
||||
) {
|
||||
completeUrl.searchParams.set("accountNotice", "same")
|
||||
}
|
||||
|
||||
const response = NextResponse.redirect(completeUrl)
|
||||
|
||||
response.cookies.delete(PKCE_COOKIE)
|
||||
response.cookies.delete(STATE_COOKIE)
|
||||
response.cookies.delete("ulti_auth_return")
|
||||
response.cookies.delete(INTENT_COOKIE)
|
||||
response.cookies.delete(PREVIOUS_SUB_COOKIE)
|
||||
|
||||
applySessionCookies(response, tokens, bearer)
|
||||
|
||||
return response
|
||||
}
|
||||
77
app/api/auth/login/route.ts
Normal file
77
app/api/auth/login/route.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import { createPkcePair, randomString } from "@/lib/auth/pkce"
|
||||
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
||||
import { resolveOidcConfig, getAppOrigin } from "@/lib/auth/oidc-config"
|
||||
|
||||
const PKCE_COOKIE = "ulti_pkce_verifier"
|
||||
const STATE_COOKIE = "ulti_oauth_state"
|
||||
const INTENT_COOKIE = "ulti_auth_intent"
|
||||
const PREVIOUS_SUB_COOKIE = "ulti_auth_previous_sub"
|
||||
const COOKIE_MAX_AGE = 600
|
||||
|
||||
function oauthCookieOptions() {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: COOKIE_MAX_AGE,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
let cfg
|
||||
try {
|
||||
cfg = await resolveOidcConfig()
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "oidc_discovery_failed"
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/login?error=${encodeURIComponent(message)}`,
|
||||
getAppOrigin()
|
||||
)
|
||||
)
|
||||
}
|
||||
const { verifier, challenge } = await createPkcePair()
|
||||
const state = randomString(16)
|
||||
const requestUrl = new URL(request.url)
|
||||
const returnTo = requestUrl.searchParams.get("returnTo") ?? "/mail/inbox"
|
||||
const intent = requestUrl.searchParams.get("intent")
|
||||
const prompt =
|
||||
requestUrl.searchParams.get("prompt") ??
|
||||
(intent === "add_account" ? "login select_account" : "select_account")
|
||||
|
||||
const jar = await cookies()
|
||||
const existingUser = platformUserFromToken(
|
||||
jar.get("ulti_access_token")?.value ?? ""
|
||||
)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: cfg.clientId,
|
||||
redirect_uri: cfg.redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid profile email offline_access",
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
prompt,
|
||||
})
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
`${cfg.authorizationEndpoint}?${params.toString()}`
|
||||
)
|
||||
const cookieOpts = oauthCookieOptions()
|
||||
response.cookies.set(PKCE_COOKIE, verifier, cookieOpts)
|
||||
response.cookies.set(STATE_COOKIE, state, cookieOpts)
|
||||
response.cookies.set("ulti_auth_return", returnTo, cookieOpts)
|
||||
if (intent === "add_account") {
|
||||
response.cookies.set(INTENT_COOKIE, "add_account", cookieOpts)
|
||||
if (existingUser?.sub) {
|
||||
response.cookies.set(PREVIOUS_SUB_COOKIE, existingUser.sub, cookieOpts)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
22
app/api/auth/logout/route.ts
Normal file
22
app/api/auth/logout/route.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
const SESSION_COOKIES = [
|
||||
"ulti_session",
|
||||
"ulti_access_token",
|
||||
"ulti_refresh_token",
|
||||
"ulti_expires_at",
|
||||
"ulti_pkce_verifier",
|
||||
"ulti_oauth_state",
|
||||
"ulti_auth_return",
|
||||
"ulti_auth_intent",
|
||||
"ulti_auth_previous_sub",
|
||||
] as const
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true })
|
||||
for (const name of SESSION_COOKIES) {
|
||||
response.cookies.delete(name)
|
||||
}
|
||||
return response
|
||||
}
|
||||
60
app/api/auth/session/route.ts
Normal file
60
app/api/auth/session/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import { platformUserFromToken } from "@/lib/auth/jwt-claims"
|
||||
import { resolveServerOidcConfig } from "@/lib/auth/oidc-config"
|
||||
import {
|
||||
SESSION_COOKIE_NAMES,
|
||||
applySessionCookies,
|
||||
computeExpiresAt,
|
||||
exchangeRefreshToken,
|
||||
isAccessTokenValid,
|
||||
resolveBearerToken,
|
||||
} from "@/lib/auth/session"
|
||||
|
||||
export async function GET() {
|
||||
const jar = await cookies()
|
||||
const accessToken = jar.get(SESSION_COOKIE_NAMES.accessToken)?.value
|
||||
const refreshToken = jar.get(SESSION_COOKIE_NAMES.refreshToken)?.value
|
||||
const expiresAtRaw = jar.get(SESSION_COOKIE_NAMES.expiresAt)?.value
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
return NextResponse.json({ authenticated: false })
|
||||
}
|
||||
|
||||
if (isAccessTokenValid(accessToken, expiresAtRaw)) {
|
||||
const expiresAt = Number(expiresAtRaw)
|
||||
const user = platformUserFromToken(accessToken!)
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
accessToken,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresAt,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ authenticated: false, expired: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const cfg = await resolveServerOidcConfig()
|
||||
const tokens = await exchangeRefreshToken(refreshToken, cfg)
|
||||
const bearer = resolveBearerToken(tokens)
|
||||
const expiresAt = computeExpiresAt(tokens.expires_in ?? 3600)
|
||||
const user = platformUserFromToken(bearer)
|
||||
|
||||
const response = NextResponse.json({
|
||||
authenticated: true,
|
||||
accessToken: bearer,
|
||||
refreshToken: tokens.refresh_token ?? refreshToken,
|
||||
expiresAt,
|
||||
user,
|
||||
refreshed: true,
|
||||
})
|
||||
applySessionCookies(response, tokens, bearer)
|
||||
return response
|
||||
} catch {
|
||||
return NextResponse.json({ authenticated: false, expired: true })
|
||||
}
|
||||
}
|
||||
69
app/auth/complete/page.tsx
Normal file
69
app/auth/complete/page.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
function AuthCompleteInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
||||
const accountNotice = searchParams.get("accountNotice")
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function finish() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" })
|
||||
const data = (await res.json()) as {
|
||||
authenticated?: boolean
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: PlatformUser | null
|
||||
}
|
||||
if (
|
||||
data.authenticated &&
|
||||
data.accessToken &&
|
||||
data.expiresAt &&
|
||||
!cancelled
|
||||
) {
|
||||
login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
data.user ?? null
|
||||
)
|
||||
if (accountNotice === "same") {
|
||||
sessionStorage.setItem("ulti_account_notice", "same")
|
||||
}
|
||||
router.replace(returnTo.startsWith("/") ? returnTo : "/mail/inbox")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
if (!cancelled) {
|
||||
router.replace("/login?error=session_failed")
|
||||
}
|
||||
}
|
||||
|
||||
void finish()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [accountNotice, login, returnTo, router])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function AuthCompletePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AuthCompleteInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -564,6 +564,36 @@ html[data-splash-seen='1'] .app-first-launch-splash {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Login : fond Aurore fixe (sm+), pas le fond mail utilisateur ── */
|
||||
html:has(.ultimail-login)::before {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
html:has(.ultimail-login) body {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.ultimail-login-card-frame {
|
||||
padding: 3px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: conic-gradient(
|
||||
from 145deg,
|
||||
#1a73e8,
|
||||
#34a853,
|
||||
#fbbc04,
|
||||
#ea4335,
|
||||
#1a73e8
|
||||
);
|
||||
box-shadow: 0 16px 40px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.ultimail-login-card-frame > [data-slot='card'] {
|
||||
border-width: 0;
|
||||
border-radius: calc(var(--radius-xl) - 3px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Mail : fond décoratif plein écran (derrière toute l’UI) ── */
|
||||
html {
|
||||
background-color: var(--mail-bg-fallback, var(--app-canvas));
|
||||
|
||||
@ -5,6 +5,8 @@ import './globals.css'
|
||||
import { ThemeInitScript } from '@/components/theme-init-script'
|
||||
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||
import { QueryProvider } from '@/lib/api/query-provider'
|
||||
import { AuthProvider } from '@/components/auth/auth-provider'
|
||||
import { MailToaster } from '@/components/gmail/mail-toaster'
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
@ -34,8 +36,11 @@ export default function RootLayout({
|
||||
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
|
||||
<ThemeInitScript />
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
<MailToaster />
|
||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
9
app/login/layout.tsx
Normal file
9
app/login/layout.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { LoginChrome } from "@/components/auth/login-chrome"
|
||||
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <LoginChrome>{children}</LoginChrome>
|
||||
}
|
||||
72
app/login/page.tsx
Normal file
72
app/login/page.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card"
|
||||
import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
import { getAuthentikEnrollmentUrl } from "@/lib/auth/oidc-config"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const LOGIN_CARD_CLASS = cn(
|
||||
"w-full gap-4 border-0 bg-transparent px-4 py-6 shadow-none",
|
||||
"sm:gap-5 sm:bg-card sm:px-8 sm:py-8 sm:text-card-foreground sm:shadow-none"
|
||||
)
|
||||
|
||||
function LoginContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const error = searchParams.get("error")
|
||||
const returnTo = searchParams.get("returnTo") ?? "/mail/inbox"
|
||||
const loginHref = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
const signupHref = getAuthentikEnrollmentUrl()
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="ultimail-login-card-frame mx-auto w-full max-w-sm">
|
||||
<Card className={LOGIN_CARD_CLASS}>
|
||||
<CardHeader className="gap-4 px-0 text-center sm:px-0">
|
||||
<UltiMailLogo variant="stacked" href={null} />
|
||||
<CardDescription>
|
||||
Connecte-toi avec ton compte Ulti (Authentik) pour accéder à la
|
||||
messagerie.
|
||||
</CardDescription>
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{decodeURIComponent(error)}
|
||||
</p>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex justify-center px-0 sm:px-0">
|
||||
<Button asChild size="lg" className="w-full sm:w-auto">
|
||||
<a href={loginHref}>Se connecter</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="px-0 sm:px-0">
|
||||
<p className="w-full text-center text-sm text-muted-foreground">
|
||||
Pas encore de compte ?{" "}
|
||||
<a className="font-medium text-primary underline" href={signupHref}>
|
||||
Créer un compte
|
||||
</a>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@ -14,8 +14,7 @@ import { useMailRoute } from "@/hooks/use-mail-route"
|
||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||
import { MailToaster } from "@/components/gmail/mail-toaster"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useRouter, usePathname } from "next/navigation"
|
||||
import { Sidebar } from "@/components/gmail/sidebar"
|
||||
import { Header } from "@/components/gmail/header"
|
||||
import { EmailList } from "@/components/gmail/email-list"
|
||||
@ -35,6 +34,19 @@ import { cn } from "@/lib/utils"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
|
||||
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
|
||||
import { MailSettingsSync } from "@/components/gmail/mail-settings-sync"
|
||||
import { MailNavSync } from "@/components/gmail/mail-nav-sync"
|
||||
import { ComposeIdentitiesSync } from "@/components/gmail/compose-identities-sync"
|
||||
import { MailSignaturesSync } from "@/components/gmail/mail-signatures-sync"
|
||||
import { MailNotificationsBridge } from "@/components/gmail/mail-notifications-bridge"
|
||||
import { useWebSocket } from "@/lib/api/ws"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
|
||||
const MAIL_SETTINGS_PATH = "/mail/settings"
|
||||
|
||||
function isMailSettingsPath(pathname: string | null): boolean {
|
||||
return pathname === MAIL_SETTINGS_PATH || pathname?.startsWith(`${MAIL_SETTINGS_PATH}/`) === true
|
||||
}
|
||||
|
||||
function MailAppInner() {
|
||||
const router = useRouter()
|
||||
@ -71,6 +83,7 @@ function MailAppInner() {
|
||||
|
||||
const handleSelectFolder = useCallback(
|
||||
(id: string) => {
|
||||
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||
navigateRoute({
|
||||
folderId: id,
|
||||
inboxTab: DEFAULT_INBOX_TAB,
|
||||
@ -85,14 +98,15 @@ function MailAppInner() {
|
||||
return (
|
||||
<SidebarNavProvider
|
||||
routeFolderId={route.folderId}
|
||||
onRouteFolderIdChange={(nextFolderId) =>
|
||||
onRouteFolderIdChange={(nextFolderId) => {
|
||||
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||
navigateRoute({
|
||||
folderId: nextFolderId,
|
||||
inboxTab: DEFAULT_INBOX_TAB,
|
||||
page: 1,
|
||||
mailId: null,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||
{!splitView ? (
|
||||
@ -192,10 +206,20 @@ function MailAppInner() {
|
||||
}
|
||||
|
||||
export function MailAppShell({
|
||||
children: _routeOutlet,
|
||||
children: routeOutlet,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const showSettingsPage = isMailSettingsPath(pathname)
|
||||
useWebSocket()
|
||||
|
||||
useEffect(() => {
|
||||
if (showSettingsPage) {
|
||||
useMailSettingsStore.getState().setQuickSettingsOpen(false)
|
||||
}
|
||||
}, [showSettingsPage])
|
||||
|
||||
useEffect(() => {
|
||||
const blockPinch = (event: Event) => event.preventDefault()
|
||||
document.addEventListener("gesturestart", blockPinch, { passive: false })
|
||||
@ -221,13 +245,21 @@ export function MailAppShell({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{showSettingsPage ? (
|
||||
<SidebarNavProvider>{routeOutlet}</SidebarNavProvider>
|
||||
) : (
|
||||
<MailAppInner />
|
||||
)}
|
||||
</Suspense>
|
||||
<MailThemeApplier />
|
||||
<MailSettingsSync />
|
||||
<MailNavSync />
|
||||
<ComposeIdentitiesSync />
|
||||
<MailSignaturesSync />
|
||||
<MailNotificationsBridge />
|
||||
<QuickSettingsRoot />
|
||||
<MoveDragIndicator />
|
||||
<ComposeModalManager />
|
||||
<MailToaster />
|
||||
</EmailDragProvider>
|
||||
</ScheduledMailProvider>
|
||||
</ComposeProvider>
|
||||
|
||||
10
app/mail/settings/[[...section]]/page.tsx
Normal file
10
app/mail/settings/[[...section]]/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { MailSettingsSectionFromSegments } from "@/components/gmail/settings/mail-settings-section-view"
|
||||
|
||||
export default async function MailSettingsSectionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ section?: string[] }>
|
||||
}) {
|
||||
const { section } = await params
|
||||
return <MailSettingsSectionFromSegments segments={section} />
|
||||
}
|
||||
9
app/mail/settings/layout.tsx
Normal file
9
app/mail/settings/layout.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { MailSettingsLayout } from "@/components/gmail/settings/mail-settings-layout"
|
||||
|
||||
export default function MailSettingsRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <MailSettingsLayout>{children}</MailSettingsLayout>
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
export default function MailSettingsPage() {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<h1 className="text-xl font-medium text-[#3c4043]">Paramètres</h1>
|
||||
<p className="text-sm text-[#5f6368]">
|
||||
Page en cours de construction.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
components/auth/auth-provider.tsx
Normal file
166
components/auth/auth-provider.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import { isOidcConfigured } from "@/lib/auth/oidc-config"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
const PUBLIC_PREFIXES = ["/login", "/auth/", "/api/auth/"]
|
||||
const REFRESH_LEAD_MS = 5 * 60 * 1000
|
||||
const REFRESH_CHECK_MS = 60 * 1000
|
||||
|
||||
function isPublicPath(pathname: string) {
|
||||
return PUBLIC_PREFIXES.some(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(prefix)
|
||||
)
|
||||
}
|
||||
|
||||
type SessionPayload = {
|
||||
authenticated?: boolean
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: PlatformUser | null
|
||||
}
|
||||
|
||||
async function fetchSession(): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function canTrustPersistedAuth() {
|
||||
return useAuthStore.persist.hasHydrated() && useAuthStore.getState().isAuthenticated()
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const [ready, setReady] = useState(
|
||||
() => !isOidcConfigured() || canTrustPersistedAuth()
|
||||
)
|
||||
|
||||
const applySession = useCallback(
|
||||
(data: SessionPayload) => {
|
||||
if (data.authenticated && data.accessToken && data.expiresAt) {
|
||||
login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
data.user ?? null
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[login]
|
||||
)
|
||||
|
||||
const syncSession = useCallback(async () => {
|
||||
const data = await fetchSession()
|
||||
if (data && applySession(data)) return true
|
||||
logout()
|
||||
return false
|
||||
}, [applySession, logout])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function bootstrap() {
|
||||
if (!isOidcConfigured()) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (canTrustPersistedAuth()) {
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
const data = await fetchSession()
|
||||
if (cancelled) return
|
||||
|
||||
if (data && applySession(data)) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.authenticated === false || !canTrustPersistedAuth()) {
|
||||
logout()
|
||||
}
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
if (!useAuthStore.persist.hasHydrated()) {
|
||||
const unsubHydrate = useAuthStore.persist.onFinishHydration(() => {
|
||||
if (useAuthStore.getState().isAuthenticated()) {
|
||||
setReady(true)
|
||||
}
|
||||
})
|
||||
void bootstrap()
|
||||
return () => {
|
||||
cancelled = true
|
||||
unsubHydrate()
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applySession, logout])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !isOidcConfigured()) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const { accessToken, expiresAt } = useAuthStore.getState()
|
||||
if (!accessToken || !expiresAt) return
|
||||
if (Date.now() >= expiresAt - REFRESH_LEAD_MS) {
|
||||
void syncSession()
|
||||
}
|
||||
}, REFRESH_CHECK_MS)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [ready, syncSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !isOidcConfigured()) return
|
||||
if (isPublicPath(pathname)) return
|
||||
if (isAuthenticated()) return
|
||||
|
||||
let cancelled = false
|
||||
void syncSession().then((ok) => {
|
||||
if (cancelled || ok) return
|
||||
const returnTo = encodeURIComponent(pathname)
|
||||
router.replace(`/login?returnTo=${returnTo}`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ready, pathname, isAuthenticated, router, syncSession])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function useAuthLogout() {
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const router = useRouter()
|
||||
|
||||
return async () => {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" })
|
||||
logout()
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("ultimail-auth")
|
||||
}
|
||||
router.replace("/login")
|
||||
}
|
||||
}
|
||||
70
components/auth/login-chrome.tsx
Normal file
70
components/auth/login-chrome.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { mailBackgroundStyle } from "@/lib/mail-settings/constants"
|
||||
|
||||
type HtmlBgState = {
|
||||
mailBackground?: string
|
||||
layer: string
|
||||
fallback: string
|
||||
}
|
||||
|
||||
function readHtmlBgState(): HtmlBgState {
|
||||
const html = document.documentElement
|
||||
return {
|
||||
mailBackground: html.dataset.mailBackground,
|
||||
layer: html.style.getPropertyValue("--mail-bg-layer"),
|
||||
fallback: html.style.getPropertyValue("--mail-bg-fallback"),
|
||||
}
|
||||
}
|
||||
|
||||
function applyHtmlBgState(state: HtmlBgState) {
|
||||
const html = document.documentElement
|
||||
if (state.mailBackground) {
|
||||
html.dataset.mailBackground = state.mailBackground
|
||||
} else {
|
||||
delete html.dataset.mailBackground
|
||||
}
|
||||
if (state.layer) {
|
||||
html.style.setProperty("--mail-bg-layer", state.layer)
|
||||
} else {
|
||||
html.style.removeProperty("--mail-bg-layer")
|
||||
}
|
||||
if (state.fallback) {
|
||||
html.style.setProperty("--mail-bg-fallback", state.fallback)
|
||||
} else {
|
||||
html.style.removeProperty("--mail-bg-fallback")
|
||||
}
|
||||
}
|
||||
|
||||
function clearHtmlBg() {
|
||||
const html = document.documentElement
|
||||
delete html.dataset.mailBackground
|
||||
html.style.removeProperty("--mail-bg-layer")
|
||||
html.style.removeProperty("--mail-bg-fallback")
|
||||
}
|
||||
|
||||
/** Login shell: fixed Aurore bg (sm+), no user mail background, canvas on xs. */
|
||||
export function LoginChrome({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const saved = readHtmlBgState()
|
||||
clearHtmlBg()
|
||||
return () => applyHtmlBgState(saved)
|
||||
}, [])
|
||||
|
||||
const aurora = mailBackgroundStyle("gradient-aurora")
|
||||
|
||||
return (
|
||||
<div className="ultimail-login relative flex min-h-dvh flex-col bg-app-canvas sm:bg-transparent">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 -z-10 hidden sm:block"
|
||||
style={{
|
||||
background: aurora.background,
|
||||
backgroundColor: aurora.fallbackColor,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { UltiMailLogo } from "@/components/ultimail-logo"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
|
||||
const SPLASH_VISIBLE_MS = 1450
|
||||
const SPLASH_VISIBLE_MS = 1750
|
||||
const SPLASH_EXIT_MS = 500
|
||||
|
||||
export function FirstLaunchSplash({
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, type RefObject } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { ApiMailAccount } from "@/lib/api/types"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
import { buildOidcLoginUrl } from "@/lib/auth/login-url"
|
||||
import {
|
||||
useAccountStore,
|
||||
useActiveAccount,
|
||||
useSignOutAll,
|
||||
} from "@/lib/stores/account-store"
|
||||
|
||||
@ -48,8 +50,8 @@ export function AccountSwitcherDropdown({
|
||||
containerRef,
|
||||
}: AccountSwitcherDropdownProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const activeAccount = useActiveAccount()
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId)
|
||||
const pathname = usePathname()
|
||||
const identity = useChromeIdentity()
|
||||
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
|
||||
const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
|
||||
const toggleOtherAccountsExpanded = useAccountStore(
|
||||
@ -58,9 +60,14 @@ export function AccountSwitcherDropdown({
|
||||
const signOutAll = useSignOutAll()
|
||||
|
||||
const { data: accounts } = useMailAccounts()
|
||||
const otherAccounts = (accounts ?? []).filter((a) => a.id !== activeAccountId)
|
||||
const mailAccounts = accounts ?? []
|
||||
const hasMultipleMailAccounts = mailAccounts.length > 1
|
||||
|
||||
const firstName = activeAccount?.name.split(" ")[0] ?? ""
|
||||
const firstName = identity?.firstName ?? ""
|
||||
const addAccountHref = buildOidcLoginUrl({
|
||||
returnTo: pathname || "/mail/inbox",
|
||||
intent: "add_account",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@ -83,13 +90,18 @@ export function AccountSwitcherDropdown({
|
||||
}
|
||||
}, [open, onOpenChange, containerRef])
|
||||
|
||||
if (!open || !activeAccount) return null
|
||||
if (!open || !identity) return null
|
||||
|
||||
const handleSelectAccount = (id: string) => {
|
||||
setActiveAccountId(id)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const handleSignOut = () => {
|
||||
void signOutAll()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
@ -99,7 +111,7 @@ export function AccountSwitcherDropdown({
|
||||
>
|
||||
<div className="relative px-4 pb-3 pt-4">
|
||||
<p className="truncate pr-8 text-center text-sm text-foreground">
|
||||
{activeAccount.email}
|
||||
{identity.email}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
@ -114,7 +126,10 @@ export function AccountSwitcherDropdown({
|
||||
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<div className="relative">
|
||||
<AccountAvatar account={activeAccount} size="lg" />
|
||||
<AccountAvatar
|
||||
account={{ name: identity.name, email: identity.email }}
|
||||
size="lg"
|
||||
/>
|
||||
<span className="absolute bottom-0 right-0 flex size-7 items-center justify-center rounded-full border-2 border-border bg-mail-surface text-muted-foreground shadow-sm">
|
||||
<Camera className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
@ -133,6 +148,7 @@ export function AccountSwitcherDropdown({
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3">
|
||||
{hasMultipleMailAccounts ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
|
||||
<button
|
||||
type="button"
|
||||
@ -141,8 +157,8 @@ export function AccountSwitcherDropdown({
|
||||
>
|
||||
<span>
|
||||
{otherAccountsExpanded
|
||||
? "Masquer plus de comptes"
|
||||
: "Afficher plus de comptes"}
|
||||
? "Masquer les comptes mail"
|
||||
: "Changer de compte mail"}
|
||||
</span>
|
||||
{otherAccountsExpanded ? (
|
||||
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
|
||||
@ -153,7 +169,7 @@ export function AccountSwitcherDropdown({
|
||||
|
||||
{otherAccountsExpanded && (
|
||||
<div className="border-t border-border px-1 pb-1 pt-0.5">
|
||||
{otherAccounts.map((account) => (
|
||||
{mailAccounts.map((account) => (
|
||||
<AccountRow
|
||||
key={account.id}
|
||||
account={account}
|
||||
@ -162,29 +178,36 @@ export function AccountSwitcherDropdown({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-border px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className={
|
||||
hasMultipleMailAccounts
|
||||
? "mt-3 overflow-hidden rounded-2xl border border-border bg-mail-surface"
|
||||
: "overflow-hidden rounded-2xl border border-border bg-mail-surface"
|
||||
}
|
||||
>
|
||||
<div className="px-1 py-1">
|
||||
<a
|
||||
href={addAccountHref}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex size-8 items-center justify-center">
|
||||
<Plus className="size-5 text-primary" aria-hidden />
|
||||
</span>
|
||||
Ajouter un compte
|
||||
</button>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOutAll()
|
||||
onOpenChange(false)
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
<span className="flex size-8 items-center justify-center">
|
||||
<LogOut className="size-5 text-muted-foreground" aria-hidden />
|
||||
</span>
|
||||
Se déconnecter de tous les comptes
|
||||
Se déconnecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
85
components/gmail/compose-identities-sync.tsx
Normal file
85
components/gmail/compose-identities-sync.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import type { ApiIdentity } from "@/lib/api/types"
|
||||
import { useMailSignatures } from "@/lib/api/hooks/use-mail-signatures"
|
||||
import { apiIdentityToCompose } from "@/lib/compose/identity-map"
|
||||
import type { Identity } from "@/lib/compose-context"
|
||||
import { useComposeIdentitiesStore } from "@/lib/stores/compose-identities-store"
|
||||
|
||||
async function fetchIdentities(accountId: string) {
|
||||
const res = await apiClient.get<ApiIdentity[] | { identities: ApiIdentity[] }>(
|
||||
`/mail/accounts/${accountId}/identities`
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.identities ?? [])
|
||||
}
|
||||
|
||||
/** Hydrate compose From identities from server for all mail accounts. */
|
||||
export function ComposeIdentitiesSync() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: accounts = [], isSuccess: accountsReady } = useMailAccounts()
|
||||
const { data: signatures = [], isSuccess: signaturesReady } = useMailSignatures()
|
||||
|
||||
const signaturesById = useMemo(
|
||||
() => new Map(signatures.map((s) => [s.id, s])),
|
||||
[signatures]
|
||||
)
|
||||
|
||||
const identityQueries = useQueries({
|
||||
queries: accounts.map((account) => ({
|
||||
queryKey: ["identities", account.id],
|
||||
queryFn: () => fetchIdentities(account.id),
|
||||
enabled: ready && authenticated && !!account.id,
|
||||
staleTime: 5 * 60_000,
|
||||
})),
|
||||
})
|
||||
|
||||
const mergedKey = identityQueries.map((q) => q.dataUpdatedAt).join("|")
|
||||
const merged = useMemo(() => {
|
||||
if (!ready || !authenticated || !accountsReady || !signaturesReady) return [] as Identity[]
|
||||
if (accounts.length === 0) return [] as Identity[]
|
||||
if (identityQueries.some((q) => q.isPending && q.fetchStatus !== "idle")) {
|
||||
return null
|
||||
}
|
||||
return identityQueries.flatMap((q) =>
|
||||
(q.data ?? []).map((id) => apiIdentityToCompose(id, signaturesById))
|
||||
)
|
||||
}, [
|
||||
ready,
|
||||
authenticated,
|
||||
accountsReady,
|
||||
signaturesReady,
|
||||
accounts.length,
|
||||
mergedKey,
|
||||
identityQueries,
|
||||
signaturesById,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !authenticated) {
|
||||
useComposeIdentitiesStore.getState().clear()
|
||||
return
|
||||
}
|
||||
if (merged === null) return
|
||||
useComposeIdentitiesStore.getState().hydrateFromApi(merged)
|
||||
}, [ready, authenticated, merged])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function useComposeIdentities(accountId?: string | null) {
|
||||
const identities = useComposeIdentitiesStore((s) => s.identities)
|
||||
const hydrated = useComposeIdentitiesStore((s) => s.hydrated)
|
||||
const scoped = accountId
|
||||
? identities.filter((i) => i.accountId === accountId)
|
||||
: identities
|
||||
const list = scoped.length > 0 ? scoped : identities
|
||||
const defaultIdentity =
|
||||
list.find((i) => i.isDefault) ?? list[0] ?? null
|
||||
|
||||
return { identities: list, defaultIdentity, hydrated }
|
||||
}
|
||||
@ -18,7 +18,6 @@ import {
|
||||
} from "lucide-react"
|
||||
import {
|
||||
type ComposeState,
|
||||
SIGNATURES,
|
||||
useComposeActions,
|
||||
} from "@/lib/compose-context"
|
||||
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
||||
@ -45,6 +44,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useMailSignaturesStore } from "@/lib/stores/mail-signatures-store"
|
||||
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
||||
import { ComposeEmojiButton } from "./compose-emoji-picker"
|
||||
|
||||
@ -236,6 +236,7 @@ export function ComposeSignatureButton({
|
||||
editor: Editor | null
|
||||
compose: ComposeState
|
||||
}) {
|
||||
const signatures = useMailSignaturesStore((s) => s.signatures)
|
||||
const { updateCompose } = useComposeActions()
|
||||
|
||||
const replaceSignature = useCallback(
|
||||
@ -299,7 +300,7 @@ export function ComposeSignatureButton({
|
||||
</span>
|
||||
Aucune signature
|
||||
</DropdownMenuItem>
|
||||
{SIGNATURES.map((sig) => (
|
||||
{signatures.map((sig) => (
|
||||
<DropdownMenuItem
|
||||
key={sig.id}
|
||||
onSelect={() => replaceSignature(sig.id)}
|
||||
|
||||
@ -24,7 +24,7 @@ const LazyPicker = lazy(() => import("@emoji-mart/react"))
|
||||
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
return (
|
||||
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement…</div>}>
|
||||
<Suspense fallback={<div className="h-[435px] w-[352px]" aria-hidden />}>
|
||||
<LazyPicker
|
||||
data={data}
|
||||
onEmojiSelect={onSelect}
|
||||
|
||||
@ -12,7 +12,7 @@ import { ChevronDown, X } from "lucide-react"
|
||||
import {
|
||||
type ComposeState,
|
||||
type Contact,
|
||||
DEFAULT_IDENTITIES,
|
||||
type Identity,
|
||||
MOCK_CONTACTS,
|
||||
} from "@/lib/compose-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -266,8 +266,9 @@ export interface ComposeRecipientFieldsProps {
|
||||
compose: ComposeState
|
||||
isInline: boolean
|
||||
showFromField: boolean
|
||||
identities?: Identity[]
|
||||
updateCompose: (id: string, patch: Partial<ComposeState>) => void
|
||||
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
|
||||
handleIdentityChange: (identity: Identity) => void
|
||||
clearFocusToMount: () => void
|
||||
subjectInputRef: RefObject<HTMLInputElement | null>
|
||||
onRecipientsActivate: () => void
|
||||
@ -277,6 +278,7 @@ export function ComposeRecipientFields({
|
||||
compose,
|
||||
isInline,
|
||||
showFromField,
|
||||
identities = [],
|
||||
updateCompose,
|
||||
handleIdentityChange,
|
||||
clearFocusToMount,
|
||||
@ -307,9 +309,16 @@ export function ComposeRecipientFields({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
|
||||
{DEFAULT_IDENTITIES.map((id) => (
|
||||
{identities.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Aucune identité d'envoi — ajoutez un compte mail dans les réglages.
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
identities.map((id) => (
|
||||
<DropdownMenuItem
|
||||
key={id.email}
|
||||
key={id.id ?? id.email}
|
||||
onSelect={() => handleIdentityChange(id)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
@ -317,7 +326,8 @@ export function ComposeRecipientFields({
|
||||
<span className="text-xs text-muted-foreground">{id.email}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Node as TipTapNode, mergeAttributes } from "@tiptap/core"
|
||||
import { SIGNATURES } from "@/lib/compose-context"
|
||||
import { getSignatureHtmlById } from "@/lib/stores/mail-signatures-store"
|
||||
|
||||
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
|
||||
export const COMPOSE_PORTAL_Z = "z-[100]"
|
||||
@ -28,9 +28,18 @@ export function stripSignature(html: string) {
|
||||
return html.replace(SIG_REGEX, "")
|
||||
}
|
||||
|
||||
export function insertSignatureHtml(html: string, sigId: string | null) {
|
||||
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
|
||||
/** Accepts signature library id or raw HTML. */
|
||||
export function insertSignatureHtml(html: string, sigIdOrHtml: string | null) {
|
||||
const clean = stripSignature(html)
|
||||
if (!sig) return clean
|
||||
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
|
||||
if (!sigIdOrHtml) return clean
|
||||
|
||||
const fromLibrary = getSignatureHtmlById(sigIdOrHtml)
|
||||
const sigHtml = fromLibrary ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null)
|
||||
if (!sigHtml?.trim()) return clean
|
||||
return `${clean}<div id="ultimail-signature"><p>--</p>${sigHtml}</div>`
|
||||
}
|
||||
|
||||
export function resolveSignatureContent(sigIdOrHtml: string | null): string | null {
|
||||
if (!sigIdOrHtml) return null
|
||||
return getSignatureHtmlById(sigIdOrHtml) ?? (sigIdOrHtml.trimStart().startsWith("<") ? sigIdOrHtml : null)
|
||||
}
|
||||
|
||||
@ -24,9 +24,11 @@ import {
|
||||
import {
|
||||
type ComposeState,
|
||||
cloneComposeForPendingSend,
|
||||
DEFAULT_IDENTITIES,
|
||||
type Identity,
|
||||
useComposeActions,
|
||||
} from "@/lib/compose-context"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useComposeIdentities } from "@/components/gmail/compose-identities-sync"
|
||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
@ -55,6 +57,8 @@ export function useComposeWindow(
|
||||
} = useComposeActions()
|
||||
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
|
||||
useScheduledMail()
|
||||
const activeAccount = useActiveAccount()
|
||||
const { identities: composeIdentities } = useComposeIdentities(activeAccount?.id)
|
||||
const isInline = compose.placement === "inline"
|
||||
const isEditingScheduled = compose.editingScheduledId != null
|
||||
const [showFormatting, setShowFormatting] = useState(false)
|
||||
@ -131,12 +135,18 @@ export function useComposeWindow(
|
||||
bodyWithoutSig !== ""
|
||||
|
||||
const handleIdentityChange = useCallback(
|
||||
(identity: (typeof DEFAULT_IDENTITIES)[number]) => {
|
||||
(identity: Identity) => {
|
||||
const sigSource =
|
||||
identity.signatureHtml ??
|
||||
(identity.defaultSignatureId ? identity.defaultSignatureId : null)
|
||||
if (compose.autoInsertSignature && editor) {
|
||||
const sigId = identity.defaultSignatureId
|
||||
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
|
||||
const newHtml = insertSignatureHtml(editor.getHTML(), sigSource)
|
||||
editor.commands.setContent(newHtml)
|
||||
updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId })
|
||||
updateCompose(compose.id, {
|
||||
from: identity,
|
||||
bodyHtml: newHtml,
|
||||
signatureId: identity.defaultSignatureId,
|
||||
})
|
||||
} else {
|
||||
updateCompose(compose.id, { from: identity })
|
||||
}
|
||||
@ -497,6 +507,7 @@ export function useComposeWindow(
|
||||
compose,
|
||||
isInline,
|
||||
showFromField,
|
||||
identities: composeIdentities,
|
||||
updateCompose,
|
||||
handleIdentityChange,
|
||||
clearFocusToMount,
|
||||
|
||||
@ -149,7 +149,7 @@ export function ContactHoverCard({
|
||||
role="presentation"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"inline min-w-0 max-w-full cursor-default text-inherit outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm",
|
||||
"inline-block min-w-0 max-w-full cursor-default text-inherit align-middle outline-none focus-visible:ring-2 focus-visible:ring-[#1a73e8]/30 focus-visible:ring-offset-1 rounded-sm",
|
||||
longPress.ackClassName,
|
||||
className
|
||||
)}
|
||||
|
||||
@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
|
||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import {
|
||||
CONTACTS_MUTED_TEXT,
|
||||
@ -28,6 +29,7 @@ interface BulkCreateDialogProps {
|
||||
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const createContactMutation = useCreateContact()
|
||||
const { bookId } = useContactsList()
|
||||
|
||||
function handleCreate() {
|
||||
const parsed = parseBulkContactText(input)
|
||||
@ -45,7 +47,7 @@ export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreat
|
||||
phones: partial.phones ?? [],
|
||||
}
|
||||
createContactMutation.mutate({
|
||||
bookId: "default",
|
||||
bookId,
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
}
|
||||
|
||||
@ -115,7 +115,7 @@ interface ContactCreatePageProps {
|
||||
}
|
||||
|
||||
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
|
||||
const { contacts } = useContactsList()
|
||||
const { contacts, bookId } = useContactsList()
|
||||
const createContactMutation = useCreateContact()
|
||||
const updateContactMutation = useUpdateContact()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
@ -225,10 +225,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
createContactMutation.mutate(
|
||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
||||
{ onSuccess: (created) => onSaved(created?.uid ?? tempId) },
|
||||
{ bookId, contact: fullContactToApiContact(fullContact) },
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
onSaved(created?.uid ?? tempId)
|
||||
},
|
||||
},
|
||||
)
|
||||
onSaved(tempId)
|
||||
} else if (contactId) {
|
||||
const fullContact: FullContact = {
|
||||
id: contactId,
|
||||
|
||||
@ -12,6 +12,7 @@ import { Info } from "lucide-react"
|
||||
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import type { FullContact } from "@/lib/contacts/types"
|
||||
import {
|
||||
CONTACTS_HEADING_TEXT,
|
||||
@ -30,6 +31,7 @@ interface ImportDialogProps {
|
||||
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const createContactMutation = useCreateContact()
|
||||
const { bookId } = useContactsList()
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||
const [previewCount, setPreviewCount] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -94,7 +96,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||
phones: partial.phones ?? [],
|
||||
}
|
||||
createContactMutation.mutate({
|
||||
bookId: "default",
|
||||
bookId,
|
||||
contact: fullContactToApiContact(fullContact),
|
||||
})
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@ const REASON_LABELS: Record<DuplicateMatchReason, string> = {
|
||||
|
||||
export function MergeDuplicatesView() {
|
||||
const [subView, setSubView] = useState<SubView>("merge")
|
||||
const { contacts } = useContactsList()
|
||||
const { contacts, bookId } = useContactsList()
|
||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
||||
const mergeDuplicatesMutation = useMergeDuplicates()
|
||||
@ -46,7 +46,7 @@ export function MergeDuplicatesView() {
|
||||
const [mergingAll, setMergingAll] = useState(false)
|
||||
|
||||
function handleMerge(_suggestion: MergeSuggestion) {
|
||||
mergeDuplicatesMutation.mutate({ bookId: "default" })
|
||||
mergeDuplicatesMutation.mutate({ bookId })
|
||||
}
|
||||
|
||||
function handleIgnore(suggestion: MergeSuggestion) {
|
||||
@ -56,7 +56,7 @@ export function MergeDuplicatesView() {
|
||||
function handleMergeAll() {
|
||||
setMergingAll(true)
|
||||
mergeDuplicatesMutation.mutate(
|
||||
{ bookId: "default" },
|
||||
{ bookId },
|
||||
{ onSettled: () => setMergingAll(false) },
|
||||
)
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ import {
|
||||
CONTACTS_HEADING_TEXT,
|
||||
CONTACTS_MUTED_TEXT,
|
||||
CONTACTS_PANEL_DIVIDER_CLASS,
|
||||
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
|
||||
CONTACTS_PANEL_HEADER_CLASS,
|
||||
CONTACTS_PANEL_ICON_BTN_CLASS,
|
||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
|
||||
@ -105,8 +105,8 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
|
||||
{/* Header */}
|
||||
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
|
||||
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
|
||||
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
||||
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -54,7 +54,7 @@ import {
|
||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
||||
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
||||
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
|
||||
CONTACTS_PANEL_HEADER_CLASS,
|
||||
CONTACTS_PANEL_ICON_BTN_CLASS,
|
||||
CONTACTS_PANEL_LINK_TEXT_CLASS,
|
||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||
@ -136,7 +136,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
createDraft,
|
||||
clearCreateDraft,
|
||||
} = useContactsStore()
|
||||
const { contacts } = useContactsList()
|
||||
const { contacts, bookId } = useContactsList()
|
||||
const createContactMutation = useCreateContact()
|
||||
const updateContactMutation = useUpdateContact()
|
||||
const labelRows = useNavStore((s) => s.labelRows)
|
||||
@ -324,10 +324,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
createContactMutation.mutate(
|
||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
||||
{ onSuccess: (created) => setView("view", created?.uid ?? tempId) },
|
||||
{ bookId, contact: fullContactToApiContact(fullContact) },
|
||||
{
|
||||
onSuccess: (created) => {
|
||||
const id = created?.uid ?? tempId
|
||||
setView("view", id)
|
||||
},
|
||||
},
|
||||
)
|
||||
setView("view", tempId)
|
||||
} else if (contactId) {
|
||||
const fullContact: FullContact = {
|
||||
id: contactId,
|
||||
@ -354,8 +358,8 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
|
||||
>
|
||||
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
|
||||
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
|
||||
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
||||
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
||||
@ -84,7 +84,7 @@ export function ContactsListView() {
|
||||
return (
|
||||
<div className={CONTACTS_PANEL_SHELL_CLASS}>
|
||||
<div className={cn(CONTACTS_PANEL_HEADER_SEARCH_CLASS, "gap-2")}>
|
||||
<ContactsPanelLogo onClick={exitSearch} compact className="-ml-1 shrink-0" />
|
||||
<ContactsPanelLogo onClick={exitSearch} className="-ml-1 shrink-0" />
|
||||
<Search className={`h-4 w-4 shrink-0 ${CONTACTS_PANEL_MUTED_ICON_CLASS}`} />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
|
||||
@ -10,15 +10,9 @@ import {
|
||||
type ContactsPanelLogoProps = {
|
||||
onClick: () => void
|
||||
className?: string
|
||||
/** Titre plus compact (barre détail / formulaire). */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function ContactsPanelLogo({
|
||||
onClick,
|
||||
className,
|
||||
compact = false,
|
||||
}: ContactsPanelLogoProps) {
|
||||
export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@ -29,16 +23,8 @@ export function ContactsPanelLogo({
|
||||
)}
|
||||
aria-label="Liste des contacts"
|
||||
>
|
||||
<Users
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
compact ? "h-5 w-5" : "h-6 w-6",
|
||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||
)}
|
||||
/>
|
||||
<span className={cn(CONTACTS_PANEL_TITLE_CLASS, compact && "text-base")}>
|
||||
Contacts
|
||||
</span>
|
||||
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
|
||||
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||
@ -222,7 +221,6 @@ export function EmailListBody({
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<>
|
||||
{selectedFolder === "scheduled" && <EmailListScheduledBanner />}
|
||||
{displayListEmails.length === 0 ? (
|
||||
@ -251,7 +249,6 @@ export function EmailListBody({
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
|
||||
export const LIST_PAGE_SIZE = 50
|
||||
export { LIST_PAGE_SIZE } from "@/lib/mail-list-page-size"
|
||||
|
||||
export {
|
||||
PULL_HOLD_HEIGHT,
|
||||
|
||||
@ -8,6 +8,7 @@ import { EmailListToolbar } from "@/components/gmail/email-list/email-list-toolb
|
||||
import { EmailListBody } from "@/components/gmail/email-list/email-list-body"
|
||||
import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane"
|
||||
import { EmailListEmpty } from "@/components/gmail/email-list/email-list-empty"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
|
||||
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
|
||||
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
|
||||
@ -102,6 +103,11 @@ export function EmailListLayout({
|
||||
openMobileXsLabelSheet,
|
||||
listPage: data.listPage,
|
||||
totalPages: data.totalPages,
|
||||
paginationTotal: data.paginationTotal,
|
||||
listPageSize: data.listPageSize,
|
||||
paginationRangeStart: data.paginationRangeStart,
|
||||
paginationRangeEnd: data.paginationRangeEnd,
|
||||
onListPageSizeChange: data.handleListPageSizeChange,
|
||||
openMailIndex: reading.openMailIndex,
|
||||
goListPrevPage: reading.goListPrevPage,
|
||||
goListNextPage: reading.goListNextPage,
|
||||
@ -133,6 +139,7 @@ export function EmailListLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||||
<EmailListToolbar {...toolbarProps} part="mobile" />
|
||||
{!isViewMode && touchNav && (
|
||||
@ -206,6 +213,7 @@ export function EmailListLayout({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -688,28 +688,33 @@ function EmailListRowInner(props: EmailListRowProps) {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-44 shrink-0 truncate pl-2 lg:w-40",
|
||||
"flex w-44 shrink-0 items-center gap-1 pl-2 lg:w-40",
|
||||
listRowPadTop,
|
||||
isCompactListRow &&
|
||||
"flex min-h-7 items-center leading-tight"
|
||||
isCompactListRow && "min-h-7 leading-tight"
|
||||
)}
|
||||
data-selectable-text
|
||||
>
|
||||
{isScheduled ? (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
"min-w-0 truncate text-sm leading-5",
|
||||
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
||||
)}
|
||||
>
|
||||
À : {email.scheduledToName ?? email.sender}
|
||||
</span>
|
||||
) : (
|
||||
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
<ContactHoverCard
|
||||
displayName={email.sender}
|
||||
email={senderHoverEmail}
|
||||
className="min-w-0 flex-1 truncate leading-5"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-sm leading-5",
|
||||
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
||||
)}>
|
||||
)}
|
||||
>
|
||||
{showDraftBadge && (
|
||||
<span className="font-medium text-[#d93025]">Brouillon </span>
|
||||
)}
|
||||
@ -718,7 +723,7 @@ function EmailListRowInner(props: EmailListRowProps) {
|
||||
</ContactHoverCard>
|
||||
)}
|
||||
{threadMessageCount > 1 && (
|
||||
<span className="text-sm text-gray-500 ml-1">
|
||||
<span className="shrink-0 text-sm leading-5 text-gray-500">
|
||||
{threadMessageCount}
|
||||
</span>
|
||||
)}
|
||||
@ -753,13 +758,15 @@ function EmailListRowInner(props: EmailListRowProps) {
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 shrink truncate text-sm",
|
||||
"min-w-0 shrink truncate text-sm leading-5",
|
||||
!isRead ? "font-semibold text-gray-900" : "font-normal text-[#3c4043]"
|
||||
)}
|
||||
>
|
||||
{email.subject}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">{email.preview}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm leading-5 text-gray-500">
|
||||
{email.preview}
|
||||
</span>
|
||||
</div>
|
||||
{showAttachmentPills && (
|
||||
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
|
||||
|
||||
@ -43,7 +43,6 @@ import {
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs"
|
||||
@ -72,7 +71,10 @@ import {
|
||||
inboxTabBadgeDotClass,
|
||||
REFRESH_SPIN_CLASS,
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
import { LIST_PAGE_SIZE } from "@/components/gmail/email-list/email-list-helpers"
|
||||
import {
|
||||
LIST_PAGE_SIZE_OPTIONS,
|
||||
type ListPageSize,
|
||||
} from "@/lib/mail-list-page-size"
|
||||
|
||||
export type EmailListToolbarProps = {
|
||||
isViewMode: boolean
|
||||
@ -123,6 +125,11 @@ export type EmailListToolbarProps = {
|
||||
openMobileXsLabelSheet: () => void
|
||||
listPage: number
|
||||
totalPages: number
|
||||
paginationTotal?: number
|
||||
listPageSize: number
|
||||
paginationRangeStart: number
|
||||
paginationRangeEnd: number
|
||||
onListPageSizeChange: (size: ListPageSize) => void
|
||||
openMailIndex: number
|
||||
goListPrevPage: () => void
|
||||
goListNextPage: () => void
|
||||
@ -205,6 +212,11 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
||||
openMobileXsLabelSheet,
|
||||
listPage,
|
||||
totalPages,
|
||||
paginationTotal,
|
||||
listPageSize,
|
||||
paginationRangeStart,
|
||||
paginationRangeEnd,
|
||||
onListPageSizeChange,
|
||||
openMailIndex,
|
||||
goListPrevPage,
|
||||
goListNextPage,
|
||||
@ -240,7 +252,7 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
||||
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
|
||||
|
||||
const openMailToolbar = (showBack: boolean) => (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<>
|
||||
{showBack ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@ -475,10 +487,15 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
const mailPaginationControls = (mode: "list" | "view") => (
|
||||
const mailPaginationControls = (mode: "list" | "view") => {
|
||||
const totalCount =
|
||||
paginationTotal ??
|
||||
(mode === "view" ? displayListEmails.length : paginationRangeEnd)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
|
||||
@ -492,11 +509,31 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
{openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {displayListEmails.length}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{(listPage - 1) * LIST_PAGE_SIZE + 1}–
|
||||
{Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
|
||||
{displayListEmails.length}
|
||||
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{paginationRangeStart} à{" "}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Choisir le nombre de messages par page"
|
||||
>
|
||||
{paginationRangeEnd}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className={dropdownSurfaceClass}>
|
||||
{LIST_PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onSelect={() => onListPageSizeChange(size)}
|
||||
className={cn(size === listPageSize && "font-medium")}
|
||||
>
|
||||
{size} par page
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>{" "}
|
||||
sur {totalCount}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
@ -553,11 +590,12 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === "reading-pane") {
|
||||
return (
|
||||
<div className="relative z-20 flex shrink-0 min-h-12 items-start gap-2 border-b border-gray-200 py-1.5 pl-2 pr-4">
|
||||
{openMailToolbar(false)}
|
||||
{openMailToolbar(true)}
|
||||
<div className="flex-1" />
|
||||
{mailPaginationControls("view")}
|
||||
</div>
|
||||
@ -783,7 +821,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
</DropdownMenu>
|
||||
|
||||
{showBulkToolbar ? (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-0.5 pl-1">
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip>
|
||||
@ -983,7 +1021,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@ -21,6 +21,7 @@ import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import {
|
||||
isThreadHeadMessage,
|
||||
} from "@/lib/mail-thread"
|
||||
import { repairSnippet } from "@/lib/mail-mime-body"
|
||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
import { useScheduledStore } from "@/lib/stores/scheduled-store"
|
||||
@ -30,7 +31,11 @@ import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
|
||||
import type { MailNavFolderMaps } from "@/lib/mail-folder-filter"
|
||||
import {
|
||||
emailMatchesInboxTab,
|
||||
type MailFolderFilterCtx,
|
||||
type MailNavFolderMaps,
|
||||
} from "@/lib/mail-folder-filter"
|
||||
import {
|
||||
getMailNavFolderLabel,
|
||||
inboxTabDisplayLabel,
|
||||
@ -55,7 +60,6 @@ import {
|
||||
useComposeDrafts,
|
||||
} from "@/lib/compose-context"
|
||||
import {
|
||||
LIST_PAGE_SIZE,
|
||||
type EmailListProps,
|
||||
buildInboxTabBarItems,
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
@ -69,6 +73,15 @@ import { threadStoreId } from "@/lib/mail-settings/list-row-id"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import type { MessageSearchFilter } from "@/lib/api/types"
|
||||
import {
|
||||
mailFlagIsRead,
|
||||
mailFlagIsStarred,
|
||||
mailFlagIsImportant,
|
||||
mailFlagsWithRead,
|
||||
mailFlagsWithStarred,
|
||||
mailFlagsWithImportant,
|
||||
} from "@/lib/mail-flags"
|
||||
import { LIST_PAGE_SIZE, type ListPageSize } from "@/lib/mail-list-page-size"
|
||||
|
||||
function apiMessageToEmail(msg: ApiMessageSummary): Email {
|
||||
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
|
||||
@ -78,11 +91,11 @@ function apiMessageToEmail(msg: ApiMessageSummary): Email {
|
||||
sender,
|
||||
senderEmail,
|
||||
subject: msg.subject,
|
||||
preview: msg.snippet,
|
||||
preview: repairSnippet(msg.snippet) ?? msg.snippet,
|
||||
date: msg.date,
|
||||
read: msg.flags.includes("read"),
|
||||
starred: msg.flags.includes("starred"),
|
||||
important: msg.flags.includes("important"),
|
||||
read: mailFlagIsRead(msg.flags),
|
||||
starred: mailFlagIsStarred(msg.flags),
|
||||
important: mailFlagIsImportant(msg.flags, msg.labels),
|
||||
spam: msg.labels.includes("spam"),
|
||||
hasAttachment: msg.has_attachments,
|
||||
labels: msg.labels,
|
||||
@ -166,15 +179,15 @@ export function useEmailListData({
|
||||
|
||||
const accountId = searchAccount?.id
|
||||
const queryClient = useQueryClient()
|
||||
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
|
||||
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
|
||||
|
||||
const effectiveApiFolder = useMemo(() => {
|
||||
if (isSearchMode) return "__search__"
|
||||
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
|
||||
if (selectedFolder !== "inbox") return selectedFolder
|
||||
const tab = normalizeInboxTabSegment(inboxTab)
|
||||
if (tab === INBOX_ALL_TAB) return "inbox"
|
||||
return tab
|
||||
}, [selectedFolder, inboxTab, isSearchMode])
|
||||
if (selectedFolder === "inbox") return "inbox"
|
||||
return selectedFolder
|
||||
}, [selectedFolder, isSearchMode])
|
||||
|
||||
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
||||
if (!isSearchMode || !searchParams) return null
|
||||
@ -194,7 +207,8 @@ export function useEmailListData({
|
||||
? "inbox"
|
||||
: effectiveApiFolder,
|
||||
accountId,
|
||||
listPage
|
||||
listPage,
|
||||
listPageSize
|
||||
)
|
||||
|
||||
const searchQuery = useMailSearch(searchFilter)
|
||||
@ -366,12 +380,10 @@ export function useEmailListData({
|
||||
for (const [id, isRead] of Object.entries(changes)) {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) continue
|
||||
const flags = [...msg.flags]
|
||||
if (isRead && !flags.includes("read")) {
|
||||
updateFlags.mutate({ id, flags: [...flags, "read"] })
|
||||
} else if (!isRead && flags.includes("read")) {
|
||||
updateFlags.mutate({ id, flags: flags.filter((f) => f !== "read") })
|
||||
}
|
||||
const alreadyRead = mailFlagIsRead(msg.flags)
|
||||
if (isRead === alreadyRead) continue
|
||||
const flags = mailFlagsWithRead(msg.flags, isRead)
|
||||
updateFlags.mutate({ id, flags })
|
||||
}
|
||||
},
|
||||
[apiMessagesById, updateFlags]
|
||||
@ -419,18 +431,14 @@ export function useEmailListData({
|
||||
toggleStar: (id: string) => {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) return
|
||||
const flags = msg.flags.includes("starred")
|
||||
? msg.flags.filter((f) => f !== "starred")
|
||||
: [...msg.flags, "starred"]
|
||||
updateFlags.mutate({ id, flags })
|
||||
const starred = mailFlagIsStarred(msg.flags)
|
||||
updateFlags.mutate({ id, flags: mailFlagsWithStarred(msg.flags, !starred) })
|
||||
},
|
||||
toggleImportant: (id: string) => {
|
||||
const msg = apiMessagesById.get(id)
|
||||
if (!msg) return
|
||||
const flags = msg.flags.includes("important")
|
||||
? msg.flags.filter((f) => f !== "important")
|
||||
: [...msg.flags, "important"]
|
||||
updateFlags.mutate({ id, flags })
|
||||
const important = mailFlagIsImportant(msg.flags, msg.labels)
|
||||
updateFlags.mutate({ id, flags: mailFlagsWithImportant(msg.flags, !important) })
|
||||
},
|
||||
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
|
||||
|
||||
@ -485,9 +493,20 @@ export function useEmailListData({
|
||||
useMailStore.getState().markSeen(id)
|
||||
}, [])
|
||||
|
||||
const folderFilterCtx = useMemo<MailFolderFilterCtx>(
|
||||
() => ({
|
||||
starredEmailIds: starredEmails,
|
||||
importantEmailIds: importantEmails,
|
||||
}),
|
||||
[starredEmails, importantEmails]
|
||||
)
|
||||
|
||||
const filteredEmails = useMemo(() => {
|
||||
return allEmails
|
||||
}, [allEmails])
|
||||
if (selectedFolder !== "inbox") return allEmails
|
||||
return allEmails.filter((e) =>
|
||||
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
|
||||
)
|
||||
}, [allEmails, selectedFolder, inboxTab, folderFilterCtx, navMaps])
|
||||
|
||||
const displayListEmails = useMemo(() => {
|
||||
let rows = filteredEmails
|
||||
@ -547,17 +566,51 @@ export function useEmailListData({
|
||||
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
|
||||
|
||||
const totalPages = useMemo(
|
||||
() => Math.max(1, Math.ceil((paginationTotal ?? displayListEmails.length) / LIST_PAGE_SIZE)),
|
||||
[paginationTotal, displayListEmails.length]
|
||||
() =>
|
||||
Math.max(
|
||||
1,
|
||||
Math.ceil((paginationTotal ?? displayListEmails.length) / listPageSize)
|
||||
),
|
||||
[paginationTotal, displayListEmails.length, listPageSize]
|
||||
)
|
||||
|
||||
const paginationRangeStart = useMemo(() => {
|
||||
if (displayListEmails.length === 0) return 0
|
||||
return (listPage - 1) * listPageSize + 1
|
||||
}, [displayListEmails.length, listPage, listPageSize])
|
||||
|
||||
const paginationRangeEnd = useMemo(() => {
|
||||
if (displayListEmails.length === 0) return 0
|
||||
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
||||
const total = paginationTotal ?? displayListEmails.length
|
||||
return Math.min(listPage * listPageSize, total)
|
||||
}
|
||||
return Math.min(listPage * listPageSize, displayListEmails.length)
|
||||
}, [
|
||||
displayListEmails.length,
|
||||
listPage,
|
||||
listPageSize,
|
||||
paginationTotal,
|
||||
effectiveApiFolder,
|
||||
isSearchMode,
|
||||
])
|
||||
|
||||
const handleListPageSizeChange = useCallback(
|
||||
(size: ListPageSize) => {
|
||||
if (size === listPageSize) return
|
||||
setListPageSize(size)
|
||||
onMailRouteNavigate({ page: 1 })
|
||||
},
|
||||
[listPageSize, setListPageSize, onMailRouteNavigate]
|
||||
)
|
||||
|
||||
const pagedEmails = useMemo(() => {
|
||||
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
||||
return displayListEmails
|
||||
}
|
||||
const start = (listPage - 1) * LIST_PAGE_SIZE
|
||||
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
|
||||
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
|
||||
const start = (listPage - 1) * listPageSize
|
||||
return displayListEmails.slice(start, start + listPageSize)
|
||||
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
|
||||
|
||||
const listEmails = useMemo(() => {
|
||||
if (isXs && !isViewMode) {
|
||||
@ -568,14 +621,6 @@ export function useEmailListData({
|
||||
|
||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
||||
|
||||
const folderFilterCtx = useMemo(
|
||||
() => ({
|
||||
starredEmailIds: [] as string[],
|
||||
importantEmailIds: [] as string[],
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const listRowExtras = useMemo(() => {
|
||||
const invitationById = new Map<
|
||||
string,
|
||||
@ -669,11 +714,26 @@ export function useEmailListData({
|
||||
const seen = new Set(
|
||||
seenSerialized.length > 0 ? seenSerialized.split(",") : []
|
||||
)
|
||||
const inboxPool = allEmails.filter((e) => !seen.has(e.id))
|
||||
const subtreeIdsCache = new Map<string, string[] | null>()
|
||||
const counts: Record<string, number> = {}
|
||||
const preview: Record<string, string> = {}
|
||||
for (const tab of inboxTabBarItems) {
|
||||
const rows = inboxPool.filter((e) => !seen.has(e.id))
|
||||
const rows: Email[] = []
|
||||
for (const e of allEmails) {
|
||||
if (seen.has(e.id)) continue
|
||||
if (
|
||||
!emailMatchesInboxTab(
|
||||
e,
|
||||
tab.id,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
subtreeIdsCache
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
rows.push(e)
|
||||
}
|
||||
counts[tab.id] = rows.length
|
||||
if (inboxTabShowsInactiveMeta(tab.id)) {
|
||||
const chain: string[] = []
|
||||
@ -689,7 +749,13 @@ export function useEmailListData({
|
||||
}
|
||||
}
|
||||
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
||||
}, [seenSerialized, allEmails, inboxTabBarItems])
|
||||
}, [
|
||||
seenSerialized,
|
||||
allEmails,
|
||||
inboxTabBarItems,
|
||||
folderFilterCtx,
|
||||
navMaps,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
onFolderUnreadCountsChange?.(folderUnreadCounts)
|
||||
@ -720,8 +786,8 @@ export function useEmailListData({
|
||||
if (e.read) continue
|
||||
const msg = apiMessagesById.get(e.id)
|
||||
if (!msg) continue
|
||||
if (!msg.flags.includes("read")) {
|
||||
updateFlags.mutate({ id: e.id, flags: [...msg.flags, "read"] })
|
||||
if (!mailFlagIsRead(msg.flags)) {
|
||||
updateFlags.mutate({ id: e.id, flags: mailFlagsWithRead(msg.flags, true) })
|
||||
}
|
||||
}
|
||||
}, [displayListEmails, apiMessagesById, updateFlags])
|
||||
@ -790,6 +856,11 @@ export function useEmailListData({
|
||||
inboxCategoryTabLabel,
|
||||
mobileUnreadCount,
|
||||
mobileFolderLabel,
|
||||
paginationTotal,
|
||||
listPageSize,
|
||||
paginationRangeStart,
|
||||
paginationRangeEnd,
|
||||
handleListPageSizeChange,
|
||||
totalPages,
|
||||
pagedEmails,
|
||||
listEmails,
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react"
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import { readStateTargets } from "@/lib/mail-thread"
|
||||
@ -19,7 +20,6 @@ import {
|
||||
parseMailNavVisitKey,
|
||||
} from "@/lib/mail-folder-display"
|
||||
import {
|
||||
LIST_PAGE_SIZE,
|
||||
escapeHtml,
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
import type { Contact } from "@/lib/compose-context"
|
||||
@ -30,6 +30,7 @@ import {
|
||||
import type { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
|
||||
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
|
||||
import type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
|
||||
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||
|
||||
export function useEmailListReading(
|
||||
props: EmailListProps,
|
||||
@ -52,6 +53,7 @@ export function useEmailListReading(
|
||||
emailById,
|
||||
displayListEmails,
|
||||
listPage,
|
||||
listPageSize,
|
||||
listRowsDep,
|
||||
listViewportRef,
|
||||
conversationMode,
|
||||
@ -92,10 +94,20 @@ export function useEmailListReading(
|
||||
[openMailId, displayListEmails]
|
||||
)
|
||||
|
||||
// Guard: emailById/setReadOverrides get new refs after each messages refetch — without
|
||||
// this guard, mark-read → invalidate → refetch → effect re-runs in a loop.
|
||||
const readAppliedForMailRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMailId) return
|
||||
if (!openMailId) {
|
||||
readAppliedForMailRef.current = null
|
||||
return
|
||||
}
|
||||
if (readAppliedForMailRef.current === openMailId) return
|
||||
const message = emailById.get(openMailId)
|
||||
if (!message) return
|
||||
|
||||
readAppliedForMailRef.current = openMailId
|
||||
const targets = readStateTargets(message, conversationMode)
|
||||
for (const id of targets) {
|
||||
markEmailSeen(id)
|
||||
@ -111,17 +123,22 @@ export function useEmailListReading(
|
||||
|
||||
const navigateToMail = useCallback(
|
||||
(id: string | null) => {
|
||||
if (id === null) {
|
||||
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||
}
|
||||
startTransition(() => {
|
||||
if (id && splitView) {
|
||||
const idx = displayListEmails.findIndex((e) => e.id === id)
|
||||
if (idx >= 0) {
|
||||
const page = Math.floor(idx / LIST_PAGE_SIZE) + 1
|
||||
const page = Math.floor(idx / listPageSize) + 1
|
||||
onMailRouteNavigate({ mailId: id, page })
|
||||
return
|
||||
}
|
||||
}
|
||||
onMailRouteNavigate({ mailId: id })
|
||||
})
|
||||
},
|
||||
[splitView, displayListEmails, onMailRouteNavigate]
|
||||
[splitView, displayListEmails, onMailRouteNavigate, listPageSize]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@ -153,9 +170,12 @@ export function useEmailListReading(
|
||||
}, [splitView, openMailId, navigateToMail, pickAdjacentMailId])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (splitView) leaveReadingPane()
|
||||
else navigateToMail(null)
|
||||
}, [splitView, leaveReadingPane, navigateToMail])
|
||||
if (splitView) {
|
||||
navigateToMail(null)
|
||||
return
|
||||
}
|
||||
navigateToMail(null)
|
||||
}, [splitView, navigateToMail])
|
||||
|
||||
const closeViewIfShowingEmail = useCallback(
|
||||
(emailId: string) => {
|
||||
@ -207,6 +227,7 @@ export function useEmailListReading(
|
||||
|
||||
const handleCategoryInboxTabClick = useCallback(
|
||||
(tabId: string) => {
|
||||
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||
startTransition(() => {
|
||||
onMailRouteNavigate({
|
||||
inboxTab: tabId,
|
||||
@ -393,32 +414,40 @@ export function useEmailListReading(
|
||||
)
|
||||
}, [openEmail, openComposeWithInitial])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onXsViewChromeChange) return
|
||||
if (!isXs || !isViewMode || !openEmail) {
|
||||
onXsViewChromeChange(null)
|
||||
return
|
||||
}
|
||||
onXsViewChromeChange({
|
||||
const xsViewChromeCallbacksRef = useRef({
|
||||
onArchive: singleArchive,
|
||||
onReply: singleReply,
|
||||
moveTargets,
|
||||
onMoveTo: singleMoveTo,
|
||||
})
|
||||
return () => onXsViewChromeChange(null)
|
||||
}, [
|
||||
onXsViewChromeChange,
|
||||
isXs,
|
||||
isViewMode,
|
||||
openEmail,
|
||||
singleArchive,
|
||||
singleReply,
|
||||
singleMoveTo,
|
||||
xsViewChromeCallbacksRef.current = {
|
||||
onArchive: singleArchive,
|
||||
onReply: singleReply,
|
||||
moveTargets,
|
||||
])
|
||||
onMoveTo: singleMoveTo,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!onXsViewChromeChange) return
|
||||
if (!isXs || !isViewMode || !openMailId) {
|
||||
onXsViewChromeChange(null)
|
||||
return
|
||||
}
|
||||
onXsViewChromeChange({
|
||||
onArchive: () => xsViewChromeCallbacksRef.current.onArchive(),
|
||||
onReply: () => xsViewChromeCallbacksRef.current.onReply(),
|
||||
moveTargets: xsViewChromeCallbacksRef.current.moveTargets,
|
||||
onMoveTo: (targetId) =>
|
||||
xsViewChromeCallbacksRef.current.onMoveTo(targetId),
|
||||
})
|
||||
return () => onXsViewChromeChange(null)
|
||||
}, [onXsViewChromeChange, isXs, isViewMode, openMailId, moveTargets])
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) return
|
||||
if (useMailUiStore.getState().consumeSuppressSplitAutoOpen()) {
|
||||
return
|
||||
}
|
||||
const firstId = displayListEmails[0]?.id ?? null
|
||||
if (!openMailId) {
|
||||
if (firstId) navigateToMail(firstId)
|
||||
|
||||
@ -8,9 +8,6 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import { Reply, ReplyAll, Forward } from "lucide-react"
|
||||
import {
|
||||
TooltipProvider,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
avatarColor,
|
||||
@ -19,11 +16,19 @@ import {
|
||||
} from "@/lib/sender-display"
|
||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
||||
import {
|
||||
mailFlagIsRead,
|
||||
mailFlagIsStarred,
|
||||
mailFlagIsImportant,
|
||||
messageIsSpam,
|
||||
messageHasFlag,
|
||||
messageHasLabel,
|
||||
} from "@/lib/mail-flags"
|
||||
import { repairSnippet } from "@/lib/mail-mime-body"
|
||||
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||
import { useMessage, useThread } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useMessage, useThread, unwrapThreadMessages } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useToggleStar,
|
||||
useMarkRead,
|
||||
useUpdateFlags,
|
||||
useUpdateLabels,
|
||||
} from "@/lib/api/hooks/use-mail-mutations"
|
||||
@ -31,11 +36,16 @@ import {
|
||||
useComposeActions,
|
||||
useComposeDrafts,
|
||||
useComposeWindows,
|
||||
DEFAULT_IDENTITIES,
|
||||
type ThreadComposeKind,
|
||||
type ComposeOpenPreset,
|
||||
savedThreadDraftToComposePreset,
|
||||
} from "@/lib/compose-context"
|
||||
import { resolveComposeIdentity } from "@/lib/compose/resolve-compose-identity"
|
||||
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
import { resolveMessageFrom } from "@/lib/mail-message-participants"
|
||||
import { buildMessageHeaderDetails } from "@/lib/mail-message-header-details"
|
||||
import { splitThreadAroundOpenMessage } from "@/lib/mail-thread"
|
||||
import {
|
||||
buildThreadComposePreset,
|
||||
withTouchFullscreenComposePreset,
|
||||
@ -51,9 +61,10 @@ import {
|
||||
MAIL_REPLY_BUTTON_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import {
|
||||
CollapsedMessage,
|
||||
ExpandedMessage,
|
||||
SpamWhyBanner,
|
||||
ThreadPriorMessage,
|
||||
formatApiMessageBody,
|
||||
} from "@/components/gmail/email-view/email-view-messages"
|
||||
|
||||
function apiToLegacyEmail(
|
||||
@ -67,13 +78,13 @@ function apiToLegacyEmail(
|
||||
sender: senderName,
|
||||
senderEmail: msg.from[0]?.address,
|
||||
subject: msg.subject,
|
||||
preview: msg.snippet,
|
||||
preview: repairSnippet(msg.snippet) ?? msg.snippet,
|
||||
body: full?.body_html ?? full?.body_text,
|
||||
date: msg.date,
|
||||
read: msg.flags.includes("read"),
|
||||
starred: msg.flags.includes("starred"),
|
||||
important: msg.flags.includes("important"),
|
||||
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
|
||||
read: mailFlagIsRead(msg.flags),
|
||||
starred: mailFlagIsStarred(msg.flags),
|
||||
important: mailFlagIsImportant(msg.flags, msg.labels),
|
||||
spam: messageIsSpam(msg.flags, msg.labels),
|
||||
labels: msg.labels,
|
||||
hasAttachment: msg.has_attachments,
|
||||
conversation: thread
|
||||
@ -114,51 +125,59 @@ export function EmailView({
|
||||
currentFolderId,
|
||||
isSingleMessageView = false,
|
||||
}: EmailViewProps) {
|
||||
const { data: fullMessage } = useMessage(email.id)
|
||||
const { data: fullMessage, isPending: fullMessagePending } = useMessage(email.id)
|
||||
const { data: threadMessages } = useThread(email.thread_id ?? null)
|
||||
const selfEmails = useSelfMailEmails()
|
||||
const chromeIdentity = useChromeIdentity()
|
||||
const selfDisplayName = chromeIdentity?.name
|
||||
|
||||
const toggleStar = useToggleStar()
|
||||
const markRead = useMarkRead()
|
||||
const updateFlags = useUpdateFlags()
|
||||
const updateLabels = useUpdateLabels()
|
||||
|
||||
const flags = fullMessage?.flags ?? email.flags
|
||||
const isStarred = flags.includes("starred")
|
||||
const isSpam = flags.includes("spam") || email.labels.includes("spam")
|
||||
const isStarred = mailFlagIsStarred(flags)
|
||||
const isSpam = messageIsSpam(flags, fullMessage?.labels ?? email.labels)
|
||||
|
||||
const initialFlagsRef = useRef(flags)
|
||||
useEffect(() => {
|
||||
initialFlagsRef.current = email.flags
|
||||
}, [email.id, email.flags])
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFlagsRef.current.includes("read")) {
|
||||
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email.id])
|
||||
|
||||
const body =
|
||||
fullMessage?.body_html ??
|
||||
fullMessage?.body_text ??
|
||||
`<p style="color:var(--muted-foreground);">${email.snippet}</p>`
|
||||
const body = useMemo(
|
||||
() =>
|
||||
formatApiMessageBody(
|
||||
fullMessage,
|
||||
email.snippet,
|
||||
fullMessagePending && !fullMessage
|
||||
),
|
||||
[fullMessage, fullMessagePending, email.snippet]
|
||||
)
|
||||
|
||||
const [showFullThread, setShowFullThread] = useState(false)
|
||||
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
|
||||
|
||||
const priorMessages = useMemo(() => {
|
||||
if (!threadMessages) return []
|
||||
return threadMessages.filter((m) => m.id !== email.id)
|
||||
}, [threadMessages, email.id])
|
||||
const threadOrdered = useMemo(
|
||||
() => unwrapThreadMessages(threadMessages),
|
||||
[threadMessages]
|
||||
)
|
||||
|
||||
const priorCount = priorMessages.length
|
||||
const { before: threadBefore, after: threadAfter } = useMemo(
|
||||
() => splitThreadAroundOpenMessage(threadOrdered, email.id),
|
||||
[threadOrdered, email.id]
|
||||
)
|
||||
|
||||
const otherThreadCount = threadBefore.length + threadAfter.length
|
||||
const showRepliesCta =
|
||||
isSingleMessageView && !showFullThread && priorCount > 0
|
||||
isSingleMessageView && !showFullThread && otherThreadCount > 0
|
||||
|
||||
const conversation =
|
||||
isSingleMessageView && !showFullThread ? [] : priorMessages
|
||||
const hasConversation = conversation.length > 0
|
||||
const showFullThreadList = !isSingleMessageView || showFullThread
|
||||
const messagesBefore = showFullThreadList ? threadBefore : []
|
||||
const messagesAfter = showFullThreadList ? threadAfter : []
|
||||
/** Conversation preview: all thread messages expanded (each gets its own remote-content banner). */
|
||||
const expandAllThreadMessages =
|
||||
showFullThreadList && (!isSingleMessageView || showFullThread)
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
const isThreadMessageExpanded = useCallback(
|
||||
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
|
||||
[expandAllThreadMessages, expandedIds]
|
||||
)
|
||||
const toggleExpanded = (msgId: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
@ -168,13 +187,32 @@ export function EmailView({
|
||||
})
|
||||
}
|
||||
|
||||
const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
|
||||
const mainSenderAddr =
|
||||
email.from[0]?.address ??
|
||||
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
||||
const mainFrom = useMemo(
|
||||
() =>
|
||||
resolveMessageFrom(fullMessage?.from ?? email.from, {
|
||||
selfEmails,
|
||||
selfDisplayName,
|
||||
}),
|
||||
[fullMessage?.from, email.from, selfEmails, selfDisplayName]
|
||||
)
|
||||
const mainHeaderDetails = useMemo(
|
||||
() =>
|
||||
buildMessageHeaderDetails(
|
||||
{
|
||||
...email,
|
||||
...fullMessage,
|
||||
from: fullMessage?.from ?? email.from,
|
||||
to: fullMessage?.to ?? email.to,
|
||||
cc: fullMessage?.cc,
|
||||
subject: email.subject,
|
||||
},
|
||||
{ selfEmails, selfDisplayName, subject: email.subject }
|
||||
),
|
||||
[email, fullMessage, selfEmails, selfDisplayName]
|
||||
)
|
||||
|
||||
const legacyEmail = useMemo(
|
||||
() => apiToLegacyEmail(email, fullMessage, threadMessages),
|
||||
() => apiToLegacyEmail(email, fullMessage, unwrapThreadMessages(threadMessages)),
|
||||
[email, fullMessage, threadMessages]
|
||||
)
|
||||
|
||||
@ -239,7 +277,16 @@ export function EmailView({
|
||||
[legacyEmail, openThreadCompose]
|
||||
)
|
||||
|
||||
const selfIdentity = DEFAULT_IDENTITIES[0]
|
||||
const onToolbarReply = useCallback(
|
||||
() => startThreadCompose("reply"),
|
||||
[startThreadCompose]
|
||||
)
|
||||
const onToolbarForward = useCallback(
|
||||
() => startThreadCompose("forward"),
|
||||
[startThreadCompose]
|
||||
)
|
||||
|
||||
const selfIdentity = resolveComposeIdentity()
|
||||
const selfName = cleanSenderName(selfIdentity.name)
|
||||
|
||||
const calendarInvitation = useMemo(
|
||||
@ -252,16 +299,16 @@ export function EmailView({
|
||||
}, [email.id, flags, isStarred, toggleStar])
|
||||
|
||||
const handleNotSpam = useCallback(() => {
|
||||
if (flags.includes("spam")) {
|
||||
if (messageHasFlag(flags, "spam")) {
|
||||
updateFlags.mutate({
|
||||
id: email.id,
|
||||
flags: flags.filter((f) => f !== "spam"),
|
||||
flags: flags.filter((f) => f.toLowerCase() !== "spam"),
|
||||
})
|
||||
}
|
||||
if (email.labels.includes("spam")) {
|
||||
if (messageHasLabel(email.labels, "spam")) {
|
||||
updateLabels.mutate({
|
||||
id: email.id,
|
||||
labels: email.labels.filter((l) => l !== "spam"),
|
||||
labels: (email.labels ?? []).filter((l) => l.toLowerCase() !== "spam"),
|
||||
})
|
||||
}
|
||||
}, [email.id, flags, email.labels, updateFlags, updateLabels])
|
||||
@ -271,7 +318,6 @@ export function EmailView({
|
||||
}, [legacyEmail])
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
|
||||
<div
|
||||
@ -306,58 +352,65 @@ export function EmailView({
|
||||
onClick={() => setShowFullThread(true)}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{priorCount === 1
|
||||
? "Afficher la réponse"
|
||||
: `Afficher les ${priorCount} réponses`}
|
||||
{otherThreadCount === 1
|
||||
? "Afficher l'autre message du fil"
|
||||
: `Afficher les ${otherThreadCount} autres messages du fil`}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasConversation &&
|
||||
conversation.map((msg) => {
|
||||
const isExpanded = expandedIds.has(msg.id)
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-border">
|
||||
<ExpandedMessage
|
||||
sender={msg.from[0]?.name ?? ""}
|
||||
senderEmail={msg.from[0]?.address ?? ""}
|
||||
dateIso={msg.date}
|
||||
body={msg.body_html ?? msg.body_text ?? ""}
|
||||
isSpam={false}
|
||||
isLast={false}
|
||||
starred={msg.flags.includes("starred")}
|
||||
onCollapse={() => toggleExpanded(msg.id)}
|
||||
onPrintConversation={handlePrint}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
{messagesBefore.map((msg) => (
|
||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||
<CollapsedMessage
|
||||
<ThreadPriorMessage
|
||||
message={msg}
|
||||
onClick={() => toggleExpanded(msg.id)}
|
||||
isExpanded={isThreadMessageExpanded(msg.id)}
|
||||
onToggle={() => toggleExpanded(msg.id)}
|
||||
onPrintConversation={handlePrint}
|
||||
onReply={onToolbarReply}
|
||||
onForward={onToolbarForward}
|
||||
selfEmails={selfEmails}
|
||||
selfDisplayName={selfDisplayName}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
|
||||
<ExpandedMessage
|
||||
sender={mainSenderName}
|
||||
senderEmail={mainSenderAddr}
|
||||
sender={mainFrom.name}
|
||||
senderEmail={mainFrom.email}
|
||||
headerDetails={mainHeaderDetails}
|
||||
dateIso={email.date}
|
||||
body={body}
|
||||
isSpam={isSpam}
|
||||
isLast={true}
|
||||
isLast={messagesAfter.length === 0}
|
||||
starred={isStarred}
|
||||
attachments={mainMessageAttachments}
|
||||
onToggleStar={handleToggleStar}
|
||||
onPrintConversation={handlePrint}
|
||||
onReply={onToolbarReply}
|
||||
onForward={onToolbarForward}
|
||||
detailsOpen={mainDetailsOpen}
|
||||
onDetailsOpenChange={setMainDetailsOpen}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
messageId={email.id}
|
||||
/>
|
||||
|
||||
{messagesAfter.map((msg) => (
|
||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||
<ThreadPriorMessage
|
||||
message={msg}
|
||||
isExpanded={isThreadMessageExpanded(msg.id)}
|
||||
onToggle={() => toggleExpanded(msg.id)}
|
||||
onPrintConversation={handlePrint}
|
||||
onReply={onToolbarReply}
|
||||
onForward={onToolbarForward}
|
||||
selfEmails={selfEmails}
|
||||
selfDisplayName={selfDisplayName}
|
||||
collapseQuotedReplies={otherThreadCount > 0}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{showReplyForwardBar ? (
|
||||
<div
|
||||
className={cn(
|
||||
@ -405,7 +458,7 @@ export function EmailView({
|
||||
{inlineCompose ? (
|
||||
<div
|
||||
ref={threadComposeAnchorRef}
|
||||
className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4"
|
||||
className="mt-6 px-4 pb-6 max-sm:px-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
@ -427,6 +480,5 @@ export function EmailView({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
154
components/gmail/email-view/email-view-details-popover.tsx
Normal file
154
components/gmail/email-view/email-view-details-popover.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { ChevronDown, Lock } from "lucide-react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||
import type { MessageHeaderDetails } from "@/lib/mail-message-header-details"
|
||||
import { UnsubscribeActionButton } from "@/components/gmail/email-view/unsubscribe-action-button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DetailRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-right text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 text-foreground">{children}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailViewDetailsPopover({
|
||||
summary,
|
||||
details,
|
||||
open,
|
||||
onOpenChange,
|
||||
isSpam,
|
||||
messageId,
|
||||
}: {
|
||||
summary: string
|
||||
details: MessageHeaderDetails
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
isSpam?: boolean
|
||||
messageId: string
|
||||
}) {
|
||||
const leaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearLeaveTimer = () => {
|
||||
if (leaveTimerRef.current) {
|
||||
clearTimeout(leaveTimerRef.current)
|
||||
leaveTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleClose = () => {
|
||||
clearLeaveTimer()
|
||||
leaveTimerRef.current = setTimeout(() => onOpenChange(false), 150)
|
||||
}
|
||||
|
||||
const keepOpen = () => {
|
||||
clearLeaveTimer()
|
||||
}
|
||||
|
||||
useEffect(() => () => clearLeaveTimer(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) clearLeaveTimer()
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={() => {
|
||||
if (open) scheduleClose()
|
||||
}}
|
||||
>
|
||||
{summary}
|
||||
<ChevronDown
|
||||
className={cn("h-3 w-3 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
side="bottom"
|
||||
className="w-[min(100vw-2rem,28rem)] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDownOutside={() => onOpenChange(false)}
|
||||
onInteractOutside={() => onOpenChange(false)}
|
||||
onEscapeKeyDown={() => onOpenChange(false)}
|
||||
onMouseEnter={keepOpen}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs leading-snug">
|
||||
<DetailRow label="de :">{details.fromLine}</DetailRow>
|
||||
{details.replyToLine ? (
|
||||
<DetailRow label="répondre à :">{details.replyToLine}</DetailRow>
|
||||
) : null}
|
||||
<DetailRow label="à :">{details.toLine}</DetailRow>
|
||||
<DetailRow label="date :">
|
||||
<MailDateText iso={details.dateIso} variant="detail" />
|
||||
</DetailRow>
|
||||
<DetailRow label="objet :">{details.subject}</DetailRow>
|
||||
{details.mailedBy ? (
|
||||
<DetailRow label="envoyé par :">{details.mailedBy}</DetailRow>
|
||||
) : null}
|
||||
{details.signedBy ? (
|
||||
<DetailRow label="signé par :">{details.signedBy}</DetailRow>
|
||||
) : null}
|
||||
{details.unsubscribe ? (
|
||||
<>
|
||||
<dt className="text-right text-muted-foreground">se désabonner :</dt>
|
||||
<dd>
|
||||
<UnsubscribeActionButton
|
||||
action={details.unsubscribe}
|
||||
messageId={messageId}
|
||||
/>
|
||||
</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt className="text-right text-muted-foreground">sécurité :</dt>
|
||||
<dd className="space-y-1">
|
||||
{details.dkimPass === true ? (
|
||||
<p>Signature DKIM conforme</p>
|
||||
) : details.dkimPass === false ? (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
Signature DKIM non conforme
|
||||
</p>
|
||||
) : null}
|
||||
{details.tls ? (
|
||||
<p className="flex items-center gap-1.5">
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
Chiffrement standard (TLS)
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">
|
||||
Chiffrement PGP : non disponible pour l'instant
|
||||
</p>
|
||||
{isSpam ? (
|
||||
<p className="text-destructive">
|
||||
Ce message est marqué comme spam
|
||||
</p>
|
||||
) : null}
|
||||
</dd>
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@ -1,39 +1,174 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Star, Info } from "lucide-react"
|
||||
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { mailFlagIsStarred, messageIsSpam } from "@/lib/mail-flags"
|
||||
import {
|
||||
avatarColor,
|
||||
cleanSenderName,
|
||||
senderInitial,
|
||||
} from "@/lib/sender-display"
|
||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||
import type { ApiMessageFull } from "@/lib/api/types"
|
||||
import type { ApiMessageFull, Recipient } from "@/lib/api/types"
|
||||
import { resolveMessageFrom } from "@/lib/mail-message-participants"
|
||||
import {
|
||||
buildMessageHeaderDetails,
|
||||
type MessageHeaderDetails,
|
||||
} from "@/lib/mail-message-header-details"
|
||||
import type { EmailAttachment } from "@/lib/email-data"
|
||||
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
||||
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
|
||||
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
||||
import { MessageBodyContent } from "@/components/gmail/email-view/message-body-content"
|
||||
import { MessageAttachmentsSection } from "@/components/gmail/email-view/message-attachments"
|
||||
import {
|
||||
MAIL_MESSAGE_HOVER_CLASS,
|
||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { repairMimeBodies } from "@/lib/mail-mime-body"
|
||||
|
||||
export function formatApiMessageBody(
|
||||
full: { body_html?: string; body_text?: string } | null | undefined,
|
||||
snippet: string | undefined,
|
||||
loading: boolean
|
||||
): string {
|
||||
const snippetHtml = snippet?.trim()
|
||||
? `<p style="color:var(--muted-foreground);">${snippet.trim()}</p>`
|
||||
: ""
|
||||
|
||||
if (loading) {
|
||||
return snippetHtml
|
||||
}
|
||||
const repaired = repairMimeBodies(full?.body_text, full?.body_html)
|
||||
const html = repaired.bodyHtml?.trim()
|
||||
if (html) return html
|
||||
const text = repaired.bodyText?.trim()
|
||||
if (text) {
|
||||
const escaped = text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
return `<pre style="white-space:pre-wrap;font-family:inherit;margin:0;">${escaped}</pre>`
|
||||
}
|
||||
if (full) {
|
||||
const s = snippet?.trim()
|
||||
if (s) {
|
||||
return `<p style="color:var(--muted-foreground);">${s}</p>`
|
||||
}
|
||||
return `<p style="color:var(--muted-foreground);">Ce message n’a pas de contenu.</p>`
|
||||
}
|
||||
const s = snippet?.trim()
|
||||
return s
|
||||
? `<p style="color:var(--muted-foreground);">${s}</p>`
|
||||
: ""
|
||||
}
|
||||
|
||||
|
||||
/** Prior message in a thread: loads full body on expand via GET /mail/messages/:id. */
|
||||
export function ThreadPriorMessage({
|
||||
message,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onPrintConversation,
|
||||
onReply,
|
||||
onForward,
|
||||
selfEmails,
|
||||
selfDisplayName,
|
||||
collapseQuotedReplies = false,
|
||||
}: {
|
||||
message: ApiMessageFull
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onPrintConversation?: () => void
|
||||
onReply?: () => void
|
||||
onForward?: () => void
|
||||
selfEmails: string[]
|
||||
selfDisplayName?: string
|
||||
collapseQuotedReplies?: boolean
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||||
const loadFull = isExpanded || detailsOpen
|
||||
const { data: fullMessage, isPending } = useMessage(loadFull ? message.id : null)
|
||||
const merged = fullMessage ?? message
|
||||
const resolved = useMemo(
|
||||
() =>
|
||||
resolveMessageFrom(merged.from, { selfEmails, selfDisplayName }),
|
||||
[merged.from, selfEmails, selfDisplayName]
|
||||
)
|
||||
const headerDetails = useMemo(
|
||||
() =>
|
||||
buildMessageHeaderDetails(merged, {
|
||||
selfEmails,
|
||||
selfDisplayName,
|
||||
subject: message.subject,
|
||||
}),
|
||||
[merged, selfEmails, selfDisplayName, message.subject]
|
||||
)
|
||||
const body = useMemo(
|
||||
() =>
|
||||
formatApiMessageBody(
|
||||
fullMessage,
|
||||
message.snippet,
|
||||
isExpanded && isPending && !fullMessage
|
||||
),
|
||||
[fullMessage, message.snippet, isExpanded, isPending]
|
||||
)
|
||||
|
||||
const isSpam = messageIsSpam(merged.flags, merged.labels)
|
||||
|
||||
if (!isExpanded) {
|
||||
return (
|
||||
<CollapsedMessage
|
||||
message={message}
|
||||
senderName={resolved.name}
|
||||
senderEmail={resolved.email}
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandedMessage
|
||||
sender={resolved.name}
|
||||
senderEmail={resolved.email}
|
||||
headerDetails={headerDetails}
|
||||
dateIso={message.date}
|
||||
body={body}
|
||||
isSpam={isSpam}
|
||||
isLast={false}
|
||||
starred={mailFlagIsStarred(message.flags ?? [])}
|
||||
onCollapse={onToggle}
|
||||
onPrintConversation={onPrintConversation}
|
||||
onReply={onReply}
|
||||
onForward={onForward}
|
||||
detailsOpen={detailsOpen}
|
||||
onDetailsOpenChange={setDetailsOpen}
|
||||
collapseQuotedReplies={collapseQuotedReplies}
|
||||
messageId={message.id}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CollapsedMessage({
|
||||
message,
|
||||
senderName: senderNameProp,
|
||||
senderEmail: senderEmailProp,
|
||||
onClick,
|
||||
}: {
|
||||
message: ApiMessageFull
|
||||
senderName?: string
|
||||
senderEmail?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
const senderName = message.from[0]?.name ?? ""
|
||||
const senderAddr = message.from[0]?.address ?? ""
|
||||
const name = cleanSenderName(senderName)
|
||||
const senderName = senderNameProp ?? message.from[0]?.name ?? ""
|
||||
const senderAddr = senderEmailProp ?? message.from[0]?.address ?? ""
|
||||
const name = cleanSenderName(senderName || senderAddr)
|
||||
const color = avatarColor(name)
|
||||
|
||||
return (
|
||||
@ -81,6 +216,7 @@ export function CollapsedMessage({
|
||||
export function ExpandedMessage({
|
||||
sender,
|
||||
senderEmail,
|
||||
headerDetails,
|
||||
dateIso,
|
||||
body,
|
||||
isSpam,
|
||||
@ -90,9 +226,17 @@ export function ExpandedMessage({
|
||||
onToggleStar,
|
||||
onCollapse,
|
||||
onPrintConversation,
|
||||
onReply,
|
||||
onForward,
|
||||
detailsOpen,
|
||||
onDetailsOpenChange,
|
||||
collapseQuotedReplies = false,
|
||||
messageId,
|
||||
}: {
|
||||
sender: string
|
||||
senderEmail: string
|
||||
headerDetails: MessageHeaderDetails
|
||||
messageId: string
|
||||
dateIso: string
|
||||
body: string
|
||||
isSpam: boolean
|
||||
@ -102,12 +246,18 @@ export function ExpandedMessage({
|
||||
onToggleStar?: () => void
|
||||
onCollapse?: () => void
|
||||
onPrintConversation?: () => void
|
||||
onReply?: () => void
|
||||
onForward?: () => void
|
||||
detailsOpen?: boolean
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
collapseQuotedReplies?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<EmailViewMessageToolbar
|
||||
sender={sender}
|
||||
senderEmail={senderEmail}
|
||||
headerDetails={headerDetails}
|
||||
dateIso={dateIso}
|
||||
isSpam={isSpam}
|
||||
isLast={isLast}
|
||||
@ -115,6 +265,11 @@ export function ExpandedMessage({
|
||||
onToggleStar={onToggleStar}
|
||||
onCollapse={onCollapse}
|
||||
onPrintConversation={onPrintConversation}
|
||||
onReply={onReply}
|
||||
onForward={onForward}
|
||||
detailsOpen={detailsOpen}
|
||||
onDetailsOpenChange={onDetailsOpenChange}
|
||||
messageId={messageId}
|
||||
/>
|
||||
|
||||
<div
|
||||
@ -124,7 +279,13 @@ export function ExpandedMessage({
|
||||
)}
|
||||
data-selectable-text
|
||||
>
|
||||
<SandboxedContent html={body} isSpam={isSpam} />
|
||||
<MessageBodyContent
|
||||
html={body}
|
||||
isSpam={isSpam}
|
||||
senderEmail={senderEmail}
|
||||
messageId={messageId}
|
||||
collapseQuotedReplies={collapseQuotedReplies}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{attachments.length > 0 && (
|
||||
|
||||
@ -38,6 +38,8 @@ import { cn } from "@/lib/utils"
|
||||
import { avatarColor, cleanSenderName, senderInitial } from "@/lib/sender-display"
|
||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
||||
import { EmailViewDetailsPopover } from "@/components/gmail/email-view/email-view-details-popover"
|
||||
import type { MessageHeaderDetails } from "@/lib/mail-message-header-details"
|
||||
import {
|
||||
MAIL_ICON_BTN,
|
||||
MAIL_MENU_SURFACE_WIDE_CLASS,
|
||||
@ -51,6 +53,7 @@ const MENU_ICON_CLASS = "size-[18px] shrink-0 text-muted-foreground"
|
||||
export interface EmailViewMessageToolbarProps {
|
||||
sender: string
|
||||
senderEmail: string
|
||||
headerDetails: MessageHeaderDetails
|
||||
dateIso: string
|
||||
isSpam: boolean
|
||||
isLast: boolean
|
||||
@ -58,11 +61,17 @@ export interface EmailViewMessageToolbarProps {
|
||||
onToggleStar?: () => void
|
||||
onCollapse?: () => void
|
||||
onPrintConversation?: () => void
|
||||
onReply?: () => void
|
||||
onForward?: () => void
|
||||
detailsOpen?: boolean
|
||||
onDetailsOpenChange?: (open: boolean) => void
|
||||
messageId: string
|
||||
}
|
||||
|
||||
export function EmailViewMessageToolbar({
|
||||
sender,
|
||||
senderEmail,
|
||||
headerDetails,
|
||||
dateIso,
|
||||
isSpam,
|
||||
isLast,
|
||||
@ -70,15 +79,29 @@ export function EmailViewMessageToolbar({
|
||||
onToggleStar,
|
||||
onCollapse,
|
||||
onPrintConversation,
|
||||
onReply,
|
||||
onForward,
|
||||
detailsOpen,
|
||||
onDetailsOpenChange,
|
||||
messageId,
|
||||
}: EmailViewMessageToolbarProps) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const name = cleanSenderName(sender)
|
||||
const [internalDetailsOpen, setInternalDetailsOpen] = useState(false)
|
||||
const detailsIsOpen = detailsOpen ?? internalDetailsOpen
|
||||
const setDetailsIsOpen = onDetailsOpenChange ?? setInternalDetailsOpen
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")}
|
||||
onClick={!isLast ? onCollapse : undefined}
|
||||
onClick={
|
||||
!isLast
|
||||
? () => {
|
||||
setDetailsIsOpen(false)
|
||||
onCollapse?.()
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isSpam ? (
|
||||
<div
|
||||
@ -105,46 +128,22 @@ export function EmailViewMessageToolbar({
|
||||
className="inline min-w-0 max-w-full align-baseline"
|
||||
>
|
||||
<span className="font-semibold text-foreground">{name}</span>
|
||||
{senderEmail ? (
|
||||
<span className="text-muted-foreground"> <{senderEmail}></span>
|
||||
) : null}
|
||||
</ContactHoverCard>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDetails(!showDetails)
|
||||
}}
|
||||
>
|
||||
à moi
|
||||
<ChevronDown
|
||||
className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")}
|
||||
<EmailViewDetailsPopover
|
||||
summary={headerDetails.recipientSummary}
|
||||
details={headerDetails}
|
||||
open={detailsIsOpen}
|
||||
onOpenChange={setDetailsIsOpen}
|
||||
isSpam={isSpam}
|
||||
messageId={messageId}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-1 space-y-0.5 text-xs text-muted-foreground">
|
||||
<p>
|
||||
de : <span className="text-foreground">{name} <{senderEmail}></span>
|
||||
</p>
|
||||
<p>
|
||||
à : <span className="text-foreground">moi</span>
|
||||
</p>
|
||||
<p>
|
||||
date :{" "}
|
||||
<MailDateText iso={dateIso} variant="detail" className="text-foreground" />
|
||||
</p>
|
||||
{isSpam && (
|
||||
<p className="text-destructive">
|
||||
sécurité : ce message est marqué comme spam — les images et appels externes
|
||||
sont bloqués
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
|
||||
@ -189,7 +188,10 @@ export function EmailViewMessageToolbar({
|
||||
size="icon"
|
||||
className={cn("h-8 w-8", MAIL_ICON_BTN)}
|
||||
aria-label="Répondre"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onReply?.()
|
||||
}}
|
||||
>
|
||||
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||
</Button>
|
||||
@ -219,11 +221,19 @@ export function EmailViewMessageToolbar({
|
||||
sideOffset={4}
|
||||
className={MESSAGE_MORE_MENU_CLASS}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onReply?.()
|
||||
}}
|
||||
>
|
||||
<Reply className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
||||
Répondre
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
onForward?.()
|
||||
}}
|
||||
>
|
||||
<Forward className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
||||
Transférer
|
||||
</DropdownMenuItem>
|
||||
|
||||
130
components/gmail/email-view/message-body-content.tsx
Normal file
130
components/gmail/email-view/message-body-content.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { splitQuotedHtml } from "@/lib/mail-quoted-content"
|
||||
import { htmlHasRemoteContent } from "@/lib/mail-remote-content"
|
||||
import { findContactByEmail } from "@/lib/contacts/find-contact"
|
||||
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||
import { useSelfMailEmails } from "@/lib/hooks/use-self-mail-emails"
|
||||
import { normalizeMailAddress } from "@/lib/mail-message-participants"
|
||||
import {
|
||||
isMessageRemoteContentAllowed,
|
||||
isTrustedSenderEmail,
|
||||
useTrustedSendersStore,
|
||||
} from "@/lib/stores/trusted-senders-store"
|
||||
import { useMessageAttachmentCidMap } from "@/lib/api/hooks/use-message-attachment-cid-map"
|
||||
import { useInlineCidUrls } from "@/lib/hooks/use-inline-cid-urls"
|
||||
import { SandboxedContent } from "@/components/gmail/email-view/sandboxed-content"
|
||||
import { RemoteContentBanner } from "@/components/gmail/email-view/remote-content-banner"
|
||||
|
||||
export function MessageBodyContent({
|
||||
html,
|
||||
isSpam,
|
||||
senderEmail,
|
||||
messageId,
|
||||
collapseQuotedReplies = false,
|
||||
}: {
|
||||
html: string
|
||||
isSpam: boolean
|
||||
senderEmail: string
|
||||
messageId: string
|
||||
/** Hide included prior messages when the thread already lists them. */
|
||||
collapseQuotedReplies?: boolean
|
||||
}) {
|
||||
const [showQuoted, setShowQuoted] = useState(false)
|
||||
const selfEmails = useSelfMailEmails()
|
||||
const { contacts } = useContactsList()
|
||||
const trustedSenderEmails = useTrustedSendersStore((s) => s.trustedSenderEmails)
|
||||
const allowedMessageIds = useTrustedSendersStore((s) => s.allowedMessageIds)
|
||||
const trustSender = useTrustedSendersStore((s) => s.trustSender)
|
||||
const allowMessageRemoteContent = useTrustedSendersStore(
|
||||
(s) => s.allowMessageRemoteContent
|
||||
)
|
||||
|
||||
const isFromSelf = useMemo(() => {
|
||||
const norm = normalizeMailAddress(senderEmail)
|
||||
if (!norm) return false
|
||||
return selfEmails.some((s) => normalizeMailAddress(s) === norm)
|
||||
}, [senderEmail, selfEmails])
|
||||
|
||||
const isContact = Boolean(findContactByEmail(contacts, senderEmail))
|
||||
const isTrusted = isTrustedSenderEmail(trustedSenderEmails, senderEmail)
|
||||
const isMessageAllowed = isMessageRemoteContentAllowed(
|
||||
allowedMessageIds,
|
||||
messageId
|
||||
)
|
||||
|
||||
const { mainHtml, quotedHtml } = useMemo(() => {
|
||||
if (!collapseQuotedReplies) {
|
||||
return { mainHtml: html, quotedHtml: null as string | null }
|
||||
}
|
||||
return splitQuotedHtml(html)
|
||||
}, [html, collapseQuotedReplies])
|
||||
|
||||
const { data: cidMap } = useMessageAttachmentCidMap(messageId)
|
||||
const cidUrlMap = useInlineCidUrls(cidMap)
|
||||
|
||||
const hasRemoteContent = useMemo(
|
||||
() =>
|
||||
htmlHasRemoteContent(mainHtml) ||
|
||||
(quotedHtml ? htmlHasRemoteContent(quotedHtml) : false),
|
||||
[mainHtml, quotedHtml]
|
||||
)
|
||||
|
||||
const remoteContentAllowed =
|
||||
isFromSelf || isContact || isTrusted || isMessageAllowed
|
||||
|
||||
const blockRemoteContent = isFromSelf
|
||||
? false
|
||||
: isSpam || (hasRemoteContent && !remoteContentAllowed)
|
||||
|
||||
const showRemoteBanner =
|
||||
!isFromSelf && !isSpam && hasRemoteContent && !remoteContentAllowed
|
||||
|
||||
const hasHiddenQuote = Boolean(quotedHtml) && !showQuoted
|
||||
|
||||
const sandboxProps = {
|
||||
blockRemoteContent,
|
||||
restrictPopups: isSpam,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
{showRemoteBanner ? (
|
||||
<RemoteContentBanner
|
||||
senderEmail={senderEmail}
|
||||
onShowOnce={() => allowMessageRemoteContent(messageId)}
|
||||
onAlwaysShow={() => {
|
||||
trustSender(senderEmail)
|
||||
allowMessageRemoteContent(messageId)
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SandboxedContent html={mainHtml} {...sandboxProps} />
|
||||
{hasHiddenQuote ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowQuoted(true)}
|
||||
className={cn(
|
||||
"inline-flex h-6 min-w-[2rem] items-center justify-center rounded-full",
|
||||
"border border-border bg-muted/80 px-2 text-sm font-medium leading-none text-muted-foreground",
|
||||
"hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
aria-label="Afficher les messages cités inclus"
|
||||
>
|
||||
…
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{quotedHtml && showQuoted ? (
|
||||
<div className="mt-2 border-t border-border/60 pt-2">
|
||||
<SandboxedContent html={quotedHtml} {...sandboxProps} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
components/gmail/email-view/remote-content-banner.tsx
Normal file
32
components/gmail/email-view/remote-content-banner.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
export function RemoteContentBanner({
|
||||
senderEmail,
|
||||
onShowOnce,
|
||||
onAlwaysShow,
|
||||
}: {
|
||||
senderEmail: string
|
||||
onShowOnce: () => void
|
||||
onAlwaysShow: () => void
|
||||
}) {
|
||||
return (
|
||||
<p className="mb-3 text-sm leading-snug text-muted-foreground">
|
||||
Le contenu distant a été masqué :{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowOnce}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
afficher le contenu distant
|
||||
</button>
|
||||
{" — "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAlwaysShow}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
toujours afficher le contenu distant venant de {senderEmail}
|
||||
</button>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@ -1,13 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
emailPreviewBaseCss,
|
||||
emailPreviewDarkOverrideCss,
|
||||
emailPreviewLightOverrideCss,
|
||||
preprocessEmailHtmlForTheme,
|
||||
emailPreviewWrapperCss,
|
||||
} from "@/lib/email-preview-dark-styles"
|
||||
import {
|
||||
prepareEmailHtmlForIframe,
|
||||
injectEmailHtmlIntoDocument,
|
||||
} from "@/lib/mail-html-iframe"
|
||||
import { buildEmailPreviewCsp } from "@/lib/mail-remote-content"
|
||||
|
||||
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
||||
display: "block",
|
||||
@ -18,21 +30,68 @@ function documentIsDark(): boolean {
|
||||
return document.documentElement.classList.contains("dark")
|
||||
}
|
||||
|
||||
function measureIframeContentHeight(doc: Document): number {
|
||||
const body = doc.body
|
||||
const root = doc.documentElement
|
||||
if (!body) return 60
|
||||
const heights = [
|
||||
body.scrollHeight,
|
||||
body.offsetHeight,
|
||||
root?.scrollHeight ?? 0,
|
||||
root?.clientHeight ?? 0,
|
||||
]
|
||||
return Math.max(60, ...heights) + 2
|
||||
}
|
||||
|
||||
export function SandboxedContent({
|
||||
html,
|
||||
isSpam,
|
||||
blockRemoteContent,
|
||||
restrictPopups = false,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
}: {
|
||||
html: string
|
||||
isSpam: boolean
|
||||
blockRemoteContent: boolean
|
||||
restrictPopups?: boolean
|
||||
senderEmail?: string
|
||||
cidUrlMap?: Record<string, string>
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [height, setHeight] = useState(120)
|
||||
|
||||
const sandboxValue = isSpam
|
||||
const sandboxValue = restrictPopups
|
||||
? "allow-same-origin"
|
||||
: "allow-same-origin allow-popups"
|
||||
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark =
|
||||
resolvedTheme === "dark" ||
|
||||
((resolvedTheme === "system" || resolvedTheme === undefined) &&
|
||||
typeof document !== "undefined" &&
|
||||
documentIsDark())
|
||||
|
||||
const parsedEmail = useMemo(
|
||||
() =>
|
||||
prepareEmailHtmlForIframe(html, {
|
||||
blockRemoteContent,
|
||||
isDark,
|
||||
senderEmail,
|
||||
cidUrlMap,
|
||||
}),
|
||||
[html, blockRemoteContent, isDark, senderEmail, cidUrlMap]
|
||||
)
|
||||
|
||||
const themeCss = useMemo(() => {
|
||||
if (!blockRemoteContent) return emailPreviewWrapperCss()
|
||||
return `${emailPreviewBaseCss(isDark)}${
|
||||
isDark ? emailPreviewDarkOverrideCss() : emailPreviewLightOverrideCss()
|
||||
}`
|
||||
}, [blockRemoteContent, isDark])
|
||||
|
||||
const cspContent = useMemo(
|
||||
() => buildEmailPreviewCsp(blockRemoteContent),
|
||||
[blockRemoteContent]
|
||||
)
|
||||
|
||||
const injectContent = useCallback(() => {
|
||||
const iframe = iframeRef.current
|
||||
@ -41,45 +100,44 @@ export function SandboxedContent({
|
||||
const doc = iframe.contentDocument
|
||||
if (!doc) return
|
||||
|
||||
const cspMeta = isSpam
|
||||
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
|
||||
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
|
||||
|
||||
const isDark = documentIsDark()
|
||||
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
|
||||
const themeOverrides = isDark
|
||||
? emailPreviewDarkOverrideCss()
|
||||
: emailPreviewLightOverrideCss()
|
||||
|
||||
doc.open()
|
||||
doc.write(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
${cspMeta}
|
||||
<style>
|
||||
${emailPreviewBaseCss(isDark)}
|
||||
${themeOverrides}
|
||||
</style>
|
||||
</head>
|
||||
<body>${processedHtml}</body>
|
||||
</html>`)
|
||||
doc.close()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const body = iframe.contentDocument?.body
|
||||
if (body) {
|
||||
setHeight(Math.max(60, body.scrollHeight + 2))
|
||||
}
|
||||
injectEmailHtmlIntoDocument(doc, {
|
||||
csp: cspContent,
|
||||
documentBaseHref: parsedEmail.documentBaseHref,
|
||||
resolveBaseHref: parsedEmail.resolveBaseHref,
|
||||
headMarkup: parsedEmail.headMarkup,
|
||||
bodyHtml: parsedEmail.bodyHtml,
|
||||
wrapperCss: themeCss,
|
||||
})
|
||||
|
||||
const syncHeight = () => {
|
||||
const liveDoc = iframe.contentDocument
|
||||
if (!liveDoc) return
|
||||
const next = measureIframeContentHeight(liveDoc)
|
||||
setHeight((prev) => (prev === next ? prev : next))
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(syncHeight)
|
||||
|
||||
if (doc.body) {
|
||||
resizeObserver.observe(doc.body)
|
||||
setHeight(Math.max(60, doc.body.scrollHeight + 2))
|
||||
for (const img of doc.images) {
|
||||
if (!img.complete) {
|
||||
img.addEventListener("load", syncHeight, { once: true })
|
||||
img.addEventListener("error", syncHeight, { once: true })
|
||||
}
|
||||
}
|
||||
for (const link of doc.querySelectorAll('link[rel~="stylesheet"]')) {
|
||||
link.addEventListener("load", syncHeight, { once: true })
|
||||
link.addEventListener("error", syncHeight, { once: true })
|
||||
}
|
||||
syncHeight()
|
||||
requestAnimationFrame(syncHeight)
|
||||
setTimeout(syncHeight, 250)
|
||||
setTimeout(syncHeight, 1000)
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [html, isSpam, resolvedTheme])
|
||||
}, [parsedEmail, themeCss, cspContent])
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = injectContent()
|
||||
@ -88,6 +146,7 @@ export function SandboxedContent({
|
||||
|
||||
return (
|
||||
<iframe
|
||||
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
|
||||
ref={iframeRef}
|
||||
sandbox={sandboxValue}
|
||||
title="Contenu du message"
|
||||
|
||||
70
components/gmail/email-view/unsubscribe-action-button.tsx
Normal file
70
components/gmail/email-view/unsubscribe-action-button.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useListUnsubscribeMailto } from "@/lib/api/hooks/use-list-unsubscribe-mailto"
|
||||
import type { UnsubscribeAction } from "@/lib/mail-unsubscribe"
|
||||
|
||||
export function UnsubscribeActionButton({
|
||||
action,
|
||||
messageId,
|
||||
}: {
|
||||
action: UnsubscribeAction
|
||||
messageId: string
|
||||
}) {
|
||||
const sendMailto = useListUnsubscribeMailto()
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (action.kind === "http") {
|
||||
return (
|
||||
<a
|
||||
href={action.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Se désabonner de cet expéditeur
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await sendMailto.mutateAsync(messageId)
|
||||
setDone(true)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Échec de l’envoi")
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
Demande de désinscription envoyée à {action.mailto.address}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleClick()}
|
||||
disabled={sendMailto.isPending}
|
||||
className="text-left text-primary hover:underline disabled:opacity-60"
|
||||
>
|
||||
{sendMailto.isPending ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Envoi…
|
||||
</span>
|
||||
) : (
|
||||
"Se désabonner de cet expéditeur"
|
||||
)}
|
||||
</button>
|
||||
{error ? <span className="text-destructive">{error}</span> : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { Icon, addCollection } from "@iconify/react"
|
||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||
import { Pencil } from "lucide-react"
|
||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||
import { useChromeIdentity } from "@/lib/hooks/use-chrome-identity"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -92,9 +93,17 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
||||
const appsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const accountMenuRef = useRef<HTMLDivElement>(null)
|
||||
const activeAccount = useActiveAccount()
|
||||
const identity = useChromeIdentity()
|
||||
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
||||
|
||||
useEffect(() => {
|
||||
const notice = sessionStorage.getItem("ulti_account_notice")
|
||||
if (notice === "same") {
|
||||
sessionStorage.removeItem("ulti_account_notice")
|
||||
toast.message("Vous utilisez déjà ce compte Ulti.")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
@ -183,7 +192,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
className="size-11 overflow-hidden rounded-full p-0"
|
||||
aria-label={`Compte : ${activeAccount?.email ?? ""}`}
|
||||
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
|
||||
aria-expanded={accountMenuOpen}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => {
|
||||
@ -191,7 +200,16 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
||||
setAppsMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
{activeAccount && <AccountAvatar account={activeAccount} size="md" />}
|
||||
{identity ? (
|
||||
<AccountAvatar
|
||||
account={{ name: identity.name, email: identity.email }}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground">
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<AccountSwitcherDropdown
|
||||
open={accountMenuOpen}
|
||||
|
||||
59
components/gmail/mail-nav-sync.tsx
Normal file
59
components/gmail/mail-nav-sync.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
|
||||
import { useUnifiedFolders } from "@/lib/api/hooks/use-unified-folder-queries"
|
||||
import { useImapFolders } from "@/lib/api/hooks/use-imap-folders"
|
||||
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
|
||||
import { useNavStore } from "@/lib/stores/nav-store"
|
||||
import { buildFolderTreeFromUnified } from "@/lib/mail-settings/unified-folder-tree"
|
||||
import { buildFolderTreeFromImap } from "@/lib/mail-settings/imap-folder-tree"
|
||||
import { normalizeLabelRow } from "@/lib/sidebar-nav-data"
|
||||
import type { LabelRowItem } from "@/lib/sidebar-nav-maps"
|
||||
|
||||
/** Hydrate sidebar navigation + display settings from backend when authenticated. */
|
||||
export function MailNavSync() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: labels } = useLabels()
|
||||
const { data: globalFolders } = useUnifiedFolders("global")
|
||||
const { data: allFolders } = useUnifiedFolders("all")
|
||||
const { folders: imapFolders, isFetched: imapFoldersFetched } = useImapFolders()
|
||||
useMailSettings(authenticated)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !authenticated || !labels) return
|
||||
|
||||
const sorted = [...labels].sort(
|
||||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.name.localeCompare(b.name)
|
||||
)
|
||||
const apiRows: LabelRowItem[] = sorted.map((label) =>
|
||||
normalizeLabelRow({
|
||||
id: label.id,
|
||||
label: label.name,
|
||||
color: label.color || "bg-gray-500",
|
||||
tabbed: false,
|
||||
favorite: false,
|
||||
excludeFromPrincipal: false,
|
||||
showInMessageList: true,
|
||||
enabled: true,
|
||||
})
|
||||
)
|
||||
useNavStore.getState().hydrateLabelRowsFromApi(apiRows)
|
||||
}, [ready, authenticated, labels])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !authenticated || !allFolders || !imapFoldersFetched) return
|
||||
|
||||
const global = globalFolders ?? allFolders.filter((f) => f.scope === "global")
|
||||
const tree = [
|
||||
...buildFolderTreeFromUnified(global),
|
||||
...buildFolderTreeFromImap(imapFolders),
|
||||
]
|
||||
if (tree.length === 0) return
|
||||
|
||||
useNavStore.getState().hydrateFolderTreeFromApi(tree)
|
||||
}, [ready, authenticated, allFolders, globalFolders, imapFolders, imapFoldersFetched])
|
||||
|
||||
return null
|
||||
}
|
||||
78
components/gmail/mail-notifications-bridge.tsx
Normal file
78
components/gmail/mail-notifications-bridge.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { buildMailPath } from "@/lib/mail-url"
|
||||
import { useWsEventListener } from "@/lib/api/ws"
|
||||
import type { WsEvent } from "@/lib/api/types"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import type { ApiMessageFull } from "@/lib/api/types"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import {
|
||||
isReplyOrMentionSubject,
|
||||
showDesktopNotification,
|
||||
} from "@/lib/notifications/desktop-notifications"
|
||||
|
||||
function mailEventPayload(evt: WsEvent): { message_id: string; account_id: string } | null {
|
||||
const payload = evt.payload
|
||||
if (!payload || typeof payload !== "object" || !("message_id" in payload)) return null
|
||||
const messageId = String(payload.message_id ?? "")
|
||||
if (!messageId) return null
|
||||
return {
|
||||
message_id: messageId,
|
||||
account_id: String(payload.account_id ?? ""),
|
||||
}
|
||||
}
|
||||
|
||||
export function MailNotificationsBridge() {
|
||||
const router = useRouter()
|
||||
const desktopNewMail = useMailSettingsStore((s) => s.desktopNewMail)
|
||||
const desktopMentions = useMailSettingsStore((s) => s.desktopMentions)
|
||||
const soundEnabled = useMailSettingsStore((s) => s.soundEnabled)
|
||||
|
||||
const handleMailCreated = useCallback(
|
||||
async (evt: WsEvent) => {
|
||||
if (evt.type !== "mail.created") return
|
||||
if (!desktopNewMail && !desktopMentions) return
|
||||
|
||||
const payload = mailEventPayload(evt)
|
||||
if (!payload) return
|
||||
|
||||
let subject = "Nouveau message"
|
||||
let sender = ""
|
||||
try {
|
||||
const message = await apiClient.get<ApiMessageFull>(
|
||||
`/mail/messages/${payload.message_id}`
|
||||
)
|
||||
subject = message.subject?.trim() || subject
|
||||
sender = message.from?.[0]?.name || message.from?.[0]?.address || ""
|
||||
} catch {}
|
||||
|
||||
const isMention = isReplyOrMentionSubject(subject)
|
||||
if (isMention && !desktopMentions) return
|
||||
if (!isMention && !desktopNewMail) return
|
||||
|
||||
showDesktopNotification({
|
||||
title: subject,
|
||||
body: sender ? `De ${sender}` : undefined,
|
||||
tag: payload.message_id,
|
||||
playSound: soundEnabled,
|
||||
onClick: () => {
|
||||
router.push(
|
||||
buildMailPath({
|
||||
folderId: "inbox",
|
||||
inboxTab: "primary",
|
||||
page: 1,
|
||||
mailId: payload.message_id,
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
},
|
||||
[desktopMentions, desktopNewMail, router, soundEnabled]
|
||||
)
|
||||
|
||||
useWsEventListener(handleMailCreated)
|
||||
|
||||
return null
|
||||
}
|
||||
312
components/gmail/mail-settings-fields.tsx
Normal file
312
components/gmail/mail-settings-fields.tsx
Normal file
@ -0,0 +1,312 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import type {
|
||||
InboxSortMode,
|
||||
MailBackgroundId,
|
||||
MailDensity,
|
||||
MailThemeMode,
|
||||
ReadingPaneMode,
|
||||
} from "@/lib/mail-settings/types"
|
||||
import {
|
||||
QuickSettingsCheckbox,
|
||||
QuickSettingsOption,
|
||||
} from "@/components/gmail/quick-settings/quick-settings-option"
|
||||
import {
|
||||
DensityCompactIcon,
|
||||
DensityDefaultIcon,
|
||||
DensityNormalIcon,
|
||||
InboxDefaultIcon,
|
||||
InboxImportantIcon,
|
||||
InboxStarredIcon,
|
||||
InboxUnreadIcon,
|
||||
ReadingPaneBelowIcon,
|
||||
ReadingPaneNoneIcon,
|
||||
ReadingPaneRightIcon,
|
||||
ThemeModePreview,
|
||||
} from "@/components/gmail/quick-settings/settings-preview-icons"
|
||||
import {
|
||||
MAIL_BACKGROUND_PRESETS,
|
||||
normalizeMailBackgroundId,
|
||||
} from "@/lib/mail-settings/constants"
|
||||
|
||||
const DENSITY_OPTIONS: {
|
||||
id: MailDensity
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}[] = [
|
||||
{ id: "default", label: "Par défaut", icon: <DensityDefaultIcon /> },
|
||||
{ id: "normal", label: "Normal", icon: <DensityNormalIcon /> },
|
||||
{ id: "compact", label: "Compact", icon: <DensityCompactIcon /> },
|
||||
]
|
||||
|
||||
const INBOX_OPTIONS: {
|
||||
id: InboxSortMode
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}[] = [
|
||||
{ id: "default", label: "Par défaut", icon: <InboxDefaultIcon /> },
|
||||
{
|
||||
id: "important",
|
||||
label: "Importants d'abord",
|
||||
icon: <InboxImportantIcon />,
|
||||
},
|
||||
{ id: "unread", label: "Non lus d'abord", icon: <InboxUnreadIcon /> },
|
||||
{ id: "starred", label: "Suivis d'abord", icon: <InboxStarredIcon /> },
|
||||
]
|
||||
|
||||
const READING_PANE_OPTIONS: {
|
||||
id: ReadingPaneMode
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
}[] = [
|
||||
{
|
||||
id: "none",
|
||||
label: "Aucune séparation",
|
||||
icon: <ReadingPaneNoneIcon />,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "À droite de la boîte de réception",
|
||||
icon: <ReadingPaneRightIcon />,
|
||||
},
|
||||
{
|
||||
id: "below",
|
||||
label: "Sous la boîte de réception",
|
||||
icon: <ReadingPaneBelowIcon />,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
id: MailThemeMode
|
||||
label: string
|
||||
}[] = [
|
||||
{ id: "light", label: "Clair" },
|
||||
{ id: "dark", label: "Sombre" },
|
||||
{ id: "system", label: "Système" },
|
||||
]
|
||||
|
||||
function ThemeModePicker({
|
||||
themeMode,
|
||||
onSelect,
|
||||
compact = false,
|
||||
}: {
|
||||
themeMode: MailThemeMode
|
||||
onSelect: (mode: MailThemeMode) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-3 gap-2", !compact && "mb-4 sm:max-w-md")}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(opt.id)}
|
||||
className={cn(
|
||||
"rounded-lg border-2 p-2 text-left transition-colors",
|
||||
compact ? "p-1.5" : "p-2.5",
|
||||
themeMode === opt.id
|
||||
? "border-primary bg-accent/60"
|
||||
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40"
|
||||
)}
|
||||
>
|
||||
<ThemeModePreview
|
||||
mode={opt.id}
|
||||
className={compact ? "h-10" : "h-14"}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 block text-foreground",
|
||||
compact ? "text-center text-xs" : "text-sm"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsSection({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("border-b border-border px-4 py-4", className)}>
|
||||
<SectionHeader title={title} action={action} />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
action?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MailSettingsFields({
|
||||
variant = "panel",
|
||||
onOpenThemeDialog,
|
||||
}: {
|
||||
variant?: "panel" | "page"
|
||||
onOpenThemeDialog?: () => void
|
||||
}) {
|
||||
const density = useMailSettingsStore((s) => s.density)
|
||||
const setDensity = useMailSettingsStore((s) => s.setDensity)
|
||||
const themeMode = useMailSettingsStore((s) => s.themeMode)
|
||||
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
|
||||
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
|
||||
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
|
||||
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
|
||||
const setInboxSort = useMailSettingsStore((s) => s.setInboxSort)
|
||||
const readingPane = useMailSettingsStore((s) => s.readingPane)
|
||||
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
|
||||
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
|
||||
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
||||
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
||||
|
||||
const sectionClassName = variant === "page" ? "px-0 py-5" : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Densité" className={sectionClassName}>
|
||||
{DENSITY_OPTIONS.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="density"
|
||||
label={opt.label}
|
||||
checked={density === opt.id}
|
||||
onSelect={() => setDensity(opt.id)}
|
||||
icon={opt.icon}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Thème"
|
||||
className={sectionClassName}
|
||||
action={
|
||||
variant === "panel" && onOpenThemeDialog ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-[#1a73e8] hover:underline"
|
||||
onClick={onOpenThemeDialog}
|
||||
>
|
||||
Arrière-plan
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{variant === "panel" && onOpenThemeDialog ? (
|
||||
<ThemeModePicker
|
||||
themeMode={themeMode}
|
||||
onSelect={setThemeMode}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ThemeModePicker themeMode={themeMode} onSelect={setThemeMode} />
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">
|
||||
Arrière-plan
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2 sm:max-w-lg sm:grid-cols-4">
|
||||
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setBackgroundId(preset.id as MailBackgroundId)
|
||||
}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1 rounded-lg p-1 transition-colors",
|
||||
activeBackgroundId === preset.id &&
|
||||
"ring-2 ring-[#1a73e8] ring-offset-1 ring-offset-background"
|
||||
)}
|
||||
title={preset.label}
|
||||
>
|
||||
<span
|
||||
className="block h-14 w-full rounded-md border border-border bg-cover bg-center"
|
||||
style={
|
||||
preset.background === "none"
|
||||
? { backgroundColor: "var(--app-canvas)" }
|
||||
: {
|
||||
backgroundColor: preset.fallbackColor,
|
||||
background: preset.background,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<span className="max-w-full truncate text-[10px] text-muted-foreground">
|
||||
{preset.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Type de boîte de réception"
|
||||
className={sectionClassName}
|
||||
>
|
||||
{INBOX_OPTIONS.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="inbox-sort"
|
||||
label={opt.label}
|
||||
checked={inboxSort === opt.id}
|
||||
onSelect={() => setInboxSort(opt.id)}
|
||||
icon={opt.icon}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Volet de lecture" className={sectionClassName}>
|
||||
{READING_PANE_OPTIONS.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="reading-pane"
|
||||
label={opt.label}
|
||||
checked={readingPane === opt.id}
|
||||
disabled={opt.disabled}
|
||||
onSelect={() => {
|
||||
if (!opt.disabled) setReadingPane(opt.id)
|
||||
}}
|
||||
icon={opt.icon}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<section className={cn("px-4 py-4", variant === "page" && "px-0 py-5")}>
|
||||
<SectionHeader title="Fils de discussion" />
|
||||
<QuickSettingsCheckbox
|
||||
label="Mode Conversation"
|
||||
checked={conversationMode}
|
||||
onChange={setConversationMode}
|
||||
helpLabel="Regrouper les messages d'une même conversation"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
107
components/gmail/mail-settings-sync.tsx
Normal file
107
components/gmail/mail-settings-sync.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useAuthStore } from "@/lib/api/auth-store"
|
||||
import {
|
||||
useMailSettings,
|
||||
useUpdateMailSettings,
|
||||
} from "@/lib/api/hooks/use-mail-settings"
|
||||
import {
|
||||
apiSettingsToStore,
|
||||
storeSettingsToPatch,
|
||||
} from "@/lib/mail-settings/map-api-settings"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
|
||||
type PersistedSettings = Pick<
|
||||
ReturnType<typeof useMailSettingsStore.getState>,
|
||||
| "density"
|
||||
| "themeMode"
|
||||
| "backgroundId"
|
||||
| "inboxSort"
|
||||
| "readingPane"
|
||||
| "conversationMode"
|
||||
| "desktopNewMail"
|
||||
| "desktopMentions"
|
||||
| "emailDigest"
|
||||
| "soundEnabled"
|
||||
>
|
||||
|
||||
function pickPersisted(state: ReturnType<typeof useMailSettingsStore.getState>): PersistedSettings {
|
||||
return {
|
||||
density: state.density,
|
||||
themeMode: state.themeMode,
|
||||
backgroundId: state.backgroundId,
|
||||
inboxSort: state.inboxSort,
|
||||
readingPane: state.readingPane,
|
||||
conversationMode: state.conversationMode,
|
||||
desktopNewMail: state.desktopNewMail,
|
||||
desktopMentions: state.desktopMentions,
|
||||
emailDigest: state.emailDigest,
|
||||
soundEnabled: state.soundEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
function diffPersisted(
|
||||
prev: PersistedSettings,
|
||||
next: PersistedSettings
|
||||
): Partial<PersistedSettings> {
|
||||
const changed: Partial<PersistedSettings> = {}
|
||||
if (prev.density !== next.density) changed.density = next.density
|
||||
if (prev.themeMode !== next.themeMode) changed.themeMode = next.themeMode
|
||||
if (prev.backgroundId !== next.backgroundId) changed.backgroundId = next.backgroundId
|
||||
if (prev.inboxSort !== next.inboxSort) changed.inboxSort = next.inboxSort
|
||||
if (prev.readingPane !== next.readingPane) changed.readingPane = next.readingPane
|
||||
if (prev.conversationMode !== next.conversationMode) {
|
||||
changed.conversationMode = next.conversationMode
|
||||
}
|
||||
if (prev.desktopNewMail !== next.desktopNewMail) changed.desktopNewMail = next.desktopNewMail
|
||||
if (prev.desktopMentions !== next.desktopMentions) {
|
||||
changed.desktopMentions = next.desktopMentions
|
||||
}
|
||||
if (prev.emailDigest !== next.emailDigest) changed.emailDigest = next.emailDigest
|
||||
if (prev.soundEnabled !== next.soundEnabled) changed.soundEnabled = next.soundEnabled
|
||||
return changed
|
||||
}
|
||||
|
||||
export function MailSettingsSync() {
|
||||
const authenticated = useAuthStore((s) => s.isAuthenticated())
|
||||
const { data } = useMailSettings(authenticated)
|
||||
const updateMutation = useUpdateMailSettings()
|
||||
const hydratingRef = useRef(false)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevRef = useRef<PersistedSettings>(pickPersisted(useMailSettingsStore.getState()))
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
hydratingRef.current = true
|
||||
const mapped = apiSettingsToStore(data)
|
||||
useMailSettingsStore.getState().hydrateFromApi(mapped)
|
||||
prevRef.current = pickPersisted(useMailSettingsStore.getState())
|
||||
queueMicrotask(() => {
|
||||
hydratingRef.current = false
|
||||
})
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = useMailSettingsStore.subscribe((state) => {
|
||||
if (hydratingRef.current) return
|
||||
|
||||
const next = pickPersisted(state)
|
||||
const changed = diffPersisted(prevRef.current, next)
|
||||
if (Object.keys(changed).length === 0) return
|
||||
|
||||
prevRef.current = next
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
updateMutation.mutate(storeSettingsToPatch(changed))
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsub()
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [updateMutation])
|
||||
|
||||
return null
|
||||
}
|
||||
23
components/gmail/mail-signatures-sync.tsx
Normal file
23
components/gmail/mail-signatures-sync.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useAuthReady } from '@/lib/api/use-auth-ready'
|
||||
import { useMailSignatures } from '@/lib/api/hooks/use-mail-signatures'
|
||||
import { useMailSignaturesStore } from '@/lib/stores/mail-signatures-store'
|
||||
|
||||
/** Hydrate signature library from server for compose and settings. */
|
||||
export function MailSignaturesSync() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: signatures = [], isSuccess } = useMailSignatures()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !authenticated) {
|
||||
useMailSignaturesStore.getState().clear()
|
||||
return
|
||||
}
|
||||
if (!isSuccess) return
|
||||
useMailSignaturesStore.getState().hydrateFromApi(signatures)
|
||||
}, [ready, authenticated, isSuccess, signatures])
|
||||
|
||||
return null
|
||||
}
|
||||
27
components/gmail/nav/nav-color-dot.tsx
Normal file
27
components/gmail/nav/nav-color-dot.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
|
||||
export function NavColorDot({
|
||||
color,
|
||||
className,
|
||||
rounded = "sm",
|
||||
}: {
|
||||
color: string
|
||||
className?: string
|
||||
rounded?: "sm" | "full"
|
||||
}) {
|
||||
const colorClass = normalizeNavColorClass(color)
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"block size-3 shrink-0 border border-black/10",
|
||||
rounded === "full" ? "rounded-full" : "rounded-sm",
|
||||
colorClass,
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
65
components/gmail/nav/nav-color-picker-trigger.tsx
Normal file
65
components/gmail/nav/nav-color-picker-trigger.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
|
||||
export function NavColorPickerTrigger({
|
||||
value,
|
||||
onChange,
|
||||
rounded = "full",
|
||||
className,
|
||||
"aria-label": ariaLabel = "Couleur",
|
||||
}: {
|
||||
value: string
|
||||
onChange: (swatch: string) => void
|
||||
rounded?: "sm" | "full"
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const colorClass = normalizeNavColorClass(value)
|
||||
const isFull = rounded === "full"
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isFull ? "size-7 rounded-full p-0.5" : "size-7 rounded-sm bg-background hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block border border-black/10",
|
||||
isFull ? "size-full rounded-full" : "size-4 rounded-sm",
|
||||
colorClass
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-2" align="start" sideOffset={6}>
|
||||
<NavColorPicker
|
||||
variant="menu"
|
||||
value={value}
|
||||
onChange={(sw) => {
|
||||
onChange(sw)
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
68
components/gmail/nav/nav-color-picker.tsx
Normal file
68
components/gmail/nav/nav-color-picker.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
LABEL_MENU_COLOR_SWATCHES,
|
||||
} from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||
import {
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
|
||||
} from "@/lib/mail-chrome-classes"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
|
||||
export type NavColorPickerVariant = "menu" | "sheet" | "field"
|
||||
|
||||
const SWATCH_SIZE: Record<NavColorPickerVariant, string> = {
|
||||
menu: "size-6",
|
||||
sheet: "size-8",
|
||||
field: "size-8",
|
||||
}
|
||||
|
||||
export function NavColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
variant = "field",
|
||||
swatches = LABEL_MENU_COLOR_SWATCHES,
|
||||
className,
|
||||
"aria-label": ariaLabel = "Couleur",
|
||||
}: {
|
||||
value: string
|
||||
onChange: (swatch: string) => void
|
||||
variant?: NavColorPickerVariant
|
||||
swatches?: readonly string[]
|
||||
className?: string
|
||||
"aria-label"?: string
|
||||
}) {
|
||||
const active = normalizeNavColorClass(value)
|
||||
const swatchClass = SWATCH_SIZE[variant]
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("grid grid-cols-6 gap-1.5", className)}
|
||||
>
|
||||
{swatches.map((sw) => (
|
||||
<button
|
||||
key={sw}
|
||||
type="button"
|
||||
title={sw}
|
||||
aria-label={sw}
|
||||
aria-pressed={active === sw}
|
||||
onClick={() => onChange(sw)}
|
||||
className={cn(
|
||||
swatchClass,
|
||||
"rounded-full border border-black/10 outline-none ring-offset-1",
|
||||
variant === "menu"
|
||||
? cn(
|
||||
"hover:ring-2",
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
|
||||
)
|
||||
: "hover:ring-2 hover:ring-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
sw,
|
||||
active === sw && "ring-2 ring-primary ring-offset-1"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
93
components/gmail/nav/nav-visibility-fields.tsx
Normal file
93
components/gmail/nav/nav-visibility-fields.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Check } from "lucide-react"
|
||||
import type {
|
||||
LabelInMessageListVisibility,
|
||||
LabelListSidebarVisibility,
|
||||
} from "@/lib/stores/nav-store"
|
||||
|
||||
function VisibilityOption({
|
||||
checked,
|
||||
onPick,
|
||||
children,
|
||||
}: {
|
||||
checked: boolean
|
||||
onPick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
|
||||
checked
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<span>{children}</span>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center" aria-hidden={!checked}>
|
||||
{checked ? <Check className="size-4" strokeWidth={2} /> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavSidebarVisibilityFields({
|
||||
listKind,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
listKind: "labels" | "folders"
|
||||
value: LabelListSidebarVisibility
|
||||
onChange: (v: LabelListSidebarVisibility) => void
|
||||
}) {
|
||||
const section =
|
||||
listKind === "labels"
|
||||
? "Dans la liste des libellés"
|
||||
: "Dans la liste des dossiers"
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{section}
|
||||
</legend>
|
||||
<VisibilityOption checked={value === "show"} onPick={() => onChange("show")}>
|
||||
Afficher
|
||||
</VisibilityOption>
|
||||
<VisibilityOption
|
||||
checked={value === "showUnread"}
|
||||
onPick={() => onChange("showUnread")}
|
||||
>
|
||||
Afficher si messages non lus
|
||||
</VisibilityOption>
|
||||
<VisibilityOption checked={value === "hide"} onPick={() => onChange("hide")}>
|
||||
Masquer
|
||||
</VisibilityOption>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavMessageVisibilityFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: LabelInMessageListVisibility
|
||||
onChange: (v: LabelInMessageListVisibility) => void
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
Dans la liste des messages
|
||||
</legend>
|
||||
<VisibilityOption checked={value === "show"} onPick={() => onChange("show")}>
|
||||
Afficher
|
||||
</VisibilityOption>
|
||||
<VisibilityOption checked={value === "hide"} onPick={() => onChange("hide")}>
|
||||
Masquer
|
||||
</VisibilityOption>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
@ -3,118 +3,17 @@
|
||||
import Link from "next/link"
|
||||
import { X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { MailSettingsFields } from "@/components/gmail/mail-settings-fields"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import type {
|
||||
InboxSortMode,
|
||||
MailDensity,
|
||||
ReadingPaneMode,
|
||||
} from "@/lib/mail-settings/types"
|
||||
import {
|
||||
QuickSettingsCheckbox,
|
||||
QuickSettingsOption,
|
||||
} from "@/components/gmail/quick-settings/quick-settings-option"
|
||||
import {
|
||||
DensityCompactIcon,
|
||||
DensityDefaultIcon,
|
||||
DensityNormalIcon,
|
||||
InboxDefaultIcon,
|
||||
InboxImportantIcon,
|
||||
InboxStarredIcon,
|
||||
InboxUnreadIcon,
|
||||
ReadingPaneBelowIcon,
|
||||
ReadingPaneNoneIcon,
|
||||
ReadingPaneRightIcon,
|
||||
ThemeThumbnailIcon,
|
||||
} from "@/components/gmail/quick-settings/settings-preview-icons"
|
||||
|
||||
function SettingsSection({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("border-b border-border px-4 py-4", className)}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuickSettingsPanel() {
|
||||
const open = useMailSettingsStore((s) => s.quickSettingsOpen)
|
||||
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
|
||||
const setOpen = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
||||
const setThemeDialogOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
|
||||
const density = useMailSettingsStore((s) => s.density)
|
||||
const setDensity = useMailSettingsStore((s) => s.setDensity)
|
||||
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
|
||||
const setInboxSort = useMailSettingsStore((s) => s.setInboxSort)
|
||||
const readingPane = useMailSettingsStore((s) => s.readingPane)
|
||||
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
|
||||
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
|
||||
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const densityOptions: {
|
||||
id: MailDensity
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}[] = [
|
||||
{ id: "default", label: "Par défaut", icon: <DensityDefaultIcon /> },
|
||||
{ id: "normal", label: "Normal", icon: <DensityNormalIcon /> },
|
||||
{ id: "compact", label: "Compact", icon: <DensityCompactIcon /> },
|
||||
]
|
||||
|
||||
const inboxOptions: {
|
||||
id: InboxSortMode
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
}[] = [
|
||||
{ id: "default", label: "Par défaut", icon: <InboxDefaultIcon /> },
|
||||
{
|
||||
id: "important",
|
||||
label: "Importants d'abord",
|
||||
icon: <InboxImportantIcon />,
|
||||
},
|
||||
{ id: "unread", label: "Non lus d'abord", icon: <InboxUnreadIcon /> },
|
||||
{ id: "starred", label: "Suivis d'abord", icon: <InboxStarredIcon /> },
|
||||
]
|
||||
|
||||
const readingPaneOptions: {
|
||||
id: ReadingPaneMode
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
}[] = [
|
||||
{
|
||||
id: "none",
|
||||
label: "Aucune séparation",
|
||||
icon: <ReadingPaneNoneIcon />,
|
||||
},
|
||||
{
|
||||
id: "right",
|
||||
label: "À droite de la boîte de réception",
|
||||
icon: <ReadingPaneRightIcon />,
|
||||
},
|
||||
{
|
||||
id: "below",
|
||||
label: "Sous la boîte de réception",
|
||||
icon: <ReadingPaneBelowIcon />,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{!themeDialogOpen && (
|
||||
@ -152,88 +51,17 @@ export function QuickSettingsPanel() {
|
||||
variant="outline"
|
||||
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
|
||||
asChild
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Link href="/mail/settings">Voir tous les paramètres</Link>
|
||||
<Link href="/mail/settings" onClick={() => setOpen(false)}>
|
||||
Voir tous les paramètres
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsSection title="Densité">
|
||||
{densityOptions.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="density"
|
||||
label={opt.label}
|
||||
checked={density === opt.id}
|
||||
onSelect={() => setDensity(opt.id)}
|
||||
icon={opt.icon}
|
||||
<MailSettingsFields
|
||||
variant="panel"
|
||||
onOpenThemeDialog={() => setThemeDialogOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Thème"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-[#1a73e8] hover:underline"
|
||||
onClick={() => {
|
||||
setThemeDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
Tout afficher
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-end rounded-md py-1 hover:bg-accent"
|
||||
onClick={() => setThemeDialogOpen(true)}
|
||||
>
|
||||
<ThemeThumbnailIcon />
|
||||
</button>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Type de boîte de réception">
|
||||
{inboxOptions.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="inbox-sort"
|
||||
label={opt.label}
|
||||
checked={inboxSort === opt.id}
|
||||
onSelect={() => setInboxSort(opt.id)}
|
||||
icon={opt.icon}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Volet de lecture">
|
||||
{readingPaneOptions.map((opt) => (
|
||||
<QuickSettingsOption
|
||||
key={opt.id}
|
||||
name="reading-pane"
|
||||
label={opt.label}
|
||||
checked={readingPane === opt.id}
|
||||
disabled={opt.disabled}
|
||||
onSelect={() => {
|
||||
if (!opt.disabled) setReadingPane(opt.id)
|
||||
}}
|
||||
icon={opt.icon}
|
||||
/>
|
||||
))}
|
||||
</SettingsSection>
|
||||
|
||||
<section className="px-4 py-4">
|
||||
<h2 className="mb-2 text-sm font-medium text-foreground">
|
||||
Fils de discussion
|
||||
</h2>
|
||||
<QuickSettingsCheckbox
|
||||
label="Mode Conversation"
|
||||
checked={conversationMode}
|
||||
onChange={setConversationMode}
|
||||
helpLabel="Regrouper les messages d'une même conversation"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
||||
@ -190,3 +190,121 @@ export function ThemeThumbnailIcon() {
|
||||
</PreviewFrame>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeModePreviewFrame({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden rounded-md border border-border",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MailChromePreview({
|
||||
headerBg,
|
||||
sidebarBg,
|
||||
listBg,
|
||||
contentBg,
|
||||
lineBg,
|
||||
}: {
|
||||
headerBg: string
|
||||
sidebarBg: string
|
||||
listBg: string
|
||||
contentBg: string
|
||||
lineBg: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn("h-2 shrink-0", headerBg)} />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className={cn("w-[24%] shrink-0", sidebarBg)} />
|
||||
<div className={cn("flex min-w-0 flex-1 flex-col p-0.5", listBg)}>
|
||||
<div className={cn("h-px w-full", lineBg)} />
|
||||
<div className={cn("mt-0.5 h-px w-3/4", lineBg)} />
|
||||
<div className={cn("mt-0.5 h-px w-1/2", lineBg)} />
|
||||
</div>
|
||||
<div className={cn("w-[30%] shrink-0", contentBg)} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeLightPreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
||||
<MailChromePreview
|
||||
headerBg="bg-white"
|
||||
sidebarBg="bg-[#f1f3f4]"
|
||||
listBg="bg-white"
|
||||
contentBg="bg-[#e8f0fe]"
|
||||
lineBg="bg-[#dadce0]"
|
||||
/>
|
||||
</ThemeModePreviewFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeDarkPreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
||||
<MailChromePreview
|
||||
headerBg="bg-[#202124]"
|
||||
sidebarBg="bg-[#3c4043]"
|
||||
listBg="bg-[#202124]"
|
||||
contentBg="bg-[#394457]"
|
||||
lineBg="bg-[#5f6368]"
|
||||
/>
|
||||
</ThemeModePreviewFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeSystemPreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<ThemeModePreviewFrame className={cn("h-12", className)}>
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex w-1/2 min-w-0 flex-col">
|
||||
<div className="h-2 shrink-0 bg-white" />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="w-[24%] shrink-0 bg-[#f1f3f4]" />
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-white p-0.5">
|
||||
<div className="h-px w-full bg-[#dadce0]" />
|
||||
<div className="mt-0.5 h-px w-3/4 bg-[#dadce0]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-1/2 min-w-0 flex-col">
|
||||
<div className="h-2 shrink-0 bg-[#202124]" />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="w-[24%] shrink-0 bg-[#3c4043]" />
|
||||
<div className="flex min-w-0 flex-1 flex-col bg-[#202124] p-0.5">
|
||||
<div className="h-px w-full bg-[#5f6368]" />
|
||||
<div className="mt-0.5 h-px w-3/4 bg-[#5f6368]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeModePreviewFrame>
|
||||
)
|
||||
}
|
||||
|
||||
export function ThemeModePreview({
|
||||
mode,
|
||||
className,
|
||||
}: {
|
||||
mode: "light" | "dark" | "system"
|
||||
className?: string
|
||||
}) {
|
||||
if (mode === "light") return <ThemeLightPreview className={className} />
|
||||
if (mode === "dark") return <ThemeDarkPreview className={className} />
|
||||
return <ThemeSystemPreview className={className} />
|
||||
}
|
||||
|
||||
@ -11,27 +11,13 @@ import {
|
||||
MAIL_BACKGROUND_PRESETS,
|
||||
normalizeMailBackgroundId,
|
||||
} from "@/lib/mail-settings/constants"
|
||||
import type { MailBackgroundId, MailThemeMode } from "@/lib/mail-settings/types"
|
||||
import type { MailBackgroundId } from "@/lib/mail-settings/types"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
|
||||
const THEME_OPTIONS: { id: MailThemeMode; label: string; previewClass: string }[] =
|
||||
[
|
||||
{ id: "light", label: "Clair", previewClass: "bg-white" },
|
||||
{ id: "dark", label: "Sombre", previewClass: "bg-[#202124]" },
|
||||
{
|
||||
id: "system",
|
||||
label: "Système",
|
||||
previewClass:
|
||||
"bg-gradient-to-br from-white from-50% to-[#202124] to-50%",
|
||||
},
|
||||
]
|
||||
|
||||
export function ThemeSettingsDialog() {
|
||||
const open = useMailSettingsStore((s) => s.themeDialogOpen)
|
||||
const setOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
|
||||
const themeMode = useMailSettingsStore((s) => s.themeMode)
|
||||
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
|
||||
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
|
||||
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
|
||||
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
||||
|
||||
@ -43,41 +29,11 @@ export function ThemeSettingsDialog() {
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-left text-base font-normal text-foreground">
|
||||
Thème
|
||||
Arrière-plan
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">Mode</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setThemeMode(opt.id)}
|
||||
className={cn(
|
||||
"rounded-lg border-2 p-2.5 text-left transition-colors",
|
||||
themeMode === opt.id
|
||||
? "border-primary bg-accent/60"
|
||||
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 h-14 rounded-md border border-border",
|
||||
opt.previewClass
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm text-foreground">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-3 text-sm font-medium text-foreground">
|
||||
Arrière-plan
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
|
||||
573
components/gmail/settings/add-mail-account-form.tsx
Normal file
573
components/gmail/settings/add-mail-account-form.tsx
Normal file
@ -0,0 +1,573 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ChevronDown, ChevronUp, Loader2, Mail } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { ProtonBridgeWizard } from "@/components/gmail/settings/proton-bridge-wizard"
|
||||
import { useDiscoverMailAccount } from "@/lib/api/hooks/use-mail-account-discover"
|
||||
import { useTestMailAccount } from "@/lib/api/hooks/use-mail-account-test"
|
||||
import { useStartMailOAuth } from "@/lib/api/hooks/use-mail-oauth"
|
||||
import { useMailOAuthProviders } from "@/lib/api/hooks/use-mail-oauth-providers"
|
||||
import type { CreateMailAccountPayload, MailAccountDiscoverResult } from "@/lib/api/types"
|
||||
import { manualMailDiscoverResult } from "@/lib/mail-settings/manual-account-discover"
|
||||
|
||||
type Step = "email" | "proton" | "credentials"
|
||||
|
||||
function displayNameFromEmail(email: string): string {
|
||||
const local = email.split("@")[0] ?? email
|
||||
return local.replace(/[._-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function usernameForHint(email: string, hint: string): string {
|
||||
if (hint === "local") {
|
||||
return email.split("@")[0] ?? email
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
function shouldShowAdvanced(discover: MailAccountDiscoverResult | null): boolean {
|
||||
if (!discover) return true
|
||||
if (discover.confidence === "low") return true
|
||||
if (!discover.imap_host || !discover.smtp_host) return true
|
||||
if (discover.provider_id === "tutanota") return true
|
||||
return false
|
||||
}
|
||||
|
||||
function oauthProviderForDiscover(
|
||||
discover: MailAccountDiscoverResult,
|
||||
enabled: string[]
|
||||
): "google" | "microsoft" | null {
|
||||
if (!discover.auth_methods.includes("oauth2")) return null
|
||||
const id = discover.provider_id
|
||||
if ((id === "gmail" || id === "google_workspace") && enabled.includes("google")) {
|
||||
return "google"
|
||||
}
|
||||
if (
|
||||
(id === "outlook" || id === "microsoft365") &&
|
||||
enabled.includes("microsoft")
|
||||
) {
|
||||
return "microsoft"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function AddMailAccountForm({
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
pending: boolean
|
||||
onSubmit: (payload: CreateMailAccountPayload) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [step, setStep] = useState<Step>("email")
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [discover, setDiscover] = useState<MailAccountDiscoverResult | null>(null)
|
||||
const [testOk, setTestOk] = useState<boolean | null>(null)
|
||||
const discoverMutation = useDiscoverMailAccount()
|
||||
const testMutation = useTestMailAccount()
|
||||
const oauthStart = useStartMailOAuth()
|
||||
const { data: oauthProviders } = useMailOAuthProviders()
|
||||
const enabledOAuth = oauthProviders?.providers ?? []
|
||||
|
||||
const [email, setEmail] = useState("")
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
imap_host: "127.0.0.1",
|
||||
imap_port: "1143",
|
||||
imap_tls: true,
|
||||
smtp_host: "127.0.0.1",
|
||||
smtp_port: "1025",
|
||||
smtp_tls: true,
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!discover) return
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
imap_host: discover.imap_host || prev.imap_host,
|
||||
imap_port: String(discover.imap_port || 993),
|
||||
imap_tls: discover.imap_tls,
|
||||
smtp_host: discover.smtp_host || prev.smtp_host,
|
||||
smtp_port: String(discover.smtp_port || 587),
|
||||
smtp_tls: discover.smtp_tls,
|
||||
username: prev.username || usernameForHint(discover.email, discover.username_hint),
|
||||
name: prev.name || displayNameFromEmail(discover.email),
|
||||
}))
|
||||
setShowAdvanced(shouldShowAdvanced(discover))
|
||||
setTestOk(null)
|
||||
}, [discover])
|
||||
|
||||
function resetForm() {
|
||||
setStep("email")
|
||||
setEmail("")
|
||||
setDiscover(null)
|
||||
setShowAdvanced(false)
|
||||
setTestOk(null)
|
||||
setForm({
|
||||
name: "",
|
||||
imap_host: "127.0.0.1",
|
||||
imap_port: "1143",
|
||||
imap_tls: true,
|
||||
smtp_host: "127.0.0.1",
|
||||
smtp_port: "1025",
|
||||
smtp_tls: true,
|
||||
username: "",
|
||||
password: "",
|
||||
})
|
||||
discoverMutation.reset()
|
||||
testMutation.reset()
|
||||
oauthStart.reset()
|
||||
}
|
||||
|
||||
async function handleEmailContinue() {
|
||||
const trimmed = email.trim()
|
||||
if (!trimmed) return
|
||||
const result = await discoverMutation.mutateAsync(trimmed)
|
||||
goToCredentials(result)
|
||||
}
|
||||
|
||||
function goToCredentials(result: MailAccountDiscoverResult) {
|
||||
setDiscover(result)
|
||||
if (result.provider_id === "proton" && result.source !== "manual") {
|
||||
setStep("proton")
|
||||
} else {
|
||||
setStep("credentials")
|
||||
}
|
||||
}
|
||||
|
||||
function handleManualContinue() {
|
||||
const trimmed = email.trim()
|
||||
if (!trimmed || !trimmed.includes("@")) return
|
||||
goToCredentials(manualMailDiscoverResult(trimmed))
|
||||
}
|
||||
|
||||
async function runConnectionTest() {
|
||||
const result = await testMutation.mutateAsync({
|
||||
imap_host: form.imap_host,
|
||||
imap_port: Number(form.imap_port) || 993,
|
||||
imap_tls: form.imap_tls,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: Number(form.smtp_port) || 587,
|
||||
smtp_tls: form.smtp_tls,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
})
|
||||
setTestOk(result.ok)
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleProtonContinue() {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
imap_host: "127.0.0.1",
|
||||
smtp_host: "127.0.0.1",
|
||||
username: discover?.email ?? prev.username,
|
||||
password: prev.password,
|
||||
}))
|
||||
const result = await runConnectionTest()
|
||||
if (result.ok) {
|
||||
setStep("credentials")
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!discover) return
|
||||
onSubmit({
|
||||
name: form.name.trim() || displayNameFromEmail(discover.email),
|
||||
email: discover.email,
|
||||
provider: discover.provider_id,
|
||||
imap_host: form.imap_host,
|
||||
imap_port: Number(form.imap_port) || 993,
|
||||
imap_tls: form.imap_tls,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: Number(form.smtp_port) || 587,
|
||||
smtp_tls: form.smtp_tls,
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: "google" | "microsoft") {
|
||||
if (!discover) return
|
||||
const { authorization_url } = await oauthStart.mutateAsync({
|
||||
provider,
|
||||
email: discover.email,
|
||||
name: form.name.trim() || displayNameFromEmail(discover.email),
|
||||
provider_id: discover.provider_id,
|
||||
imap_host: form.imap_host,
|
||||
imap_port: Number(form.imap_port) || 993,
|
||||
imap_tls: form.imap_tls,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: Number(form.smtp_port) || 587,
|
||||
smtp_tls: form.smtp_tls,
|
||||
})
|
||||
window.location.href = authorization_url
|
||||
}
|
||||
|
||||
const oauthProvider = discover ? oauthProviderForDiscover(discover, enabledOAuth) : null
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(true)}>
|
||||
Ajouter un compte mail
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Nouveau compte mail</CardTitle>
|
||||
<CardDescription>
|
||||
Saisissez votre adresse e-mail : nous détectons le fournisseur et préremplissons IMAP/SMTP.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{step === "email" ? (
|
||||
<div className="space-y-3 max-w-md">
|
||||
<Field
|
||||
label="Adresse e-mail"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vous@exemple.com"
|
||||
/>
|
||||
{discoverMutation.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Détection impossible. Utilisez « Configurer manuellement » ou réessayez.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!email.trim() || discoverMutation.isPending}
|
||||
onClick={() => void handleEmailContinue()}
|
||||
>
|
||||
{discoverMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Détection…
|
||||
</>
|
||||
) : (
|
||||
"Détecter la configuration"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!email.trim() || !email.includes("@")}
|
||||
onClick={handleManualContinue}
|
||||
>
|
||||
Configurer manuellement
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
La détection préremplit IMAP/SMTP. Choisissez « Configurer manuellement » pour saisir
|
||||
vos paramètres sans appel réseau.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === "proton" && discover ? (
|
||||
<ProtonBridgeWizard
|
||||
email={discover.email}
|
||||
imapPort={form.imap_port}
|
||||
smtpPort={form.smtp_port}
|
||||
bridgePassword={form.password}
|
||||
onBridgePasswordChange={(v) => setForm({ ...form, password: v })}
|
||||
onImapPortChange={(v) => setForm({ ...form, imap_port: v })}
|
||||
onSmtpPortChange={(v) => setForm({ ...form, smtp_port: v })}
|
||||
onContinue={() => void handleProtonContinue()}
|
||||
onBack={() => setStep("email")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{step === "credentials" && discover ? (
|
||||
<>
|
||||
<ProviderBanner discover={discover} onBack={() => setStep("email")} />
|
||||
|
||||
{discover.notes?.length ? (
|
||||
<ul className="rounded-md border border-border bg-muted/40 px-3 py-2 text-xs text-muted-foreground space-y-1">
|
||||
{discover.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{oauthProvider ? (
|
||||
<div className="space-y-2 max-w-md">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Connexion recommandée sans mot de passe :
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={oauthStart.isPending}
|
||||
onClick={() => void handleOAuth(oauthProvider)}
|
||||
>
|
||||
{oauthStart.isPending ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{oauthProvider === "google"
|
||||
? "Continuer avec Google"
|
||||
: "Continuer avec Microsoft"}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">ou identifiant / mot de passe ci-dessous</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 max-w-2xl">
|
||||
<Field
|
||||
label="Nom affiché"
|
||||
value={form.name}
|
||||
onChange={(v) => setForm({ ...form, name: v })}
|
||||
/>
|
||||
<Field
|
||||
label="Identifiant"
|
||||
value={form.username}
|
||||
onChange={(v) => setForm({ ...form, username: v })}
|
||||
autoComplete="username"
|
||||
/>
|
||||
<Field
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(v) => {
|
||||
setTestOk(null)
|
||||
setForm({ ...form, password: v })
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="size-4" />
|
||||
) : (
|
||||
<ChevronDown className="size-4" />
|
||||
)}
|
||||
Paramètres serveur {showAdvanced ? "" : "(avancé)"}
|
||||
</button>
|
||||
{showAdvanced ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 max-w-2xl">
|
||||
<Field
|
||||
label="IMAP hôte"
|
||||
value={form.imap_host}
|
||||
onChange={(v) => setForm({ ...form, imap_host: v })}
|
||||
/>
|
||||
<Field
|
||||
label="IMAP port"
|
||||
value={form.imap_port}
|
||||
onChange={(v) => setForm({ ...form, imap_port: v })}
|
||||
/>
|
||||
<Field
|
||||
label="SMTP hôte"
|
||||
value={form.smtp_host}
|
||||
onChange={(v) => setForm({ ...form, smtp_host: v })}
|
||||
/>
|
||||
<Field
|
||||
label="SMTP port"
|
||||
value={form.smtp_port}
|
||||
onChange={(v) => setForm({ ...form, smtp_port: v })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.imap_tls}
|
||||
onChange={(e) => setForm({ ...form, imap_tls: e.target.checked })}
|
||||
/>
|
||||
IMAP TLS
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.smtp_tls}
|
||||
onChange={(e) => setForm({ ...form, smtp_tls: e.target.checked })}
|
||||
/>
|
||||
SMTP TLS / STARTTLS
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Réception {form.imap_host}:{form.imap_port} · Envoi {form.smtp_host}:{form.smtp_port}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TestResultBanner
|
||||
testing={testMutation.isPending}
|
||||
result={testMutation.data}
|
||||
testOk={testOk}
|
||||
error={testMutation.isError}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
testMutation.isPending ||
|
||||
!form.password ||
|
||||
!form.username ||
|
||||
!form.imap_host ||
|
||||
!form.smtp_host
|
||||
}
|
||||
onClick={() => void runConnectionTest()}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Test…
|
||||
</>
|
||||
) : (
|
||||
"Tester la connexion"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
pending ||
|
||||
!form.password ||
|
||||
!form.username ||
|
||||
!form.imap_host ||
|
||||
!form.smtp_host ||
|
||||
testOk === false
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function TestResultBanner({
|
||||
testing,
|
||||
result,
|
||||
testOk,
|
||||
error,
|
||||
}: {
|
||||
testing: boolean
|
||||
result?: { ok: boolean; imap_ok: boolean; imap_error?: string; smtp_ok: boolean; smtp_error?: string }
|
||||
testOk: boolean | null
|
||||
error: boolean
|
||||
}) {
|
||||
if (testing || testOk === null) {
|
||||
if (error) {
|
||||
return <p className="text-sm text-destructive">Échec du test de connexion.</p>
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (testOk && result?.ok) {
|
||||
return (
|
||||
<p className="text-sm text-green-600 dark:text-green-500">
|
||||
Connexion IMAP et SMTP validée.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="text-sm text-destructive space-y-1">
|
||||
<p>Échec du test de connexion.</p>
|
||||
{result?.imap_error ? <p className="text-xs">IMAP : {result.imap_error}</p> : null}
|
||||
{result?.smtp_error ? <p className="text-xs">SMTP : {result.smtp_error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderBanner({
|
||||
discover,
|
||||
onBack,
|
||||
}: {
|
||||
discover: MailAccountDiscoverResult
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Mail className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{discover.email}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{discover.provider_name}
|
||||
{discover.confidence !== "high" ? ` · confiance ${discover.confidence}` : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
|
||||
Modifier l'adresse
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
autoComplete,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
autoComplete?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
components/gmail/settings/automation/automation-rules-panel.tsx
Normal file
194
components/gmail/settings/automation/automation-rules-panel.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Pencil, Plus, Trash2, FunctionSquare, Workflow } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
useMailRules,
|
||||
useCreateMailRule,
|
||||
useUpdateMailRule,
|
||||
useDeleteMailRule,
|
||||
} from '@/lib/api/hooks/use-mail-automation-queries'
|
||||
import { useAuthReady } from '@/lib/api/use-auth-ready'
|
||||
import { SettingsSyncBanner } from '@/components/gmail/settings/settings-sync-banner'
|
||||
import { RuleWorkflowEditor } from './rule-workflow-editor'
|
||||
import { RuleSimulatorPanel } from './rule-simulator-panel'
|
||||
import type { ApiRule } from '@/lib/api/types'
|
||||
import type { RuleEditorState } from '@/lib/mail-automation/types'
|
||||
import {
|
||||
createDefaultRuleEditorState,
|
||||
workflowToApiPayload,
|
||||
} from '@/lib/mail-automation/defaults'
|
||||
import { parseWorkflowFromRule } from '@/lib/mail-automation/workflow-flow'
|
||||
import { AutomationSuggestionsProvider } from './automation-suggest-input'
|
||||
|
||||
export function AutomationRulesPanel() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: rules = [], isFetching, isError, refetch, isPending } = useMailRules()
|
||||
const createRule = useCreateMailRule()
|
||||
const updateRule = useUpdateMailRule()
|
||||
const deleteRule = useDeleteMailRule()
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editorState, setEditorState] = useState<RuleEditorState>(() =>
|
||||
createDefaultRuleEditorState('rule')
|
||||
)
|
||||
|
||||
const showInitialLoad = ready && authenticated && isPending && rules.length === 0
|
||||
|
||||
const rulesOnly = useMemo(() => rules.filter((r) => (r.rule_kind ?? 'rule') === 'rule'), [rules])
|
||||
const functionsOnly = useMemo(() => rules.filter((r) => r.rule_kind === 'function'), [rules])
|
||||
|
||||
function openNew(kind: 'rule' | 'function') {
|
||||
setEditingId(null)
|
||||
setEditorState(createDefaultRuleEditorState(kind))
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(rule: ApiRule) {
|
||||
const kind = rule.rule_kind ?? 'rule'
|
||||
const workflow = parseWorkflowFromRule(rule.workflow, kind)
|
||||
setEditingId(rule.id)
|
||||
setEditorState({
|
||||
name: rule.name,
|
||||
priority: rule.priority,
|
||||
is_active: rule.is_active,
|
||||
rule_kind: kind,
|
||||
account_id: rule.account_id,
|
||||
workflow:
|
||||
workflow ??
|
||||
createDefaultRuleEditorState(kind).workflow,
|
||||
})
|
||||
setEditorOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const payload = workflowToApiPayload(editorState)
|
||||
if (editingId) {
|
||||
await updateRule.mutateAsync({ ruleId: editingId, ...payload })
|
||||
} else {
|
||||
await createRule.mutateAsync(payload)
|
||||
}
|
||||
setEditorOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => openNew('rule')}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
Nouvelle règle
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => openNew('function')}>
|
||||
<FunctionSquare className="mr-1 size-3.5" />
|
||||
Nouvelle fonction
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showInitialLoad ? null : rules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucune règle. Créez une règle graphique avec déclencheurs, conditions et actions.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<RuleList title="Règles actives" icon={Workflow} items={rulesOnly} onEdit={openEdit} onDelete={(id) => deleteRule.mutate(id)} />
|
||||
{functionsOnly.length > 0 ? (
|
||||
<RuleList title="Fonctions réutilisables" icon={FunctionSquare} items={functionsOnly} onEdit={openEdit} onDelete={(id) => deleteRule.mutate(id)} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
|
||||
<DialogContent className="flex max-h-[95vh] max-w-[95vw] flex-col gap-0 overflow-hidden p-0 sm:max-w-6xl">
|
||||
<DialogHeader className="border-b border-border px-4 py-3">
|
||||
<DialogTitle className="text-base">
|
||||
{editingId ? 'Modifier' : 'Créer'}{' '}
|
||||
{editorState.rule_kind === 'function' ? 'une fonction' : 'une règle'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<AutomationSuggestionsProvider>
|
||||
<RuleWorkflowEditor
|
||||
key={editingId ?? `new-${editorState.rule_kind}`}
|
||||
state={editorState}
|
||||
allRules={rules}
|
||||
onChange={setEditorState}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<RuleSimulatorPanel state={editorState} ruleId={editingId ?? undefined} />
|
||||
</div>
|
||||
</AutomationSuggestionsProvider>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border px-4 py-3">
|
||||
<Button type="button" variant="outline" onClick={() => setEditorOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!editorState.name.trim() || createRule.isPending || updateRule.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleList({
|
||||
title,
|
||||
icon: Icon,
|
||||
items,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
title: string
|
||||
icon: typeof Workflow
|
||||
items: ApiRule[]
|
||||
onEdit: (rule: ApiRule) => void
|
||||
onDelete: (id: string) => void
|
||||
}) {
|
||||
if (items.length === 0) return null
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Icon className="size-4 opacity-70" />
|
||||
{title}
|
||||
</div>
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{items.map((rule) => (
|
||||
<li key={rule.id} className="flex items-start justify-between gap-2 px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{rule.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Priorité {rule.priority}
|
||||
{rule.is_active === false ? ' · inactive' : ''}
|
||||
{rule.match_count != null ? ` · ${rule.match_count} exécutions` : ''}
|
||||
{rule.workflow ? ' · graphique' : ' · legacy'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onEdit(rule)}>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => onDelete(rule.id)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
useAutomationSuggestions,
|
||||
type AutomationSuggestion,
|
||||
} from '@/lib/mail-automation/use-automation-suggestions'
|
||||
import type { AutomationSuggestionKind } from '@/lib/mail-automation/condition-helpers'
|
||||
|
||||
interface AutomationSuggestionsContextValue {
|
||||
suggestionsFor: (kind: AutomationSuggestionKind) => AutomationSuggestion[]
|
||||
}
|
||||
|
||||
const AutomationSuggestionsContext = createContext<AutomationSuggestionsContextValue | null>(null)
|
||||
|
||||
export function AutomationSuggestionsProvider({ children }: { children: React.ReactNode }) {
|
||||
const { suggestionsFor } = useAutomationSuggestions()
|
||||
const value = useMemo(() => ({ suggestionsFor }), [suggestionsFor])
|
||||
return (
|
||||
<AutomationSuggestionsContext.Provider value={value}>
|
||||
{children}
|
||||
</AutomationSuggestionsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function useAutomationSuggestionsContext() {
|
||||
return useContext(AutomationSuggestionsContext)
|
||||
}
|
||||
|
||||
interface AutomationSuggestInputProps {
|
||||
kind: AutomationSuggestionKind
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function AutomationSuggestInput({
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
disabled,
|
||||
}: AutomationSuggestInputProps) {
|
||||
const ctx = useAutomationSuggestionsContext()
|
||||
const [listId] = useState(() => `automation-suggest-${kind}-${Math.random().toString(36).slice(2, 9)}`)
|
||||
const suggestions = kind === 'none' || !ctx ? [] : ctx.suggestionsFor(kind)
|
||||
const filtered = useMemo(() => {
|
||||
if (!value.trim()) return suggestions.slice(0, 40)
|
||||
const q = value.trim().toLowerCase()
|
||||
return suggestions
|
||||
.filter(
|
||||
(s) =>
|
||||
s.value.toLowerCase().includes(q) ||
|
||||
(s.label?.toLowerCase().includes(q) ?? false)
|
||||
)
|
||||
.slice(0, 40)
|
||||
}, [suggestions, value])
|
||||
|
||||
if (kind === 'none' || suggestions.length === 0) {
|
||||
return (
|
||||
<Input
|
||||
className={cn('h-8 text-xs', className)}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
className={cn('h-8 text-xs', className)}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
list={listId}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{filtered.map((s) => (
|
||||
<option key={`${s.value}-${s.label ?? ''}`} value={s.value}>
|
||||
{s.label && s.label !== s.value ? s.label : undefined}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
{filtered.length > 0 && value.trim() && !disabled ? (
|
||||
<ul className="absolute z-20 mt-1 max-h-40 w-full overflow-y-auto rounded-md border border-border bg-popover py-1 shadow-md">
|
||||
{filtered.slice(0, 8).map((s) => (
|
||||
<li key={`pick-${s.value}-${s.label ?? ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2 py-1.5 text-left text-xs hover:bg-muted"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
onChange(s.value)
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{s.label ?? s.value}</span>
|
||||
{s.label && s.label !== s.value ? (
|
||||
<span className="block truncate font-mono text-[10px] text-muted-foreground">{s.value}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
components/gmail/settings/automation/rule-simulator-panel.tsx
Normal file
103
components/gmail/settings/automation/rule-simulator-panel.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
'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 { Play } from 'lucide-react'
|
||||
import { useSimulateMailRule } from '@/lib/api/hooks/use-mail-automation-queries'
|
||||
import type { RuleEditorState, RuleSimulationResult } from '@/lib/mail-automation/types'
|
||||
import { DEFAULT_SIMULATION_MESSAGE, workflowToApiPayload } from '@/lib/mail-automation/defaults'
|
||||
|
||||
interface RuleSimulatorPanelProps {
|
||||
state: RuleEditorState
|
||||
ruleId?: string
|
||||
}
|
||||
|
||||
export function RuleSimulatorPanel({ state, ruleId }: RuleSimulatorPanelProps) {
|
||||
const simulate = useSimulateMailRule()
|
||||
const [message, setMessage] = useState(DEFAULT_SIMULATION_MESSAGE)
|
||||
const [result, setResult] = useState<RuleSimulationResult | null>(null)
|
||||
|
||||
async function runSimulation() {
|
||||
const payload = workflowToApiPayload(state)
|
||||
const res = await simulate.mutateAsync({
|
||||
message,
|
||||
...(ruleId
|
||||
? { rule_id: ruleId }
|
||||
: {
|
||||
rule: {
|
||||
conditions: payload.conditions,
|
||||
actions: payload.actions,
|
||||
workflow: payload.workflow,
|
||||
},
|
||||
}),
|
||||
})
|
||||
setResult(res)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/10 p-3">
|
||||
<p className="text-xs font-medium">Tester avec un message exemple</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">De</Label>
|
||||
<Input className="h-8 text-xs" value={message.from} onChange={(e) => setMessage({ ...message, from: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">Sujet</Label>
|
||||
<Input className="h-8 text-xs" value={message.subject} onChange={(e) => setMessage({ ...message, subject: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">Corps</Label>
|
||||
<textarea
|
||||
className="mt-1 min-h-16 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||
value={message.body_text}
|
||||
onChange={(e) => setMessage({ ...message, body_text: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" size="sm" disabled={simulate.isPending} onClick={runSimulation}>
|
||||
<Play className="mr-1 size-3.5" />
|
||||
Simuler
|
||||
</Button>
|
||||
|
||||
{result ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 bg-background p-2 text-xs">
|
||||
<p>
|
||||
Correspondance :{' '}
|
||||
<span className={result.matched ? 'text-emerald-600' : 'text-muted-foreground'}>
|
||||
{result.matched ? 'oui' : 'non'}
|
||||
</span>
|
||||
</p>
|
||||
{result.steps?.length ? (
|
||||
<div>
|
||||
<p className="font-medium">Parcours</p>
|
||||
<ol className="mt-1 list-decimal pl-4 text-muted-foreground">
|
||||
{result.steps.map((s, i) => (
|
||||
<li key={i}>
|
||||
{s.node_type}
|
||||
{s.handle ? ` → ${s.handle}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
{result.actions?.length ? (
|
||||
<div>
|
||||
<p className="font-medium">Actions</p>
|
||||
<ul className="mt-1 space-y-0.5 text-muted-foreground">
|
||||
{result.actions.map((a, i) => (
|
||||
<li key={i}>
|
||||
{a.type}
|
||||
{a.value ? `: ${a.value}` : ''} {a.ok ? '✓' : `✗ ${a.error ?? ''}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
330
components/gmail/settings/automation/rule-workflow-editor.tsx
Normal file
330
components/gmail/settings/automation/rule-workflow-editor.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
addEdge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Node,
|
||||
} from '@xyflow/react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
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 { Plus, Variable } from 'lucide-react'
|
||||
import { workflowNodeTypes } from './workflow-nodes'
|
||||
import { WorkflowTriggersPanel } from './workflow-triggers-panel'
|
||||
import { WorkflowNodeInspector } from './workflow-node-inspector'
|
||||
import {
|
||||
PALETTE_NODE_TYPES,
|
||||
NODE_TYPE_LABELS,
|
||||
NODE_TYPE_DESCRIPTIONS,
|
||||
} from '@/lib/mail-automation/node-definitions'
|
||||
import {
|
||||
createFlowNode,
|
||||
flowToWorkflow,
|
||||
workflowEdgesToFlow,
|
||||
workflowNodesToFlow,
|
||||
} from '@/lib/mail-automation/workflow-flow'
|
||||
import type { ApiRule } from '@/lib/api/types'
|
||||
import type { ExecVariable, RuleEditorState, WorkflowNodeType } from '@/lib/mail-automation/types'
|
||||
import { nextNodeId } from '@/lib/mail-automation/defaults'
|
||||
|
||||
interface RuleWorkflowEditorProps {
|
||||
state: RuleEditorState
|
||||
allRules: ApiRule[]
|
||||
onChange: (state: RuleEditorState) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function RuleWorkflowEditor(props: RuleWorkflowEditorProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<RuleWorkflowEditorInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RuleWorkflowEditorInner({
|
||||
state,
|
||||
allRules,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: RuleWorkflowEditorProps) {
|
||||
const { setCenter, getNodes } = useReactFlow()
|
||||
const initialNodes = useMemo(() => workflowNodesToFlow(state.workflow.nodes), [state.workflow.nodes])
|
||||
const initialEdges = useMemo(() => workflowEdgesToFlow(state.workflow.edges), [state.workflow.edges])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const selectedNode = nodes.find((n) => n.id === selectedId) ?? null
|
||||
|
||||
const syncWorkflow = useCallback(
|
||||
(nextNodes: Node[], nextEdges: typeof edges) => {
|
||||
onChange({
|
||||
...state,
|
||||
workflow: flowToWorkflow(
|
||||
state.rule_kind,
|
||||
state.workflow.triggers,
|
||||
state.workflow.variables,
|
||||
nextNodes,
|
||||
nextEdges
|
||||
),
|
||||
})
|
||||
},
|
||||
[onChange, state]
|
||||
)
|
||||
|
||||
const applySelection = useCallback(
|
||||
(nodeId: string | null) => {
|
||||
setSelectedId(nodeId)
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => ({
|
||||
...n,
|
||||
selected: nodeId !== null && n.id === nodeId,
|
||||
}))
|
||||
)
|
||||
},
|
||||
[setNodes]
|
||||
)
|
||||
|
||||
const focusNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
requestAnimationFrame(() => {
|
||||
const node = getNodes().find((n) => n.id === nodeId)
|
||||
if (!node) return
|
||||
const w = node.measured?.width ?? 200
|
||||
const h = node.measured?.height ?? 80
|
||||
setCenter(node.position.x + w / 2, node.position.y + h / 2, {
|
||||
zoom: 1,
|
||||
duration: 300,
|
||||
})
|
||||
})
|
||||
},
|
||||
[getNodes, setCenter]
|
||||
)
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (readOnly) return
|
||||
const next = addEdge({ ...connection, animated: true, id: nextNodeId('e') }, edges)
|
||||
setEdges(next)
|
||||
syncWorkflow(nodes, next)
|
||||
},
|
||||
[edges, nodes, readOnly, setEdges, syncWorkflow]
|
||||
)
|
||||
|
||||
const onNodeDragStop = useCallback(() => {
|
||||
syncWorkflow(nodes, edges)
|
||||
}, [edges, nodes, syncWorkflow])
|
||||
|
||||
function addNode(type: WorkflowNodeType) {
|
||||
if (readOnly || type === 'start' || type === 'end') return
|
||||
const last = nodes[nodes.length - 1]
|
||||
const x = last ? last.position.x + 220 : 300
|
||||
const y = last ? last.position.y : 200
|
||||
const node = createFlowNode(type, { x, y })
|
||||
const nextNodes = nodes.map((n) => ({ ...n, selected: false })).concat({ ...node, selected: true })
|
||||
setNodes(nextNodes)
|
||||
syncWorkflow(nextNodes, edges)
|
||||
applySelection(node.id)
|
||||
setTimeout(() => focusNode(node.id), 50)
|
||||
}
|
||||
|
||||
function updateNodeData(nodeId: string, data: Record<string, unknown>) {
|
||||
const nextNodes = nodes.map((n) => (n.id === nodeId ? { ...n, data } : n))
|
||||
setNodes(nextNodes)
|
||||
syncWorkflow(nextNodes, edges)
|
||||
}
|
||||
|
||||
function deleteNode(nodeId: string) {
|
||||
const node = nodes.find((n) => n.id === nodeId)
|
||||
if (!node || node.type === 'start' || node.type === 'end') return
|
||||
const nextNodes = nodes.filter((n) => n.id !== nodeId)
|
||||
const nextEdges = edges.filter((e) => e.source !== nodeId && e.target !== nodeId)
|
||||
setNodes(nextNodes)
|
||||
setEdges(nextEdges)
|
||||
syncWorkflow(nextNodes, nextEdges)
|
||||
applySelection(null)
|
||||
}
|
||||
|
||||
function updateVariables(variables: ExecVariable[]) {
|
||||
onChange({
|
||||
...state,
|
||||
workflow: { ...state.workflow, variables },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[520px] flex-col gap-3 lg:flex-row">
|
||||
<aside className="w-full shrink-0 space-y-2 lg:w-52">
|
||||
<p className="text-xs font-medium text-muted-foreground">Ajouter un nœud</p>
|
||||
<div className="space-y-1">
|
||||
{PALETTE_NODE_TYPES.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto w-full min-w-0 justify-start px-2 py-1.5 text-left text-xs"
|
||||
disabled={readOnly}
|
||||
onClick={() => addNode(type)}
|
||||
title={NODE_TYPE_DESCRIPTIONS[type]}
|
||||
>
|
||||
<Plus className="mr-1.5 mt-0.5 size-3 shrink-0 self-start" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium leading-tight">{NODE_TYPE_LABELS[type]}</span>
|
||||
<span className="mt-0.5 block text-wrap break-words text-[10px] leading-snug text-muted-foreground">
|
||||
{NODE_TYPE_DESCRIPTIONS[type]}
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<VariablesPanel variables={state.workflow.variables} disabled={readOnly} onChange={updateVariables} />
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input
|
||||
className="mt-1 h-8 text-xs"
|
||||
value={state.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ ...state, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Priorité</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1 h-8 text-xs"
|
||||
value={state.priority}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange({ ...state, priority: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-1">
|
||||
<Switch
|
||||
checked={state.is_active}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(v) => onChange({ ...state, is_active: v })}
|
||||
/>
|
||||
<Label className="text-xs">Active</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Type</Label>
|
||||
<Input className="mt-1 h-8 text-xs" value={state.rule_kind === 'function' ? 'Fonction' : 'Règle'} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.rule_kind === 'rule' ? (
|
||||
<WorkflowTriggersPanel
|
||||
triggers={state.workflow.triggers}
|
||||
disabled={readOnly}
|
||||
onChange={(triggers) =>
|
||||
onChange({ ...state, workflow: { ...state.workflow, triggers } })
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="h-[420px] overflow-hidden rounded-lg border border-border">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={workflowNodeTypes}
|
||||
onNodesChange={readOnly ? undefined : onNodesChange}
|
||||
onEdgesChange={readOnly ? undefined : onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeClick={(_, n) => applySelection(n.id)}
|
||||
onPaneClick={() => applySelection(null)}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={16} />
|
||||
<Controls showInteractive={!readOnly} />
|
||||
<MiniMap pannable zoomable />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="w-full shrink-0 overflow-hidden rounded-lg border border-border lg:w-64">
|
||||
<WorkflowNodeInspector
|
||||
node={selectedNode}
|
||||
allRules={allRules}
|
||||
onUpdate={updateNodeData}
|
||||
onDelete={deleteNode}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VariablesPanel({
|
||||
variables,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
variables: ExecVariable[]
|
||||
onChange: (v: ExecVariable[]) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 space-y-2 rounded-lg border border-border/60 p-2">
|
||||
<div className="flex items-center gap-1 text-xs font-medium">
|
||||
<Variable className="size-3.5" />
|
||||
Variables d'exécution
|
||||
</div>
|
||||
{variables.map((v, i) => (
|
||||
<div key={i} className="flex gap-1">
|
||||
<Input
|
||||
className="h-7 flex-1 font-mono text-[10px]"
|
||||
placeholder="nom"
|
||||
value={v.name}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = [...variables]
|
||||
next[i] = { ...v, name: e.target.value }
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-7 flex-1 text-[10px]"
|
||||
placeholder="défaut"
|
||||
value={v.default ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = [...variables]
|
||||
next[i] = { ...v, default: e.target.value }
|
||||
onChange(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange([...variables, { name: '', type: 'string', default: '' }])}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Variable
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
components/gmail/settings/automation/webhooks-panel.tsx
Normal file
84
components/gmail/settings/automation/webhooks-panel.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
useMailWebhooks,
|
||||
useCreateMailWebhook,
|
||||
useDeleteMailWebhook,
|
||||
} from "@/lib/api/hooks/use-mail-automation-queries"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
|
||||
export function WebhooksPanel() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: webhooks = [], isFetching, isError, refetch, isPending } = useMailWebhooks()
|
||||
const createWebhook = useCreateMailWebhook()
|
||||
const deleteWebhook = useDeleteMailWebhook()
|
||||
const [name, setName] = useState("")
|
||||
const [url, setUrl] = useState("")
|
||||
const [template, setTemplate] = useState(
|
||||
'{"text":"Nouveau mail de $sender.name : $subject"}'
|
||||
)
|
||||
const showInitialLoad = ready && authenticated && isPending && webhooks.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||
<Label className="text-xs">Nouveau webhook</Label>
|
||||
<Input placeholder="Nom" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="URL HTTPS" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
<textarea
|
||||
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
placeholder="body_template JSON"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!name.trim() || !url.trim() || createWebhook.isPending}
|
||||
onClick={() =>
|
||||
createWebhook.mutate({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
method: "POST",
|
||||
body_template: template,
|
||||
})
|
||||
}
|
||||
>
|
||||
Créer le webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showInitialLoad ? null : webhooks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucun webhook.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{webhooks.map((hook) => (
|
||||
<li
|
||||
key={hook.id}
|
||||
className="flex items-start justify-between gap-2 px-3 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{hook.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{hook.url}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteWebhook.mutate(hook.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
398
components/gmail/settings/automation/workflow-node-inspector.tsx
Normal file
398
components/gmail/settings/automation/workflow-node-inspector.tsx
Normal file
@ -0,0 +1,398 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import {
|
||||
ACTION_TYPES,
|
||||
CONDITION_FIELDS,
|
||||
NODE_TYPE_LABELS,
|
||||
} from '@/lib/mail-automation/node-definitions'
|
||||
import {
|
||||
actionValueSuggestionKind,
|
||||
conditionValueSuggestionKind,
|
||||
defaultOperatorForField,
|
||||
formatConditionSummary,
|
||||
operatorOptionsForConditionField,
|
||||
switchFieldSuggestionKind,
|
||||
} from '@/lib/mail-automation/condition-helpers'
|
||||
import type {
|
||||
ActionsNodeData,
|
||||
ConditionNodeData,
|
||||
LLMCheckNodeData,
|
||||
SetVarNodeData,
|
||||
SwitchNodeData,
|
||||
CallRuleNodeData,
|
||||
WorkflowNodeType,
|
||||
ConditionField,
|
||||
} from '@/lib/mail-automation/types'
|
||||
import type { ApiRule } from '@/lib/api/types'
|
||||
import { createEmptyAction } from '@/lib/mail-automation/defaults'
|
||||
import { AutomationSuggestInput } from './automation-suggest-input'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface WorkflowNodeInspectorProps {
|
||||
node: Node | null
|
||||
allRules: ApiRule[]
|
||||
onUpdate: (nodeId: string, data: Record<string, unknown>) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
}
|
||||
|
||||
export function WorkflowNodeInspector({
|
||||
node,
|
||||
allRules,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: WorkflowNodeInspectorProps) {
|
||||
if (!node || node.type === 'start' || node.type === 'end') {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
||||
Sélectionnez un nœud pour le configurer
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const type = node.type as WorkflowNodeType
|
||||
const data = node.data as unknown as Record<string, unknown>
|
||||
|
||||
function patch(partial: Record<string, unknown>) {
|
||||
onUpdate(node!.id, { ...data, ...partial })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{NODE_TYPE_LABELS[type]}</p>
|
||||
<p className="truncate font-mono text-[10px] text-muted-foreground">{node.id}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Supprimer le nœud"
|
||||
onClick={() => onDelete(node.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
||||
{type === 'condition' || type === 'label_check' ? (
|
||||
<ConditionEditor
|
||||
data={
|
||||
type === 'label_check'
|
||||
? {
|
||||
field: 'label',
|
||||
operator: (data as { operator?: string }).operator === 'not_has' ? 'not_has' : 'has',
|
||||
value: String((data as { label?: string }).label ?? ''),
|
||||
}
|
||||
: (data as unknown as ConditionNodeData)
|
||||
}
|
||||
onChange={patch}
|
||||
/>
|
||||
) : null}
|
||||
{type === 'switch' ? (
|
||||
<SwitchEditor data={data as unknown as SwitchNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{type === 'llm_check' ? (
|
||||
<LLMEditor data={data as unknown as LLMCheckNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{type === 'actions' ? (
|
||||
<ActionsEditor data={data as unknown as ActionsNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{type === 'set_var' ? (
|
||||
<SetVarEditor data={data as unknown as SetVarNodeData} onChange={patch} />
|
||||
) : null}
|
||||
{type === 'call_function' || type === 'call_rule' ? (
|
||||
<CallRuleEditor
|
||||
data={data as unknown as CallRuleNodeData}
|
||||
allRules={allRules}
|
||||
kindFilter={type === 'call_function' ? 'function' : 'rule'}
|
||||
onChange={patch}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConditionEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ConditionNodeData
|
||||
onChange: (p: Partial<ConditionNodeData>) => void
|
||||
}) {
|
||||
const operatorOptions = operatorOptionsForConditionField(data.field)
|
||||
const valueKind = conditionValueSuggestionKind(data.field, data.operator)
|
||||
const isRegex = data.operator === 'regex' || data.operator === 'not_regex'
|
||||
|
||||
function onFieldChange(field: ConditionField) {
|
||||
onChange({
|
||||
field,
|
||||
operator: defaultOperatorForField(field),
|
||||
value: field === 'has_attachment' ? 'true' : '',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldSelect
|
||||
label="Champ"
|
||||
value={data.field}
|
||||
options={CONDITION_FIELDS}
|
||||
onChange={(v) => onFieldChange(v as ConditionField)}
|
||||
/>
|
||||
<FieldSelect
|
||||
label={data.field === 'label' ? 'Mode' : 'Opérateur'}
|
||||
value={data.operator}
|
||||
options={operatorOptions}
|
||||
onChange={(v) => onChange({ operator: v as ConditionNodeData['operator'] })}
|
||||
/>
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
{data.field === 'label' ? 'Libellé' : isRegex ? 'Expression régulière' : 'Valeur'}
|
||||
</Label>
|
||||
<div className="mt-1">
|
||||
{data.field === 'has_attachment' ? (
|
||||
<FieldSelect
|
||||
label=""
|
||||
value={data.value || 'true'}
|
||||
options={[
|
||||
{ value: 'true', label: 'Oui' },
|
||||
{ value: 'false', label: 'Non' },
|
||||
]}
|
||||
onChange={(v) => onChange({ value: v })}
|
||||
/>
|
||||
) : (
|
||||
<AutomationSuggestInput
|
||||
kind={valueKind}
|
||||
value={data.value}
|
||||
placeholder={isRegex ? '(?i)facture|invoice' : undefined}
|
||||
onChange={(value) => onChange({ value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SwitchEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: SwitchNodeData
|
||||
onChange: (p: Partial<SwitchNodeData>) => void
|
||||
}) {
|
||||
const cases = data.cases ?? []
|
||||
const fieldKind = switchFieldSuggestionKind(data.field)
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldSelect label="Champ" value={data.field} options={CONDITION_FIELDS} onChange={(v) => onChange({ field: v })} />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Cas de sortie</Label>
|
||||
{cases.map((c, i) => (
|
||||
<div key={i} className="flex gap-1">
|
||||
<AutomationSuggestInput
|
||||
kind={fieldKind}
|
||||
className="flex-1"
|
||||
placeholder="Valeur"
|
||||
value={c.value}
|
||||
onChange={(value) => {
|
||||
const next = [...cases]
|
||||
next[i] = { ...c, value }
|
||||
onChange({ cases: next })
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className="h-8 flex-1 text-xs"
|
||||
placeholder="Libellé"
|
||||
value={c.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...cases]
|
||||
next[i] = { ...c, label: e.target.value }
|
||||
onChange({ cases: next })
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => onChange({ cases: cases.filter((_, j) => j !== i) })}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ cases: [...cases, { value: '', label: '' }] })}>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Ajouter un cas
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LLMEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: LLMCheckNodeData
|
||||
onChange: (p: Partial<LLMCheckNodeData>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">Prompt</Label>
|
||||
<textarea
|
||||
className="mt-1 min-h-20 w-full rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||
value={data.prompt}
|
||||
onChange={(e) => onChange({ prompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Fournisseur (optionnel)</Label>
|
||||
<Input className="mt-1 h-8 text-xs" value={data.provider ?? ''} onChange={(e) => onChange({ provider: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionsEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ActionsNodeData
|
||||
onChange: (p: Partial<ActionsNodeData>) => void
|
||||
}) {
|
||||
const actions = data.actions ?? []
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{actions.map((action, i) => {
|
||||
const meta = ACTION_TYPES.find((a) => a.value === action.type)
|
||||
const suggestKind = actionValueSuggestionKind(action.type)
|
||||
return (
|
||||
<div key={i} className="space-y-1 rounded border border-border/60 p-2">
|
||||
<Select value={action.type} onValueChange={(v) => {
|
||||
const next = [...actions]
|
||||
next[i] = { ...action, type: v as typeof action.type, value: '' }
|
||||
onChange({ actions: next })
|
||||
}}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_TYPES.map((a) => (
|
||||
<SelectItem key={a.value} value={a.value} className="text-xs">{a.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{meta?.needsValue ? (
|
||||
<AutomationSuggestInput
|
||||
kind={suggestKind}
|
||||
value={action.value}
|
||||
placeholder={meta.placeholder}
|
||||
onChange={(value) => {
|
||||
const next = [...actions]
|
||||
next[i] = { ...action, value }
|
||||
onChange({ actions: next })
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="sm" className="h-6 text-xs" onClick={() => onChange({ actions: actions.filter((_, j) => j !== i) })}>
|
||||
Retirer
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Button type="button" variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => onChange({ actions: [...actions, createEmptyAction()] })}>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Ajouter une action
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SetVarEditor({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: SetVarNodeData
|
||||
onChange: (p: Partial<SetVarNodeData>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input className="mt-1 h-8 font-mono text-xs" value={data.name} onChange={(e) => onChange({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Valeur ({"{{var}}"} pour interpolation)</Label>
|
||||
<Input className="mt-1 h-8 font-mono text-xs" value={data.value} onChange={(e) => onChange({ value: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CallRuleEditor({
|
||||
data,
|
||||
allRules,
|
||||
kindFilter,
|
||||
onChange,
|
||||
}: {
|
||||
data: CallRuleNodeData
|
||||
allRules: ApiRule[]
|
||||
kindFilter: 'rule' | 'function'
|
||||
onChange: (p: Partial<CallRuleNodeData>) => void
|
||||
}) {
|
||||
const options = allRules.filter((r) => (r.rule_kind ?? 'rule') === kindFilter)
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-xs">{kindFilter === 'function' ? 'Fonction' : 'Règle'}</Label>
|
||||
<Select value={data.rule_id || undefined} onValueChange={(v) => onChange({ rule_id: v })}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue placeholder="Choisir…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id} className="text-xs">{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label ? <Label className="text-xs">{label}</Label> : null}
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className={label ? 'mt-1 h-8 text-xs' : 'h-8 text-xs'}><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
291
components/gmail/settings/automation/workflow-nodes.tsx
Normal file
291
components/gmail/settings/automation/workflow-nodes.tsx
Normal file
@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { memo, type ReactNode } from 'react'
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
NODE_COLORS,
|
||||
CONDITION_FIELDS,
|
||||
CONDITION_OPERATORS,
|
||||
LABEL_CONDITION_OPERATORS,
|
||||
ACTION_TYPES,
|
||||
} from '@/lib/mail-automation/node-definitions'
|
||||
import { formatConditionSummary } from '@/lib/mail-automation/condition-helpers'
|
||||
import type {
|
||||
ActionsNodeData,
|
||||
ConditionNodeData,
|
||||
LabelCheckNodeData,
|
||||
LLMCheckNodeData,
|
||||
SetVarNodeData,
|
||||
SwitchNodeData,
|
||||
CallRuleNodeData,
|
||||
WorkflowNodeType,
|
||||
} from '@/lib/mail-automation/types'
|
||||
|
||||
function fieldLabel(field: string) {
|
||||
return CONDITION_FIELDS.find((f) => f.value === field)?.label ?? field
|
||||
}
|
||||
|
||||
function opLabel(op: string) {
|
||||
const fromString = CONDITION_OPERATORS.find((o) => o.value === op)?.label
|
||||
if (fromString) return fromString
|
||||
return LABEL_CONDITION_OPERATORS.find((o) => o.value === op)?.label ?? op
|
||||
}
|
||||
|
||||
function actionLabel(type: string) {
|
||||
return ACTION_TYPES.find((a) => a.value === type)?.label ?? type
|
||||
}
|
||||
|
||||
function nodeShell(
|
||||
type: WorkflowNodeType,
|
||||
selected: boolean | undefined,
|
||||
className: string,
|
||||
children: ReactNode
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border-2 px-3 py-2 shadow-sm transition-shadow',
|
||||
NODE_COLORS[type],
|
||||
selected && 'border-primary ring-2 ring-primary/40 shadow-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchOutputRow({
|
||||
id,
|
||||
label,
|
||||
labelClassName,
|
||||
handleClassName,
|
||||
top,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
labelClassName?: string
|
||||
handleClassName?: string
|
||||
top: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-5 -translate-y-1/2 text-[10px] font-medium',
|
||||
labelClassName
|
||||
)}
|
||||
style={{ top }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={id}
|
||||
style={{ top }}
|
||||
className={cn('!size-2.5', handleClassName)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BranchOutputs({
|
||||
branches,
|
||||
}: {
|
||||
branches: {
|
||||
id: string
|
||||
label: string
|
||||
labelClassName?: string
|
||||
handleClassName?: string
|
||||
}[]
|
||||
}) {
|
||||
const rowHeight = 24
|
||||
const blockHeight = branches.length * rowHeight
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mt-3 border-t border-border/40 pt-2"
|
||||
style={{ height: blockHeight }}
|
||||
>
|
||||
{branches.map((b, i) => {
|
||||
const topPx = rowHeight * i + rowHeight / 2
|
||||
return <BranchOutputRow key={b.id} {...b} top={`${topPx}px`} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StartNode = memo(function StartNode({ selected }: NodeProps) {
|
||||
return nodeShell('start', selected, 'min-w-[120px]', (
|
||||
<>
|
||||
<p className="text-xs font-semibold">Début</p>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const EndNode = memo(function EndNode({ selected }: NodeProps) {
|
||||
return nodeShell('end', selected, 'min-w-[100px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold text-muted-foreground">Fin</p>
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const ConditionNode = memo(function ConditionNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as ConditionNodeData
|
||||
return nodeShell('condition', selected, 'min-w-[200px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Si…</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{formatConditionSummary(d, fieldLabel, opLabel)}
|
||||
</p>
|
||||
<BranchOutputs
|
||||
branches={[
|
||||
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
|
||||
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const LabelCheckNode = memo(function LabelCheckNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as LabelCheckNodeData
|
||||
return nodeShell('label_check', selected, 'min-w-[200px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Libellé</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{d.operator === 'not_has' ? 'Sans' : 'A'} « {d.label || '…'} »
|
||||
</p>
|
||||
<BranchOutputs
|
||||
branches={[
|
||||
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
|
||||
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const SwitchNode = memo(function SwitchNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as SwitchNodeData
|
||||
const cases = d.cases ?? []
|
||||
const caseBranches = cases.map((c, i) => ({
|
||||
id: `case-${i}`,
|
||||
label: c.label || c.value || `Cas ${i + 1}`,
|
||||
labelClassName: 'text-violet-600',
|
||||
handleClassName: '!bg-violet-500',
|
||||
}))
|
||||
|
||||
return nodeShell('switch', selected, 'min-w-[220px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Switch · {fieldLabel(d.field)}</p>
|
||||
<BranchOutputs
|
||||
branches={[
|
||||
...caseBranches,
|
||||
{
|
||||
id: 'default',
|
||||
label: 'défaut',
|
||||
labelClassName: 'text-muted-foreground',
|
||||
handleClassName: '!bg-muted-foreground',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const LLMCheckNode = memo(function LLMCheckNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as LLMCheckNodeData
|
||||
return nodeShell('llm_check', selected, 'min-w-[220px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">LLM</p>
|
||||
<p className="mt-1 line-clamp-2 text-[11px] text-muted-foreground">{d.prompt || 'Prompt…'}</p>
|
||||
<BranchOutputs
|
||||
branches={[
|
||||
{ id: 'true', label: 'vrai', labelClassName: 'text-emerald-600', handleClassName: '!bg-emerald-500' },
|
||||
{ id: 'false', label: 'faux', labelClassName: 'text-rose-600', handleClassName: '!bg-rose-500' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const ActionsNode = memo(function ActionsNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as ActionsNodeData
|
||||
const actions = d.actions ?? []
|
||||
return nodeShell('actions', selected, 'min-w-[200px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Actions ({actions.length})</p>
|
||||
<ul className="mt-1 space-y-0.5 text-[11px] text-muted-foreground">
|
||||
{actions.slice(0, 4).map((a, i) => (
|
||||
<li key={i}>
|
||||
{actionLabel(a.type)}
|
||||
{a.value ? `: ${a.value}` : ''}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const SetVarNode = memo(function SetVarNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as SetVarNodeData
|
||||
return nodeShell('set_var', selected, 'min-w-[180px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Variable</p>
|
||||
<p className="mt-1 font-mono text-[11px] text-muted-foreground">
|
||||
{d.name || 'var'} = {d.value || '…'}
|
||||
</p>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const CallFunctionNode = memo(function CallFunctionNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as CallRuleNodeData
|
||||
return nodeShell('call_function', selected, 'min-w-[180px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Fonction</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.rule_id || 'Choisir…'}</p>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const CallRuleNode = memo(function CallRuleNode({ data, selected }: NodeProps) {
|
||||
const d = data as unknown as CallRuleNodeData
|
||||
return nodeShell('call_rule', selected, 'min-w-[180px]', (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2.5 !bg-primary" />
|
||||
<p className="text-xs font-semibold">Règle cascade</p>
|
||||
<p className="mt-1 truncate text-[11px] text-muted-foreground">{d.rule_id || 'Choisir…'}</p>
|
||||
<Handle type="source" position={Position.Right} className="!size-2.5 !bg-primary" />
|
||||
</>
|
||||
))
|
||||
})
|
||||
|
||||
export const workflowNodeTypes = {
|
||||
start: StartNode,
|
||||
end: EndNode,
|
||||
condition: ConditionNode,
|
||||
label_check: LabelCheckNode,
|
||||
switch: SwitchNode,
|
||||
llm_check: LLMCheckNode,
|
||||
actions: ActionsNode,
|
||||
set_var: SetVarNode,
|
||||
call_function: CallFunctionNode,
|
||||
call_rule: CallRuleNode,
|
||||
}
|
||||
214
components/gmail/settings/automation/workflow-triggers-panel.tsx
Normal file
214
components/gmail/settings/automation/workflow-triggers-panel.tsx
Normal file
@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type {
|
||||
AutomationTrigger,
|
||||
TriggerAndGroup,
|
||||
TriggerOrGroup,
|
||||
TriggerType,
|
||||
} from '@/lib/mail-automation/types'
|
||||
import { TRIGGER_LABELS } from '@/lib/mail-automation/node-definitions'
|
||||
import { AutomationSuggestInput } from './automation-suggest-input'
|
||||
|
||||
const TRIGGER_TYPES: TriggerType[] = ['message_received', 'label_added', 'label_removed']
|
||||
|
||||
interface WorkflowTriggersPanelProps {
|
||||
triggers: TriggerOrGroup
|
||||
onChange: (triggers: TriggerOrGroup) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function WorkflowTriggersPanel({
|
||||
triggers,
|
||||
onChange,
|
||||
disabled,
|
||||
}: WorkflowTriggersPanelProps) {
|
||||
const groups =
|
||||
triggers.groups.length > 0
|
||||
? triggers.groups
|
||||
: [{ operator: 'and' as const, items: [{ type: 'message_received' as const }] }]
|
||||
|
||||
function updateGroups(next: TriggerAndGroup[]) {
|
||||
onChange({
|
||||
operator: 'or',
|
||||
groups: next.length > 0 ? next : [{ operator: 'and', items: [] }],
|
||||
})
|
||||
}
|
||||
|
||||
function updateGroup(gi: number, group: TriggerAndGroup) {
|
||||
const next = [...groups]
|
||||
next[gi] = group
|
||||
updateGroups(next)
|
||||
}
|
||||
|
||||
function addOrGroup() {
|
||||
onChange({
|
||||
operator: 'or',
|
||||
groups: [...groups, { operator: 'and', items: [{ type: 'message_received' }] }],
|
||||
})
|
||||
}
|
||||
|
||||
function removeOrGroup(gi: number) {
|
||||
updateGroups(groups.filter((_, i) => i !== gi))
|
||||
}
|
||||
|
||||
function removeTrigger(gi: number, ti: number) {
|
||||
const group = groups[gi]
|
||||
const items = group.items.filter((_, i) => i !== ti)
|
||||
if (items.length === 0) {
|
||||
removeOrGroup(gi)
|
||||
return
|
||||
}
|
||||
updateGroup(gi, { ...group, items })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium">Déclencheurs (OU entre groupes, ET dans un groupe)</Label>
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={addOrGroup}>
|
||||
<Plus className="mr-1 size-3" />
|
||||
Groupe OU
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map((group, gi) => (
|
||||
<div key={gi} className="space-y-2 rounded-md border border-border/60 bg-background p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{gi > 0 ? (
|
||||
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">OU</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{groups.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px] text-muted-foreground hover:text-destructive"
|
||||
disabled={disabled}
|
||||
onClick={() => removeOrGroup(gi)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3" />
|
||||
Retirer le groupe
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{group.items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Aucun déclencheur — ajoutez-en un ci-dessous</p>
|
||||
) : null}
|
||||
{group.items.map((item, ti) => (
|
||||
<TriggerRow
|
||||
key={ti}
|
||||
item={item}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
const items = [...group.items]
|
||||
items[ti] = next
|
||||
updateGroup(gi, { ...group, items })
|
||||
}}
|
||||
onRemove={() => removeTrigger(gi, ti)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
updateGroup(gi, {
|
||||
...group,
|
||||
items: [...group.items, { type: 'message_received' }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
ET — ajouter déclencheur
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TriggerRow({
|
||||
item,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled,
|
||||
}: {
|
||||
item: AutomationTrigger
|
||||
onChange: (item: AutomationTrigger) => void
|
||||
onRemove: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[140px] flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Type</Label>
|
||||
<Select
|
||||
value={item.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(v) => onChange({ ...item, type: v as TriggerType })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs">
|
||||
{TRIGGER_LABELS[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{item.type === 'message_received' ? (
|
||||
<div className="min-w-[120px] flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Dossier (optionnel)</Label>
|
||||
<AutomationSuggestInput
|
||||
kind="folder_id"
|
||||
placeholder="Choisir un dossier…"
|
||||
value={item.folder_id ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange({ ...item, folder_id: value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-[120px] flex-1">
|
||||
<Label className="text-[10px] text-muted-foreground">Libellé</Label>
|
||||
<AutomationSuggestInput
|
||||
kind="label"
|
||||
placeholder="Nom libellé"
|
||||
value={item.label ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange({ ...item, label: value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
disabled={disabled}
|
||||
title="Retirer ce déclencheur"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
335
components/gmail/settings/edit-mail-account-form.tsx
Normal file
335
components/gmail/settings/edit-mail-account-form.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useMailAccount } from "@/lib/api/hooks/use-mail-account"
|
||||
import { useUpdateMailAccount } from "@/lib/api/hooks/use-mail-account-mutations"
|
||||
import { useTestMailAccount } from "@/lib/api/hooks/use-mail-account-test"
|
||||
import type { ApiMailAccount, ApiMailAccountDetail, UpdateMailAccountPayload } from "@/lib/api/types"
|
||||
|
||||
function parsePort(value: string, fallback: number): number {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||
}
|
||||
|
||||
function asString(value: string | undefined | null): string {
|
||||
return value ?? ""
|
||||
}
|
||||
|
||||
function trimField(value: string | undefined | null): string {
|
||||
return asString(value).trim()
|
||||
}
|
||||
|
||||
type EditAccountFormState = {
|
||||
name: string
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
imap_host: string
|
||||
imap_port: string
|
||||
imap_tls: boolean
|
||||
smtp_host: string
|
||||
smtp_port: string
|
||||
smtp_tls: boolean
|
||||
}
|
||||
|
||||
function formStateFromAccount(
|
||||
account: ApiMailAccount,
|
||||
detail?: ApiMailAccountDetail | null
|
||||
): EditAccountFormState {
|
||||
const src = detail ?? account
|
||||
return {
|
||||
name: asString(src.name ?? account.name),
|
||||
email: asString(src.email ?? account.email),
|
||||
username: asString(detail?.username ?? detail?.email ?? account.email),
|
||||
password: "",
|
||||
imap_host: asString(detail?.imap_host ?? account.imap_host),
|
||||
imap_port: String(detail?.imap_port ?? 993),
|
||||
imap_tls: detail?.imap_tls ?? true,
|
||||
smtp_host: asString(detail?.smtp_host ?? account.smtp_host),
|
||||
smtp_port: String(detail?.smtp_port ?? 587),
|
||||
smtp_tls: detail?.smtp_tls ?? true,
|
||||
}
|
||||
}
|
||||
|
||||
export function EditMailAccountForm({
|
||||
account,
|
||||
onCancel,
|
||||
}: {
|
||||
account: ApiMailAccount
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const { data: detail, isPending, isError, refetch } = useMailAccount(account.id)
|
||||
const updateAccount = useUpdateMailAccount(account.id)
|
||||
const testMutation = useTestMailAccount(account.id)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [testOk, setTestOk] = useState<boolean | null>(null)
|
||||
const [form, setForm] = useState<EditAccountFormState>(() => formStateFromAccount(account))
|
||||
|
||||
const isOAuth = detail?.auth_type === "oauth2"
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
setForm(formStateFromAccount(account, detail))
|
||||
setTestOk(null)
|
||||
}, [account, detail])
|
||||
|
||||
if (isPending && !detail) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">Impossible de charger ce compte.</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => void refetch()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildPayload(): UpdateMailAccountPayload {
|
||||
return {
|
||||
name: trimField(form.name) || trimField(form.email),
|
||||
email: trimField(form.email),
|
||||
provider: detail?.provider,
|
||||
imap_host: trimField(form.imap_host),
|
||||
imap_port: parsePort(form.imap_port, 993),
|
||||
imap_tls: form.imap_tls,
|
||||
smtp_host: trimField(form.smtp_host),
|
||||
smtp_port: parsePort(form.smtp_port, 587),
|
||||
smtp_tls: form.smtp_tls,
|
||||
username: trimField(form.username),
|
||||
password: form.password || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function runConnectionTest() {
|
||||
setTestOk(null)
|
||||
try {
|
||||
const result = await testMutation.mutateAsync({
|
||||
imap_host: trimField(form.imap_host),
|
||||
imap_port: parsePort(form.imap_port, 993),
|
||||
imap_tls: form.imap_tls,
|
||||
smtp_host: trimField(form.smtp_host),
|
||||
smtp_port: parsePort(form.smtp_port, 587),
|
||||
smtp_tls: form.smtp_tls,
|
||||
username: trimField(form.username) || undefined,
|
||||
password: form.password || undefined,
|
||||
...(isOAuth ? { auth_type: "oauth2" as const } : {}),
|
||||
})
|
||||
setTestOk(result.ok)
|
||||
} catch {
|
||||
setTestOk(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canTestConnection =
|
||||
Boolean(trimField(form.imap_host)) && Boolean(trimField(form.smtp_host))
|
||||
|
||||
function handleSave() {
|
||||
const payload = buildPayload()
|
||||
if (!isOAuth && form.password && testOk === false) return
|
||||
updateAccount.mutate(payload, { onSuccess: onCancel })
|
||||
}
|
||||
|
||||
const passwordChanged = Boolean(form.password)
|
||||
const canSave =
|
||||
!updateAccount.isPending &&
|
||||
Boolean(trimField(form.email)) &&
|
||||
Boolean(trimField(form.imap_host)) &&
|
||||
Boolean(trimField(form.smtp_host)) &&
|
||||
(isOAuth || Boolean(trimField(form.username))) &&
|
||||
(!passwordChanged || testOk !== false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-sm font-medium">Modifier la connexion</h3>
|
||||
|
||||
{isOAuth ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Compte connecté via OAuth
|
||||
{detail?.oauth_provider ? ` (${detail.oauth_provider})` : ""}. Vous pouvez ajuster le
|
||||
nom et les serveurs ; le mot de passe n'est pas utilisé.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 max-w-2xl">
|
||||
<Field
|
||||
label="Nom affiché"
|
||||
value={form.name}
|
||||
onChange={(v) => setForm({ ...form, name: v })}
|
||||
/>
|
||||
<Field
|
||||
label="Adresse e-mail"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => setForm({ ...form, email: v })}
|
||||
/>
|
||||
<Field
|
||||
label="Identifiant"
|
||||
value={form.username}
|
||||
onChange={(v) => setForm({ ...form, username: v })}
|
||||
autoComplete="username"
|
||||
/>
|
||||
{!isOAuth ? (
|
||||
<Field
|
||||
label="Nouveau mot de passe"
|
||||
type="password"
|
||||
value={form.password}
|
||||
placeholder="Laisser vide pour ne pas changer"
|
||||
onChange={(v) => {
|
||||
setTestOk(null)
|
||||
setForm({ ...form, password: v })
|
||||
}}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||
Paramètres serveur {showAdvanced ? "" : "(avancé)"}
|
||||
</button>
|
||||
{showAdvanced ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 max-w-2xl">
|
||||
<Field
|
||||
label="IMAP hôte"
|
||||
value={form.imap_host}
|
||||
onChange={(v) => setForm({ ...form, imap_host: v })}
|
||||
/>
|
||||
<Field
|
||||
label="IMAP port"
|
||||
value={form.imap_port}
|
||||
onChange={(v) => setForm({ ...form, imap_port: v })}
|
||||
/>
|
||||
<Field
|
||||
label="SMTP hôte"
|
||||
value={form.smtp_host}
|
||||
onChange={(v) => setForm({ ...form, smtp_host: v })}
|
||||
/>
|
||||
<Field
|
||||
label="SMTP port"
|
||||
value={form.smtp_port}
|
||||
onChange={(v) => setForm({ ...form, smtp_port: v })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.imap_tls}
|
||||
onChange={(e) => setForm({ ...form, imap_tls: e.target.checked })}
|
||||
/>
|
||||
IMAP TLS
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.smtp_tls}
|
||||
onChange={(e) => setForm({ ...form, smtp_tls: e.target.checked })}
|
||||
/>
|
||||
SMTP TLS / STARTTLS
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Réception {form.imap_host}:{form.imap_port} · Envoi {form.smtp_host}:{form.smtp_port}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{testOk === true ? (
|
||||
<p className="text-sm text-green-600 dark:text-green-500">
|
||||
Connexion IMAP et SMTP validée.
|
||||
</p>
|
||||
) : null}
|
||||
{testOk === false ? (
|
||||
<div className="text-sm text-destructive space-y-1">
|
||||
<p>Échec du test de connexion.</p>
|
||||
{testMutation.data?.imap_error ? (
|
||||
<p className="text-xs">IMAP : {testMutation.data.imap_error}</p>
|
||||
) : null}
|
||||
{testMutation.data?.smtp_error ? (
|
||||
<p className="text-xs">SMTP : {testMutation.data.smtp_error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={testMutation.isPending || !canTestConnection}
|
||||
onClick={() => void runConnectionTest()}
|
||||
>
|
||||
{testMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Test…
|
||||
</>
|
||||
) : (
|
||||
"Tester la connexion"
|
||||
)}
|
||||
</Button>
|
||||
{!isOAuth && !passwordChanged ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Le mot de passe enregistré est utilisé si le champ ci-dessus est vide.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" disabled={!canSave} onClick={handleSave}>
|
||||
{updateAccount.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Enregistrement…
|
||||
</>
|
||||
) : (
|
||||
"Enregistrer"
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
autoComplete,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
autoComplete?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
type={type}
|
||||
autoComplete={autoComplete}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
components/gmail/settings/mail-settings-layout.tsx
Normal file
106
components/gmail/settings/mail-settings-layout.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
isMailSettingsNavActive,
|
||||
MAIL_SETTINGS_NAV,
|
||||
} from "@/lib/mail-settings/settings-nav"
|
||||
|
||||
export function MailSettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh max-h-dvh flex-col overflow-hidden bg-background">
|
||||
<header className="shrink-0 border-b border-border bg-background px-4 py-4 sm:px-6">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3">
|
||||
<Link
|
||||
href="/mail/inbox"
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
|
||||
aria-label="Retour à la boîte de réception"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-normal text-foreground">
|
||||
Paramètres Ultimail
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configuration du compte, de l'affichage et des automatisations
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<aside className="hidden w-64 shrink-0 overflow-y-auto border-r border-border bg-muted/20 p-3 md:block lg:w-72">
|
||||
<nav className="space-y-1" aria-label="Sections des paramètres">
|
||||
{MAIL_SETTINGS_NAV.map((item) => {
|
||||
const active = isMailSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="mt-0.5 size-4 shrink-0 opacity-70" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium">{item.label}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<nav
|
||||
className="shrink-0 border-b border-border bg-muted/20 px-2 py-2 md:hidden"
|
||||
aria-label="Sections des paramètres"
|
||||
>
|
||||
<div className="flex gap-1 overflow-x-auto">
|
||||
{MAIL_SETTINGS_NAV.map((item) => {
|
||||
const active = isMailSettingsNavActive(pathname, item)
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
aria-label={item.label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center rounded-lg transition-colors",
|
||||
active
|
||||
? "gap-2 bg-accent px-3 py-2 text-accent-foreground"
|
||||
: "size-9 justify-center text-foreground hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 opacity-70" />
|
||||
{active ? (
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-8">
|
||||
<div className="mx-auto max-w-3xl">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
components/gmail/settings/mail-settings-section-view.tsx
Normal file
39
components/gmail/settings/mail-settings-section-view.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
resolveMailSettingsSection,
|
||||
type MailSettingsSectionId,
|
||||
} from "@/lib/mail-settings/settings-nav"
|
||||
import { DisplaySettingsSection } from "@/components/gmail/settings/sections/display-settings-section"
|
||||
import { AccountsSettingsSection } from "@/components/gmail/settings/sections/accounts-settings-section"
|
||||
import { SignaturesSettingsSection } from "@/components/gmail/settings/sections/signatures-settings-section"
|
||||
import { LabelsFoldersSettingsSection } from "@/components/gmail/settings/sections/labels-folders-settings-section"
|
||||
import { NotificationsSettingsSection } from "@/components/gmail/settings/sections/notifications-settings-section"
|
||||
import { AutomationSettingsSection } from "@/components/gmail/settings/sections/automation-settings-section"
|
||||
|
||||
const SECTIONS: Record<MailSettingsSectionId, React.ComponentType> = {
|
||||
display: DisplaySettingsSection,
|
||||
accounts: AccountsSettingsSection,
|
||||
signatures: SignaturesSettingsSection,
|
||||
labels: LabelsFoldersSettingsSection,
|
||||
notifications: NotificationsSettingsSection,
|
||||
automation: AutomationSettingsSection,
|
||||
}
|
||||
|
||||
export function MailSettingsSectionView({
|
||||
sectionId,
|
||||
}: {
|
||||
sectionId: MailSettingsSectionId
|
||||
}) {
|
||||
const Section = SECTIONS[sectionId]
|
||||
return <Section />
|
||||
}
|
||||
|
||||
export function MailSettingsSectionFromSegments({
|
||||
segments,
|
||||
}: {
|
||||
segments?: string[]
|
||||
}) {
|
||||
const sectionId = resolveMailSettingsSection(segments)
|
||||
return <MailSettingsSectionView sectionId={sectionId} />
|
||||
}
|
||||
443
components/gmail/settings/nav-item-settings-card.tsx
Normal file
443
components/gmail/settings/nav-item-settings-card.tsx
Normal file
@ -0,0 +1,443 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
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 {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { NavColorPickerTrigger } from "@/components/gmail/nav/nav-color-picker-trigger"
|
||||
import {
|
||||
NavMessageVisibilityFields,
|
||||
NavSidebarVisibilityFields,
|
||||
} from "@/components/gmail/nav/nav-visibility-fields"
|
||||
import { useSidebarNav, folderMoveParentOptions } from "@/lib/sidebar-nav-context"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function NavLabelSettingsCard({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
depth = 0,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
depth?: number
|
||||
}) {
|
||||
const nav = useSidebarNav()
|
||||
const prefs = nav.getNavItemPrefs(id)
|
||||
const colorClass = normalizeNavColorClass(color)
|
||||
|
||||
const [renameDraft, setRenameDraft] = useState(name)
|
||||
const [sublabelName, setSublabelName] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
setRenameDraft(name)
|
||||
}, [name])
|
||||
|
||||
return (
|
||||
<NavItemSettingsShell
|
||||
title={name}
|
||||
color={colorClass}
|
||||
depth={depth}
|
||||
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
|
||||
onDelete={() => nav.removeFolderOrLabelRow(id)}
|
||||
deleteLabel="Supprimer le libellé"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<NavSidebarVisibilityFields
|
||||
listKind="labels"
|
||||
value={prefs.sidebar}
|
||||
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
||||
/>
|
||||
<NavMessageVisibilityFields
|
||||
value={prefs.messages}
|
||||
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs" htmlFor={`rename-label-${id}`}>
|
||||
Renommer
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id={`rename-label-${id}`}
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!renameDraft.trim() || renameDraft.trim() === name}
|
||||
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs" htmlFor={`sublabel-${id}`}>
|
||||
Ajouter un sous-libellé
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id={`sublabel-${id}`}
|
||||
value={sublabelName}
|
||||
onChange={(e) => setSublabelName(e.target.value)}
|
||||
placeholder="Nom du sous-libellé"
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!sublabelName.trim()}
|
||||
onClick={() => {
|
||||
nav.addChildLabelRow(id, sublabelName.trim())
|
||||
setSublabelName("")
|
||||
}}
|
||||
>
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavItemSettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavImapFolderSettingsCard({
|
||||
id,
|
||||
name,
|
||||
remoteName,
|
||||
depth = 0,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
remoteName?: string
|
||||
depth?: number
|
||||
}) {
|
||||
const nav = useSidebarNav()
|
||||
const prefs = nav.getNavItemPrefs(id)
|
||||
const subtitle =
|
||||
remoteName && remoteName !== name ? remoteName : undefined
|
||||
|
||||
return (
|
||||
<NavItemSettingsShell
|
||||
title={name}
|
||||
subtitle={subtitle}
|
||||
depth={depth}
|
||||
hideDelete
|
||||
hideColor
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<NavSidebarVisibilityFields
|
||||
listKind="folders"
|
||||
value={prefs.sidebar}
|
||||
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
||||
/>
|
||||
<NavMessageVisibilityFields
|
||||
value={prefs.messages}
|
||||
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Dossier synchronisé depuis le serveur mail — structure non modifiable ici.
|
||||
</p>
|
||||
</div>
|
||||
</NavItemSettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavFolderSettingsCard({
|
||||
id,
|
||||
name,
|
||||
color,
|
||||
depth = 0,
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
color?: string
|
||||
depth?: number
|
||||
}) {
|
||||
const nav = useSidebarNav()
|
||||
const prefs = nav.getNavItemPrefs(id)
|
||||
const colorClass = normalizeNavColorClass(color)
|
||||
|
||||
const [renameDraft, setRenameDraft] = useState(name)
|
||||
const [moveParent, setMoveParent] = useState("__root__")
|
||||
const [subfolderName, setSubfolderName] = useState("")
|
||||
|
||||
const moveTargets = useMemo(
|
||||
() => folderMoveParentOptions(nav.folderTree, id),
|
||||
[nav.folderTree, id]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setRenameDraft(name)
|
||||
}, [name])
|
||||
|
||||
return (
|
||||
<NavItemSettingsShell
|
||||
title={name}
|
||||
color={colorClass}
|
||||
depth={depth}
|
||||
onColorChange={(sw) => nav.updateFolderOrLabelColor(id, sw)}
|
||||
onDelete={() => nav.removeFolderOrLabelRow(id)}
|
||||
deleteLabel="Supprimer le dossier"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<NavSidebarVisibilityFields
|
||||
listKind="folders"
|
||||
value={prefs.sidebar}
|
||||
onChange={(v) => nav.setNavItemSidebarVisibility(id, v)}
|
||||
/>
|
||||
<NavMessageVisibilityFields
|
||||
value={prefs.messages}
|
||||
onChange={(v) => nav.setNavItemMessageVisibility(id, v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs" htmlFor={`rename-folder-${id}`}>
|
||||
Renommer
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id={`rename-folder-${id}`}
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!renameDraft.trim() || renameDraft.trim() === name}
|
||||
onClick={() => nav.renameFolderOrLabel(id, renameDraft.trim())}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Déplacer vers</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={moveParent} onValueChange={setMoveParent}>
|
||||
<SelectTrigger className="min-w-[200px] flex-1">
|
||||
<SelectValue placeholder="Emplacement" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{moveTargets.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
nav.moveFolder(id, moveParent === "__root__" ? null : moveParent)
|
||||
}
|
||||
>
|
||||
Déplacer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs" htmlFor={`subfolder-${id}`}>
|
||||
Nouveau sous-dossier
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
id={`subfolder-${id}`}
|
||||
value={subfolderName}
|
||||
onChange={(e) => setSubfolderName(e.target.value)}
|
||||
placeholder="Nom du sous-dossier"
|
||||
className="min-w-[160px] flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!subfolderName.trim()}
|
||||
onClick={() => {
|
||||
nav.addSubfolder(id, subfolderName.trim())
|
||||
setSubfolderName("")
|
||||
}}
|
||||
>
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavItemSettingsShell>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItemSettingsShell({
|
||||
title,
|
||||
subtitle,
|
||||
color,
|
||||
depth,
|
||||
onColorChange,
|
||||
onDelete,
|
||||
deleteLabel,
|
||||
hideDelete = false,
|
||||
hideColor = false,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
color?: string
|
||||
depth: number
|
||||
onColorChange?: (swatch: string) => void
|
||||
onDelete?: () => void
|
||||
deleteLabel?: string
|
||||
hideDelete?: boolean
|
||||
hideColor?: boolean
|
||||
children: import("react").ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="rounded-lg border border-border bg-card"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1 px-3 py-2"
|
||||
style={{ paddingLeft: `${depth * 12 + 12}px` }}
|
||||
>
|
||||
{!hideColor && color && onColorChange ? (
|
||||
<NavColorPickerTrigger
|
||||
value={color}
|
||||
onChange={onColorChange}
|
||||
aria-label="Couleur"
|
||||
/>
|
||||
) : null}
|
||||
<CollapsibleTrigger className="flex min-w-0 flex-1 flex-col items-start text-left">
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
{subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">{subtitle}</span>
|
||||
) : null}
|
||||
</CollapsibleTrigger>
|
||||
{!hideDelete && onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
aria-label={deleteLabel ?? "Supprimer"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<CollapsibleContent className="border-t border-border px-3 py-4">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
/** Flatten label rows for settings (parent/child via `/` in name). */
|
||||
export function flattenLabelRowsForSettings(
|
||||
rows: { id: string; label: string; color: string }[]
|
||||
) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
depth: Math.max(0, row.label.split("/").length - 1),
|
||||
}))
|
||||
}
|
||||
|
||||
type FolderSettingsNode = {
|
||||
id: string
|
||||
label: string
|
||||
color?: string
|
||||
children?: FolderSettingsNode[]
|
||||
}
|
||||
|
||||
export function FolderSettingsTree({
|
||||
nodes,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: FolderSettingsNode[]
|
||||
depth?: number
|
||||
}) {
|
||||
return (
|
||||
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<NavFolderSettingsCard
|
||||
id={node.id}
|
||||
name={node.label}
|
||||
color={node.color}
|
||||
depth={depth}
|
||||
/>
|
||||
{node.children?.length ? (
|
||||
<FolderSettingsTree nodes={node.children} depth={depth + 1} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
type ImapFolderSettingsNode = {
|
||||
id: string
|
||||
label: string
|
||||
remoteName?: string
|
||||
children?: ImapFolderSettingsNode[]
|
||||
}
|
||||
|
||||
export function ImapFolderSettingsTree({
|
||||
nodes,
|
||||
depth = 0,
|
||||
}: {
|
||||
nodes: ImapFolderSettingsNode[]
|
||||
depth?: number
|
||||
}) {
|
||||
return (
|
||||
<ul className={cn("space-y-2", depth > 0 && "mt-2")}>
|
||||
{nodes.map((node) => (
|
||||
<li key={node.id}>
|
||||
<NavImapFolderSettingsCard
|
||||
id={node.id}
|
||||
name={node.label}
|
||||
remoteName={node.remoteName}
|
||||
depth={depth}
|
||||
/>
|
||||
{node.children?.length ? (
|
||||
<ImapFolderSettingsTree nodes={node.children} depth={depth + 1} />
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
112
components/gmail/settings/proton-bridge-wizard.tsx
Normal file
112
components/gmail/settings/proton-bridge-wizard.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const PROTON_BRIDGE_DOWNLOAD = "https://proton.me/mail/bridge"
|
||||
|
||||
export function ProtonBridgeWizard({
|
||||
email,
|
||||
imapPort,
|
||||
smtpPort,
|
||||
bridgePassword,
|
||||
onBridgePasswordChange,
|
||||
onImapPortChange,
|
||||
onSmtpPortChange,
|
||||
onContinue,
|
||||
onBack,
|
||||
}: {
|
||||
email: string
|
||||
imapPort: string
|
||||
smtpPort: string
|
||||
bridgePassword: string
|
||||
onBridgePasswordChange: (v: string) => void
|
||||
onImapPortChange: (v: string) => void
|
||||
onSmtpPortChange: (v: string) => void
|
||||
onContinue: () => void
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3 text-sm">
|
||||
<p className="font-medium">Proton Mail via Bridge</p>
|
||||
<ol className="list-decimal list-inside space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
Installez{" "}
|
||||
<a
|
||||
href={PROTON_BRIDGE_DOWNLOAD}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-foreground underline"
|
||||
>
|
||||
Proton Mail Bridge
|
||||
<ExternalLink className="size-3" />
|
||||
</a>{" "}
|
||||
sur cet ordinateur.
|
||||
</li>
|
||||
<li>
|
||||
Connectez-vous avec <span className="text-foreground">{email}</span>.
|
||||
</li>
|
||||
<li>Copiez le mot de passe Bridge généré pour IMAP/SMTP.</li>
|
||||
<li>Vérifiez que Bridge écoute sur localhost (ports par défaut ci-dessous).</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="IMAP port (Bridge)" value={imapPort} onChange={onImapPortChange} />
|
||||
<Field label="SMTP port (Bridge)" value={smtpPort} onChange={onSmtpPortChange} />
|
||||
<div className="sm:col-span-2">
|
||||
<Field
|
||||
label="Mot de passe Bridge"
|
||||
type="password"
|
||||
value={bridgePassword}
|
||||
onChange={onBridgePasswordChange}
|
||||
placeholder="Généré par Proton Bridge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bridge doit rester ouvert en arrière-plan. Ultimail se connecte à{" "}
|
||||
<code className="text-foreground">127.0.0.1</code>.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" disabled={!bridgePassword} onClick={onContinue}>
|
||||
Tester et continuer
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onBack}>
|
||||
Retour
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = "text",
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
type?: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
value={value}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
377
components/gmail/settings/sections/accounts-settings-section.tsx
Normal file
377
components/gmail/settings/sections/accounts-settings-section.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { AddMailAccountForm } from "@/components/gmail/settings/add-mail-account-form"
|
||||
import { EditMailAccountForm } from "@/components/gmail/settings/edit-mail-account-form"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import {
|
||||
useCreateMailAccount,
|
||||
useDeleteMailAccount,
|
||||
useResanitizeBodies,
|
||||
useSyncMailAccount,
|
||||
} from "@/lib/api/hooks/use-mail-account-mutations"
|
||||
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
|
||||
import {
|
||||
useCreateIdentity,
|
||||
useUpdateIdentity,
|
||||
useDeleteIdentity,
|
||||
} from "@/lib/api/hooks/use-identity-mutations"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import type { ApiMailAccount } from "@/lib/api/types"
|
||||
|
||||
export function AccountsSettingsSection() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const oauthStatus = searchParams.get("oauth")
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: accounts = [], isFetching, isError, refetch, isPending } = useMailAccounts()
|
||||
const createAccount = useCreateMailAccount()
|
||||
const showInitialLoad = ready && authenticated && isPending && accounts.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthStatus === "success") {
|
||||
void refetch()
|
||||
router.replace("/mail/settings/accounts")
|
||||
}
|
||||
}, [oauthStatus, refetch, router])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Comptes mail"
|
||||
description="Connexions IMAP/SMTP et identités d'envoi par compte."
|
||||
/>
|
||||
{oauthStatus === "success" ? (
|
||||
<p className="text-sm text-green-600 dark:text-green-500">
|
||||
Compte mail connecté via OAuth.
|
||||
</p>
|
||||
) : null}
|
||||
{oauthStatus === "error" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Échec de la connexion OAuth
|
||||
{searchParams.get("code") ? ` (${searchParams.get("code")})` : ""}.
|
||||
</p>
|
||||
) : null}
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AddMailAccountForm
|
||||
pending={createAccount.isPending}
|
||||
onSubmit={(payload) => createAccount.mutate(payload)}
|
||||
/>
|
||||
|
||||
{showInitialLoad ? null : accounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aucun compte mail configuré. Ajoutez votre adresse e-mail ci-dessus pour commencer.
|
||||
</p>
|
||||
) : (
|
||||
accounts.map((account) => <AccountCard key={account.id} account={account} />)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountCard({ account }: { account: ApiMailAccount }) {
|
||||
const deleteAccount = useDeleteMailAccount()
|
||||
const resanitizeBodies = useResanitizeBodies(account.id)
|
||||
const syncAccount = useSyncMailAccount(account.id)
|
||||
const { data: identities = [] } = useIdentities(account.id)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [maintenanceMessage, setMaintenanceMessage] = useState<string | null>(null)
|
||||
|
||||
async function runResanitize() {
|
||||
setMaintenanceMessage(null)
|
||||
try {
|
||||
const result = await resanitizeBodies.mutateAsync()
|
||||
setMaintenanceMessage(
|
||||
`HTML re-sanitisé : ${result.updated} message(s) mis à jour sur ${result.scanned} analysé(s).`
|
||||
)
|
||||
} catch {
|
||||
setMaintenanceMessage("Échec de la re-sanitisation du HTML.")
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync() {
|
||||
setMaintenanceMessage(null)
|
||||
try {
|
||||
await syncAccount.mutateAsync()
|
||||
setMaintenanceMessage("Synchronisation IMAP terminée.")
|
||||
} catch {
|
||||
setMaintenanceMessage("Échec de la synchronisation IMAP.")
|
||||
}
|
||||
}
|
||||
|
||||
const maintenancePending = resanitizeBodies.isPending || syncAccount.isPending
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">{account.name}</CardTitle>
|
||||
<CardDescription>{account.email}</CardDescription>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
IMAP {account.imap_host} · SMTP {account.smtp_host}
|
||||
{account.last_sync_at
|
||||
? ` · Dernière sync : ${new Date(account.last_sync_at).toLocaleString("fr-FR")}`
|
||||
: null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Actions avancées du compte"
|
||||
disabled={maintenancePending}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={maintenancePending}
|
||||
onClick={() => void runResanitize()}
|
||||
>
|
||||
{resanitizeBodies.isPending
|
||||
? "Re-sanitisation…"
|
||||
: "Re-sanitiser le HTML"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={maintenancePending}
|
||||
onClick={() => void runSync()}
|
||||
>
|
||||
{syncAccount.isPending ? "Synchronisation…" : "Synchroniser IMAP"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Modifier le compte"
|
||||
aria-pressed={editing}
|
||||
onClick={() => setEditing((v) => !v)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Supprimer le compte"
|
||||
onClick={() => deleteAccount.mutate(account.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{maintenanceMessage ? (
|
||||
<p className="text-sm text-muted-foreground">{maintenanceMessage}</p>
|
||||
) : null}
|
||||
{editing ? (
|
||||
<EditMailAccountForm account={account} onCancel={() => setEditing(false)} />
|
||||
) : null}
|
||||
<IdentitiesBlock
|
||||
accountId={account.id}
|
||||
accountEmail={account.email}
|
||||
identities={identities}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentitiesBlock({
|
||||
accountId,
|
||||
accountEmail,
|
||||
identities,
|
||||
}: {
|
||||
accountId: string
|
||||
accountEmail: string
|
||||
identities: Array<{
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
signature_html?: string
|
||||
default_signature_id?: string
|
||||
reply_to_addrs?: string[]
|
||||
}>
|
||||
}) {
|
||||
const createIdentity = useCreateIdentity(accountId)
|
||||
const updateIdentity = useUpdateIdentity(accountId)
|
||||
const deleteIdentity = useDeleteIdentity(accountId)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newIdentity, setNewIdentity] = useState({ email: accountEmail, name: "" })
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAddForm) {
|
||||
setNewIdentity({ email: accountEmail, name: "" })
|
||||
}
|
||||
}, [accountEmail, showAddForm])
|
||||
|
||||
function identityPayload(
|
||||
identity: (typeof identities)[number],
|
||||
patch: Partial<{
|
||||
email: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return {
|
||||
identityId: identity.id,
|
||||
email: patch.email ?? identity.email,
|
||||
name: patch.name ?? identity.name,
|
||||
is_default: patch.is_default ?? identity.is_default,
|
||||
signature_html: identity.signature_html ?? "",
|
||||
default_signature_id: identity.default_signature_id ?? "",
|
||||
reply_to_addrs: identity.reply_to_addrs,
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateIdentity() {
|
||||
const email = newIdentity.email.trim()
|
||||
const name = newIdentity.name.trim()
|
||||
if (!email) return
|
||||
createIdentity.mutate(
|
||||
{
|
||||
email,
|
||||
name: name || email.split("@")[0] || "Identité",
|
||||
is_default: identities.length === 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false)
|
||||
setNewIdentity({ email: accountEmail, name: "" })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Identités d'envoi</h3>
|
||||
{identities.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune identité configurée.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{identities.map((identity) => (
|
||||
<li key={identity.id} className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="grid flex-1 gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Nom affiché</Label>
|
||||
<Input
|
||||
defaultValue={identity.name}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (!next || next === identity.name) return
|
||||
updateIdentity.mutate(identityPayload(identity, { name: next }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Adresse d'envoi</Label>
|
||||
<Input
|
||||
type="email"
|
||||
defaultValue={identity.email}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (!next || next === identity.email) return
|
||||
updateIdentity.mutate(identityPayload(identity, { email: next }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{identity.is_default ? (
|
||||
<p className="text-xs text-muted-foreground sm:col-span-2">
|
||||
Identité par défaut
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Supprimer l'identité"
|
||||
onClick={() => deleteIdentity.mutate(identity.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="rounded-lg border border-border p-3 space-y-3 max-w-lg">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Nom affiché</Label>
|
||||
<Input
|
||||
value={newIdentity.name}
|
||||
placeholder="Nom visible"
|
||||
onChange={(e) => setNewIdentity({ ...newIdentity, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Adresse d'envoi</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={newIdentity.email}
|
||||
onChange={(e) => setNewIdentity({ ...newIdentity, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={createIdentity.isPending || !newIdentity.email.trim()}
|
||||
onClick={handleCreateIdentity}
|
||||
>
|
||||
Créer l'identité
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={createIdentity.isPending}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
Ajouter une identité
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsComingSoon } from "@/components/gmail/settings/settings-coming-soon"
|
||||
import { AutomationRulesPanel } from "@/components/gmail/settings/automation/automation-rules-panel"
|
||||
import { WebhooksPanel } from "@/components/gmail/settings/automation/webhooks-panel"
|
||||
|
||||
export function AutomationSettingsSection() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Automatisations"
|
||||
description="Règles graphiques de tri, webhooks, fonctions réutilisables et variables d'exécution."
|
||||
/>
|
||||
<Tabs defaultValue="rules">
|
||||
<TabsList className="flex h-auto flex-wrap">
|
||||
<TabsTrigger value="rules">Règles</TabsTrigger>
|
||||
<TabsTrigger value="webhooks">Webhooks</TabsTrigger>
|
||||
<TabsTrigger value="llm">Fournisseurs LLM</TabsTrigger>
|
||||
<TabsTrigger value="tokens">Tokens API</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="rules" className="mt-4">
|
||||
<AutomationRulesPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="webhooks" className="mt-4">
|
||||
<WebhooksPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="llm" className="mt-4">
|
||||
<SettingsComingSoon
|
||||
title="Tri par LLM"
|
||||
description="Configurez des fournisseurs OpenAI-compatibles. Les nœuds LLM utilisent un heuristique en attendant le branchement complet."
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="tokens" className="mt-4">
|
||||
<SettingsComingSoon
|
||||
title="Tokens API agents"
|
||||
description="Créez des jetons fine-grained pour agents IA (lecture partielle, envoi, catégorisation)."
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
|
||||
import { MailSettingsFields } from "@/components/gmail/mail-settings-fields"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
|
||||
export function DisplaySettingsSection() {
|
||||
const { isFetching, isError, refetch } = useMailSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Réglages d'affichage"
|
||||
description="Densité, thème, type de boîte de réception et volet de lecture."
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
<MailSettingsFields variant="page" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,370 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react"
|
||||
import { Folder, Tag, type LucideIcon } from "lucide-react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
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 { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useLabels } from "@/lib/api/hooks/use-folder-label-queries"
|
||||
import { useCreateUnifiedFolder, useUnifiedFolders } from "@/lib/api/hooks/use-unified-folder-queries"
|
||||
import { useImapFoldersForAccount } from "@/lib/api/hooks/use-imap-folders"
|
||||
import { buildImapFolderSettingsTree } from "@/lib/mail-settings/imap-folder-tree"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { buildFolderTreeFromUnified } from "@/lib/mail-settings/unified-folder-tree"
|
||||
import { useSidebarNav } from "@/lib/sidebar-nav-context"
|
||||
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
|
||||
import { NavColorPickerTrigger } from "@/components/gmail/nav/nav-color-picker-trigger"
|
||||
import { DEFAULT_NAV_COLOR } from "@/lib/nav-color"
|
||||
import {
|
||||
flattenLabelRowsForSettings,
|
||||
FolderSettingsTree,
|
||||
ImapFolderSettingsTree,
|
||||
NavLabelSettingsCard,
|
||||
} from "@/components/gmail/settings/nav-item-settings-card"
|
||||
|
||||
function SettingsFormHeading({
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Icon className="size-4 shrink-0 opacity-70" aria-hidden />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSettingsListPanel({
|
||||
title,
|
||||
icon: Icon,
|
||||
loading,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
icon?: LucideIcon
|
||||
loading: boolean
|
||||
emptyTitle: string
|
||||
emptyDescription?: string
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon ? <Icon className="size-4 shrink-0 opacity-70" aria-hidden /> : null}
|
||||
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div
|
||||
className="min-h-[120px] rounded-lg border border-border bg-muted/20"
|
||||
aria-hidden
|
||||
/>
|
||||
) : children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-border bg-muted/10 px-4 py-8 text-center">
|
||||
<p className="text-sm text-foreground">{emptyTitle}</p>
|
||||
{emptyDescription ? (
|
||||
<p className="max-w-sm text-xs text-muted-foreground">{emptyDescription}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function LabelsFoldersSettingsSection() {
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Libellés et dossiers"
|
||||
description="Mêmes réglages que dans la barre latérale : couleur, affichage dans les listes, arborescence, renommage."
|
||||
/>
|
||||
<Tabs defaultValue="labels">
|
||||
<TabsList>
|
||||
<TabsTrigger value="labels">Libellés</TabsTrigger>
|
||||
<TabsTrigger value="folders-global">Dossiers globaux</TabsTrigger>
|
||||
<TabsTrigger value="folders-account">Dossiers par compte</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="labels" className="mt-4">
|
||||
<LabelsPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="folders-global" className="mt-4">
|
||||
<UnifiedFoldersPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="folders-account" className="mt-4">
|
||||
<ImapAccountFoldersPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LabelsPanel() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const nav = useSidebarNav()
|
||||
const { isPending } = useLabels()
|
||||
const [name, setName] = useState("")
|
||||
const [color, setColor] = useState(DEFAULT_NAV_COLOR)
|
||||
|
||||
const userLabels = useMemo(
|
||||
() =>
|
||||
flattenLabelRowsForSettings(
|
||||
nav.labelRows.filter((row) => !isSystemNavLabelId(row.id))
|
||||
),
|
||||
[nav.labelRows]
|
||||
)
|
||||
|
||||
const listLoading = ready && authenticated && isPending && userLabels.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cliquez sur un libellé pour modifier couleur, visibilité et sous-libellés — comme le menu
|
||||
clic droit dans la barre latérale.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<SettingsFormHeading icon={Tag}>Nouveau libellé</SettingsFormHeading>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[160px] flex-1 space-y-1">
|
||||
<Label className="text-xs" htmlFor="new-label-name">
|
||||
Nom
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<NavColorPickerTrigger
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
aria-label="Couleur du libellé"
|
||||
/>
|
||||
<Input
|
||||
id="new-label-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0"
|
||||
disabled={!name.trim()}
|
||||
onClick={() => {
|
||||
nav.addLabelRowFromSidebar(name.trim(), color)
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavSettingsListPanel
|
||||
title="Vos libellés"
|
||||
icon={Tag}
|
||||
loading={listLoading}
|
||||
emptyTitle="Aucun libellé personnalisé"
|
||||
emptyDescription="Utilisez le formulaire ci-dessus pour en créer un."
|
||||
>
|
||||
{userLabels.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{userLabels.map((row) => (
|
||||
<li key={row.id}>
|
||||
<NavLabelSettingsCard
|
||||
id={row.id}
|
||||
name={row.label}
|
||||
color={row.color}
|
||||
depth={row.depth}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</NavSettingsListPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UnifiedFoldersPanel() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const createFolder = useCreateUnifiedFolder()
|
||||
const { data: folders = [], isFetching, isError, refetch, isPending } =
|
||||
useUnifiedFolders("global")
|
||||
|
||||
const [name, setName] = useState("")
|
||||
const [color, setColor] = useState(DEFAULT_NAV_COLOR)
|
||||
const [parentId, setParentId] = useState<string>("__root__")
|
||||
|
||||
const visible = folders.filter((f) => f.scope === "global")
|
||||
const tree = useMemo(() => buildFolderTreeFromUnified(visible), [visible])
|
||||
|
||||
const parentOptions = useMemo(() => {
|
||||
const opts: { value: string; label: string }[] = [{ value: "__root__", label: "Racine" }]
|
||||
const walk = (nodes: typeof tree, depth: number) => {
|
||||
for (const n of nodes) {
|
||||
opts.push({
|
||||
value: n.id,
|
||||
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
|
||||
})
|
||||
if (n.children?.length) walk(n.children, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(tree, 0)
|
||||
return opts
|
||||
}, [tree])
|
||||
|
||||
const listLoading = ready && authenticated && isPending && visible.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dossiers Ultimail globaux — organisation virtuelle cross-comptes.
|
||||
</p>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<SettingsFormHeading icon={Folder}>Nouveau dossier</SettingsFormHeading>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-[160px] flex-1 space-y-1">
|
||||
<Label className="text-xs" htmlFor="new-folder-name-global">
|
||||
Nom
|
||||
</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
<NavColorPickerTrigger
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
aria-label="Couleur du dossier"
|
||||
/>
|
||||
<Input
|
||||
id="new-folder-name-global"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[200px] flex-1 space-y-1">
|
||||
<Label className="text-xs">Emplacement</Label>
|
||||
<Select value={parentId} onValueChange={setParentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parentOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0"
|
||||
disabled={!name.trim() || createFolder.isPending}
|
||||
onClick={() => {
|
||||
const parent = parentId === "__root__" ? undefined : parentId
|
||||
createFolder.mutate({
|
||||
name: name.trim(),
|
||||
color,
|
||||
parent_id: parent,
|
||||
})
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavSettingsListPanel
|
||||
title="Vos dossiers"
|
||||
icon={Folder}
|
||||
loading={listLoading}
|
||||
emptyTitle="Aucun dossier Ultimail"
|
||||
emptyDescription="Utilisez le formulaire ci-dessus pour en créer un."
|
||||
>
|
||||
{visible.length > 0 ? <FolderSettingsTree nodes={tree} /> : null}
|
||||
</NavSettingsListPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImapAccountFoldersPanel() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const { data: accounts = [] } = useMailAccounts()
|
||||
const [accountId, setAccountId] = useState("")
|
||||
const selectedAccountId = accountId || accounts[0]?.id
|
||||
const {
|
||||
data: folders = [],
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
isPending,
|
||||
} = useImapFoldersForAccount(selectedAccountId)
|
||||
|
||||
const tree = useMemo(
|
||||
() =>
|
||||
selectedAccountId
|
||||
? buildImapFolderSettingsTree(folders, selectedAccountId)
|
||||
: [],
|
||||
[folders, selectedAccountId]
|
||||
)
|
||||
|
||||
const listLoading = ready && authenticated && isPending && folders.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dossiers synchronisés depuis vos serveurs mail (IMAP). Masquez ceux que vous ne voulez
|
||||
pas voir dans la barre latérale.
|
||||
</p>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="max-w-xs space-y-1">
|
||||
<Label className="text-xs">Compte mail</Label>
|
||||
<Select
|
||||
value={selectedAccountId ?? ""}
|
||||
onValueChange={setAccountId}
|
||||
disabled={accounts.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choisir un compte" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<NavSettingsListPanel
|
||||
title="Dossiers IMAP"
|
||||
icon={Folder}
|
||||
loading={listLoading}
|
||||
emptyTitle="Aucun dossier IMAP personnalisé"
|
||||
emptyDescription="Les dossiers système (Boîte de réception, Envoyés…) restent dans la navigation principale. Les dossiers personnalisés apparaissent ici après synchronisation."
|
||||
>
|
||||
{tree.length > 0 ? <ImapFolderSettingsTree nodes={tree} /> : null}
|
||||
</NavSettingsListPanel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useMailSettings } from "@/lib/api/hooks/use-mail-settings"
|
||||
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import { requestDesktopNotificationPermission } from "@/lib/notifications/desktop-notifications"
|
||||
|
||||
export function NotificationsSettingsSection() {
|
||||
const { isFetching, isError, refetch } = useMailSettings()
|
||||
const desktopNewMail = useMailSettingsStore((s) => s.desktopNewMail)
|
||||
const desktopMentions = useMailSettingsStore((s) => s.desktopMentions)
|
||||
const emailDigest = useMailSettingsStore((s) => s.emailDigest)
|
||||
const soundEnabled = useMailSettingsStore((s) => s.soundEnabled)
|
||||
const setDesktopNewMail = useMailSettingsStore((s) => s.setDesktopNewMail)
|
||||
const setDesktopMentions = useMailSettingsStore((s) => s.setDesktopMentions)
|
||||
const setEmailDigest = useMailSettingsStore((s) => s.setEmailDigest)
|
||||
const setSoundEnabled = useMailSettingsStore((s) => s.setSoundEnabled)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Notifications"
|
||||
description="Préférences d'alertes synchronisées avec votre compte Ultimail."
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Nouveaux messages (bureau)"
|
||||
checked={desktopNewMail}
|
||||
onChange={async (checked) => {
|
||||
if (checked) await requestDesktopNotificationPermission()
|
||||
setDesktopNewMail(checked)
|
||||
}}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Mentions et réponses"
|
||||
checked={desktopMentions}
|
||||
onChange={async (checked) => {
|
||||
if (checked) await requestDesktopNotificationPermission()
|
||||
setDesktopMentions(checked)
|
||||
}}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Résumé quotidien par e-mail"
|
||||
checked={emailDigest}
|
||||
onChange={setEmailDigest}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Son de notification"
|
||||
checked={soundEnabled}
|
||||
onChange={setSoundEnabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void | Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center justify-between gap-4 rounded-lg border border-border px-4 py-3">
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="size-4 accent-[#1a73e8]"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,378 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { PenLine, Plus, Trash2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { SettingsSectionHeader } from "@/components/gmail/settings/settings-section-header"
|
||||
import { SettingsSyncBanner } from "@/components/gmail/settings/settings-sync-banner"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
||||
import { useIdentities } from "@/lib/api/hooks/use-folder-label-queries"
|
||||
import {
|
||||
useCreateMailSignature,
|
||||
useDeleteMailSignature,
|
||||
useMailSignatures,
|
||||
useUpdateMailSignature,
|
||||
} from "@/lib/api/hooks/use-mail-signatures"
|
||||
import { useUpdateIdentity } from "@/lib/api/hooks/use-identity-mutations"
|
||||
import type { ApiIdentity, ApiMailSignature } from "@/lib/api/types"
|
||||
|
||||
const NONE_SIGNATURE = "__none__"
|
||||
|
||||
export function SignaturesSettingsSection() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
const {
|
||||
data: signatures = [],
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
isPending,
|
||||
} = useMailSignatures()
|
||||
const showInitialLoad = ready && authenticated && isPending && signatures.length === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionHeader
|
||||
title="Signatures"
|
||||
description="Bibliothèque de signatures réutilisables et attribution par identité d'envoi."
|
||||
/>
|
||||
<SettingsSyncBanner isFetching={isFetching} isError={isError} onRetry={() => refetch()} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<SignatureLibrary
|
||||
signatures={signatures}
|
||||
showInitialLoad={showInitialLoad}
|
||||
/>
|
||||
<IdentitySignatureAssignments signatures={signatures} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SignatureLibrary({
|
||||
signatures,
|
||||
showInitialLoad,
|
||||
}: {
|
||||
signatures: ApiMailSignature[]
|
||||
showInitialLoad: boolean
|
||||
}) {
|
||||
const createSignature = useCreateMailSignature()
|
||||
const updateSignature = useUpdateMailSignature()
|
||||
const deleteSignature = useDeleteMailSignature()
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [draft, setDraft] = useState({ name: "", html: "" })
|
||||
|
||||
function handleCreate() {
|
||||
const name = draft.name.trim()
|
||||
if (!name) return
|
||||
createSignature.mutate(
|
||||
{ name, html: draft.html },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false)
|
||||
setDraft({ name: "", html: "" })
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<PenLine className="size-4" />
|
||||
Bibliothèque
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Créez des signatures nommées que vous pourrez réutiliser sur plusieurs identités.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{showInitialLoad ? null : signatures.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucune signature enregistrée.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{signatures.map((signature) => (
|
||||
<li
|
||||
key={signature.id}
|
||||
className="rounded-lg border border-border p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input
|
||||
defaultValue={signature.name}
|
||||
onBlur={(e) => {
|
||||
const next = e.target.value.trim()
|
||||
if (!next || next === signature.name) return
|
||||
updateSignature.mutate({
|
||||
signatureId: signature.id,
|
||||
name: next,
|
||||
html: signature.html,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Supprimer la signature"
|
||||
onClick={() => deleteSignature.mutate(signature.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Contenu HTML</Label>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||
defaultValue={signature.html}
|
||||
placeholder="<div>…</div>"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value === signature.html) return
|
||||
updateSignature.mutate({
|
||||
signatureId: signature.id,
|
||||
name: signature.name,
|
||||
html: e.target.value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{signature.html?.trim() ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/30 p-3 text-sm">
|
||||
<p className="mb-2 text-xs text-muted-foreground">Aperçu</p>
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert"
|
||||
dangerouslySetInnerHTML={{ __html: signature.html }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="rounded-lg border border-border p-3 space-y-3 max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Nom</Label>
|
||||
<Input
|
||||
value={draft.name}
|
||||
placeholder="Professionnelle"
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Contenu HTML</Label>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||
value={draft.html}
|
||||
placeholder="<div style="color:#5f6368">…</div>"
|
||||
onChange={(e) => setDraft({ ...draft, html: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={createSignature.isPending || !draft.name.trim()}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowAddForm(false)
|
||||
setDraft({ name: "", html: "" })
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
Ajouter une signature
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentitySignatureAssignments({
|
||||
signatures,
|
||||
}: {
|
||||
signatures: ApiMailSignature[]
|
||||
}) {
|
||||
const { data: accounts = [] } = useMailAccounts()
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Attribution par identité</CardTitle>
|
||||
<CardDescription>
|
||||
Ajoutez un compte mail pour configurer les signatures par défaut.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Attribution par identité</CardTitle>
|
||||
<CardDescription>
|
||||
Choisissez la signature insérée par défaut pour chaque adresse d'envoi.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{accounts.map((account) => (
|
||||
<AccountIdentitySignatures
|
||||
key={account.id}
|
||||
accountId={account.id}
|
||||
accountLabel={`${account.name} · ${account.email}`}
|
||||
signatures={signatures}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountIdentitySignatures({
|
||||
accountId,
|
||||
accountLabel,
|
||||
signatures,
|
||||
}: {
|
||||
accountId: string
|
||||
accountLabel: string
|
||||
signatures: ApiMailSignature[]
|
||||
}) {
|
||||
const { data: identities = [] } = useIdentities(accountId)
|
||||
const updateIdentity = useUpdateIdentity(accountId)
|
||||
|
||||
const signatureOptions = useMemo(
|
||||
() => [
|
||||
{ value: NONE_SIGNATURE, label: "Aucune" },
|
||||
...signatures.map((s) => ({ value: s.id, label: s.name })),
|
||||
],
|
||||
[signatures]
|
||||
)
|
||||
|
||||
if (identities.length === 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{accountLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">Aucune identité d'envoi.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">{accountLabel}</p>
|
||||
<ul className="space-y-2">
|
||||
{identities.map((identity) => (
|
||||
<IdentitySignatureRow
|
||||
key={identity.id}
|
||||
identity={identity}
|
||||
options={signatureOptions}
|
||||
pending={updateIdentity.isPending}
|
||||
onAssign={(defaultSignatureId) =>
|
||||
updateIdentity.mutate({
|
||||
identityId: identity.id,
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
is_default: identity.is_default,
|
||||
signature_html: identity.signature_html ?? "",
|
||||
default_signature_id: defaultSignatureId,
|
||||
reply_to_addrs: identity.reply_to_addrs,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentitySignatureRow({
|
||||
identity,
|
||||
options,
|
||||
pending,
|
||||
onAssign,
|
||||
}: {
|
||||
identity: ApiIdentity
|
||||
options: Array<{ value: string; label: string }>
|
||||
pending: boolean
|
||||
onAssign: (defaultSignatureId: string) => void
|
||||
}) {
|
||||
const current =
|
||||
identity.default_signature_id && identity.default_signature_id !== ""
|
||||
? identity.default_signature_id
|
||||
: NONE_SIGNATURE
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-lg border border-border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{identity.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{identity.email}</p>
|
||||
{identity.is_default ? (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Identité par défaut</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 sm:w-64">
|
||||
<Label className="text-xs sr-only">Signature par défaut</Label>
|
||||
<Select
|
||||
value={current}
|
||||
disabled={pending}
|
||||
onValueChange={(value) =>
|
||||
onAssign(value === NONE_SIGNATURE ? "" : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Signature" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
20
components/gmail/settings/settings-coming-soon.tsx
Normal file
20
components/gmail/settings/settings-coming-soon.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
export function SettingsComingSoon({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-dashed bg-muted/20 py-4 shadow-none">
|
||||
<CardHeader className="px-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pt-0 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
16
components/gmail/settings/settings-section-header.tsx
Normal file
16
components/gmail/settings/settings-section-header.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export function SettingsSectionHeader({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-6">
|
||||
<h2 className="text-lg font-medium text-foreground">{title}</h2>
|
||||
{description ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
48
components/gmail/settings/settings-sync-banner.tsx
Normal file
48
components/gmail/settings/settings-sync-banner.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuthReady } from "@/lib/api/use-auth-ready"
|
||||
|
||||
export function SettingsSyncBanner({
|
||||
isFetching,
|
||||
isError,
|
||||
onRetry,
|
||||
}: {
|
||||
isFetching?: boolean
|
||||
isError?: boolean
|
||||
onRetry?: () => void
|
||||
}) {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
if (!ready || isFetching) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<p className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
Connectez-vous pour synchroniser avec le serveur. Les réglages locaux restent
|
||||
disponibles hors ligne.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <SyncErrorBanner onRetry={onRetry} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function SyncErrorBanner({ onRetry }: { onRetry?: () => void }) {
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<span>Échec de synchronisation avec le serveur.</span>
|
||||
{onRetry ? (
|
||||
<Button type="button" variant="outline" size="sm" className="h-7" onClick={onRetry}>
|
||||
Réessayer
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -14,7 +14,6 @@ import { useEmailDropTarget } from "@/lib/drag-context"
|
||||
import {
|
||||
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
|
||||
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
|
||||
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
||||
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
||||
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
||||
@ -77,6 +76,8 @@ import {
|
||||
mailSidebarFolderBranchStickyTopPx,
|
||||
mailSidebarFolderBranchStickyZ,
|
||||
} from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
import {
|
||||
SidebarNavOptionsSheet,
|
||||
SidebarNavSheetAction,
|
||||
@ -151,7 +152,7 @@ export function SidebarFolderRowExpanded({
|
||||
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
|
||||
const hasChildren = !!(node.children?.length)
|
||||
const isBranchOpen = expandedFolderIds.has(node.id)
|
||||
const dotClass = node.color ?? "bg-gray-400"
|
||||
const dotClass = normalizeNavColorClass(node.color)
|
||||
const isSelected = selectedFolder === node.id
|
||||
const unread = folderUnreadCounts[node.id] ?? 0
|
||||
const hasUnread = unread > 0
|
||||
@ -223,26 +224,15 @@ export function SidebarFolderRowExpanded({
|
||||
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
|
||||
</SubTr>
|
||||
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
||||
<button
|
||||
key={sw}
|
||||
type="button"
|
||||
title={sw}
|
||||
onClick={() => {
|
||||
<NavColorPicker
|
||||
variant="menu"
|
||||
value={dotClass}
|
||||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||
onChange={(sw) => {
|
||||
updateFolderOrLabelColor(node.id, sw)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
cn(
|
||||
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
|
||||
),
|
||||
sw
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SubCo>
|
||||
</Sub>
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ import { cn } from "@/lib/utils"
|
||||
import { useEmailDropTarget } from "@/lib/drag-context"
|
||||
import {
|
||||
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
|
||||
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
||||
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
||||
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
||||
@ -56,6 +55,8 @@ import {
|
||||
setSidebarNavDragData,
|
||||
} from "@/lib/sidebar-nav-dnd"
|
||||
import { LABEL_MENU_COLOR_SWATCHES } from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
|
||||
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||
import {
|
||||
SidebarNavOptionsSheet,
|
||||
SidebarNavSheetAction,
|
||||
@ -146,7 +147,7 @@ export function SidebarLabelItemRow({
|
||||
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
|
||||
|
||||
const prefs = getNavItemPrefs(item.id)
|
||||
const labelDotClass = item.color ?? "bg-gray-400"
|
||||
const labelDotClass = normalizeNavColorClass(item.color)
|
||||
const labelMenuSurface =
|
||||
MAIL_SIDEBAR_MENU_SURFACE_CLASS
|
||||
|
||||
@ -176,26 +177,15 @@ export function SidebarLabelItemRow({
|
||||
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
|
||||
</SubTr>
|
||||
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
||||
<button
|
||||
key={sw}
|
||||
type="button"
|
||||
title={sw}
|
||||
onClick={() => {
|
||||
<NavColorPicker
|
||||
variant="menu"
|
||||
value={labelDotClass}
|
||||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||
onChange={(sw) => {
|
||||
updateFolderOrLabelColor(item.id, sw)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
cn(
|
||||
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
|
||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
|
||||
),
|
||||
sw
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SubCo>
|
||||
</Sub>
|
||||
)
|
||||
|
||||
@ -10,6 +10,8 @@ import {
|
||||
} from "@/components/ui/sheet"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { NavColorDot } from "@/components/gmail/nav/nav-color-dot"
|
||||
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
|
||||
|
||||
const sheetContentClass =
|
||||
"max-h-[min(85vh,560px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-0 select-none left-1/2 right-auto w-[calc(100%-2rem)] max-w-md -translate-x-1/2 sm:max-w-lg"
|
||||
@ -89,27 +91,16 @@ export function SidebarNavSheetColorPicker({
|
||||
<div className="px-4 py-2">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#3c4043]">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
|
||||
<span
|
||||
className={cn("block size-3 rounded-sm border border-black/10", dotClass)}
|
||||
aria-hidden
|
||||
/>
|
||||
<NavColorDot color={dotClass} />
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{swatches.map((sw) => (
|
||||
<button
|
||||
key={sw}
|
||||
type="button"
|
||||
title={sw}
|
||||
onClick={() => onPick(sw)}
|
||||
className={cn(
|
||||
"size-8 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
|
||||
sw
|
||||
)}
|
||||
<NavColorPicker
|
||||
variant="sheet"
|
||||
value={dotClass}
|
||||
swatches={swatches}
|
||||
onChange={onPick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -136,12 +127,7 @@ export function SidebarNavOptionsSheet({
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white"
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block size-3 rounded-sm border border-black/10",
|
||||
colorDotClass
|
||||
)}
|
||||
/>
|
||||
<NavColorDot color={colorDotClass} />
|
||||
</span>
|
||||
) : null}
|
||||
<SheetTitle className="min-w-0 flex-1 truncate text-left text-base font-medium leading-5 text-[#3c4043]">
|
||||
|
||||
@ -6,6 +6,23 @@ import {
|
||||
type ThemeProviderProps,
|
||||
} from 'next-themes'
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
scriptProps,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
// next-themes renders an inline <script> to prevent theme flicker.
|
||||
// React 19 warns about script tags inside client components on hydration.
|
||||
// SSR keeps the default executable script; the client uses a non-executable
|
||||
// type so React does not warn (the blocking script already ran from HTML).
|
||||
const resolvedScriptProps =
|
||||
typeof window === 'undefined'
|
||||
? scriptProps
|
||||
: { ...scriptProps, type: 'application/json' as const }
|
||||
|
||||
return (
|
||||
<NextThemesProvider {...props} scriptProps={resolvedScriptProps}>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -21,11 +21,7 @@ function TooltipProvider({
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
|
||||
@ -5,14 +5,15 @@ import { cn } from "@/lib/utils"
|
||||
|
||||
type UltiMailLogoProps = {
|
||||
className?: string
|
||||
/** `horizontal` = picto source + « Ultimail » (lisible, aligné barre). `mark` = picto seul (launcher). */
|
||||
variant?: "horizontal" | "mark"
|
||||
/** `horizontal` = picto + texte. `mark` = picto seul. `stacked` = wordmark empilé. */
|
||||
variant?: "horizontal" | "mark" | "stacked"
|
||||
/** Lien au clic ; `null` = pas de lien. Défaut : boîte de réception. */
|
||||
href?: string | null
|
||||
}
|
||||
|
||||
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
|
||||
const HEADER_ICON = "/brand/ultimail-header-icon.png"
|
||||
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.png"
|
||||
const DEFAULT_INBOX_HREF = "/mail/inbox"
|
||||
|
||||
export function UltiMailLogo({
|
||||
@ -53,6 +54,38 @@ export function UltiMailLogo({
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === "stacked") {
|
||||
const stacked = (
|
||||
<div className="flex h-[6.25rem] items-center justify-center overflow-hidden sm:h-[6.75rem]">
|
||||
<img
|
||||
src={STACKED_WORDMARK}
|
||||
alt="Ultimail"
|
||||
width={320}
|
||||
height={320}
|
||||
draggable={false}
|
||||
className="h-[11.25rem] w-auto max-w-none shrink-0 object-contain select-none sm:h-[12rem]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (href === null) {
|
||||
return <div className={cn("flex justify-center", className)}>{stacked}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
className
|
||||
)}
|
||||
aria-label="Ultimail — Boîte de réception"
|
||||
>
|
||||
{stacked}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const body = (
|
||||
<div
|
||||
role="img"
|
||||
|
||||
@ -20,7 +20,14 @@ export function useMailRoute() {
|
||||
const pathname = usePathname()
|
||||
const currentSearchParams = useSearchParams()
|
||||
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
|
||||
const route = useMemo(() => parseMailSegments(segments), [segments])
|
||||
const route = useMemo(() => {
|
||||
const parsed = parseMailSegments(segments)
|
||||
const queryMailId = currentSearchParams.get("message")?.trim()
|
||||
if (!parsed.mailId && queryMailId) {
|
||||
return { ...parsed, mailId: queryMailId }
|
||||
}
|
||||
return parsed
|
||||
}, [segments, currentSearchParams])
|
||||
|
||||
const navigateRoute = useCallback(
|
||||
(patch: Partial<MailRouteState>) => {
|
||||
@ -34,8 +41,11 @@ export function useMailRoute() {
|
||||
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
|
||||
}
|
||||
let url = buildMailPath(next)
|
||||
if (next.folderId === "search" && currentSearchParams.toString()) {
|
||||
url += `?${currentSearchParams.toString()}`
|
||||
if (next.folderId === "search") {
|
||||
const qs = new URLSearchParams(currentSearchParams.toString())
|
||||
qs.delete("message")
|
||||
const q = qs.toString()
|
||||
if (q) url += `?${q}`
|
||||
}
|
||||
router.push(url, { scroll: false })
|
||||
},
|
||||
|
||||
@ -6,6 +6,7 @@ import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||
import type { ReadingPaneMode } from "@/lib/mail-settings/types"
|
||||
|
||||
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
|
||||
const LG_MQ = "(min-width: 1024px)"
|
||||
const LANDSCAPE_MQ = "(orientation: landscape)"
|
||||
|
||||
export function readMailSplitViewMatches(
|
||||
@ -37,22 +38,26 @@ export function useMailSplitView() {
|
||||
const mqlCoarse = window.matchMedia(
|
||||
"(hover: none) and (pointer: coarse)"
|
||||
)
|
||||
const update = () =>
|
||||
setSplitView(readMailSplitViewMatches(readingPane))
|
||||
const mqlLg = window.matchMedia(LG_MQ)
|
||||
let raf = 0
|
||||
const update = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(() => {
|
||||
const next = readMailSplitViewMatches(readingPane)
|
||||
setSplitView((prev) => (prev === next ? prev : next))
|
||||
})
|
||||
}
|
||||
update()
|
||||
mqlMd.addEventListener("change", update)
|
||||
mqlLandscape.addEventListener("change", update)
|
||||
mqlCoarse.addEventListener("change", update)
|
||||
window
|
||||
.matchMedia(`(min-width: 1024px)`)
|
||||
.addEventListener("change", update)
|
||||
mqlLg.addEventListener("change", update)
|
||||
return () => {
|
||||
mqlMd.removeEventListener("change", update)
|
||||
mqlLandscape.removeEventListener("change", update)
|
||||
mqlCoarse.removeEventListener("change", update)
|
||||
window
|
||||
.matchMedia(`(min-width: 1024px)`)
|
||||
.removeEventListener("change", update)
|
||||
mqlLg.removeEventListener("change", update)
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [readingPane])
|
||||
|
||||
|
||||
28
hooks/use-match-media.ts
Normal file
28
hooks/use-match-media.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
|
||||
/** matchMedia with rAF batching — avoids resize storms re-rendering every pixel. */
|
||||
export function useMatchMedia(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mql = window.matchMedia(query)
|
||||
let raf = 0
|
||||
|
||||
const sync = () => {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(() => {
|
||||
const next = mql.matches
|
||||
setMatches((prev) => (prev === next ? prev : next))
|
||||
})
|
||||
}
|
||||
|
||||
sync()
|
||||
mql.addEventListener("change", sync)
|
||||
return () => {
|
||||
mql.removeEventListener("change", sync)
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
@ -9,7 +9,7 @@ type PersistApi = {
|
||||
|
||||
/** True after a zustand `persist` store has rehydrated from storage (client-only). */
|
||||
export function usePersistHydrated(store: { persist: PersistApi }): boolean {
|
||||
const [hydrated, setHydrated] = useState(() => store.persist.hasHydrated())
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (store.persist.hasHydrated()) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useState } from "react"
|
||||
import { useMatchMedia } from "@/hooks/use-match-media"
|
||||
|
||||
/** Tailwind `sm` breakpoint — viewports below are treated as xs. */
|
||||
export const XS_MAX_PX = 639
|
||||
@ -12,15 +12,5 @@ export function readXsMatches(): boolean {
|
||||
}
|
||||
|
||||
export function useIsXs() {
|
||||
const [isXs, setIsXs] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const mql = window.matchMedia(XS_MQ)
|
||||
const update = () => setIsXs(mql.matches)
|
||||
update()
|
||||
mql.addEventListener("change", update)
|
||||
return () => mql.removeEventListener("change", update)
|
||||
}, [])
|
||||
|
||||
return isXs
|
||||
return useMatchMedia(XS_MQ)
|
||||
}
|
||||
|
||||
@ -3,12 +3,19 @@
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null
|
||||
refreshToken: string | null
|
||||
expiresAt: number | null
|
||||
login: (accessToken: string, refreshToken: string, expiresAt: number) => void
|
||||
user: PlatformUser | null
|
||||
login: (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
expiresAt: number,
|
||||
user?: PlatformUser | null
|
||||
) => void
|
||||
logout: () => void
|
||||
isAuthenticated: () => boolean
|
||||
}
|
||||
@ -19,14 +26,22 @@ export const useAuthStore = create<AuthState>()(
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
login: (accessToken, refreshToken, expiresAt) =>
|
||||
set({ accessToken, refreshToken, expiresAt }),
|
||||
user: null,
|
||||
login: (accessToken, refreshToken, expiresAt, user = null) =>
|
||||
set({ accessToken, refreshToken, expiresAt, user }),
|
||||
logout: () =>
|
||||
set({ accessToken: null, refreshToken: null, expiresAt: null }),
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
}),
|
||||
isAuthenticated: () => {
|
||||
const { accessToken, expiresAt } = get()
|
||||
if (!accessToken || !expiresAt) return false
|
||||
return Date.now() < expiresAt
|
||||
const { accessToken, expiresAt, refreshToken } = get()
|
||||
if (!accessToken) return false
|
||||
if (expiresAt && Date.now() < expiresAt) return true
|
||||
// Access token expired — session may still be renewed via refresh token.
|
||||
return Boolean(refreshToken)
|
||||
},
|
||||
}),
|
||||
{
|
||||
@ -36,6 +51,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresAt: state.expiresAt,
|
||||
user: state.user,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useAuthStore } from "./auth-store"
|
||||
import type { ApiError } from "./types"
|
||||
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||
|
||||
export class OfflineError extends Error {
|
||||
constructor() {
|
||||
@ -22,13 +23,49 @@ export class ApiRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
const DEFAULT_TIMEOUT = 10_000
|
||||
const DEFAULT_RETRIES = 3
|
||||
const BASE_DELAY = 1000
|
||||
|
||||
async function tryRefreshSession(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session", { credentials: "include" })
|
||||
if (!res.ok) return false
|
||||
const data = (await res.json()) as {
|
||||
authenticated?: boolean
|
||||
accessToken?: string
|
||||
refreshToken?: string | null
|
||||
expiresAt?: number
|
||||
user?: unknown
|
||||
}
|
||||
if (data.authenticated && data.accessToken && data.expiresAt) {
|
||||
useAuthStore.getState().login(
|
||||
data.accessToken,
|
||||
data.refreshToken ?? "",
|
||||
data.expiresAt,
|
||||
(data.user as PlatformUser | null | undefined) ?? null
|
||||
)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
constructor(private baseUrl: string) {}
|
||||
|
||||
private resolveUrl(path: string): URL {
|
||||
const base = this.baseUrl.startsWith("http")
|
||||
? this.baseUrl
|
||||
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
|
||||
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
|
||||
const normalizedBase = base.endsWith("/") ? base : `${base}/`
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
|
||||
return new URL(normalizedPath, normalizedBase)
|
||||
}
|
||||
|
||||
private getHeaders(): HeadersInit {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
@ -54,7 +91,7 @@ class ApiClient {
|
||||
throw new OfflineError()
|
||||
}
|
||||
|
||||
const url = new URL(path, this.baseUrl.startsWith("http") ? this.baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`)
|
||||
const url = this.resolveUrl(path)
|
||||
if (opts?.params) {
|
||||
for (const [key, value] of Object.entries(opts.params)) {
|
||||
if (value !== undefined) {
|
||||
@ -67,6 +104,7 @@ class ApiClient {
|
||||
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
|
||||
|
||||
let lastError: Error | null = null
|
||||
let authRetried = false
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
@ -100,6 +138,13 @@ class ApiClient {
|
||||
errorBody?.details
|
||||
)
|
||||
|
||||
if (response.status === 401 && !authRetried) {
|
||||
authRetried = true
|
||||
if (await tryRefreshSession()) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw err
|
||||
}
|
||||
@ -112,7 +157,12 @@ class ApiClient {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
const text = await response.text()
|
||||
if (!text.trim()) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
|
||||
@ -143,6 +193,10 @@ class ApiClient {
|
||||
return this.request<T>("PUT", path, { body })
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
return this.request<T>("PATCH", path, { body })
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
await this.request<void>("DELETE", path)
|
||||
}
|
||||
|
||||
@ -5,13 +5,34 @@ import { apiClient, OfflineError } from '../client'
|
||||
import { enqueue } from '../offline-queue'
|
||||
import type { ApiContact } from '../types'
|
||||
|
||||
function appendContactToBookCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
bookId: string,
|
||||
contact: ApiContact,
|
||||
) {
|
||||
queryClient.setQueryData<ApiContact[]>(['contacts', bookId], (old) => {
|
||||
const list = old ?? []
|
||||
if (list.some((item) => item.uid === contact.uid)) return list
|
||||
return [...list, contact]
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateContact() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) =>
|
||||
apiClient.post<ApiContact>(`/contacts/books/${vars.bookId}`, vars.contact),
|
||||
onSuccess: (_data, vars) => {
|
||||
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) => {
|
||||
const created = await apiClient.post<ApiContact | undefined>(
|
||||
`/contacts/books/${vars.bookId}`,
|
||||
vars.contact,
|
||||
)
|
||||
if (created?.uid) return created
|
||||
return vars.contact as ApiContact
|
||||
},
|
||||
onSuccess: (data, vars) => {
|
||||
if (data?.uid) {
|
||||
appendContactToBookCache(queryClient, vars.bookId, data)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||
},
|
||||
onError: (err, vars) => {
|
||||
|
||||
@ -1,14 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiClient, OfflineError } from '../client'
|
||||
import type { ApiContact, ApiContactSyncResponse } from '../types'
|
||||
|
||||
export const FALLBACK_CONTACT_BOOK_ID = 'contacts'
|
||||
|
||||
type ApiContactBook = { id: string; name: string }
|
||||
|
||||
type ApiContactsListResponse = {
|
||||
contacts: ApiContact[]
|
||||
}
|
||||
|
||||
export function normalizeContactBooksResponse(booksRaw: unknown): ApiContactBook[] {
|
||||
if (Array.isArray(booksRaw)) return booksRaw as ApiContactBook[]
|
||||
if (booksRaw && typeof booksRaw === 'object' && 'address_books' in booksRaw) {
|
||||
return (booksRaw as { address_books: ApiContactBook[] }).address_books ?? []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function fetchContactsForBook(bookId: string): Promise<ApiContact[]> {
|
||||
const res = await apiClient.get<ApiContact[] | ApiContactsListResponse>(
|
||||
`/contacts/books/${bookId}`,
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.contacts ?? [])
|
||||
}
|
||||
|
||||
export function useDefaultContactBookId() {
|
||||
const { data: booksRaw } = useContactBooks()
|
||||
return useMemo(() => {
|
||||
const books = normalizeContactBooksResponse(booksRaw)
|
||||
return books[0]?.id ?? FALLBACK_CONTACT_BOOK_ID
|
||||
}, [booksRaw])
|
||||
}
|
||||
|
||||
export function useContacts(bookId?: string) {
|
||||
const defaultBookId = useDefaultContactBookId()
|
||||
const resolvedBookId = bookId ?? defaultBookId
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['contacts', bookId],
|
||||
queryFn: () => apiClient.get<ApiContact[]>(`/contacts/books/${bookId}`),
|
||||
enabled: !!bookId,
|
||||
queryKey: ['contacts', resolvedBookId],
|
||||
queryFn: () => fetchContactsForBook(resolvedBookId),
|
||||
enabled: !!resolvedBookId,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
@ -16,7 +51,12 @@ export function useContacts(bookId?: string) {
|
||||
export function useContactBooks() {
|
||||
return useQuery({
|
||||
queryKey: ['contact-books'],
|
||||
queryFn: () => apiClient.get<{ id: string; name: string }[]>('/contacts/books'),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<ApiContactBook[] | { address_books: ApiContactBook[] }>(
|
||||
'/contacts/books',
|
||||
)
|
||||
return normalizeContactBooksResponse(res)
|
||||
},
|
||||
staleTime: 10 * 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,23 +2,38 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { apiClient } from '../client'
|
||||
import { useAuthReady } from '../use-auth-ready'
|
||||
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
|
||||
|
||||
export function useFolders(accountId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['folders', accountId],
|
||||
queryFn: () =>
|
||||
apiClient.get<ApiFolder[]>('/mail/folders', { account_id: accountId }),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<ApiFolder[] | { folders: ApiFolder[] }>(
|
||||
'/mail/folders',
|
||||
{ account_id: accountId }
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.folders ?? [])
|
||||
},
|
||||
enabled: !!accountId,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useLabels() {
|
||||
const { ready, authenticated } = useAuthReady()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['labels'],
|
||||
queryFn: () => apiClient.get<ApiLabel[]>('/mail/labels'),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<ApiLabel[] | { labels: ApiLabel[] }>(
|
||||
'/mail/labels'
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.labels ?? [])
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: ready && authenticated,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
@ -70,11 +85,27 @@ export function useDeleteLabel() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useReorderLabels() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (items: { id: string; sort_order: number }[]) =>
|
||||
apiClient.post<void>('/mail/labels/reorder', { items }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['labels'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useIdentities(accountId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['identities', accountId],
|
||||
queryFn: () =>
|
||||
apiClient.get<ApiIdentity[]>(`/mail/accounts/${accountId}/identities`),
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.get<ApiIdentity[] | { identities: ApiIdentity[] }>(
|
||||
`/mail/accounts/${accountId}/identities`
|
||||
)
|
||||
return Array.isArray(res) ? res : (res.identities ?? [])
|
||||
},
|
||||
enabled: !!accountId,
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user