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) ── */
|
/* ── Mail : fond décoratif plein écran (derrière toute l’UI) ── */
|
||||||
html {
|
html {
|
||||||
background-color: var(--mail-bg-fallback, var(--app-canvas));
|
background-color: var(--mail-bg-fallback, var(--app-canvas));
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import './globals.css'
|
|||||||
import { ThemeInitScript } from '@/components/theme-init-script'
|
import { ThemeInitScript } from '@/components/theme-init-script'
|
||||||
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
import { FirstLaunchSplash } from '@/components/first-launch-splash'
|
||||||
import { QueryProvider } from '@/lib/api/query-provider'
|
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 _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ 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">
|
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
|
||||||
<ThemeInitScript />
|
<ThemeInitScript />
|
||||||
<QueryProvider>
|
<QueryProvider>
|
||||||
|
<AuthProvider>
|
||||||
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
<FirstLaunchSplash>{children}</FirstLaunchSplash>
|
||||||
|
</AuthProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
<MailToaster />
|
||||||
{process.env.NODE_ENV === 'production' && <Analytics />}
|
{process.env.NODE_ENV === 'production' && <Analytics />}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||||
import { MailToaster } from "@/components/gmail/mail-toaster"
|
import { useRouter, usePathname } from "next/navigation"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { Sidebar } from "@/components/gmail/sidebar"
|
import { Sidebar } from "@/components/gmail/sidebar"
|
||||||
import { Header } from "@/components/gmail/header"
|
import { Header } from "@/components/gmail/header"
|
||||||
import { EmailList } from "@/components/gmail/email-list"
|
import { EmailList } from "@/components/gmail/email-list"
|
||||||
@ -35,6 +34,19 @@ import { cn } from "@/lib/utils"
|
|||||||
import { ThemeProvider } from "@/components/theme-provider"
|
import { ThemeProvider } from "@/components/theme-provider"
|
||||||
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
|
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
|
||||||
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
|
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() {
|
function MailAppInner() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -71,6 +83,7 @@ function MailAppInner() {
|
|||||||
|
|
||||||
const handleSelectFolder = useCallback(
|
const handleSelectFolder = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||||
navigateRoute({
|
navigateRoute({
|
||||||
folderId: id,
|
folderId: id,
|
||||||
inboxTab: DEFAULT_INBOX_TAB,
|
inboxTab: DEFAULT_INBOX_TAB,
|
||||||
@ -85,14 +98,15 @@ function MailAppInner() {
|
|||||||
return (
|
return (
|
||||||
<SidebarNavProvider
|
<SidebarNavProvider
|
||||||
routeFolderId={route.folderId}
|
routeFolderId={route.folderId}
|
||||||
onRouteFolderIdChange={(nextFolderId) =>
|
onRouteFolderIdChange={(nextFolderId) => {
|
||||||
|
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||||
navigateRoute({
|
navigateRoute({
|
||||||
folderId: nextFolderId,
|
folderId: nextFolderId,
|
||||||
inboxTab: DEFAULT_INBOX_TAB,
|
inboxTab: DEFAULT_INBOX_TAB,
|
||||||
page: 1,
|
page: 1,
|
||||||
mailId: null,
|
mailId: null,
|
||||||
})
|
})
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
<div className="ultimail-app flex h-dvh max-h-dvh flex-col overflow-hidden bg-app-canvas">
|
||||||
{!splitView ? (
|
{!splitView ? (
|
||||||
@ -192,10 +206,20 @@ function MailAppInner() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MailAppShell({
|
export function MailAppShell({
|
||||||
children: _routeOutlet,
|
children: routeOutlet,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const showSettingsPage = isMailSettingsPath(pathname)
|
||||||
|
useWebSocket()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSettingsPage) {
|
||||||
|
useMailSettingsStore.getState().setQuickSettingsOpen(false)
|
||||||
|
}
|
||||||
|
}, [showSettingsPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blockPinch = (event: Event) => event.preventDefault()
|
const blockPinch = (event: Event) => event.preventDefault()
|
||||||
document.addEventListener("gesturestart", blockPinch, { passive: false })
|
document.addEventListener("gesturestart", blockPinch, { passive: false })
|
||||||
@ -221,13 +245,21 @@ export function MailAppShell({
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{showSettingsPage ? (
|
||||||
|
<SidebarNavProvider>{routeOutlet}</SidebarNavProvider>
|
||||||
|
) : (
|
||||||
<MailAppInner />
|
<MailAppInner />
|
||||||
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<MailThemeApplier />
|
<MailThemeApplier />
|
||||||
|
<MailSettingsSync />
|
||||||
|
<MailNavSync />
|
||||||
|
<ComposeIdentitiesSync />
|
||||||
|
<MailSignaturesSync />
|
||||||
|
<MailNotificationsBridge />
|
||||||
<QuickSettingsRoot />
|
<QuickSettingsRoot />
|
||||||
<MoveDragIndicator />
|
<MoveDragIndicator />
|
||||||
<ComposeModalManager />
|
<ComposeModalManager />
|
||||||
<MailToaster />
|
|
||||||
</EmailDragProvider>
|
</EmailDragProvider>
|
||||||
</ScheduledMailProvider>
|
</ScheduledMailProvider>
|
||||||
</ComposeProvider>
|
</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"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
|
const SPLASH_SEEN_KEY = "ultimail-splash-seen-v1"
|
||||||
const SPLASH_VISIBLE_MS = 1450
|
const SPLASH_VISIBLE_MS = 1750
|
||||||
const SPLASH_EXIT_MS = 500
|
const SPLASH_EXIT_MS = 500
|
||||||
|
|
||||||
export function FirstLaunchSplash({
|
export function FirstLaunchSplash({
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useRef, type RefObject } from "react"
|
import { useEffect, useRef, type RefObject } from "react"
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
|
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
|
||||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import type { ApiMailAccount } from "@/lib/api/types"
|
import type { ApiMailAccount } from "@/lib/api/types"
|
||||||
import { useMailAccounts } from "@/lib/api/hooks/use-mail-queries"
|
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 {
|
import {
|
||||||
useAccountStore,
|
useAccountStore,
|
||||||
useActiveAccount,
|
|
||||||
useSignOutAll,
|
useSignOutAll,
|
||||||
} from "@/lib/stores/account-store"
|
} from "@/lib/stores/account-store"
|
||||||
|
|
||||||
@ -48,8 +50,8 @@ export function AccountSwitcherDropdown({
|
|||||||
containerRef,
|
containerRef,
|
||||||
}: AccountSwitcherDropdownProps) {
|
}: AccountSwitcherDropdownProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null)
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
const activeAccount = useActiveAccount()
|
const pathname = usePathname()
|
||||||
const activeAccountId = useAccountStore((s) => s.activeAccountId)
|
const identity = useChromeIdentity()
|
||||||
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
|
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
|
||||||
const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
|
const setActiveAccountId = useAccountStore((s) => s.setActiveAccountId)
|
||||||
const toggleOtherAccountsExpanded = useAccountStore(
|
const toggleOtherAccountsExpanded = useAccountStore(
|
||||||
@ -58,9 +60,14 @@ export function AccountSwitcherDropdown({
|
|||||||
const signOutAll = useSignOutAll()
|
const signOutAll = useSignOutAll()
|
||||||
|
|
||||||
const { data: accounts } = useMailAccounts()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@ -83,13 +90,18 @@ export function AccountSwitcherDropdown({
|
|||||||
}
|
}
|
||||||
}, [open, onOpenChange, containerRef])
|
}, [open, onOpenChange, containerRef])
|
||||||
|
|
||||||
if (!open || !activeAccount) return null
|
if (!open || !identity) return null
|
||||||
|
|
||||||
const handleSelectAccount = (id: string) => {
|
const handleSelectAccount = (id: string) => {
|
||||||
setActiveAccountId(id)
|
setActiveAccountId(id)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSignOut = () => {
|
||||||
|
void signOutAll()
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@ -99,7 +111,7 @@ export function AccountSwitcherDropdown({
|
|||||||
>
|
>
|
||||||
<div className="relative px-4 pb-3 pt-4">
|
<div className="relative px-4 pb-3 pt-4">
|
||||||
<p className="truncate pr-8 text-center text-sm text-foreground">
|
<p className="truncate pr-8 text-center text-sm text-foreground">
|
||||||
{activeAccount.email}
|
{identity.email}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -114,7 +126,10 @@ export function AccountSwitcherDropdown({
|
|||||||
|
|
||||||
<div className="mt-4 flex flex-col items-center">
|
<div className="mt-4 flex flex-col items-center">
|
||||||
<div className="relative">
|
<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">
|
<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 />
|
<Camera className="size-3.5" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
@ -133,6 +148,7 @@ export function AccountSwitcherDropdown({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 pb-3">
|
<div className="px-3 pb-3">
|
||||||
|
{hasMultipleMailAccounts ? (
|
||||||
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
|
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -141,8 +157,8 @@ export function AccountSwitcherDropdown({
|
|||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{otherAccountsExpanded
|
{otherAccountsExpanded
|
||||||
? "Masquer plus de comptes"
|
? "Masquer les comptes mail"
|
||||||
: "Afficher plus de comptes"}
|
: "Changer de compte mail"}
|
||||||
</span>
|
</span>
|
||||||
{otherAccountsExpanded ? (
|
{otherAccountsExpanded ? (
|
||||||
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
|
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
|
||||||
@ -153,7 +169,7 @@ export function AccountSwitcherDropdown({
|
|||||||
|
|
||||||
{otherAccountsExpanded && (
|
{otherAccountsExpanded && (
|
||||||
<div className="border-t border-border px-1 pb-1 pt-0.5">
|
<div className="border-t border-border px-1 pb-1 pt-0.5">
|
||||||
{otherAccounts.map((account) => (
|
{mailAccounts.map((account) => (
|
||||||
<AccountRow
|
<AccountRow
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
@ -162,29 +178,36 @@ export function AccountSwitcherDropdown({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="border-t border-border px-1 py-1">
|
<div
|
||||||
<button
|
className={
|
||||||
type="button"
|
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"
|
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">
|
<span className="flex size-8 items-center justify-center">
|
||||||
<Plus className="size-5 text-primary" aria-hidden />
|
<Plus className="size-5 text-primary" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
Ajouter un compte
|
Ajouter un compte
|
||||||
</button>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleSignOut}
|
||||||
signOutAll()
|
|
||||||
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"
|
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">
|
<span className="flex size-8 items-center justify-center">
|
||||||
<LogOut className="size-5 text-muted-foreground" aria-hidden />
|
<LogOut className="size-5 text-muted-foreground" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
Se déconnecter de tous les comptes
|
Se déconnecter
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
} from "lucide-react"
|
||||||
import {
|
import {
|
||||||
type ComposeState,
|
type ComposeState,
|
||||||
SIGNATURES,
|
|
||||||
useComposeActions,
|
useComposeActions,
|
||||||
} from "@/lib/compose-context"
|
} from "@/lib/compose-context"
|
||||||
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
|
||||||
@ -45,6 +44,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover"
|
} from "@/components/ui/popover"
|
||||||
|
import { useMailSignaturesStore } from "@/lib/stores/mail-signatures-store"
|
||||||
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
|
||||||
import { ComposeEmojiButton } from "./compose-emoji-picker"
|
import { ComposeEmojiButton } from "./compose-emoji-picker"
|
||||||
|
|
||||||
@ -236,6 +236,7 @@ export function ComposeSignatureButton({
|
|||||||
editor: Editor | null
|
editor: Editor | null
|
||||||
compose: ComposeState
|
compose: ComposeState
|
||||||
}) {
|
}) {
|
||||||
|
const signatures = useMailSignaturesStore((s) => s.signatures)
|
||||||
const { updateCompose } = useComposeActions()
|
const { updateCompose } = useComposeActions()
|
||||||
|
|
||||||
const replaceSignature = useCallback(
|
const replaceSignature = useCallback(
|
||||||
@ -299,7 +300,7 @@ export function ComposeSignatureButton({
|
|||||||
</span>
|
</span>
|
||||||
Aucune signature
|
Aucune signature
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{SIGNATURES.map((sig) => (
|
{signatures.map((sig) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={sig.id}
|
key={sig.id}
|
||||||
onSelect={() => replaceSignature(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 }) {
|
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
return (
|
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
|
<LazyPicker
|
||||||
data={data}
|
data={data}
|
||||||
onEmojiSelect={onSelect}
|
onEmojiSelect={onSelect}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { ChevronDown, X } from "lucide-react"
|
|||||||
import {
|
import {
|
||||||
type ComposeState,
|
type ComposeState,
|
||||||
type Contact,
|
type Contact,
|
||||||
DEFAULT_IDENTITIES,
|
type Identity,
|
||||||
MOCK_CONTACTS,
|
MOCK_CONTACTS,
|
||||||
} from "@/lib/compose-context"
|
} from "@/lib/compose-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@ -266,8 +266,9 @@ export interface ComposeRecipientFieldsProps {
|
|||||||
compose: ComposeState
|
compose: ComposeState
|
||||||
isInline: boolean
|
isInline: boolean
|
||||||
showFromField: boolean
|
showFromField: boolean
|
||||||
|
identities?: Identity[]
|
||||||
updateCompose: (id: string, patch: Partial<ComposeState>) => void
|
updateCompose: (id: string, patch: Partial<ComposeState>) => void
|
||||||
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
|
handleIdentityChange: (identity: Identity) => void
|
||||||
clearFocusToMount: () => void
|
clearFocusToMount: () => void
|
||||||
subjectInputRef: RefObject<HTMLInputElement | null>
|
subjectInputRef: RefObject<HTMLInputElement | null>
|
||||||
onRecipientsActivate: () => void
|
onRecipientsActivate: () => void
|
||||||
@ -277,6 +278,7 @@ export function ComposeRecipientFields({
|
|||||||
compose,
|
compose,
|
||||||
isInline,
|
isInline,
|
||||||
showFromField,
|
showFromField,
|
||||||
|
identities = [],
|
||||||
updateCompose,
|
updateCompose,
|
||||||
handleIdentityChange,
|
handleIdentityChange,
|
||||||
clearFocusToMount,
|
clearFocusToMount,
|
||||||
@ -307,9 +309,16 @@ export function ComposeRecipientFields({
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
|
<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
|
<DropdownMenuItem
|
||||||
key={id.email}
|
key={id.id ?? id.email}
|
||||||
onSelect={() => handleIdentityChange(id)}
|
onSelect={() => handleIdentityChange(id)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@ -317,7 +326,8 @@ export function ComposeRecipientFields({
|
|||||||
<span className="text-xs text-muted-foreground">{id.email}</span>
|
<span className="text-xs text-muted-foreground">{id.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Node as TipTapNode, mergeAttributes } from "@tiptap/core"
|
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+. */
|
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
|
||||||
export const COMPOSE_PORTAL_Z = "z-[100]"
|
export const COMPOSE_PORTAL_Z = "z-[100]"
|
||||||
@ -28,9 +28,18 @@ export function stripSignature(html: string) {
|
|||||||
return html.replace(SIG_REGEX, "")
|
return html.replace(SIG_REGEX, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertSignatureHtml(html: string, sigId: string | null) {
|
/** Accepts signature library id or raw HTML. */
|
||||||
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
|
export function insertSignatureHtml(html: string, sigIdOrHtml: string | null) {
|
||||||
const clean = stripSignature(html)
|
const clean = stripSignature(html)
|
||||||
if (!sig) return clean
|
if (!sigIdOrHtml) return clean
|
||||||
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
|
|
||||||
|
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 {
|
import {
|
||||||
type ComposeState,
|
type ComposeState,
|
||||||
cloneComposeForPendingSend,
|
cloneComposeForPendingSend,
|
||||||
DEFAULT_IDENTITIES,
|
type Identity,
|
||||||
useComposeActions,
|
useComposeActions,
|
||||||
} from "@/lib/compose-context"
|
} 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 { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||||
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
import type { ScheduleSendPayload } from "@/lib/api/scheduled-mail"
|
||||||
import type { Email } from "@/lib/email-data"
|
import type { Email } from "@/lib/email-data"
|
||||||
@ -55,6 +57,8 @@ export function useComposeWindow(
|
|||||||
} = useComposeActions()
|
} = useComposeActions()
|
||||||
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
|
const { scheduleSend, requestUpdateScheduledSend, requestSendScheduledNow } =
|
||||||
useScheduledMail()
|
useScheduledMail()
|
||||||
|
const activeAccount = useActiveAccount()
|
||||||
|
const { identities: composeIdentities } = useComposeIdentities(activeAccount?.id)
|
||||||
const isInline = compose.placement === "inline"
|
const isInline = compose.placement === "inline"
|
||||||
const isEditingScheduled = compose.editingScheduledId != null
|
const isEditingScheduled = compose.editingScheduledId != null
|
||||||
const [showFormatting, setShowFormatting] = useState(false)
|
const [showFormatting, setShowFormatting] = useState(false)
|
||||||
@ -131,12 +135,18 @@ export function useComposeWindow(
|
|||||||
bodyWithoutSig !== ""
|
bodyWithoutSig !== ""
|
||||||
|
|
||||||
const handleIdentityChange = useCallback(
|
const handleIdentityChange = useCallback(
|
||||||
(identity: (typeof DEFAULT_IDENTITIES)[number]) => {
|
(identity: Identity) => {
|
||||||
|
const sigSource =
|
||||||
|
identity.signatureHtml ??
|
||||||
|
(identity.defaultSignatureId ? identity.defaultSignatureId : null)
|
||||||
if (compose.autoInsertSignature && editor) {
|
if (compose.autoInsertSignature && editor) {
|
||||||
const sigId = identity.defaultSignatureId
|
const newHtml = insertSignatureHtml(editor.getHTML(), sigSource)
|
||||||
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
|
|
||||||
editor.commands.setContent(newHtml)
|
editor.commands.setContent(newHtml)
|
||||||
updateCompose(compose.id, { from: identity, bodyHtml: newHtml, signatureId: sigId })
|
updateCompose(compose.id, {
|
||||||
|
from: identity,
|
||||||
|
bodyHtml: newHtml,
|
||||||
|
signatureId: identity.defaultSignatureId,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
updateCompose(compose.id, { from: identity })
|
updateCompose(compose.id, { from: identity })
|
||||||
}
|
}
|
||||||
@ -497,6 +507,7 @@ export function useComposeWindow(
|
|||||||
compose,
|
compose,
|
||||||
isInline,
|
isInline,
|
||||||
showFromField,
|
showFromField,
|
||||||
|
identities: composeIdentities,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
handleIdentityChange,
|
handleIdentityChange,
|
||||||
clearFocusToMount,
|
clearFocusToMount,
|
||||||
|
|||||||
@ -149,7 +149,7 @@ export function ContactHoverCard({
|
|||||||
role="presentation"
|
role="presentation"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
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,
|
longPress.ackClassName,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
|
import { parseBulkContactText } from "@/lib/contacts/import-parsers"
|
||||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
import {
|
import {
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
@ -28,6 +29,7 @@ interface BulkCreateDialogProps {
|
|||||||
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
|
export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreateDialogProps) {
|
||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const createContactMutation = useCreateContact()
|
const createContactMutation = useCreateContact()
|
||||||
|
const { bookId } = useContactsList()
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
const parsed = parseBulkContactText(input)
|
const parsed = parseBulkContactText(input)
|
||||||
@ -45,7 +47,7 @@ export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreat
|
|||||||
phones: partial.phones ?? [],
|
phones: partial.phones ?? [],
|
||||||
}
|
}
|
||||||
createContactMutation.mutate({
|
createContactMutation.mutate({
|
||||||
bookId: "default",
|
bookId,
|
||||||
contact: fullContactToApiContact(fullContact),
|
contact: fullContactToApiContact(fullContact),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,7 +115,7 @@ interface ContactCreatePageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
|
export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactCreatePageProps) {
|
||||||
const { contacts } = useContactsList()
|
const { contacts, bookId } = useContactsList()
|
||||||
const createContactMutation = useCreateContact()
|
const createContactMutation = useCreateContact()
|
||||||
const updateContactMutation = useUpdateContact()
|
const updateContactMutation = useUpdateContact()
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
@ -225,10 +225,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
createContactMutation.mutate(
|
createContactMutation.mutate(
|
||||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
{ bookId, contact: fullContactToApiContact(fullContact) },
|
||||||
{ onSuccess: (created) => onSaved(created?.uid ?? tempId) },
|
{
|
||||||
|
onSuccess: (created) => {
|
||||||
|
onSaved(created?.uid ?? tempId)
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
onSaved(tempId)
|
|
||||||
} else if (contactId) {
|
} else if (contactId) {
|
||||||
const fullContact: FullContact = {
|
const fullContact: FullContact = {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import { Info } from "lucide-react"
|
|||||||
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
import { parseContactFile } from "@/lib/contacts/import-parsers"
|
||||||
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
import { useCreateContact } from "@/lib/api/hooks/use-contact-mutations"
|
||||||
import { fullContactToApiContact } from "@/lib/api/adapters"
|
import { fullContactToApiContact } from "@/lib/api/adapters"
|
||||||
|
import { useContactsList } from "@/lib/contacts/use-contacts-list"
|
||||||
import type { FullContact } from "@/lib/contacts/types"
|
import type { FullContact } from "@/lib/contacts/types"
|
||||||
import {
|
import {
|
||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
@ -30,6 +31,7 @@ interface ImportDialogProps {
|
|||||||
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const createContactMutation = useCreateContact()
|
const createContactMutation = useCreateContact()
|
||||||
|
const { bookId } = useContactsList()
|
||||||
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
const [pendingFile, setPendingFile] = useState<File | null>(null)
|
||||||
const [previewCount, setPreviewCount] = useState(0)
|
const [previewCount, setPreviewCount] = useState(0)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -94,7 +96,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
|
|||||||
phones: partial.phones ?? [],
|
phones: partial.phones ?? [],
|
||||||
}
|
}
|
||||||
createContactMutation.mutate({
|
createContactMutation.mutate({
|
||||||
bookId: "default",
|
bookId,
|
||||||
contact: fullContactToApiContact(fullContact),
|
contact: fullContactToApiContact(fullContact),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const REASON_LABELS: Record<DuplicateMatchReason, string> = {
|
|||||||
|
|
||||||
export function MergeDuplicatesView() {
|
export function MergeDuplicatesView() {
|
||||||
const [subView, setSubView] = useState<SubView>("merge")
|
const [subView, setSubView] = useState<SubView>("merge")
|
||||||
const { contacts } = useContactsList()
|
const { contacts, bookId } = useContactsList()
|
||||||
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
const ignoredMergePairs = useContactsStore((s) => s.ignoredMergePairs)
|
||||||
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
const ignoreMergePair = useContactsStore((s) => s.ignoreMergePair)
|
||||||
const mergeDuplicatesMutation = useMergeDuplicates()
|
const mergeDuplicatesMutation = useMergeDuplicates()
|
||||||
@ -46,7 +46,7 @@ export function MergeDuplicatesView() {
|
|||||||
const [mergingAll, setMergingAll] = useState(false)
|
const [mergingAll, setMergingAll] = useState(false)
|
||||||
|
|
||||||
function handleMerge(_suggestion: MergeSuggestion) {
|
function handleMerge(_suggestion: MergeSuggestion) {
|
||||||
mergeDuplicatesMutation.mutate({ bookId: "default" })
|
mergeDuplicatesMutation.mutate({ bookId })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleIgnore(suggestion: MergeSuggestion) {
|
function handleIgnore(suggestion: MergeSuggestion) {
|
||||||
@ -56,7 +56,7 @@ export function MergeDuplicatesView() {
|
|||||||
function handleMergeAll() {
|
function handleMergeAll() {
|
||||||
setMergingAll(true)
|
setMergingAll(true)
|
||||||
mergeDuplicatesMutation.mutate(
|
mergeDuplicatesMutation.mutate(
|
||||||
{ bookId: "default" },
|
{ bookId },
|
||||||
{ onSettled: () => setMergingAll(false) },
|
{ onSettled: () => setMergingAll(false) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import {
|
|||||||
CONTACTS_HEADING_TEXT,
|
CONTACTS_HEADING_TEXT,
|
||||||
CONTACTS_MUTED_TEXT,
|
CONTACTS_MUTED_TEXT,
|
||||||
CONTACTS_PANEL_DIVIDER_CLASS,
|
CONTACTS_PANEL_DIVIDER_CLASS,
|
||||||
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
|
CONTACTS_PANEL_HEADER_CLASS,
|
||||||
CONTACTS_PANEL_ICON_BTN_CLASS,
|
CONTACTS_PANEL_ICON_BTN_CLASS,
|
||||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||||
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
|
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
|
||||||
@ -105,8 +105,8 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
|
|||||||
return (
|
return (
|
||||||
<div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
|
<div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
||||||
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -54,7 +54,7 @@ import {
|
|||||||
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
|
||||||
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
|
||||||
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
|
CONTACTS_PANEL_HEADER_CLASS,
|
||||||
CONTACTS_PANEL_ICON_BTN_CLASS,
|
CONTACTS_PANEL_ICON_BTN_CLASS,
|
||||||
CONTACTS_PANEL_LINK_TEXT_CLASS,
|
CONTACTS_PANEL_LINK_TEXT_CLASS,
|
||||||
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
CONTACTS_PANEL_MUTED_ICON_CLASS,
|
||||||
@ -136,7 +136,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
createDraft,
|
createDraft,
|
||||||
clearCreateDraft,
|
clearCreateDraft,
|
||||||
} = useContactsStore()
|
} = useContactsStore()
|
||||||
const { contacts } = useContactsList()
|
const { contacts, bookId } = useContactsList()
|
||||||
const createContactMutation = useCreateContact()
|
const createContactMutation = useCreateContact()
|
||||||
const updateContactMutation = useUpdateContact()
|
const updateContactMutation = useUpdateContact()
|
||||||
const labelRows = useNavStore((s) => s.labelRows)
|
const labelRows = useNavStore((s) => s.labelRows)
|
||||||
@ -324,10 +324,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
}
|
}
|
||||||
createContactMutation.mutate(
|
createContactMutation.mutate(
|
||||||
{ bookId: "default", contact: fullContactToApiContact(fullContact) },
|
{ bookId, contact: fullContactToApiContact(fullContact) },
|
||||||
{ onSuccess: (created) => setView("view", created?.uid ?? tempId) },
|
{
|
||||||
|
onSuccess: (created) => {
|
||||||
|
const id = created?.uid ?? tempId
|
||||||
|
setView("view", id)
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
setView("view", tempId)
|
|
||||||
} else if (contactId) {
|
} else if (contactId) {
|
||||||
const fullContact: FullContact = {
|
const fullContact: FullContact = {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
@ -354,8 +358,8 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
|
|||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
|
className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
|
||||||
>
|
>
|
||||||
<div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
|
<div className={CONTACTS_PANEL_HEADER_CLASS}>
|
||||||
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
|
<ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export function ContactsListView() {
|
|||||||
return (
|
return (
|
||||||
<div className={CONTACTS_PANEL_SHELL_CLASS}>
|
<div className={CONTACTS_PANEL_SHELL_CLASS}>
|
||||||
<div className={cn(CONTACTS_PANEL_HEADER_SEARCH_CLASS, "gap-2")}>
|
<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}`} />
|
<Search className={`h-4 w-4 shrink-0 ${CONTACTS_PANEL_MUTED_ICON_CLASS}`} />
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
|
|||||||
@ -10,15 +10,9 @@ import {
|
|||||||
type ContactsPanelLogoProps = {
|
type ContactsPanelLogoProps = {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
className?: string
|
className?: string
|
||||||
/** Titre plus compact (barre détail / formulaire). */
|
|
||||||
compact?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContactsPanelLogo({
|
export function ContactsPanelLogo({ onClick, className }: ContactsPanelLogoProps) {
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
compact = false,
|
|
||||||
}: ContactsPanelLogoProps) {
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -29,16 +23,8 @@ export function ContactsPanelLogo({
|
|||||||
)}
|
)}
|
||||||
aria-label="Liste des contacts"
|
aria-label="Liste des contacts"
|
||||||
>
|
>
|
||||||
<Users
|
<Users className={cn("h-6 w-6 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
|
||||||
className={cn(
|
<span className={CONTACTS_PANEL_TITLE_CLASS}>Contacts</span>
|
||||||
"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>
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react"
|
import { ChevronLeft, ChevronUp, ChevronDown, RefreshCw } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
||||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||||
@ -222,7 +221,6 @@ export function EmailListBody({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TooltipProvider delayDuration={400}>
|
|
||||||
<>
|
<>
|
||||||
{selectedFolder === "scheduled" && <EmailListScheduledBanner />}
|
{selectedFolder === "scheduled" && <EmailListScheduledBanner />}
|
||||||
{displayListEmails.length === 0 ? (
|
{displayListEmails.length === 0 ? (
|
||||||
@ -251,7 +249,6 @@ export function EmailListBody({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS,
|
MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS,
|
||||||
} from "@/lib/mail-chrome-classes"
|
} from "@/lib/mail-chrome-classes"
|
||||||
|
|
||||||
export const LIST_PAGE_SIZE = 50
|
export { LIST_PAGE_SIZE } from "@/lib/mail-list-page-size"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PULL_HOLD_HEIGHT,
|
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 { EmailListBody } from "@/components/gmail/email-list/email-list-body"
|
||||||
import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane"
|
import { EmailListEmailViewPane } from "@/components/gmail/email-list/email-list-email-view-pane"
|
||||||
import { EmailListEmpty } from "@/components/gmail/email-list/email-list-empty"
|
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 { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
|
||||||
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
|
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 type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
|
||||||
@ -102,6 +103,11 @@ export function EmailListLayout({
|
|||||||
openMobileXsLabelSheet,
|
openMobileXsLabelSheet,
|
||||||
listPage: data.listPage,
|
listPage: data.listPage,
|
||||||
totalPages: data.totalPages,
|
totalPages: data.totalPages,
|
||||||
|
paginationTotal: data.paginationTotal,
|
||||||
|
listPageSize: data.listPageSize,
|
||||||
|
paginationRangeStart: data.paginationRangeStart,
|
||||||
|
paginationRangeEnd: data.paginationRangeEnd,
|
||||||
|
onListPageSizeChange: data.handleListPageSizeChange,
|
||||||
openMailIndex: reading.openMailIndex,
|
openMailIndex: reading.openMailIndex,
|
||||||
goListPrevPage: reading.goListPrevPage,
|
goListPrevPage: reading.goListPrevPage,
|
||||||
goListNextPage: reading.goListNextPage,
|
goListNextPage: reading.goListNextPage,
|
||||||
@ -133,6 +139,7 @@ export function EmailListLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<TooltipProvider delayDuration={400}>
|
||||||
<div className="flex h-full min-h-0 flex-1 flex-col">
|
<div className="flex h-full min-h-0 flex-1 flex-col">
|
||||||
<EmailListToolbar {...toolbarProps} part="mobile" />
|
<EmailListToolbar {...toolbarProps} part="mobile" />
|
||||||
{!isViewMode && touchNav && (
|
{!isViewMode && touchNav && (
|
||||||
@ -206,6 +213,7 @@ export function EmailListLayout({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -688,28 +688,33 @@ function EmailListRowInner(props: EmailListRowProps) {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
listRowPadTop,
|
||||||
isCompactListRow &&
|
isCompactListRow && "min-h-7 leading-tight"
|
||||||
"flex min-h-7 items-center leading-tight"
|
|
||||||
)}
|
)}
|
||||||
data-selectable-text
|
data-selectable-text
|
||||||
>
|
>
|
||||||
{isScheduled ? (
|
{isScheduled ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm",
|
"min-w-0 truncate text-sm leading-5",
|
||||||
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
À : {email.scheduledToName ?? email.sender}
|
À : {email.scheduledToName ?? email.sender}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<ContactHoverCard displayName={email.sender} email={senderHoverEmail}>
|
<ContactHoverCard
|
||||||
<span className={cn(
|
displayName={email.sender}
|
||||||
"text-sm",
|
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"
|
!isRead ? "font-semibold text-gray-900" : "text-gray-700"
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
{showDraftBadge && (
|
{showDraftBadge && (
|
||||||
<span className="font-medium text-[#d93025]">Brouillon </span>
|
<span className="font-medium text-[#d93025]">Brouillon </span>
|
||||||
)}
|
)}
|
||||||
@ -718,7 +723,7 @@ function EmailListRowInner(props: EmailListRowProps) {
|
|||||||
</ContactHoverCard>
|
</ContactHoverCard>
|
||||||
)}
|
)}
|
||||||
{threadMessageCount > 1 && (
|
{threadMessageCount > 1 && (
|
||||||
<span className="text-sm text-gray-500 ml-1">
|
<span className="shrink-0 text-sm leading-5 text-gray-500">
|
||||||
{threadMessageCount}
|
{threadMessageCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -753,13 +758,15 @@ function EmailListRowInner(props: EmailListRowProps) {
|
|||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
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]"
|
!isRead ? "font-semibold text-gray-900" : "font-normal text-[#3c4043]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{email.subject}
|
{email.subject}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{showAttachmentPills && (
|
{showAttachmentPills && (
|
||||||
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
|
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
|
||||||
|
|||||||
@ -43,7 +43,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs"
|
import { CompactInboxCategoryTabs } from "@/components/gmail/compact-inbox-category-tabs"
|
||||||
@ -72,7 +71,10 @@ import {
|
|||||||
inboxTabBadgeDotClass,
|
inboxTabBadgeDotClass,
|
||||||
REFRESH_SPIN_CLASS,
|
REFRESH_SPIN_CLASS,
|
||||||
} from "@/components/gmail/email-list/email-list-helpers"
|
} 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 = {
|
export type EmailListToolbarProps = {
|
||||||
isViewMode: boolean
|
isViewMode: boolean
|
||||||
@ -123,6 +125,11 @@ export type EmailListToolbarProps = {
|
|||||||
openMobileXsLabelSheet: () => void
|
openMobileXsLabelSheet: () => void
|
||||||
listPage: number
|
listPage: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
|
paginationTotal?: number
|
||||||
|
listPageSize: number
|
||||||
|
paginationRangeStart: number
|
||||||
|
paginationRangeEnd: number
|
||||||
|
onListPageSizeChange: (size: ListPageSize) => void
|
||||||
openMailIndex: number
|
openMailIndex: number
|
||||||
goListPrevPage: () => void
|
goListPrevPage: () => void
|
||||||
goListNextPage: () => void
|
goListNextPage: () => void
|
||||||
@ -205,6 +212,11 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
|||||||
openMobileXsLabelSheet,
|
openMobileXsLabelSheet,
|
||||||
listPage,
|
listPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
paginationTotal,
|
||||||
|
listPageSize,
|
||||||
|
paginationRangeStart,
|
||||||
|
paginationRangeEnd,
|
||||||
|
onListPageSizeChange,
|
||||||
openMailIndex,
|
openMailIndex,
|
||||||
goListPrevPage,
|
goListPrevPage,
|
||||||
goListNextPage,
|
goListNextPage,
|
||||||
@ -240,7 +252,7 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
|||||||
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
|
const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
|
||||||
|
|
||||||
const openMailToolbar = (showBack: boolean) => (
|
const openMailToolbar = (showBack: boolean) => (
|
||||||
<TooltipProvider delayDuration={400}>
|
<>
|
||||||
{showBack ? (
|
{showBack ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@ -475,10 +487,15 @@ export function EmailListToolbar(props: EmailListToolbarProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const mailPaginationControls = (mode: "list" | "view") => (
|
const mailPaginationControls = (mode: "list" | "view") => {
|
||||||
|
const totalCount =
|
||||||
|
paginationTotal ??
|
||||||
|
(mode === "view" ? displayListEmails.length : paginationRangeEnd)
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex shrink-0 items-center gap-2 whitespace-nowrap text-sm text-gray-600",
|
"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}
|
{openMailIndex >= 0 ? openMailIndex + 1 : "–"} sur {displayListEmails.length}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span className="inline-flex items-center gap-1">
|
||||||
{(listPage - 1) * LIST_PAGE_SIZE + 1}–
|
{paginationRangeStart} à{" "}
|
||||||
{Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
|
<DropdownMenu>
|
||||||
{displayListEmails.length}
|
<DropdownMenuTrigger asChild>
|
||||||
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -553,11 +590,12 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (variant === "reading-pane") {
|
if (variant === "reading-pane") {
|
||||||
return (
|
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">
|
<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" />
|
<div className="flex-1" />
|
||||||
{mailPaginationControls("view")}
|
{mailPaginationControls("view")}
|
||||||
</div>
|
</div>
|
||||||
@ -783,7 +821,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{showBulkToolbar ? (
|
{showBulkToolbar ? (
|
||||||
<TooltipProvider delayDuration={400}>
|
<>
|
||||||
<div className="flex min-w-0 items-center gap-0.5 pl-1">
|
<div className="flex min-w-0 items-center gap-0.5 pl-1">
|
||||||
<div className="flex shrink-0 items-center gap-0.5">
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@ -983,7 +1021,7 @@ const mailPaginationControls = (mode: "list" | "view") => (
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import type { Email, EmailAttachment } from "@/lib/email-data"
|
|||||||
import {
|
import {
|
||||||
isThreadHeadMessage,
|
isThreadHeadMessage,
|
||||||
} from "@/lib/mail-thread"
|
} from "@/lib/mail-thread"
|
||||||
|
import { repairSnippet } from "@/lib/mail-mime-body"
|
||||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||||
import { useMailStore } from "@/lib/stores/mail-store"
|
import { useMailStore } from "@/lib/stores/mail-store"
|
||||||
import { useScheduledStore } from "@/lib/stores/scheduled-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 { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
import { useActiveAccount } from "@/lib/stores/account-store"
|
import { useActiveAccount } from "@/lib/stores/account-store"
|
||||||
import { useMailSearchStore } from "@/lib/stores/mail-search-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 {
|
import {
|
||||||
getMailNavFolderLabel,
|
getMailNavFolderLabel,
|
||||||
inboxTabDisplayLabel,
|
inboxTabDisplayLabel,
|
||||||
@ -55,7 +60,6 @@ import {
|
|||||||
useComposeDrafts,
|
useComposeDrafts,
|
||||||
} from "@/lib/compose-context"
|
} from "@/lib/compose-context"
|
||||||
import {
|
import {
|
||||||
LIST_PAGE_SIZE,
|
|
||||||
type EmailListProps,
|
type EmailListProps,
|
||||||
buildInboxTabBarItems,
|
buildInboxTabBarItems,
|
||||||
} from "@/components/gmail/email-list/email-list-helpers"
|
} 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 { useIsXs } from "@/hooks/use-xs"
|
||||||
import { useTouchNav } from "@/hooks/use-touch-nav"
|
import { useTouchNav } from "@/hooks/use-touch-nav"
|
||||||
import type { MessageSearchFilter } from "@/lib/api/types"
|
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 {
|
function apiMessageToEmail(msg: ApiMessageSummary): Email {
|
||||||
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
|
const sender = msg.from[0]?.name || msg.from[0]?.address || ""
|
||||||
@ -78,11 +91,11 @@ function apiMessageToEmail(msg: ApiMessageSummary): Email {
|
|||||||
sender,
|
sender,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
subject: msg.subject,
|
subject: msg.subject,
|
||||||
preview: msg.snippet,
|
preview: repairSnippet(msg.snippet) ?? msg.snippet,
|
||||||
date: msg.date,
|
date: msg.date,
|
||||||
read: msg.flags.includes("read"),
|
read: mailFlagIsRead(msg.flags),
|
||||||
starred: msg.flags.includes("starred"),
|
starred: mailFlagIsStarred(msg.flags),
|
||||||
important: msg.flags.includes("important"),
|
important: mailFlagIsImportant(msg.flags, msg.labels),
|
||||||
spam: msg.labels.includes("spam"),
|
spam: msg.labels.includes("spam"),
|
||||||
hasAttachment: msg.has_attachments,
|
hasAttachment: msg.has_attachments,
|
||||||
labels: msg.labels,
|
labels: msg.labels,
|
||||||
@ -166,15 +179,15 @@ export function useEmailListData({
|
|||||||
|
|
||||||
const accountId = searchAccount?.id
|
const accountId = searchAccount?.id
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const listPageSize = useMailSettingsStore((s) => s.listPageSize)
|
||||||
|
const setListPageSize = useMailSettingsStore((s) => s.setListPageSize)
|
||||||
|
|
||||||
const effectiveApiFolder = useMemo(() => {
|
const effectiveApiFolder = useMemo(() => {
|
||||||
if (isSearchMode) return "__search__"
|
if (isSearchMode) return "__search__"
|
||||||
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
|
if (selectedFolder === "scheduled" || selectedFolder === "snoozed") return "__local__"
|
||||||
if (selectedFolder !== "inbox") return selectedFolder
|
if (selectedFolder === "inbox") return "inbox"
|
||||||
const tab = normalizeInboxTabSegment(inboxTab)
|
return selectedFolder
|
||||||
if (tab === INBOX_ALL_TAB) return "inbox"
|
}, [selectedFolder, isSearchMode])
|
||||||
return tab
|
|
||||||
}, [selectedFolder, inboxTab, isSearchMode])
|
|
||||||
|
|
||||||
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
const searchFilter = useMemo<MessageSearchFilter | null>(() => {
|
||||||
if (!isSearchMode || !searchParams) return null
|
if (!isSearchMode || !searchParams) return null
|
||||||
@ -194,7 +207,8 @@ export function useEmailListData({
|
|||||||
? "inbox"
|
? "inbox"
|
||||||
: effectiveApiFolder,
|
: effectiveApiFolder,
|
||||||
accountId,
|
accountId,
|
||||||
listPage
|
listPage,
|
||||||
|
listPageSize
|
||||||
)
|
)
|
||||||
|
|
||||||
const searchQuery = useMailSearch(searchFilter)
|
const searchQuery = useMailSearch(searchFilter)
|
||||||
@ -366,12 +380,10 @@ export function useEmailListData({
|
|||||||
for (const [id, isRead] of Object.entries(changes)) {
|
for (const [id, isRead] of Object.entries(changes)) {
|
||||||
const msg = apiMessagesById.get(id)
|
const msg = apiMessagesById.get(id)
|
||||||
if (!msg) continue
|
if (!msg) continue
|
||||||
const flags = [...msg.flags]
|
const alreadyRead = mailFlagIsRead(msg.flags)
|
||||||
if (isRead && !flags.includes("read")) {
|
if (isRead === alreadyRead) continue
|
||||||
updateFlags.mutate({ id, flags: [...flags, "read"] })
|
const flags = mailFlagsWithRead(msg.flags, isRead)
|
||||||
} else if (!isRead && flags.includes("read")) {
|
updateFlags.mutate({ id, flags })
|
||||||
updateFlags.mutate({ id, flags: flags.filter((f) => f !== "read") })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[apiMessagesById, updateFlags]
|
[apiMessagesById, updateFlags]
|
||||||
@ -419,18 +431,14 @@ export function useEmailListData({
|
|||||||
toggleStar: (id: string) => {
|
toggleStar: (id: string) => {
|
||||||
const msg = apiMessagesById.get(id)
|
const msg = apiMessagesById.get(id)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
const flags = msg.flags.includes("starred")
|
const starred = mailFlagIsStarred(msg.flags)
|
||||||
? msg.flags.filter((f) => f !== "starred")
|
updateFlags.mutate({ id, flags: mailFlagsWithStarred(msg.flags, !starred) })
|
||||||
: [...msg.flags, "starred"]
|
|
||||||
updateFlags.mutate({ id, flags })
|
|
||||||
},
|
},
|
||||||
toggleImportant: (id: string) => {
|
toggleImportant: (id: string) => {
|
||||||
const msg = apiMessagesById.get(id)
|
const msg = apiMessagesById.get(id)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
const flags = msg.flags.includes("important")
|
const important = mailFlagIsImportant(msg.flags, msg.labels)
|
||||||
? msg.flags.filter((f) => f !== "important")
|
updateFlags.mutate({ id, flags: mailFlagsWithImportant(msg.flags, !important) })
|
||||||
: [...msg.flags, "important"]
|
|
||||||
updateFlags.mutate({ id, flags })
|
|
||||||
},
|
},
|
||||||
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
|
}), [deleteMessage, updateLabels, updateFlags, apiMessagesById])
|
||||||
|
|
||||||
@ -485,9 +493,20 @@ export function useEmailListData({
|
|||||||
useMailStore.getState().markSeen(id)
|
useMailStore.getState().markSeen(id)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const folderFilterCtx = useMemo<MailFolderFilterCtx>(
|
||||||
|
() => ({
|
||||||
|
starredEmailIds: starredEmails,
|
||||||
|
importantEmailIds: importantEmails,
|
||||||
|
}),
|
||||||
|
[starredEmails, importantEmails]
|
||||||
|
)
|
||||||
|
|
||||||
const filteredEmails = useMemo(() => {
|
const filteredEmails = useMemo(() => {
|
||||||
return allEmails
|
if (selectedFolder !== "inbox") return allEmails
|
||||||
}, [allEmails])
|
return allEmails.filter((e) =>
|
||||||
|
emailMatchesInboxTab(e, inboxTab, folderFilterCtx, navMaps)
|
||||||
|
)
|
||||||
|
}, [allEmails, selectedFolder, inboxTab, folderFilterCtx, navMaps])
|
||||||
|
|
||||||
const displayListEmails = useMemo(() => {
|
const displayListEmails = useMemo(() => {
|
||||||
let rows = filteredEmails
|
let rows = filteredEmails
|
||||||
@ -547,17 +566,51 @@ export function useEmailListData({
|
|||||||
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
|
}, [isSearchMode, effectiveApiFolder, searchQuery.data, messagesQuery.data, allEmails.length])
|
||||||
|
|
||||||
const totalPages = useMemo(
|
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(() => {
|
const pagedEmails = useMemo(() => {
|
||||||
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
if (effectiveApiFolder !== "__local__" && !isSearchMode) {
|
||||||
return displayListEmails
|
return displayListEmails
|
||||||
}
|
}
|
||||||
const start = (listPage - 1) * LIST_PAGE_SIZE
|
const start = (listPage - 1) * listPageSize
|
||||||
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
|
return displayListEmails.slice(start, start + listPageSize)
|
||||||
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode])
|
}, [displayListEmails, listPage, effectiveApiFolder, isSearchMode, listPageSize])
|
||||||
|
|
||||||
const listEmails = useMemo(() => {
|
const listEmails = useMemo(() => {
|
||||||
if (isXs && !isViewMode) {
|
if (isXs && !isViewMode) {
|
||||||
@ -568,14 +621,6 @@ export function useEmailListData({
|
|||||||
|
|
||||||
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
|
||||||
|
|
||||||
const folderFilterCtx = useMemo(
|
|
||||||
() => ({
|
|
||||||
starredEmailIds: [] as string[],
|
|
||||||
importantEmailIds: [] as string[],
|
|
||||||
}),
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const listRowExtras = useMemo(() => {
|
const listRowExtras = useMemo(() => {
|
||||||
const invitationById = new Map<
|
const invitationById = new Map<
|
||||||
string,
|
string,
|
||||||
@ -669,11 +714,26 @@ export function useEmailListData({
|
|||||||
const seen = new Set(
|
const seen = new Set(
|
||||||
seenSerialized.length > 0 ? seenSerialized.split(",") : []
|
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 counts: Record<string, number> = {}
|
||||||
const preview: Record<string, string> = {}
|
const preview: Record<string, string> = {}
|
||||||
for (const tab of inboxTabBarItems) {
|
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
|
counts[tab.id] = rows.length
|
||||||
if (inboxTabShowsInactiveMeta(tab.id)) {
|
if (inboxTabShowsInactiveMeta(tab.id)) {
|
||||||
const chain: string[] = []
|
const chain: string[] = []
|
||||||
@ -689,7 +749,13 @@ export function useEmailListData({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
||||||
}, [seenSerialized, allEmails, inboxTabBarItems])
|
}, [
|
||||||
|
seenSerialized,
|
||||||
|
allEmails,
|
||||||
|
inboxTabBarItems,
|
||||||
|
folderFilterCtx,
|
||||||
|
navMaps,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onFolderUnreadCountsChange?.(folderUnreadCounts)
|
onFolderUnreadCountsChange?.(folderUnreadCounts)
|
||||||
@ -720,8 +786,8 @@ export function useEmailListData({
|
|||||||
if (e.read) continue
|
if (e.read) continue
|
||||||
const msg = apiMessagesById.get(e.id)
|
const msg = apiMessagesById.get(e.id)
|
||||||
if (!msg) continue
|
if (!msg) continue
|
||||||
if (!msg.flags.includes("read")) {
|
if (!mailFlagIsRead(msg.flags)) {
|
||||||
updateFlags.mutate({ id: e.id, flags: [...msg.flags, "read"] })
|
updateFlags.mutate({ id: e.id, flags: mailFlagsWithRead(msg.flags, true) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [displayListEmails, apiMessagesById, updateFlags])
|
}, [displayListEmails, apiMessagesById, updateFlags])
|
||||||
@ -790,6 +856,11 @@ export function useEmailListData({
|
|||||||
inboxCategoryTabLabel,
|
inboxCategoryTabLabel,
|
||||||
mobileUnreadCount,
|
mobileUnreadCount,
|
||||||
mobileFolderLabel,
|
mobileFolderLabel,
|
||||||
|
paginationTotal,
|
||||||
|
listPageSize,
|
||||||
|
paginationRangeStart,
|
||||||
|
paginationRangeEnd,
|
||||||
|
handleListPageSizeChange,
|
||||||
totalPages,
|
totalPages,
|
||||||
pagedEmails,
|
pagedEmails,
|
||||||
listEmails,
|
listEmails,
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
} from "react"
|
} from "react"
|
||||||
import type { Email } from "@/lib/email-data"
|
import type { Email } from "@/lib/email-data"
|
||||||
import { readStateTargets } from "@/lib/mail-thread"
|
import { readStateTargets } from "@/lib/mail-thread"
|
||||||
@ -19,7 +20,6 @@ import {
|
|||||||
parseMailNavVisitKey,
|
parseMailNavVisitKey,
|
||||||
} from "@/lib/mail-folder-display"
|
} from "@/lib/mail-folder-display"
|
||||||
import {
|
import {
|
||||||
LIST_PAGE_SIZE,
|
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
} from "@/components/gmail/email-list/email-list-helpers"
|
} from "@/components/gmail/email-list/email-list-helpers"
|
||||||
import type { Contact } from "@/lib/compose-context"
|
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 { EmailListProps } from "@/components/gmail/email-list/email-list-helpers"
|
||||||
import type { EmailListData } from "@/components/gmail/email-list/hooks/use-email-list-data"
|
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 type { EmailListLabels } from "@/components/gmail/email-list/hooks/use-email-list-labels"
|
||||||
|
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||||
|
|
||||||
export function useEmailListReading(
|
export function useEmailListReading(
|
||||||
props: EmailListProps,
|
props: EmailListProps,
|
||||||
@ -52,6 +53,7 @@ export function useEmailListReading(
|
|||||||
emailById,
|
emailById,
|
||||||
displayListEmails,
|
displayListEmails,
|
||||||
listPage,
|
listPage,
|
||||||
|
listPageSize,
|
||||||
listRowsDep,
|
listRowsDep,
|
||||||
listViewportRef,
|
listViewportRef,
|
||||||
conversationMode,
|
conversationMode,
|
||||||
@ -92,10 +94,20 @@ export function useEmailListReading(
|
|||||||
[openMailId, displayListEmails]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!openMailId) return
|
if (!openMailId) {
|
||||||
|
readAppliedForMailRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (readAppliedForMailRef.current === openMailId) return
|
||||||
const message = emailById.get(openMailId)
|
const message = emailById.get(openMailId)
|
||||||
if (!message) return
|
if (!message) return
|
||||||
|
|
||||||
|
readAppliedForMailRef.current = openMailId
|
||||||
const targets = readStateTargets(message, conversationMode)
|
const targets = readStateTargets(message, conversationMode)
|
||||||
for (const id of targets) {
|
for (const id of targets) {
|
||||||
markEmailSeen(id)
|
markEmailSeen(id)
|
||||||
@ -111,17 +123,22 @@ export function useEmailListReading(
|
|||||||
|
|
||||||
const navigateToMail = useCallback(
|
const navigateToMail = useCallback(
|
||||||
(id: string | null) => {
|
(id: string | null) => {
|
||||||
|
if (id === null) {
|
||||||
|
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||||
|
}
|
||||||
|
startTransition(() => {
|
||||||
if (id && splitView) {
|
if (id && splitView) {
|
||||||
const idx = displayListEmails.findIndex((e) => e.id === id)
|
const idx = displayListEmails.findIndex((e) => e.id === id)
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
const page = Math.floor(idx / LIST_PAGE_SIZE) + 1
|
const page = Math.floor(idx / listPageSize) + 1
|
||||||
onMailRouteNavigate({ mailId: id, page })
|
onMailRouteNavigate({ mailId: id, page })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMailRouteNavigate({ mailId: id })
|
onMailRouteNavigate({ mailId: id })
|
||||||
|
})
|
||||||
},
|
},
|
||||||
[splitView, displayListEmails, onMailRouteNavigate]
|
[splitView, displayListEmails, onMailRouteNavigate, listPageSize]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -153,9 +170,12 @@ export function useEmailListReading(
|
|||||||
}, [splitView, openMailId, navigateToMail, pickAdjacentMailId])
|
}, [splitView, openMailId, navigateToMail, pickAdjacentMailId])
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
if (splitView) leaveReadingPane()
|
if (splitView) {
|
||||||
else navigateToMail(null)
|
navigateToMail(null)
|
||||||
}, [splitView, leaveReadingPane, navigateToMail])
|
return
|
||||||
|
}
|
||||||
|
navigateToMail(null)
|
||||||
|
}, [splitView, navigateToMail])
|
||||||
|
|
||||||
const closeViewIfShowingEmail = useCallback(
|
const closeViewIfShowingEmail = useCallback(
|
||||||
(emailId: string) => {
|
(emailId: string) => {
|
||||||
@ -207,6 +227,7 @@ export function useEmailListReading(
|
|||||||
|
|
||||||
const handleCategoryInboxTabClick = useCallback(
|
const handleCategoryInboxTabClick = useCallback(
|
||||||
(tabId: string) => {
|
(tabId: string) => {
|
||||||
|
useMailUiStore.getState().requestSuppressSplitAutoOpen()
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
onMailRouteNavigate({
|
onMailRouteNavigate({
|
||||||
inboxTab: tabId,
|
inboxTab: tabId,
|
||||||
@ -393,32 +414,40 @@ export function useEmailListReading(
|
|||||||
)
|
)
|
||||||
}, [openEmail, openComposeWithInitial])
|
}, [openEmail, openComposeWithInitial])
|
||||||
|
|
||||||
useEffect(() => {
|
const xsViewChromeCallbacksRef = useRef({
|
||||||
if (!onXsViewChromeChange) return
|
|
||||||
if (!isXs || !isViewMode || !openEmail) {
|
|
||||||
onXsViewChromeChange(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onXsViewChromeChange({
|
|
||||||
onArchive: singleArchive,
|
onArchive: singleArchive,
|
||||||
onReply: singleReply,
|
onReply: singleReply,
|
||||||
moveTargets,
|
moveTargets,
|
||||||
onMoveTo: singleMoveTo,
|
onMoveTo: singleMoveTo,
|
||||||
})
|
})
|
||||||
return () => onXsViewChromeChange(null)
|
xsViewChromeCallbacksRef.current = {
|
||||||
}, [
|
onArchive: singleArchive,
|
||||||
onXsViewChromeChange,
|
onReply: singleReply,
|
||||||
isXs,
|
|
||||||
isViewMode,
|
|
||||||
openEmail,
|
|
||||||
singleArchive,
|
|
||||||
singleReply,
|
|
||||||
singleMoveTo,
|
|
||||||
moveTargets,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!splitView) return
|
if (!splitView) return
|
||||||
|
if (useMailUiStore.getState().consumeSuppressSplitAutoOpen()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const firstId = displayListEmails[0]?.id ?? null
|
const firstId = displayListEmails[0]?.id ?? null
|
||||||
if (!openMailId) {
|
if (!openMailId) {
|
||||||
if (firstId) navigateToMail(firstId)
|
if (firstId) navigateToMail(firstId)
|
||||||
|
|||||||
@ -8,9 +8,6 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react"
|
} from "react"
|
||||||
import { Reply, ReplyAll, Forward } from "lucide-react"
|
import { Reply, ReplyAll, Forward } from "lucide-react"
|
||||||
import {
|
|
||||||
TooltipProvider,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
avatarColor,
|
avatarColor,
|
||||||
@ -19,11 +16,19 @@ import {
|
|||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
import type { ApiMessageSummary, ApiMessageFull } from "@/lib/api/types"
|
||||||
import type { Email, EmailAttachment } from "@/lib/email-data"
|
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 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 {
|
import {
|
||||||
useToggleStar,
|
useToggleStar,
|
||||||
useMarkRead,
|
|
||||||
useUpdateFlags,
|
useUpdateFlags,
|
||||||
useUpdateLabels,
|
useUpdateLabels,
|
||||||
} from "@/lib/api/hooks/use-mail-mutations"
|
} from "@/lib/api/hooks/use-mail-mutations"
|
||||||
@ -31,11 +36,16 @@ import {
|
|||||||
useComposeActions,
|
useComposeActions,
|
||||||
useComposeDrafts,
|
useComposeDrafts,
|
||||||
useComposeWindows,
|
useComposeWindows,
|
||||||
DEFAULT_IDENTITIES,
|
|
||||||
type ThreadComposeKind,
|
type ThreadComposeKind,
|
||||||
type ComposeOpenPreset,
|
type ComposeOpenPreset,
|
||||||
savedThreadDraftToComposePreset,
|
savedThreadDraftToComposePreset,
|
||||||
} from "@/lib/compose-context"
|
} 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 {
|
import {
|
||||||
buildThreadComposePreset,
|
buildThreadComposePreset,
|
||||||
withTouchFullscreenComposePreset,
|
withTouchFullscreenComposePreset,
|
||||||
@ -51,9 +61,10 @@ import {
|
|||||||
MAIL_REPLY_BUTTON_CLASS,
|
MAIL_REPLY_BUTTON_CLASS,
|
||||||
} from "@/lib/mail-chrome-classes"
|
} from "@/lib/mail-chrome-classes"
|
||||||
import {
|
import {
|
||||||
CollapsedMessage,
|
|
||||||
ExpandedMessage,
|
ExpandedMessage,
|
||||||
SpamWhyBanner,
|
SpamWhyBanner,
|
||||||
|
ThreadPriorMessage,
|
||||||
|
formatApiMessageBody,
|
||||||
} from "@/components/gmail/email-view/email-view-messages"
|
} from "@/components/gmail/email-view/email-view-messages"
|
||||||
|
|
||||||
function apiToLegacyEmail(
|
function apiToLegacyEmail(
|
||||||
@ -67,13 +78,13 @@ function apiToLegacyEmail(
|
|||||||
sender: senderName,
|
sender: senderName,
|
||||||
senderEmail: msg.from[0]?.address,
|
senderEmail: msg.from[0]?.address,
|
||||||
subject: msg.subject,
|
subject: msg.subject,
|
||||||
preview: msg.snippet,
|
preview: repairSnippet(msg.snippet) ?? msg.snippet,
|
||||||
body: full?.body_html ?? full?.body_text,
|
body: full?.body_html ?? full?.body_text,
|
||||||
date: msg.date,
|
date: msg.date,
|
||||||
read: msg.flags.includes("read"),
|
read: mailFlagIsRead(msg.flags),
|
||||||
starred: msg.flags.includes("starred"),
|
starred: mailFlagIsStarred(msg.flags),
|
||||||
important: msg.flags.includes("important"),
|
important: mailFlagIsImportant(msg.flags, msg.labels),
|
||||||
spam: msg.flags.includes("spam") || msg.labels.includes("spam"),
|
spam: messageIsSpam(msg.flags, msg.labels),
|
||||||
labels: msg.labels,
|
labels: msg.labels,
|
||||||
hasAttachment: msg.has_attachments,
|
hasAttachment: msg.has_attachments,
|
||||||
conversation: thread
|
conversation: thread
|
||||||
@ -114,51 +125,59 @@ export function EmailView({
|
|||||||
currentFolderId,
|
currentFolderId,
|
||||||
isSingleMessageView = false,
|
isSingleMessageView = false,
|
||||||
}: EmailViewProps) {
|
}: EmailViewProps) {
|
||||||
const { data: fullMessage } = useMessage(email.id)
|
const { data: fullMessage, isPending: fullMessagePending } = useMessage(email.id)
|
||||||
const { data: threadMessages } = useThread(email.thread_id ?? null)
|
const { data: threadMessages } = useThread(email.thread_id ?? null)
|
||||||
|
const selfEmails = useSelfMailEmails()
|
||||||
|
const chromeIdentity = useChromeIdentity()
|
||||||
|
const selfDisplayName = chromeIdentity?.name
|
||||||
|
|
||||||
const toggleStar = useToggleStar()
|
const toggleStar = useToggleStar()
|
||||||
const markRead = useMarkRead()
|
|
||||||
const updateFlags = useUpdateFlags()
|
const updateFlags = useUpdateFlags()
|
||||||
const updateLabels = useUpdateLabels()
|
const updateLabels = useUpdateLabels()
|
||||||
|
|
||||||
const flags = fullMessage?.flags ?? email.flags
|
const flags = fullMessage?.flags ?? email.flags
|
||||||
const isStarred = flags.includes("starred")
|
const isStarred = mailFlagIsStarred(flags)
|
||||||
const isSpam = flags.includes("spam") || email.labels.includes("spam")
|
const isSpam = messageIsSpam(flags, fullMessage?.labels ?? email.labels)
|
||||||
|
|
||||||
const initialFlagsRef = useRef(flags)
|
const body = useMemo(
|
||||||
useEffect(() => {
|
() =>
|
||||||
initialFlagsRef.current = email.flags
|
formatApiMessageBody(
|
||||||
}, [email.id, email.flags])
|
fullMessage,
|
||||||
|
email.snippet,
|
||||||
useEffect(() => {
|
fullMessagePending && !fullMessage
|
||||||
if (!initialFlagsRef.current.includes("read")) {
|
),
|
||||||
markRead.mutate({ id: email.id, flags: initialFlagsRef.current })
|
[fullMessage, fullMessagePending, email.snippet]
|
||||||
}
|
)
|
||||||
// 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 [showFullThread, setShowFullThread] = useState(false)
|
const [showFullThread, setShowFullThread] = useState(false)
|
||||||
|
const [mainDetailsOpen, setMainDetailsOpen] = useState(false)
|
||||||
|
|
||||||
const priorMessages = useMemo(() => {
|
const threadOrdered = useMemo(
|
||||||
if (!threadMessages) return []
|
() => unwrapThreadMessages(threadMessages),
|
||||||
return threadMessages.filter((m) => m.id !== email.id)
|
[threadMessages]
|
||||||
}, [threadMessages, email.id])
|
)
|
||||||
|
|
||||||
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 =
|
const showRepliesCta =
|
||||||
isSingleMessageView && !showFullThread && priorCount > 0
|
isSingleMessageView && !showFullThread && otherThreadCount > 0
|
||||||
|
|
||||||
const conversation =
|
const showFullThreadList = !isSingleMessageView || showFullThread
|
||||||
isSingleMessageView && !showFullThread ? [] : priorMessages
|
const messagesBefore = showFullThreadList ? threadBefore : []
|
||||||
const hasConversation = conversation.length > 0
|
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 [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||||
|
const isThreadMessageExpanded = useCallback(
|
||||||
|
(msgId: string) => expandAllThreadMessages || expandedIds.has(msgId),
|
||||||
|
[expandAllThreadMessages, expandedIds]
|
||||||
|
)
|
||||||
const toggleExpanded = (msgId: string) => {
|
const toggleExpanded = (msgId: string) => {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@ -168,13 +187,32 @@ export function EmailView({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainSenderName = cleanSenderName(email.from[0]?.name ?? "")
|
const mainFrom = useMemo(
|
||||||
const mainSenderAddr =
|
() =>
|
||||||
email.from[0]?.address ??
|
resolveMessageFrom(fullMessage?.from ?? email.from, {
|
||||||
`${mainSenderName.toLowerCase().replace(/\s+/g, ".")}@example.com`
|
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(
|
const legacyEmail = useMemo(
|
||||||
() => apiToLegacyEmail(email, fullMessage, threadMessages),
|
() => apiToLegacyEmail(email, fullMessage, unwrapThreadMessages(threadMessages)),
|
||||||
[email, fullMessage, threadMessages]
|
[email, fullMessage, threadMessages]
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -239,7 +277,16 @@ export function EmailView({
|
|||||||
[legacyEmail, openThreadCompose]
|
[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 selfName = cleanSenderName(selfIdentity.name)
|
||||||
|
|
||||||
const calendarInvitation = useMemo(
|
const calendarInvitation = useMemo(
|
||||||
@ -252,16 +299,16 @@ export function EmailView({
|
|||||||
}, [email.id, flags, isStarred, toggleStar])
|
}, [email.id, flags, isStarred, toggleStar])
|
||||||
|
|
||||||
const handleNotSpam = useCallback(() => {
|
const handleNotSpam = useCallback(() => {
|
||||||
if (flags.includes("spam")) {
|
if (messageHasFlag(flags, "spam")) {
|
||||||
updateFlags.mutate({
|
updateFlags.mutate({
|
||||||
id: email.id,
|
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({
|
updateLabels.mutate({
|
||||||
id: email.id,
|
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])
|
}, [email.id, flags, email.labels, updateFlags, updateLabels])
|
||||||
@ -271,7 +318,6 @@ export function EmailView({
|
|||||||
}, [legacyEmail])
|
}, [legacyEmail])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={400}>
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
|
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
|
||||||
<div
|
<div
|
||||||
@ -306,58 +352,65 @@ export function EmailView({
|
|||||||
onClick={() => setShowFullThread(true)}
|
onClick={() => setShowFullThread(true)}
|
||||||
className="text-sm font-medium text-primary hover:underline"
|
className="text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
{priorCount === 1
|
{otherThreadCount === 1
|
||||||
? "Afficher la réponse"
|
? "Afficher l'autre message du fil"
|
||||||
: `Afficher les ${priorCount} réponses`}
|
: `Afficher les ${otherThreadCount} autres messages du fil`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{hasConversation &&
|
{messagesBefore.map((msg) => (
|
||||||
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 (
|
|
||||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||||
<CollapsedMessage
|
<ThreadPriorMessage
|
||||||
message={msg}
|
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>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
<ExpandedMessage
|
<ExpandedMessage
|
||||||
sender={mainSenderName}
|
sender={mainFrom.name}
|
||||||
senderEmail={mainSenderAddr}
|
senderEmail={mainFrom.email}
|
||||||
|
headerDetails={mainHeaderDetails}
|
||||||
dateIso={email.date}
|
dateIso={email.date}
|
||||||
body={body}
|
body={body}
|
||||||
isSpam={isSpam}
|
isSpam={isSpam}
|
||||||
isLast={true}
|
isLast={messagesAfter.length === 0}
|
||||||
starred={isStarred}
|
starred={isStarred}
|
||||||
attachments={mainMessageAttachments}
|
attachments={mainMessageAttachments}
|
||||||
onToggleStar={handleToggleStar}
|
onToggleStar={handleToggleStar}
|
||||||
onPrintConversation={handlePrint}
|
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 ? (
|
{showReplyForwardBar ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -405,7 +458,7 @@ export function EmailView({
|
|||||||
{inlineCompose ? (
|
{inlineCompose ? (
|
||||||
<div
|
<div
|
||||||
ref={threadComposeAnchorRef}
|
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 className="flex items-start gap-3">
|
||||||
<div
|
<div
|
||||||
@ -427,6 +480,5 @@ export function EmailView({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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"
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
import { Star, Info } from "lucide-react"
|
import { Star, Info } from "lucide-react"
|
||||||
|
import { useMessage } from "@/lib/api/hooks/use-mail-queries"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { mailFlagIsStarred, messageIsSpam } from "@/lib/mail-flags"
|
||||||
import {
|
import {
|
||||||
avatarColor,
|
avatarColor,
|
||||||
cleanSenderName,
|
cleanSenderName,
|
||||||
senderInitial,
|
senderInitial,
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
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 type { EmailAttachment } from "@/lib/email-data"
|
||||||
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
||||||
import { EmailViewMessageToolbar } from "@/components/gmail/email-view/email-view-toolbar"
|
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 { MessageAttachmentsSection } from "@/components/gmail/email-view/message-attachments"
|
||||||
import {
|
import {
|
||||||
MAIL_MESSAGE_HOVER_CLASS,
|
MAIL_MESSAGE_HOVER_CLASS,
|
||||||
MAIL_TOOLTIP_CONTENT_CLASS,
|
MAIL_TOOLTIP_CONTENT_CLASS,
|
||||||
} from "@/lib/mail-chrome-classes"
|
} 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({
|
export function CollapsedMessage({
|
||||||
message,
|
message,
|
||||||
|
senderName: senderNameProp,
|
||||||
|
senderEmail: senderEmailProp,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
message: ApiMessageFull
|
message: ApiMessageFull
|
||||||
|
senderName?: string
|
||||||
|
senderEmail?: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const senderName = message.from[0]?.name ?? ""
|
const senderName = senderNameProp ?? message.from[0]?.name ?? ""
|
||||||
const senderAddr = message.from[0]?.address ?? ""
|
const senderAddr = senderEmailProp ?? message.from[0]?.address ?? ""
|
||||||
const name = cleanSenderName(senderName)
|
const name = cleanSenderName(senderName || senderAddr)
|
||||||
const color = avatarColor(name)
|
const color = avatarColor(name)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -81,6 +216,7 @@ export function CollapsedMessage({
|
|||||||
export function ExpandedMessage({
|
export function ExpandedMessage({
|
||||||
sender,
|
sender,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
|
headerDetails,
|
||||||
dateIso,
|
dateIso,
|
||||||
body,
|
body,
|
||||||
isSpam,
|
isSpam,
|
||||||
@ -90,9 +226,17 @@ export function ExpandedMessage({
|
|||||||
onToggleStar,
|
onToggleStar,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onPrintConversation,
|
onPrintConversation,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
detailsOpen,
|
||||||
|
onDetailsOpenChange,
|
||||||
|
collapseQuotedReplies = false,
|
||||||
|
messageId,
|
||||||
}: {
|
}: {
|
||||||
sender: string
|
sender: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
|
headerDetails: MessageHeaderDetails
|
||||||
|
messageId: string
|
||||||
dateIso: string
|
dateIso: string
|
||||||
body: string
|
body: string
|
||||||
isSpam: boolean
|
isSpam: boolean
|
||||||
@ -102,12 +246,18 @@ export function ExpandedMessage({
|
|||||||
onToggleStar?: () => void
|
onToggleStar?: () => void
|
||||||
onCollapse?: () => void
|
onCollapse?: () => void
|
||||||
onPrintConversation?: () => void
|
onPrintConversation?: () => void
|
||||||
|
onReply?: () => void
|
||||||
|
onForward?: () => void
|
||||||
|
detailsOpen?: boolean
|
||||||
|
onDetailsOpenChange?: (open: boolean) => void
|
||||||
|
collapseQuotedReplies?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<EmailViewMessageToolbar
|
<EmailViewMessageToolbar
|
||||||
sender={sender}
|
sender={sender}
|
||||||
senderEmail={senderEmail}
|
senderEmail={senderEmail}
|
||||||
|
headerDetails={headerDetails}
|
||||||
dateIso={dateIso}
|
dateIso={dateIso}
|
||||||
isSpam={isSpam}
|
isSpam={isSpam}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
@ -115,6 +265,11 @@ export function ExpandedMessage({
|
|||||||
onToggleStar={onToggleStar}
|
onToggleStar={onToggleStar}
|
||||||
onCollapse={onCollapse}
|
onCollapse={onCollapse}
|
||||||
onPrintConversation={onPrintConversation}
|
onPrintConversation={onPrintConversation}
|
||||||
|
onReply={onReply}
|
||||||
|
onForward={onForward}
|
||||||
|
detailsOpen={detailsOpen}
|
||||||
|
onDetailsOpenChange={onDetailsOpenChange}
|
||||||
|
messageId={messageId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@ -124,7 +279,13 @@ export function ExpandedMessage({
|
|||||||
)}
|
)}
|
||||||
data-selectable-text
|
data-selectable-text
|
||||||
>
|
>
|
||||||
<SandboxedContent html={body} isSpam={isSpam} />
|
<MessageBodyContent
|
||||||
|
html={body}
|
||||||
|
isSpam={isSpam}
|
||||||
|
senderEmail={senderEmail}
|
||||||
|
messageId={messageId}
|
||||||
|
collapseQuotedReplies={collapseQuotedReplies}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
|
|||||||
@ -38,6 +38,8 @@ import { cn } from "@/lib/utils"
|
|||||||
import { avatarColor, cleanSenderName, senderInitial } from "@/lib/sender-display"
|
import { avatarColor, cleanSenderName, senderInitial } from "@/lib/sender-display"
|
||||||
import { MailDateText } from "@/components/gmail/mail-date-text"
|
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||||
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
|
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 {
|
import {
|
||||||
MAIL_ICON_BTN,
|
MAIL_ICON_BTN,
|
||||||
MAIL_MENU_SURFACE_WIDE_CLASS,
|
MAIL_MENU_SURFACE_WIDE_CLASS,
|
||||||
@ -51,6 +53,7 @@ const MENU_ICON_CLASS = "size-[18px] shrink-0 text-muted-foreground"
|
|||||||
export interface EmailViewMessageToolbarProps {
|
export interface EmailViewMessageToolbarProps {
|
||||||
sender: string
|
sender: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
|
headerDetails: MessageHeaderDetails
|
||||||
dateIso: string
|
dateIso: string
|
||||||
isSpam: boolean
|
isSpam: boolean
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
@ -58,11 +61,17 @@ export interface EmailViewMessageToolbarProps {
|
|||||||
onToggleStar?: () => void
|
onToggleStar?: () => void
|
||||||
onCollapse?: () => void
|
onCollapse?: () => void
|
||||||
onPrintConversation?: () => void
|
onPrintConversation?: () => void
|
||||||
|
onReply?: () => void
|
||||||
|
onForward?: () => void
|
||||||
|
detailsOpen?: boolean
|
||||||
|
onDetailsOpenChange?: (open: boolean) => void
|
||||||
|
messageId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmailViewMessageToolbar({
|
export function EmailViewMessageToolbar({
|
||||||
sender,
|
sender,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
|
headerDetails,
|
||||||
dateIso,
|
dateIso,
|
||||||
isSpam,
|
isSpam,
|
||||||
isLast,
|
isLast,
|
||||||
@ -70,15 +79,29 @@ export function EmailViewMessageToolbar({
|
|||||||
onToggleStar,
|
onToggleStar,
|
||||||
onCollapse,
|
onCollapse,
|
||||||
onPrintConversation,
|
onPrintConversation,
|
||||||
|
onReply,
|
||||||
|
onForward,
|
||||||
|
detailsOpen,
|
||||||
|
onDetailsOpenChange,
|
||||||
|
messageId,
|
||||||
}: EmailViewMessageToolbarProps) {
|
}: EmailViewMessageToolbarProps) {
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
|
||||||
const name = cleanSenderName(sender)
|
const name = cleanSenderName(sender)
|
||||||
|
const [internalDetailsOpen, setInternalDetailsOpen] = useState(false)
|
||||||
|
const detailsIsOpen = detailsOpen ?? internalDetailsOpen
|
||||||
|
const setDetailsIsOpen = onDetailsOpenChange ?? setInternalDetailsOpen
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")}
|
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 ? (
|
{isSpam ? (
|
||||||
<div
|
<div
|
||||||
@ -105,46 +128,22 @@ export function EmailViewMessageToolbar({
|
|||||||
className="inline min-w-0 max-w-full align-baseline"
|
className="inline min-w-0 max-w-full align-baseline"
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-foreground">{name}</span>
|
<span className="font-semibold text-foreground">{name}</span>
|
||||||
|
{senderEmail ? (
|
||||||
<span className="text-muted-foreground"> <{senderEmail}></span>
|
<span className="text-muted-foreground"> <{senderEmail}></span>
|
||||||
|
) : null}
|
||||||
</ContactHoverCard>
|
</ContactHoverCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<EmailViewDetailsPopover
|
||||||
type="button"
|
summary={headerDetails.recipientSummary}
|
||||||
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
|
details={headerDetails}
|
||||||
onClick={(e) => {
|
open={detailsIsOpen}
|
||||||
e.stopPropagation()
|
onOpenChange={setDetailsIsOpen}
|
||||||
setShowDetails(!showDetails)
|
isSpam={isSpam}
|
||||||
}}
|
messageId={messageId}
|
||||||
>
|
|
||||||
à moi
|
|
||||||
<ChevronDown
|
|
||||||
className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
|
<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"
|
size="icon"
|
||||||
className={cn("h-8 w-8", MAIL_ICON_BTN)}
|
className={cn("h-8 w-8", MAIL_ICON_BTN)}
|
||||||
aria-label="Répondre"
|
aria-label="Répondre"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onReply?.()
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
||||||
</Button>
|
</Button>
|
||||||
@ -219,11 +221,19 @@ export function EmailViewMessageToolbar({
|
|||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
className={MESSAGE_MORE_MENU_CLASS}
|
className={MESSAGE_MORE_MENU_CLASS}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
onReply?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Reply className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
<Reply className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
||||||
Répondre
|
Répondre
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
onForward?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Forward className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
<Forward className={MENU_ICON_CLASS} strokeWidth={1.5} />
|
||||||
Transférer
|
Transférer
|
||||||
</DropdownMenuItem>
|
</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"
|
"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 { useTheme } from "next-themes"
|
||||||
import {
|
import {
|
||||||
emailPreviewBaseCss,
|
emailPreviewBaseCss,
|
||||||
emailPreviewDarkOverrideCss,
|
emailPreviewDarkOverrideCss,
|
||||||
emailPreviewLightOverrideCss,
|
emailPreviewLightOverrideCss,
|
||||||
preprocessEmailHtmlForTheme,
|
emailPreviewWrapperCss,
|
||||||
} from "@/lib/email-preview-dark-styles"
|
} 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 = {
|
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
|
||||||
display: "block",
|
display: "block",
|
||||||
@ -18,21 +30,68 @@ function documentIsDark(): boolean {
|
|||||||
return document.documentElement.classList.contains("dark")
|
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({
|
export function SandboxedContent({
|
||||||
html,
|
html,
|
||||||
isSpam,
|
blockRemoteContent,
|
||||||
|
restrictPopups = false,
|
||||||
|
senderEmail,
|
||||||
|
cidUrlMap,
|
||||||
}: {
|
}: {
|
||||||
html: string
|
html: string
|
||||||
isSpam: boolean
|
blockRemoteContent: boolean
|
||||||
|
restrictPopups?: boolean
|
||||||
|
senderEmail?: string
|
||||||
|
cidUrlMap?: Record<string, string>
|
||||||
}) {
|
}) {
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||||
const [height, setHeight] = useState(120)
|
const [height, setHeight] = useState(120)
|
||||||
|
|
||||||
const sandboxValue = isSpam
|
const sandboxValue = restrictPopups
|
||||||
? "allow-same-origin"
|
? "allow-same-origin"
|
||||||
: "allow-same-origin allow-popups"
|
: "allow-same-origin allow-popups"
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme()
|
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 injectContent = useCallback(() => {
|
||||||
const iframe = iframeRef.current
|
const iframe = iframeRef.current
|
||||||
@ -41,45 +100,44 @@ export function SandboxedContent({
|
|||||||
const doc = iframe.contentDocument
|
const doc = iframe.contentDocument
|
||||||
if (!doc) return
|
if (!doc) return
|
||||||
|
|
||||||
const cspMeta = isSpam
|
injectEmailHtmlIntoDocument(doc, {
|
||||||
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
|
csp: cspContent,
|
||||||
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
|
documentBaseHref: parsedEmail.documentBaseHref,
|
||||||
|
resolveBaseHref: parsedEmail.resolveBaseHref,
|
||||||
const isDark = documentIsDark()
|
headMarkup: parsedEmail.headMarkup,
|
||||||
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
|
bodyHtml: parsedEmail.bodyHtml,
|
||||||
const themeOverrides = isDark
|
wrapperCss: themeCss,
|
||||||
? 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))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) {
|
if (doc.body) {
|
||||||
resizeObserver.observe(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()
|
return () => resizeObserver.disconnect()
|
||||||
}, [html, isSpam, resolvedTheme])
|
}, [parsedEmail, themeCss, cspContent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cleanup = injectContent()
|
const cleanup = injectContent()
|
||||||
@ -88,6 +146,7 @@ export function SandboxedContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
key={blockRemoteContent ? "remote-blocked" : "remote-allowed"}
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
sandbox={sandboxValue}
|
sandbox={sandboxValue}
|
||||||
title="Contenu du message"
|
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 { useState, useRef, useEffect } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { toast } from "sonner"
|
||||||
import { Icon, addCollection } from "@iconify/react"
|
import { Icon, addCollection } from "@iconify/react"
|
||||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||||
import { Pencil } from "lucide-react"
|
import { Pencil } from "lucide-react"
|
||||||
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
import { AccountAvatar } from "@/components/gmail/account-avatar"
|
||||||
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
|
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
|
||||||
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
|
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@ -92,9 +93,17 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
|||||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false)
|
||||||
const appsMenuRef = useRef<HTMLDivElement>(null)
|
const appsMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const accountMenuRef = useRef<HTMLDivElement>(null)
|
const accountMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const activeAccount = useActiveAccount()
|
const identity = useChromeIdentity()
|
||||||
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
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(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (
|
if (
|
||||||
@ -183,7 +192,7 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-lg"
|
size="icon-lg"
|
||||||
className="size-11 overflow-hidden rounded-full p-0"
|
className="size-11 overflow-hidden rounded-full p-0"
|
||||||
aria-label={`Compte : ${activeAccount?.email ?? ""}`}
|
aria-label={`Compte : ${identity?.email ?? "Utilisateur"}`}
|
||||||
aria-expanded={accountMenuOpen}
|
aria-expanded={accountMenuOpen}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -191,7 +200,16 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
|
|||||||
setAppsMenuOpen(false)
|
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>
|
</Button>
|
||||||
<AccountSwitcherDropdown
|
<AccountSwitcherDropdown
|
||||||
open={accountMenuOpen}
|
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 Link from "next/link"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { 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() {
|
export function QuickSettingsPanel() {
|
||||||
const open = useMailSettingsStore((s) => s.quickSettingsOpen)
|
const open = useMailSettingsStore((s) => s.quickSettingsOpen)
|
||||||
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
|
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
|
||||||
const setOpen = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
const setOpen = useMailSettingsStore((s) => s.setQuickSettingsOpen)
|
||||||
const setThemeDialogOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
|
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
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!themeDialogOpen && (
|
{!themeDialogOpen && (
|
||||||
@ -152,88 +51,17 @@ export function QuickSettingsPanel() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
|
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
|
||||||
asChild
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsSection title="Densité">
|
<MailSettingsFields
|
||||||
{densityOptions.map((opt) => (
|
variant="panel"
|
||||||
<QuickSettingsOption
|
onOpenThemeDialog={() => setThemeDialogOpen(true)}
|
||||||
key={opt.id}
|
|
||||||
name="density"
|
|
||||||
label={opt.label}
|
|
||||||
checked={density === opt.id}
|
|
||||||
onSelect={() => setDensity(opt.id)}
|
|
||||||
icon={opt.icon}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -190,3 +190,121 @@ export function ThemeThumbnailIcon() {
|
|||||||
</PreviewFrame>
|
</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,
|
MAIL_BACKGROUND_PRESETS,
|
||||||
normalizeMailBackgroundId,
|
normalizeMailBackgroundId,
|
||||||
} from "@/lib/mail-settings/constants"
|
} 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"
|
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() {
|
export function ThemeSettingsDialog() {
|
||||||
const open = useMailSettingsStore((s) => s.themeDialogOpen)
|
const open = useMailSettingsStore((s) => s.themeDialogOpen)
|
||||||
const setOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
|
const setOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
|
||||||
const themeMode = useMailSettingsStore((s) => s.themeMode)
|
|
||||||
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
|
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
|
||||||
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
|
|
||||||
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
|
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
|
||||||
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
|
||||||
|
|
||||||
@ -43,41 +29,11 @@ export function ThemeSettingsDialog() {
|
|||||||
>
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-left text-base font-normal text-foreground">
|
<DialogTitle className="text-left text-base font-normal text-foreground">
|
||||||
Thème
|
Arrière-plan
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<section>
|
<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">
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
||||||
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
{MAIL_BACKGROUND_PRESETS.map((preset) => (
|
||||||
<button
|
<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 {
|
import {
|
||||||
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
|
MAIL_SIDEBAR_BLUR_SURFACE_CLASS,
|
||||||
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
||||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
|
|
||||||
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
||||||
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
||||||
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
||||||
@ -77,6 +76,8 @@ import {
|
|||||||
mailSidebarFolderBranchStickyTopPx,
|
mailSidebarFolderBranchStickyTopPx,
|
||||||
mailSidebarFolderBranchStickyZ,
|
mailSidebarFolderBranchStickyZ,
|
||||||
} from "@/components/gmail/sidebar/sidebar-nav-constants"
|
} from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||||
|
import { NavColorPicker } from "@/components/gmail/nav/nav-color-picker"
|
||||||
|
import { normalizeNavColorClass } from "@/lib/nav-color"
|
||||||
import {
|
import {
|
||||||
SidebarNavOptionsSheet,
|
SidebarNavOptionsSheet,
|
||||||
SidebarNavSheetAction,
|
SidebarNavSheetAction,
|
||||||
@ -151,7 +152,7 @@ export function SidebarFolderRowExpanded({
|
|||||||
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
|
const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label)
|
||||||
const hasChildren = !!(node.children?.length)
|
const hasChildren = !!(node.children?.length)
|
||||||
const isBranchOpen = expandedFolderIds.has(node.id)
|
const isBranchOpen = expandedFolderIds.has(node.id)
|
||||||
const dotClass = node.color ?? "bg-gray-400"
|
const dotClass = normalizeNavColorClass(node.color)
|
||||||
const isSelected = selectedFolder === node.id
|
const isSelected = selectedFolder === node.id
|
||||||
const unread = folderUnreadCounts[node.id] ?? 0
|
const unread = folderUnreadCounts[node.id] ?? 0
|
||||||
const hasUnread = unread > 0
|
const hasUnread = unread > 0
|
||||||
@ -223,26 +224,15 @@ export function SidebarFolderRowExpanded({
|
|||||||
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
|
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
|
||||||
</SubTr>
|
</SubTr>
|
||||||
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
||||||
<div className="grid grid-cols-6 gap-1.5">
|
<NavColorPicker
|
||||||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
variant="menu"
|
||||||
<button
|
value={dotClass}
|
||||||
key={sw}
|
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||||
type="button"
|
onChange={(sw) => {
|
||||||
title={sw}
|
|
||||||
onClick={() => {
|
|
||||||
updateFolderOrLabelColor(node.id, sw)
|
updateFolderOrLabelColor(node.id, sw)
|
||||||
setMenuOpen(false)
|
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>
|
</SubCo>
|
||||||
</Sub>
|
</Sub>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import { cn } from "@/lib/utils"
|
|||||||
import { useEmailDropTarget } from "@/lib/drag-context"
|
import { useEmailDropTarget } from "@/lib/drag-context"
|
||||||
import {
|
import {
|
||||||
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
|
||||||
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
|
|
||||||
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
|
||||||
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
|
||||||
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
|
||||||
@ -56,6 +55,8 @@ import {
|
|||||||
setSidebarNavDragData,
|
setSidebarNavDragData,
|
||||||
} from "@/lib/sidebar-nav-dnd"
|
} from "@/lib/sidebar-nav-dnd"
|
||||||
import { LABEL_MENU_COLOR_SWATCHES } from "@/components/gmail/sidebar/sidebar-nav-constants"
|
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 {
|
import {
|
||||||
SidebarNavOptionsSheet,
|
SidebarNavOptionsSheet,
|
||||||
SidebarNavSheetAction,
|
SidebarNavSheetAction,
|
||||||
@ -146,7 +147,7 @@ export function SidebarLabelItemRow({
|
|||||||
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
|
!isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen)
|
||||||
|
|
||||||
const prefs = getNavItemPrefs(item.id)
|
const prefs = getNavItemPrefs(item.id)
|
||||||
const labelDotClass = item.color ?? "bg-gray-400"
|
const labelDotClass = normalizeNavColorClass(item.color)
|
||||||
const labelMenuSurface =
|
const labelMenuSurface =
|
||||||
MAIL_SIDEBAR_MENU_SURFACE_CLASS
|
MAIL_SIDEBAR_MENU_SURFACE_CLASS
|
||||||
|
|
||||||
@ -176,26 +177,15 @@ export function SidebarLabelItemRow({
|
|||||||
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
|
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
|
||||||
</SubTr>
|
</SubTr>
|
||||||
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
<SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
|
||||||
<div className="grid grid-cols-6 gap-1.5">
|
<NavColorPicker
|
||||||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
variant="menu"
|
||||||
<button
|
value={labelDotClass}
|
||||||
key={sw}
|
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||||||
type="button"
|
onChange={(sw) => {
|
||||||
title={sw}
|
|
||||||
onClick={() => {
|
|
||||||
updateFolderOrLabelColor(item.id, sw)
|
updateFolderOrLabelColor(item.id, sw)
|
||||||
setMenuOpen(false)
|
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>
|
</SubCo>
|
||||||
</Sub>
|
</Sub>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 =
|
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"
|
"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="px-4 py-2">
|
||||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#3c4043]">
|
<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="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
|
||||||
<span
|
<NavColorDot color={dotClass} />
|
||||||
className={cn("block size-3 rounded-sm border border-black/10", dotClass)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-6 gap-1.5">
|
<NavColorPicker
|
||||||
{swatches.map((sw) => (
|
variant="sheet"
|
||||||
<button
|
value={dotClass}
|
||||||
key={sw}
|
swatches={swatches}
|
||||||
type="button"
|
onChange={onPick}
|
||||||
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
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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"
|
className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<span
|
<NavColorDot color={colorDotClass} />
|
||||||
className={cn(
|
|
||||||
"block size-3 rounded-sm border border-black/10",
|
|
||||||
colorDotClass
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<SheetTitle className="min-w-0 flex-1 truncate text-left text-base font-medium leading-5 text-[#3c4043]">
|
<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,
|
type ThemeProviderProps,
|
||||||
} from 'next-themes'
|
} from 'next-themes'
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
export function ThemeProvider({
|
||||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
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({
|
function Tooltip({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
return (
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
<TooltipProvider>
|
|
||||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({
|
||||||
|
|||||||
@ -5,14 +5,15 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type UltiMailLogoProps = {
|
type UltiMailLogoProps = {
|
||||||
className?: string
|
className?: string
|
||||||
/** `horizontal` = picto source + « Ultimail » (lisible, aligné barre). `mark` = picto seul (launcher). */
|
/** `horizontal` = picto + texte. `mark` = picto seul. `stacked` = wordmark empilé. */
|
||||||
variant?: "horizontal" | "mark"
|
variant?: "horizontal" | "mark" | "stacked"
|
||||||
/** Lien au clic ; `null` = pas de lien. Défaut : boîte de réception. */
|
/** Lien au clic ; `null` = pas de lien. Défaut : boîte de réception. */
|
||||||
href?: string | null
|
href?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
|
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */
|
||||||
const HEADER_ICON = "/brand/ultimail-header-icon.png"
|
const HEADER_ICON = "/brand/ultimail-header-icon.png"
|
||||||
|
const STACKED_WORDMARK = "/brand/ultimail-wordmark-stacked.png"
|
||||||
const DEFAULT_INBOX_HREF = "/mail/inbox"
|
const DEFAULT_INBOX_HREF = "/mail/inbox"
|
||||||
|
|
||||||
export function UltiMailLogo({
|
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 = (
|
const body = (
|
||||||
<div
|
<div
|
||||||
role="img"
|
role="img"
|
||||||
|
|||||||
@ -20,7 +20,14 @@ export function useMailRoute() {
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const currentSearchParams = useSearchParams()
|
const currentSearchParams = useSearchParams()
|
||||||
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
|
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(
|
const navigateRoute = useCallback(
|
||||||
(patch: Partial<MailRouteState>) => {
|
(patch: Partial<MailRouteState>) => {
|
||||||
@ -34,8 +41,11 @@ export function useMailRoute() {
|
|||||||
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
|
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
|
||||||
}
|
}
|
||||||
let url = buildMailPath(next)
|
let url = buildMailPath(next)
|
||||||
if (next.folderId === "search" && currentSearchParams.toString()) {
|
if (next.folderId === "search") {
|
||||||
url += `?${currentSearchParams.toString()}`
|
const qs = new URLSearchParams(currentSearchParams.toString())
|
||||||
|
qs.delete("message")
|
||||||
|
const q = qs.toString()
|
||||||
|
if (q) url += `?${q}`
|
||||||
}
|
}
|
||||||
router.push(url, { scroll: false })
|
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"
|
import type { ReadingPaneMode } from "@/lib/mail-settings/types"
|
||||||
|
|
||||||
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
|
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
|
||||||
|
const LG_MQ = "(min-width: 1024px)"
|
||||||
const LANDSCAPE_MQ = "(orientation: landscape)"
|
const LANDSCAPE_MQ = "(orientation: landscape)"
|
||||||
|
|
||||||
export function readMailSplitViewMatches(
|
export function readMailSplitViewMatches(
|
||||||
@ -37,22 +38,26 @@ export function useMailSplitView() {
|
|||||||
const mqlCoarse = window.matchMedia(
|
const mqlCoarse = window.matchMedia(
|
||||||
"(hover: none) and (pointer: coarse)"
|
"(hover: none) and (pointer: coarse)"
|
||||||
)
|
)
|
||||||
const update = () =>
|
const mqlLg = window.matchMedia(LG_MQ)
|
||||||
setSplitView(readMailSplitViewMatches(readingPane))
|
let raf = 0
|
||||||
|
const update = () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
const next = readMailSplitViewMatches(readingPane)
|
||||||
|
setSplitView((prev) => (prev === next ? prev : next))
|
||||||
|
})
|
||||||
|
}
|
||||||
update()
|
update()
|
||||||
mqlMd.addEventListener("change", update)
|
mqlMd.addEventListener("change", update)
|
||||||
mqlLandscape.addEventListener("change", update)
|
mqlLandscape.addEventListener("change", update)
|
||||||
mqlCoarse.addEventListener("change", update)
|
mqlCoarse.addEventListener("change", update)
|
||||||
window
|
mqlLg.addEventListener("change", update)
|
||||||
.matchMedia(`(min-width: 1024px)`)
|
|
||||||
.addEventListener("change", update)
|
|
||||||
return () => {
|
return () => {
|
||||||
mqlMd.removeEventListener("change", update)
|
mqlMd.removeEventListener("change", update)
|
||||||
mqlLandscape.removeEventListener("change", update)
|
mqlLandscape.removeEventListener("change", update)
|
||||||
mqlCoarse.removeEventListener("change", update)
|
mqlCoarse.removeEventListener("change", update)
|
||||||
window
|
mqlLg.removeEventListener("change", update)
|
||||||
.matchMedia(`(min-width: 1024px)`)
|
cancelAnimationFrame(raf)
|
||||||
.removeEventListener("change", update)
|
|
||||||
}
|
}
|
||||||
}, [readingPane])
|
}, [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). */
|
/** True after a zustand `persist` store has rehydrated from storage (client-only). */
|
||||||
export function usePersistHydrated(store: { persist: PersistApi }): boolean {
|
export function usePersistHydrated(store: { persist: PersistApi }): boolean {
|
||||||
const [hydrated, setHydrated] = useState(() => store.persist.hasHydrated())
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (store.persist.hasHydrated()) {
|
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. */
|
/** Tailwind `sm` breakpoint — viewports below are treated as xs. */
|
||||||
export const XS_MAX_PX = 639
|
export const XS_MAX_PX = 639
|
||||||
@ -12,15 +12,5 @@ export function readXsMatches(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useIsXs() {
|
export function useIsXs() {
|
||||||
const [isXs, setIsXs] = useState(false)
|
return useMatchMedia(XS_MQ)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const mql = window.matchMedia(XS_MQ)
|
|
||||||
const update = () => setIsXs(mql.matches)
|
|
||||||
update()
|
|
||||||
mql.addEventListener("change", update)
|
|
||||||
return () => mql.removeEventListener("change", update)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return isXs
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,12 +3,19 @@
|
|||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
import { persist } from "zustand/middleware"
|
import { persist } from "zustand/middleware"
|
||||||
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
|
||||||
|
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
accessToken: string | null
|
accessToken: string | null
|
||||||
refreshToken: string | null
|
refreshToken: string | null
|
||||||
expiresAt: number | 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
|
logout: () => void
|
||||||
isAuthenticated: () => boolean
|
isAuthenticated: () => boolean
|
||||||
}
|
}
|
||||||
@ -19,14 +26,22 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
login: (accessToken, refreshToken, expiresAt) =>
|
user: null,
|
||||||
set({ accessToken, refreshToken, expiresAt }),
|
login: (accessToken, refreshToken, expiresAt, user = null) =>
|
||||||
|
set({ accessToken, refreshToken, expiresAt, user }),
|
||||||
logout: () =>
|
logout: () =>
|
||||||
set({ accessToken: null, refreshToken: null, expiresAt: null }),
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
expiresAt: null,
|
||||||
|
user: null,
|
||||||
|
}),
|
||||||
isAuthenticated: () => {
|
isAuthenticated: () => {
|
||||||
const { accessToken, expiresAt } = get()
|
const { accessToken, expiresAt, refreshToken } = get()
|
||||||
if (!accessToken || !expiresAt) return false
|
if (!accessToken) return false
|
||||||
return Date.now() < expiresAt
|
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,
|
accessToken: state.accessToken,
|
||||||
refreshToken: state.refreshToken,
|
refreshToken: state.refreshToken,
|
||||||
expiresAt: state.expiresAt,
|
expiresAt: state.expiresAt,
|
||||||
|
user: state.user,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useAuthStore } from "./auth-store"
|
import { useAuthStore } from "./auth-store"
|
||||||
import type { ApiError } from "./types"
|
import type { ApiError } from "./types"
|
||||||
|
import type { PlatformUser } from "@/lib/auth/jwt-claims"
|
||||||
|
|
||||||
export class OfflineError extends Error {
|
export class OfflineError extends Error {
|
||||||
constructor() {
|
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 DEFAULT_RETRIES = 3
|
||||||
const BASE_DELAY = 1000
|
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 {
|
class ApiClient {
|
||||||
constructor(private baseUrl: string) {}
|
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 {
|
private getHeaders(): HeadersInit {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@ -54,7 +91,7 @@ class ApiClient {
|
|||||||
throw new OfflineError()
|
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) {
|
if (opts?.params) {
|
||||||
for (const [key, value] of Object.entries(opts.params)) {
|
for (const [key, value] of Object.entries(opts.params)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
@ -67,6 +104,7 @@ class ApiClient {
|
|||||||
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
|
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
|
||||||
|
|
||||||
let lastError: Error | null = null
|
let lastError: Error | null = null
|
||||||
|
let authRetried = false
|
||||||
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
@ -100,6 +138,13 @@ class ApiClient {
|
|||||||
errorBody?.details
|
errorBody?.details
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (response.status === 401 && !authRetried) {
|
||||||
|
authRetried = true
|
||||||
|
if (await tryRefreshSession()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (response.status >= 400 && response.status < 500) {
|
if (response.status >= 400 && response.status < 500) {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@ -112,7 +157,12 @@ class ApiClient {
|
|||||||
return undefined as T
|
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) {
|
} catch (err) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
|
|
||||||
@ -143,6 +193,10 @@ class ApiClient {
|
|||||||
return this.request<T>("PUT", path, { body })
|
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> {
|
async delete(path: string): Promise<void> {
|
||||||
await this.request<void>("DELETE", path)
|
await this.request<void>("DELETE", path)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,13 +5,34 @@ import { apiClient, OfflineError } from '../client'
|
|||||||
import { enqueue } from '../offline-queue'
|
import { enqueue } from '../offline-queue'
|
||||||
import type { ApiContact } from '../types'
|
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() {
|
export function useCreateContact() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) =>
|
mutationFn: async (vars: { bookId: string; contact: Partial<ApiContact> }) => {
|
||||||
apiClient.post<ApiContact>(`/contacts/books/${vars.bookId}`, vars.contact),
|
const created = await apiClient.post<ApiContact | undefined>(
|
||||||
onSuccess: (_data, vars) => {
|
`/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] })
|
queryClient.invalidateQueries({ queryKey: ['contacts', vars.bookId] })
|
||||||
},
|
},
|
||||||
onError: (err, vars) => {
|
onError: (err, vars) => {
|
||||||
|
|||||||
@ -1,14 +1,49 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiClient, OfflineError } from '../client'
|
import { apiClient, OfflineError } from '../client'
|
||||||
import type { ApiContact, ApiContactSyncResponse } from '../types'
|
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) {
|
export function useContacts(bookId?: string) {
|
||||||
|
const defaultBookId = useDefaultContactBookId()
|
||||||
|
const resolvedBookId = bookId ?? defaultBookId
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['contacts', bookId],
|
queryKey: ['contacts', resolvedBookId],
|
||||||
queryFn: () => apiClient.get<ApiContact[]>(`/contacts/books/${bookId}`),
|
queryFn: () => fetchContactsForBook(resolvedBookId),
|
||||||
enabled: !!bookId,
|
enabled: !!resolvedBookId,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -16,7 +51,12 @@ export function useContacts(bookId?: string) {
|
|||||||
export function useContactBooks() {
|
export function useContactBooks() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['contact-books'],
|
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,
|
staleTime: 10 * 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,23 +2,38 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiClient } from '../client'
|
import { apiClient } from '../client'
|
||||||
|
import { useAuthReady } from '../use-auth-ready'
|
||||||
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
|
import type { ApiFolder, ApiLabel, ApiIdentity } from '../types'
|
||||||
|
|
||||||
export function useFolders(accountId?: string) {
|
export function useFolders(accountId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['folders', accountId],
|
queryKey: ['folders', accountId],
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
apiClient.get<ApiFolder[]>('/mail/folders', { account_id: accountId }),
|
const res = await apiClient.get<ApiFolder[] | { folders: ApiFolder[] }>(
|
||||||
|
'/mail/folders',
|
||||||
|
{ account_id: accountId }
|
||||||
|
)
|
||||||
|
return Array.isArray(res) ? res : (res.folders ?? [])
|
||||||
|
},
|
||||||
enabled: !!accountId,
|
enabled: !!accountId,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLabels() {
|
export function useLabels() {
|
||||||
|
const { ready, authenticated } = useAuthReady()
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['labels'],
|
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,
|
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) {
|
export function useIdentities(accountId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['identities', accountId],
|
queryKey: ['identities', accountId],
|
||||||
queryFn: () =>
|
queryFn: async () => {
|
||||||
apiClient.get<ApiIdentity[]>(`/mail/accounts/${accountId}/identities`),
|
const res = await apiClient.get<ApiIdentity[] | { identities: ApiIdentity[] }>(
|
||||||
|
`/mail/accounts/${accountId}/identities`
|
||||||
|
)
|
||||||
|
return Array.isArray(res) ? res : (res.identities ?? [])
|
||||||
|
},
|
||||||
enabled: !!accountId,
|
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