This commit is contained in:
R3D347HR4Y 2026-05-19 22:20:43 +02:00
parent 77f99d8d8a
commit 9266aa34cd
76 changed files with 4656 additions and 933 deletions

View File

@ -39,9 +39,48 @@
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
/** Fond chrome (layout mail : header, rails, sidebar). */ /** Fond chrome (layout mail : header, rails, sidebar). */
--app-canvas: #fafbfc; --app-canvas: #fafbfc;
--mail-surface: #ffffff;
--mail-surface-elevated: #ffffff;
--mail-surface-muted: #f1f3f4;
--mail-border: #dadce0;
--mail-border-subtle: #eceff1;
--mail-text: #3c4043;
--mail-text-strong: #202124;
--mail-text-muted: #5f6368;
--mail-hover: #f1f3f4;
--mail-active: #e8f0fe;
--mail-row-unread: #ffffff;
--mail-row-read: #f5f5f5;
--mail-row-selected: #e8f0fe;
--mail-row-active-split: #e8f0fe;
--mail-nav-selected: #d3e3fd;
--mail-nav-selected-fg: #202124;
--mail-nav-hover: #f1f3f4;
--mail-nav-drop: #fef7cd;
--mail-invitation: #e8f0fe;
} }
.dark { .dark {
--app-canvas: #202124;
--mail-surface: #2d2e30;
--mail-surface-elevated: #35363a;
--mail-surface-muted: #3c4043;
--mail-border: #5f6368;
--mail-border-subtle: #3c4043;
--mail-text: #e8eaed;
--mail-text-strong: #ffffff;
--mail-text-muted: #9aa0a6;
--mail-hover: #3c4043;
--mail-active: #394457;
--mail-row-unread: #2d2e30;
--mail-row-read: #35363a;
--mail-row-selected: #394457;
--mail-row-active-split: #394457;
--mail-nav-selected: #394457;
--mail-nav-selected-fg: #e8eaed;
--mail-nav-hover: #3c4043;
--mail-nav-drop: #4a4428;
--mail-invitation: #2d3a4d;
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0); --card: oklch(0.145 0 0);
@ -116,6 +155,12 @@
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-app-canvas: var(--app-canvas); --color-app-canvas: var(--app-canvas);
--color-mail-surface: var(--mail-surface);
--color-mail-surface-elevated: var(--mail-surface-elevated);
--color-mail-surface-muted: var(--mail-surface-muted);
--color-mail-border: var(--mail-border);
--color-mail-border-subtle: var(--mail-border-subtle);
--color-mail-invitation: var(--mail-invitation);
} }
@layer base { @layer base {
@ -295,3 +340,262 @@ body {
animation: long-press-ack 0.28s cubic-bezier(0.2, 0.8, 0.2, 1); animation: long-press-ack 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
transform-origin: center; transform-origin: center;
} }
/* ── Mail : fond décoratif plein écran (derrière toute lUI) ── */
html {
background-color: var(--mail-bg-fallback, var(--app-canvas));
}
html::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-color: var(--mail-bg-fallback, transparent);
background-image: var(--mail-bg-layer, none);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
transition: opacity 0.25s ease;
}
html[data-mail-background]:not([data-mail-background='none'])::before {
opacity: 1;
}
html[data-mail-background]:not([data-mail-background='none']) .ultimail-app {
background-color: transparent !important;
}
html[data-mail-background]:not([data-mail-background='none']) .ultimail-app :where(.bg-app-canvas) {
background-color: color-mix(in srgb, var(--app-canvas) 78%, transparent) !important;
}
html[data-mail-background]:not([data-mail-background='none'])
.ultimail-app
:where(.bg-mail-surface, .bg-white) {
background-color: color-mix(in srgb, var(--mail-surface) 88%, transparent) !important;
}
.ultimail-app {
position: relative;
isolation: isolate;
}
/* Lignes de liste */
.bg-mail-row-unread {
background-color: var(--mail-row-unread);
}
.bg-mail-row-read {
background-color: var(--mail-row-read);
}
.bg-mail-row-selected {
background-color: var(--mail-row-selected);
}
.bg-mail-row-active-split {
background-color: var(--mail-row-active-split);
}
.bg-mail-nav-selected {
background-color: var(--mail-nav-selected);
}
.text-mail-nav-selected {
color: var(--mail-nav-selected-fg);
}
.bg-mail-nav-hover {
background-color: var(--mail-nav-hover);
}
.bg-mail-nav-drop {
background-color: var(--mail-nav-drop);
}
.bg-mail-invitation {
background-color: var(--mail-invitation);
}
/* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
html.dark .ultimail-app {
color-scheme: dark;
}
html.dark .ultimail-app :where(.bg-white) {
background-color: var(--mail-surface) !important;
}
html.dark .ultimail-app :where(
.bg-\[\#f1f3f4\],
.bg-\[\#f8f9fa\],
.bg-\[\#fafbfc\],
.bg-\[\#edf2fc\],
.bg-\[\#eaf1fb\],
.bg-\[\#f6f9fe\],
.bg-\[\#f5f5f5\],
.bg-\[\#e8eaed\]
) {
background-color: var(--mail-surface-muted) !important;
}
html.dark .ultimail-app :where(.bg-\[\#e8f0fe\]) {
background-color: var(--mail-active) !important;
}
html.dark .ultimail-app :where([class*='bg-white/']) {
background-color: color-mix(in srgb, var(--mail-surface) 82%, transparent) !important;
}
html.dark .ultimail-app :where(.bg-gray-200) {
background-color: var(--mail-surface-muted) !important;
}
html.dark .ultimail-app :where(.hover\:bg-\[\#f1f3f4\]:hover, .hover\:bg-\[\#f6f9fe\]:hover, .hover\:bg-gray-100:hover) {
background-color: var(--mail-hover) !important;
}
html.dark .ultimail-app :where(.border-gray-200, .border-\[\#dadce0\], .border-\[\#eceff1\]) {
border-color: var(--mail-border-subtle) !important;
}
html.dark .ultimail-app :where(.divide-gray-200 > :not(:last-child), .divide-\[\#eceff1\] > :not(:last-child)) {
border-color: var(--mail-border-subtle) !important;
}
html.dark .ultimail-app :where(.text-gray-900, .text-gray-800, .text-gray-700, .text-\[\#202124\], .text-\[\#3c4043\], .text-\[\#1f1f1f\]) {
color: var(--mail-text) !important;
}
html.dark .ultimail-app :where(.text-gray-600, .text-gray-500, .text-\[\#5f6368\], .text-\[\#444746\]) {
color: var(--mail-text-muted) !important;
}
html.dark .ultimail-app :where(.text-gray-400, .text-\[\#c2c2c2\]) {
color: #80868b !important;
}
html.dark .ultimail-app :where(.shadow-sm, .shadow-lg) {
--tw-shadow-color: rgb(0 0 0 / 0.35);
}
html.dark .ultimail-app :where(input, textarea, select) {
background-color: var(--mail-surface-muted);
color: var(--mail-text);
border-color: var(--mail-border-subtle);
}
html.dark .ultimail-app :where(.tiptap blockquote) {
border-left-color: var(--mail-border);
color: var(--mail-text-muted);
}
html.dark .ultimail-app :where(.tiptap code, .tiptap pre) {
background-color: var(--mail-surface-muted);
}
/* ── Dark : portails Radix & toasts (rendus hors .ultimail-app) ── */
html.dark [data-slot='dropdown-menu-content'],
html.dark [data-slot='dropdown-menu-sub-content'],
html.dark [data-slot='context-menu-content'],
html.dark [data-slot='context-menu-sub-content'],
html.dark [data-slot='popover-content'],
html.dark [data-slot='select-content'],
html.dark [data-slot='menubar-content'] {
background-color: var(--popover) !important;
color: var(--popover-foreground) !important;
border-color: var(--border) !important;
}
html.dark [data-slot='dropdown-menu-item']:focus,
html.dark [data-slot='dropdown-menu-item'][data-highlighted],
html.dark [data-slot='dropdown-menu-sub-trigger']:focus,
html.dark [data-slot='dropdown-menu-sub-trigger'][data-state='open'],
html.dark [data-slot='context-menu-item']:focus,
html.dark [data-slot='context-menu-item'][data-highlighted],
html.dark [data-slot='context-menu-sub-trigger']:focus,
html.dark [data-slot='context-menu-sub-trigger'][data-state='open'] {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html.dark [data-slot='dropdown-menu-separator'],
html.dark [data-slot='context-menu-separator'] {
background-color: var(--border) !important;
}
html.dark .ultimail-app :where(.hover\:bg-gray-50:hover, .hover\:bg-gray-100:hover) {
background-color: var(--mail-nav-hover) !important;
}
html.dark .ultimail-app :where(.bg-\[\#d3e3fd\]) {
background-color: var(--mail-nav-selected) !important;
}
html.dark .ultimail-app :where(.bg-yellow-100) {
background-color: var(--mail-nav-drop) !important;
}
html.dark .ultimail-app :where(.text-\[#0f172a\], .text-\[#0b57d0\]) {
color: var(--foreground) !important;
}
html.dark .ultimail-app :where([data-slot='checkbox']) {
background-color: transparent;
border-color: #9aa0a6;
}
html.dark .ultimail-app :where([data-slot='checkbox'][data-state='checked']) {
background-color: #1a73e8;
border-color: #1a73e8;
}
/* ── Dark : fenêtre de composition ── */
html.dark [data-compose-window] {
color: var(--foreground);
}
html.dark [data-compose-window] :where(.text-\[\#202124\], .text-\[\#3c4043\]) {
color: var(--foreground) !important;
}
html.dark [data-compose-window] :where(.text-\[\#5f6368\], .text-\[\#80868b\]) {
color: var(--muted-foreground) !important;
}
html.dark [data-compose-window] :where(.hover\:bg-\[\#f1f3f4\]:hover, .hover\:bg-\[\#f6f9fe\]:hover) {
background-color: var(--accent) !important;
}
html.dark [data-compose-window] :where(.bg-\[\#e8eaed\], .bg-\[\#e8f0fe\]) {
background-color: var(--accent) !important;
color: var(--accent-foreground) !important;
}
html.dark [data-compose-window] .compose-toolbar :where(.bg-\[\#e8eaed\]) {
background-color: var(--accent) !important;
}
/* Iframes daperçu mail : fond du navigateur, pas blanc par défaut */
html.dark .ultimail-app iframe[title='Contenu du message'],
html.dark .ultimail-app iframe[title='Sujet du message'] {
background: transparent !important;
color-scheme: dark;
}
/* ── Dark : panneau Contacts (formulaires) ── */
html.dark :where([data-contacts-panel] .bg-white) {
background-color: var(--mail-surface) !important;
}
html.dark :where([data-contacts-panel] .text-\[\#1f1f1f\], [data-contacts-panel] .text-\[\#3c4043\]) {
color: var(--foreground) !important;
}
html.dark :where([data-contacts-panel] .text-\[\#5f6368\]) {
color: var(--muted-foreground) !important;
}
html.dark :where([data-contacts-panel] .hover\:bg-gray-100:hover, [data-contacts-panel] .hover\:bg-\[\#f5f5f5\]:hover) {
background-color: var(--accent) !important;
}
html.dark :where([data-contacts-panel] .border-gray-200, [data-contacts-panel] .border-gray-300) {
border-color: var(--border) !important;
}

View File

@ -2,6 +2,7 @@ import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google' import { Geist, Geist_Mono } from 'next/font/google'
import { Analytics } from '@vercel/analytics/next' import { Analytics } from '@vercel/analytics/next'
import './globals.css' import './globals.css'
import { ThemeInitScript } from '@/components/theme-init-script'
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] });
@ -27,8 +28,9 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en" className="h-dvh max-h-dvh overflow-hidden bg-white"> <html lang="fr" suppressHydrationWarning className="h-dvh max-h-dvh overflow-hidden">
<body className="h-dvh max-h-dvh overflow-hidden font-sans antialiased touch-manipulation"> <body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
{children} {children}
{process.env.NODE_ENV === 'production' && <Analytics />} {process.env.NODE_ENV === 'production' && <Analytics />}
</body> </body>

View File

@ -7,13 +7,13 @@ import {
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useState, useState,
type CSSProperties,
} from "react" } from "react"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view" import { useMailSplitView } from "@/hooks/use-mail-split-view"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { Toaster } from "sonner" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import { MailToaster } from "@/components/gmail/mail-toaster"
import { useRouter, usePathname } from "next/navigation" import { useRouter, usePathname } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar" import { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header" import { Header } from "@/components/gmail/header"
@ -35,6 +35,9 @@ import {
type MailRouteState, type MailRouteState,
} from "@/lib/mail-url" } from "@/lib/mail-url"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider"
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
function segmentsFromPathname(pathname: string | null): string[] | undefined { function segmentsFromPathname(pathname: string | null): string[] | undefined {
if (!pathname?.startsWith("/mail")) return undefined if (!pathname?.startsWith("/mail")) return undefined
@ -70,6 +73,7 @@ function MailAppInner() {
const [folderUnreadCounts, setFolderUnreadCounts] = useState< const [folderUnreadCounts, setFolderUnreadCounts] = useState<
Record<string, number> Record<string, number>
>({}) >({})
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
const navigateRoute = useCallback( const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => { (patch: Partial<MailRouteState>) => {
@ -125,7 +129,7 @@ function MailAppInner() {
<div <div
className={cn( className={cn(
"relative flex min-h-0 flex-1 gap-0 overflow-hidden pl-0 pr-0", "relative flex min-h-0 flex-1 gap-0 overflow-hidden pl-0 pr-0",
splitView ? "bg-white p-0" : "bg-app-canvas pb-1 pt-1 sm:gap-1 sm:pl-1" splitView ? "bg-mail-surface p-0" : "bg-app-canvas sm:gap-1 sm:pb-1 sm:pl-1 sm:pt-1"
)} )}
> >
{!sidebarCollapsed && touchNav && ( {!sidebarCollapsed && touchNav && (
@ -154,8 +158,10 @@ function MailAppInner() {
/> />
<main <main
className={cn( className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden bg-white", "flex min-h-0 flex-1 flex-col overflow-hidden bg-mail-surface",
splitView ? "rounded-none shadow-none" : "rounded-none shadow-sm sm:rounded-2xl" splitView
? "rounded-none shadow-none"
: "rounded-none shadow-none sm:rounded-2xl sm:shadow-sm"
)} )}
> >
<Suspense> <Suspense>
@ -169,6 +175,7 @@ function MailAppInner() {
onMailRouteNavigate={navigateRoute} onMailRouteNavigate={navigateRoute}
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
onFolderUnreadCountsChange={setFolderUnreadCounts} onFolderUnreadCountsChange={setFolderUnreadCounts}
onXsViewChromeChange={setXsViewChrome}
/> />
</Suspense> </Suspense>
</main> </main>
@ -186,6 +193,7 @@ function MailAppInner() {
<MobileBottomBar <MobileBottomBar
sidebarOpen={!sidebarCollapsed} sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)} onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
xsViewChrome={xsViewChrome}
/> />
) : null} ) : null}
</div> </div>
@ -211,6 +219,7 @@ export function MailAppShell({
}, []) }, [])
return ( return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ComposeProvider> <ComposeProvider>
<ScheduledMailProvider> <ScheduledMailProvider>
<EmailDragProvider> <EmailDragProvider>
@ -224,24 +233,14 @@ export function MailAppShell({
> >
<MailAppInner /> <MailAppInner />
</Suspense> </Suspense>
<MailThemeApplier />
<QuickSettingsRoot />
<MoveDragIndicator /> <MoveDragIndicator />
<ComposeModalManager /> <ComposeModalManager />
<Toaster <MailToaster />
position="bottom-right"
offset={{ right: 16, bottom: 16 }}
mobileOffset={{ right: 16, left: 16, bottom: 16 }}
style={
{
// Default Sonner --width is 356px; widen and clamp so wide custom toasts stay on-screen.
["--width"]: "min(420px, calc(100vw - 2.5rem))",
} as CSSProperties
}
theme="light"
richColors
closeButton
/>
</EmailDragProvider> </EmailDragProvider>
</ScheduledMailProvider> </ScheduledMailProvider>
</ComposeProvider> </ComposeProvider>
</ThemeProvider>
) )
} }

View File

@ -0,0 +1,10 @@
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>
)
}

View File

@ -0,0 +1,57 @@
"use client"
import { useState } from "react"
import type { UserAccount } from "@/lib/accounts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
import { cn } from "@/lib/utils"
interface AccountAvatarProps {
account: UserAccount
size?: "sm" | "md" | "lg"
className?: string
}
const sizeClasses = {
sm: "size-8 text-sm",
md: "size-10 text-base",
lg: "size-20 text-3xl",
} as const
export function AccountAvatar({
account,
size = "md",
className,
}: AccountAvatarProps) {
const [imageFailed, setImageFailed] = useState(false)
const initial = senderInitial(account.displayName)
const color = avatarColor(account.displayName)
if (account.avatarUrl && !imageFailed) {
return (
<img
src={account.avatarUrl}
alt=""
className={cn(
"shrink-0 rounded-full object-cover",
sizeClasses[size],
className,
)}
onError={() => setImageFailed(true)}
/>
)
}
return (
<div
className={cn(
"flex shrink-0 items-center justify-center rounded-full font-medium text-white",
sizeClasses[size],
className,
)}
style={{ backgroundColor: color }}
aria-hidden
>
{initial}
</div>
)
}

View File

@ -0,0 +1,219 @@
"use client"
import { useEffect, useRef, type RefObject } from "react"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { Camera, ChevronDown, ChevronUp, LogOut, Plus, X } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { Button } from "@/components/ui/button"
import { MOCK_USER_ACCOUNTS, STORAGE_USAGE } from "@/lib/accounts/mock-accounts"
import type { UserAccount } from "@/lib/accounts/types"
import {
useAccountStore,
useActiveAccount,
} from "@/lib/stores/account-store"
addCollection(mdiIcons)
interface AccountSwitcherDropdownProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Clicks inside this node (e.g. avatar trigger) do not close the panel. */
containerRef: RefObject<HTMLElement | null>
}
function AccountRow({
account,
onSelect,
}: {
account: UserAccount
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-accent"
>
<AccountAvatar account={account} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{account.displayName}
</p>
<p className="truncate text-xs text-muted-foreground">{account.email}</p>
</div>
</button>
)
}
export function AccountSwitcherDropdown({
open,
onOpenChange,
containerRef,
}: AccountSwitcherDropdownProps) {
const panelRef = useRef<HTMLDivElement>(null)
const activeAccount = useActiveAccount()
const activeAccountId = useAccountStore((s) => s.activeAccountId)
const otherAccountsExpanded = useAccountStore((s) => s.otherAccountsExpanded)
const setActiveAccount = useAccountStore((s) => s.setActiveAccount)
const toggleOtherAccountsExpanded = useAccountStore(
(s) => s.toggleOtherAccountsExpanded,
)
const signOutAll = useAccountStore((s) => s.signOutAll)
const otherAccounts = MOCK_USER_ACCOUNTS.filter((a) => a.id !== activeAccountId)
useEffect(() => {
if (!open) return
function handleClickOutside(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
onOpenChange(false)
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") onOpenChange(false)
}
document.addEventListener("mousedown", handleClickOutside)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
document.removeEventListener("keydown", handleEscape)
}
}, [open, onOpenChange, containerRef])
if (!open) return null
const handleSelectAccount = (id: string) => {
setActiveAccount(id)
onOpenChange(false)
}
return (
<div
ref={panelRef}
role="dialog"
aria-label="Comptes connectés"
className="absolute right-0 top-12 z-50 w-[min(100vw-1rem,356px)] overflow-hidden rounded-[28px] bg-mail-surface-elevated text-foreground shadow-[0_4px_16px_rgba(0,0,0,0.35)] border border-border"
>
{/* Current account header */}
<div className="relative px-4 pb-3 pt-4">
<p className="truncate pr-8 text-center text-sm text-foreground">
{activeAccount.email}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 size-8 text-muted-foreground hover:bg-accent"
aria-label="Fermer"
onClick={() => onOpenChange(false)}
>
<X className="size-4" />
</Button>
<div className="mt-4 flex flex-col items-center">
<div className="relative">
<AccountAvatar account={activeAccount} size="lg" />
<span className="absolute bottom-0 right-0 flex size-7 items-center justify-center rounded-full border-2 border-border bg-mail-surface text-muted-foreground shadow-sm">
<Camera className="size-3.5" aria-hidden />
</span>
</div>
<h2 className="mt-3 text-xl font-normal text-foreground">
Bonjour {activeAccount.firstName} !
</h2>
<Button
type="button"
variant="outline"
className="mt-4 h-9 rounded-full border-border bg-transparent px-5 text-sm font-medium text-primary hover:bg-accent hover:text-primary"
>
Gérer votre compte Google
</Button>
</div>
</div>
{/* Other accounts + actions */}
<div className="px-3 pb-3">
<div className="overflow-hidden rounded-2xl border border-border bg-mail-surface">
<button
type="button"
onClick={toggleOtherAccountsExpanded}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm text-foreground hover:bg-accent"
>
<span>
{otherAccountsExpanded
? "Masquer plus de comptes"
: "Afficher plus de comptes"}
</span>
{otherAccountsExpanded ? (
<ChevronUp className="size-5 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-5 text-muted-foreground" aria-hidden />
)}
</button>
{otherAccountsExpanded && (
<div className="border-t border-border px-1 pb-1 pt-0.5">
{otherAccounts.map((account) => (
<AccountRow
key={account.id}
account={account}
onSelect={() => handleSelectAccount(account.id)}
/>
))}
</div>
)}
<div className="border-t border-border px-1 py-1">
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm text-foreground transition-colors hover:bg-accent"
>
<span className="flex size-8 items-center justify-center">
<Plus className="size-5 text-primary" aria-hidden />
</span>
Ajouter un compte
</button>
<button
type="button"
onClick={() => {
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"
>
<span className="flex size-8 items-center justify-center">
<LogOut className="size-5 text-muted-foreground" aria-hidden />
</span>
Se déconnecter de tous les comptes
</button>
</div>
</div>
{/* Storage */}
<div className="mt-3 flex items-center gap-2 rounded-full border border-border bg-mail-surface px-4 py-2.5">
<Icon
icon="mdi:alert-circle"
className="size-5 shrink-0 text-[#e8710a]"
aria-hidden
/>
<span className="text-sm text-foreground">
{STORAGE_USAGE.percentUsed} % utilisé(s) sur {STORAGE_USAGE.totalLabel}
</span>
</div>
{/* Footer links */}
<div className="mt-4 flex flex-wrap items-center justify-center gap-1 pb-2 text-center text-xs text-muted-foreground">
<button type="button" className="hover:underline">
Règles de confidentialité
</button>
<span aria-hidden>·</span>
<button type="button" className="hover:underline">
Conditions d&apos;utilisation
</button>
</div>
</div>
</div>
)
}

View File

@ -11,6 +11,7 @@ import {
} from "@/lib/calendar-invitation" } from "@/lib/calendar-invitation"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos" import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { MAIL_INVITATION_CARD_CLASS } from "@/lib/mail-chrome-classes"
function attendeeDisplayList(inv: ParsedCalendarInvitation): { function attendeeDisplayList(inv: ParsedCalendarInvitation): {
organizerLine?: string organizerLine?: string
@ -40,7 +41,7 @@ const RSVP_BTN =
"rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#1557b0]" "rounded-full bg-[#1a73e8] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#1557b0]"
const RSVP_SECONDARY = const RSVP_SECONDARY =
"rounded-full border border-[#dadce0] bg-[#e8f0fe] px-4 py-2 text-sm font-medium text-[#1a73e8] transition-colors hover:bg-[#d2e3fc]" "rounded-full border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary transition-colors hover:bg-accent"
export function CalendarInvitationPreview({ export function CalendarInvitationPreview({
invitation, invitation,
@ -63,35 +64,35 @@ export function CalendarInvitationPreview({
return ( return (
<div <div
className={cn( className={cn(
"mx-6 mb-4 rounded-xl border border-[#dadce0] bg-[#e8f0fe]/90 px-4 py-3 shadow-sm", MAIL_INVITATION_CARD_CLASS,
className className
)} )}
> >
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4"> <div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between md:gap-4">
<div className="min-w-0 flex-1 space-y-2"> <div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-[#5f6368]"> <div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Icon icon={confIcon} className="size-5 shrink-0" aria-hidden /> <Icon icon={confIcon} className="size-5 shrink-0" aria-hidden />
<InvitationTimeChipText <InvitationTimeChipText
start={invitation.start} start={invitation.start}
end={invitation.end} end={invitation.end}
/> />
</div> </div>
<h2 className="text-xl font-normal leading-snug text-[#202124]"> <h2 className="text-xl font-normal leading-snug text-foreground">
{invitation.summary} {invitation.summary}
</h2> </h2>
{organizerLine && ( {organizerLine && (
<p className="text-sm text-[#3c4043]">{organizerLine}</p> <p className="text-sm text-foreground/90">{organizerLine}</p>
)} )}
{othersLine && ( {othersLine && (
<p className="flex flex-wrap items-start gap-1.5 text-sm text-[#3c4043]"> <p className="flex flex-wrap items-start gap-1.5 text-sm text-foreground/90">
<Users className="mt-0.5 size-4 shrink-0 text-[#5f6368]" aria-hidden /> <Users className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />
<span>{othersLine}</span> <span>{othersLine}</span>
</p> </p>
)} )}
</div> </div>
<div className="flex shrink-0 flex-row items-start gap-3 md:flex-col md:items-end"> <div className="flex shrink-0 flex-row items-start gap-3 md:flex-col md:items-end">
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-[#dadce0] bg-white shadow-sm"> <div className="flex size-12 shrink-0 items-center justify-center rounded-lg border border-border bg-mail-surface shadow-sm">
<img <img
src="/agenda-mark.svg" src="/agenda-mark.svg"
alt="" alt=""
@ -99,8 +100,8 @@ export function CalendarInvitationPreview({
aria-hidden aria-hidden
/> />
</div> </div>
<div className="min-w-0 text-right text-sm leading-snug text-[#5f6368]"> <div className="min-w-0 text-right text-sm leading-snug text-muted-foreground">
<p className="font-medium text-[#3c4043]">Dans votre agenda</p> <p className="font-medium text-foreground">Dans votre agenda</p>
<p className="mt-0.5">Aucun autre événement à cette date</p> <p className="mt-0.5">Aucun autre événement à cette date</p>
</div> </div>
</div> </div>
@ -122,14 +123,14 @@ export function CalendarInvitationPreview({
</button> </button>
<button <button
type="button" type="button"
className="ml-auto flex size-10 items-center justify-center rounded-full border border-[#dadce0] bg-[#e8f0fe] text-[#5f6368] hover:bg-[#d2e3fc] md:ml-0" className="ml-auto flex size-10 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground hover:bg-accent md:ml-0"
aria-label="Plus doptions" aria-label="Plus doptions"
> >
<MoreVertical className="size-[18px]" strokeWidth={1.5} /> <MoreVertical className="size-[18px]" strokeWidth={1.5} />
</button> </button>
</div> </div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[#dadce0]/60 pt-3 text-xs text-[#5f6368]"> <div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-border/60 pt-3 text-xs text-muted-foreground">
<span>Daprès cet e-mail</span> <span>Daprès cet e-mail</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>Correct ?</span> <span>Correct ?</span>

View File

@ -3,6 +3,9 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react" import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react" import { Icon } from "@iconify/react"
import { inboxTabActiveAccentColor } from "@/lib/inbox-category-tabs" import { inboxTabActiveAccentColor } from "@/lib/inbox-category-tabs"
import {
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
} from "@/lib/mail-chrome-classes"
import { inboxTabShowsInactiveMeta } from "@/lib/mail-url" import { inboxTabShowsInactiveMeta } from "@/lib/mail-url"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -17,7 +20,7 @@ const TAB_ICON_CLASS = "h-4 w-4 shrink-0"
function inboxTabBadgeDotClass(badgeColor: string) { function inboxTabBadgeDotClass(badgeColor: string) {
return cn( return cn(
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white", "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface",
badgeColor badgeColor
) )
} }
@ -130,7 +133,7 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
"relative z-[1] flex min-h-10 cursor-pointer items-center justify-center px-1", "relative z-[1] flex min-h-10 cursor-pointer items-center justify-center px-1",
"transition-colors duration-200 motion-reduce:transition-none", "transition-colors duration-200 motion-reduce:transition-none",
isActive ? "shrink-0 flex-none" : "min-w-0 flex-1 overflow-hidden", isActive ? "shrink-0 flex-none" : "min-w-0 flex-1 overflow-hidden",
!isActive && "hover:bg-[#f1f3f4]" !isActive && "hover:bg-mail-nav-hover"
)} )}
> >
<div <div
@ -145,7 +148,8 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
className={cn( className={cn(
TAB_ICON_CLASS, TAB_ICON_CLASS,
"transition-colors duration-200 motion-reduce:transition-none", "transition-colors duration-200 motion-reduce:transition-none",
!isActive && "text-[#5f6368]" MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-muted-foreground"
)} )}
style={accentColor ? { color: accentColor } : undefined} style={accentColor ? { color: accentColor } : undefined}
aria-hidden aria-hidden
@ -159,8 +163,12 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
</div> </div>
{isActive ? ( {isActive ? (
<span <span
className="shrink-0 whitespace-nowrap text-[13px] font-semibold leading-tight" className={cn(
style={{ color: accentColor }} "shrink-0 whitespace-nowrap text-[13px] font-semibold leading-tight",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
accentColor ? undefined : "text-foreground"
)}
style={accentColor ? { color: accentColor } : undefined}
> >
{tab.label} {tab.label}
</span> </span>

View File

@ -81,6 +81,14 @@ import {
import { toast } from "sonner" import { toast } from "sonner"
import { showPendingSendToast } from "@/lib/pending-send-toast" import { showPendingSendToast } from "@/lib/pending-send-toast"
import { cn, getNextLocalWallClockDate } from "@/lib/utils" import { cn, getNextLocalWallClockDate } from "@/lib/utils"
import {
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_POPOVER_CLASS,
MAIL_COMPOSE_TITLEBAR_CLASS,
MAIL_ICON_BTN,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -101,13 +109,14 @@ import data from "@emoji-mart/data"
const LazyPicker = lazy(() => import("@emoji-mart/react")) const LazyPicker = lazy(() => import("@emoji-mart/react"))
function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) { function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return ( return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-[#5f6368]">Chargement</div>}> <Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<LazyPicker <LazyPicker
data={data} data={data}
onEmojiSelect={onSelect} onEmojiSelect={onSelect}
locale="fr" locale="fr"
theme="light" theme={resolvedTheme === "dark" ? "dark" : "light"}
previewPosition="none" previewPosition="none"
skinTonePosition="search" skinTonePosition="search"
set="native" set="native"
@ -349,7 +358,7 @@ function RecipientField({
/> />
</div> </div>
{showSuggestions && suggestions.length > 0 && ( {showSuggestions && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[240px] overflow-y-auto rounded-lg border border-[#dadce0] bg-white py-1 shadow-lg"> <div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[240px] overflow-y-auto rounded-lg border border-border bg-popover py-1 text-popover-foreground shadow-lg">
{suggestions.map((s, idx) => ( {suggestions.map((s, idx) => (
<button <button
key={s.email} key={s.email}
@ -421,19 +430,19 @@ function AlignmentDropdown({
> >
<DropdownMenuItem <DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("left").run()} onSelect={() => editor.chain().focus().setTextAlign("left").run()}
className={cn(editor.isActive({ textAlign: "left" }) && "bg-[#e8eaed]")} className={cn(editor.isActive({ textAlign: "left" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
> >
<AlignLeft className="h-4 w-4" /> Aligner à gauche <AlignLeft className="h-4 w-4" /> Aligner à gauche
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("center").run()} onSelect={() => editor.chain().focus().setTextAlign("center").run()}
className={cn(editor.isActive({ textAlign: "center" }) && "bg-[#e8eaed]")} className={cn(editor.isActive({ textAlign: "center" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
> >
<AlignCenter className="h-4 w-4" /> Centrer <AlignCenter className="h-4 w-4" /> Centrer
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("right").run()} onSelect={() => editor.chain().focus().setTextAlign("right").run()}
className={cn(editor.isActive({ textAlign: "right" }) && "bg-[#e8eaed]")} className={cn(editor.isActive({ textAlign: "right" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
> >
<AlignRight className="h-4 w-4" /> Aligner à droite <AlignRight className="h-4 w-4" /> Aligner à droite
</DropdownMenuItem> </DropdownMenuItem>
@ -649,7 +658,7 @@ function FormattingToolbar({
const sep = <span className="mx-0.5 h-5 w-px bg-[#dadce0]" aria-hidden /> const sep = <span className="mx-0.5 h-5 w-px bg-[#dadce0]" aria-hidden />
return ( return (
<div className="flex flex-wrap items-center border-t border-[#eef0f2] bg-[#f8f9fa] px-1 py-1"> <div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
{/* Undo / Redo */} {/* Undo / Redo */}
<button <button
type="button" type="button"
@ -792,7 +801,7 @@ function EmojiButton({
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className={cn("w-auto border-0 p-0 shadow-xl", COMPOSE_PORTAL_Z)} className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<EmojiPicker onSelect={handleSelect} /> <EmojiPicker onSelect={handleSelect} />
@ -912,25 +921,25 @@ function LinkButton({
<PopoverContent <PopoverContent
align="start" align="start"
side="top" side="top"
className={cn("w-[340px] p-3", COMPOSE_PORTAL_Z)} className={cn("w-[340px]", MAIL_COMPOSE_POPOVER_CLASS, COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
<div className="text-sm font-medium text-[#202124]"> <div className="text-sm font-medium text-foreground">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"} {isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-[#5f6368]">Texte à afficher</label> <label className="text-xs text-muted-foreground">Texte à afficher</label>
<input <input
type="text" type="text"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="Texte du lien" placeholder="Texte du lien"
className="h-8 rounded border border-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]" className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
/> />
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs text-[#5f6368]">URL</label> <label className="text-xs text-muted-foreground">URL</label>
<input <input
type="text" type="text"
value={url} value={url}
@ -942,7 +951,7 @@ function LinkButton({
handleInsert() handleInsert()
} }
}} }}
className="h-8 rounded border border-[#dadce0] bg-white px-2 text-sm text-[#202124] outline-none focus:border-[#1a73e8] focus:ring-1 focus:ring-[#1a73e8]" className="h-8 rounded border border-border bg-mail-surface px-2 text-sm text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring"
autoFocus autoFocus
/> />
</div> </div>
@ -962,7 +971,7 @@ function LinkButton({
<button <button
type="button" type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-[#5f6368] hover:bg-[#f1f3f4] transition-colors" className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
> >
Annuler Annuler
</button> </button>
@ -1028,7 +1037,7 @@ function SignatureButton({
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
side="top" side="top"
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)} className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
> >
<DropdownMenuItem <DropdownMenuItem
onSelect={(e) => { onSelect={(e) => {
@ -1045,7 +1054,7 @@ function SignatureButton({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => replaceSignature(null)} onSelect={() => replaceSignature(null)}
className={cn("gap-2", !compose.signatureId && "bg-[#e8eaed]")} className={cn("gap-2", !compose.signatureId && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
> >
<span className="flex h-4 w-4 items-center justify-center"> <span className="flex h-4 w-4 items-center justify-center">
{!compose.signatureId && <span className="text-xs"></span>} {!compose.signatureId && <span className="text-xs"></span>}
@ -1056,7 +1065,7 @@ function SignatureButton({
<DropdownMenuItem <DropdownMenuItem
key={sig.id} key={sig.id}
onSelect={() => replaceSignature(sig.id)} onSelect={() => replaceSignature(sig.id)}
className={cn("gap-2", compose.signatureId === sig.id && "bg-[#e8eaed]")} className={cn("gap-2", compose.signatureId === sig.id && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
> >
<span className="flex h-4 w-4 items-center justify-center"> <span className="flex h-4 w-4 items-center justify-center">
{compose.signatureId === sig.id && <span className="text-xs"></span>} {compose.signatureId === sig.id && <span className="text-xs"></span>}
@ -1689,10 +1698,11 @@ export function ComposeWindow({
const modalContent = ( const modalContent = (
<div <div
data-compose-window
className={cn( className={cn(
"relative flex flex-col overflow-hidden bg-white", "relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
isInline isInline
? "min-h-[360px] w-full rounded-xl border border-[#dadce0] shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]" ? "min-h-[360px] w-full rounded-xl border border-border shadow-none transition-shadow focus-within:shadow-[0_1px_4px_rgba(60,64,67,0.12)]"
: isXsSheet : isXsSheet
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none" ? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
: cn( : cn(
@ -1747,7 +1757,7 @@ export function ComposeWindow({
{isInline ? ( {isInline ? (
<div ref={inlineRecipientShellRef} className="flex shrink-0 flex-col"> <div ref={inlineRecipientShellRef} className="flex shrink-0 flex-col">
<div <div
className="flex h-10 shrink-0 items-center gap-2 bg-white px-2" className="flex h-10 shrink-0 items-center gap-2 bg-mail-surface px-2"
title={ title={
compose.threading compose.threading
? `In-Reply-To: ${compose.threading.inReplyTo}\nReferences: ${compose.threading.references.join(" ")}` ? `In-Reply-To: ${compose.threading.inReplyTo}\nReferences: ${compose.threading.references.join(" ")}`
@ -1864,17 +1874,17 @@ export function ComposeWindow({
) : isXsSheet ? ( ) : isXsSheet ? (
<div <div
className={cn( className={cn(
"flex h-11 shrink-0 items-center border-b border-[#dadce0] bg-[#f2f6fc] px-3", "flex h-11 shrink-0 items-center border-b border-[#dadce0] bg-[#f2f6fc] dark:border-zinc-700 dark:bg-zinc-800 px-3",
"pt-[max(_0.25rem,env(safe-area-inset-top))]" "pt-[max(_0.25rem,env(safe-area-inset-top))]"
)} )}
> >
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]"> <span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043] dark:text-zinc-100">
{titleText} {titleText}
</span> </span>
<button <button
type="button" type="button"
onClick={handleClose} onClick={handleClose}
className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5" className="flex h-8 w-8 items-center justify-center rounded-full text-[#5f6368] dark:text-zinc-400 hover:text-[#202124] dark:hover:text-zinc-100 hover:bg-black/5 dark:hover:bg-white/10"
title="Fermer" title="Fermer"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -1884,10 +1894,10 @@ export function ComposeWindow({
<> <>
{/* Title bar */} {/* Title bar */}
<div <div
className="flex h-10 shrink-0 cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3" className={MAIL_COMPOSE_TITLEBAR_CLASS}
onClick={() => toggleMinimize(compose.id)} onClick={() => toggleMinimize(compose.id)}
> >
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]"> <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText} {titleText}
</span> </span>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
@ -1966,14 +1976,14 @@ export function ComposeWindow({
{compose.attachments.map((att) => ( {compose.attachments.map((att) => (
<div <div
key={att.id} key={att.id}
className="flex items-center gap-2 rounded-lg border border-[#dadce0] bg-[#f8f9fa] px-3 py-1.5" className="flex items-center gap-2 rounded-lg border border-border bg-muted px-3 py-1.5"
> >
{att.type.startsWith("image/") ? ( {att.type.startsWith("image/") ? (
<ImageIcon className="h-4 w-4 shrink-0 text-[#1a73e8]" /> <ImageIcon className="h-4 w-4 shrink-0 text-[#1a73e8]" />
) : ( ) : (
<Paperclip className="h-4 w-4 shrink-0 text-[#5f6368]" /> <Paperclip className="h-4 w-4 shrink-0 text-[#5f6368]" />
)} )}
<span className="min-w-0 flex-1 truncate text-sm text-[#3c4043]"> <span className="min-w-0 flex-1 truncate text-sm text-foreground">
{att.name} {att.name}
</span> </span>
<span className="shrink-0 text-xs text-[#80868b]"> <span className="shrink-0 text-xs text-[#80868b]">
@ -2182,10 +2192,13 @@ export function ComposeWindow({
if (compose.minimized && !isInline && !isXsSheet) { if (compose.minimized && !isInline && !isXsSheet) {
return ( return (
<div <div
className="flex h-9 w-[280px] cursor-pointer items-center rounded-t-lg bg-[#f2f6fc] px-3 shadow-lg transition-shadow hover:shadow-xl" className={cn(
MAIL_COMPOSE_TITLEBAR_CLASS,
"h-9 w-[280px] cursor-pointer shadow-lg transition-shadow hover:shadow-xl"
)}
onClick={() => toggleMinimize(compose.id)} onClick={() => toggleMinimize(compose.id)}
> >
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[#3c4043]"> <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText} {titleText}
</span> </span>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
@ -2195,7 +2208,7 @@ export function ComposeWindow({
e.stopPropagation() e.stopPropagation()
toggleMaximize(compose.id) toggleMaximize(compose.id)
}} }}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5" className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
> >
<Maximize2 className="h-3.5 w-3.5" /> <Maximize2 className="h-3.5 w-3.5" />
</button> </button>
@ -2205,7 +2218,7 @@ export function ComposeWindow({
e.stopPropagation() e.stopPropagation()
handleClose() handleClose()
}} }}
className="flex h-6 w-6 items-center justify-center rounded-full text-[#5f6368] hover:text-[#202124] hover:bg-black/5" className={cn("flex h-6 w-6 items-center justify-center rounded-full", MAIL_ICON_BTN)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>

View File

@ -170,7 +170,7 @@ export function ContactHoverCard({
align={align} align={align}
sideOffset={8} sideOffset={8}
className={cn( className={cn(
"min-w-[380px] w-max max-w-[min(440px,calc(100vw-24px))] rounded-2xl border border-[#e8eaed] bg-white p-0 shadow-lg", "min-w-[380px] w-max max-w-[min(440px,calc(100vw-24px))] rounded-2xl border border-border bg-popover p-0 text-popover-foreground shadow-lg",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 outline-hidden" "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 outline-hidden"
)} )}
> >
@ -220,21 +220,21 @@ export function ContactHoverCard({
</button> </button>
<button <button
type="button" type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Message" aria-label="Message"
> >
<MessageSquare className="h-[18px] w-[18px]" strokeWidth={1.5} /> <MessageSquare className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button> </button>
<button <button
type="button" type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Visioconférence" aria-label="Visioconférence"
> >
<Video className="h-[18px] w-[18px]" strokeWidth={1.5} /> <Video className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button> </button>
<button <button
type="button" type="button"
className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-[#dadce0] bg-white text-[#5f6368] transition-colors hover:bg-[#f1f3f4]" className="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-border bg-mail-surface text-muted-foreground transition-colors hover:bg-accent"
aria-label="Planifier" aria-label="Planifier"
> >
<Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} /> <Calendar className="h-[18px] w-[18px]" strokeWidth={1.5} />

View File

@ -5,6 +5,16 @@ import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
export function AddCoordinatesView() { export function AddCoordinatesView() {
const { getCoordinateSuggestions, updateContact } = useContactsStore() const { getCoordinateSuggestions, updateContact } = useContactsStore()
@ -32,21 +42,18 @@ export function AddCoordinatesView() {
return ( return (
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-normal text-[#1f1f1f]"> <h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Ajouter des coordonnées ({visible.length}) Ajouter des coordonnées ({visible.length})
</h3> </h3>
{visible.length > 0 && ( {visible.length > 0 && (
<Button <Button onClick={handleAddAll} className={CONTACTS_PRIMARY_BTN_CLASS}>
onClick={handleAddAll}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
Ajouter tous les détails Ajouter tous les détails
</Button> </Button>
)} )}
</div> </div>
{visible.length === 0 && ( {visible.length === 0 && (
<p className="py-8 text-center text-sm text-[#5f6368]"> <p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucune suggestion disponible Aucune suggestion disponible
</p> </p>
)} )}
@ -60,8 +67,8 @@ export function AddCoordinatesView() {
const initial = senderInitial(name) const initial = senderInitial(name)
return ( return (
<div key={contact.id} className="rounded-xl border border-gray-200 p-5"> <div key={contact.id} className={CONTACTS_PAGE_CARD_CLASS}>
<p className="mb-2 text-xs font-medium text-[#5f6368]">Contact à modifier</p> <p className={cn("mb-2 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Contact à modifier</p>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{contact.avatarUrl ? ( {contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" /> <img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
@ -74,32 +81,30 @@ export function AddCoordinatesView() {
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-[#1f1f1f]">{name}</p> <p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
{contact.emails[0] && ( {contact.emails[0] && (
<p className="truncate text-xs text-[#5f6368]">{contact.emails[0].value}</p> <p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
)} )}
{contact.phones[0] && ( {contact.phones[0] && (
<p className="truncate text-xs text-[#5f6368]">{contact.phones[0].value} ({contact.phones[0].label})</p> <p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
{contact.phones[0].value} ({contact.phones[0].label})
</p>
)} )}
</div> </div>
</div> </div>
<div className="mt-3 border-t border-gray-100 pt-3"> <div className={CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS}>
<p className="text-xs font-medium text-[#5f6368]">Détails à ajouter</p> <p className={cn("text-xs font-medium", CONTACTS_MUTED_TEXT)}>Détails à ajouter</p>
<p className="mt-1 text-sm text-[#1f1f1f]">{suggestedValue}</p> <p className={cn("mt-1 text-sm", CONTACTS_HEADING_TEXT)}>{suggestedValue}</p>
</div> </div>
<div className="mt-4 flex items-center justify-end gap-3"> <div className="mt-4 flex items-center justify-end gap-3">
<button <button type="button" onClick={() => handleIgnore(contact.id)} className={CONTACTS_PAGE_LINK_BTN_CLASS}>
type="button"
onClick={() => handleIgnore(contact.id)}
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
>
Ignorer Ignorer
</button> </button>
<Button <Button
onClick={() => handleAdd(contact.id, suggestedField, suggestedValue)} onClick={() => handleAdd(contact.id, suggestedField, suggestedValue)}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]" className={CONTACTS_PRIMARY_BTN_CLASS}
> >
Ajouter Ajouter
</Button> </Button>

View File

@ -10,6 +10,12 @@ import {
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseBulkContactText } from "@/lib/contacts/import-parsers" import { parseBulkContactText } from "@/lib/contacts/import-parsers"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_TEXTAREA_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface BulkCreateDialogProps { interface BulkCreateDialogProps {
open: boolean open: boolean
@ -37,28 +43,31 @@ export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreat
<DialogTitle>Créer plusieurs contacts</DialogTitle> <DialogTitle>Créer plusieurs contacts</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<p className="text-sm text-[#5f6368]"> <p className={cn("text-sm", CONTACTS_MUTED_TEXT)}>
Ajoutez des noms, des adresses e-mail ou les deux Ajoutez des noms, des adresses e-mail ou les deux
</p> </p>
<textarea <textarea
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Exemples : Andrea Fisher, weaver.blake98@gmail.com, Elisa Beckett <elisa.beckett@gmail.com>" placeholder="Exemples : Andrea Fisher, weaver.blake98@gmail.com, Elisa Beckett <elisa.beckett@gmail.com>"
className="h-24 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500" className={CONTACTS_PAGE_TEXTAREA_CLASS}
/> />
<p className="text-xs text-[#5f6368]"> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
Vous avez un fichier CSV ou vCard ?{" "} Vous avez un fichier CSV ou vCard ?{" "}
<button <button
type="button" type="button"
className="cursor-pointer text-[#1a73e8] hover:underline" className="cursor-pointer text-primary hover:underline"
onClick={() => { onOpenChange(false); onOpenImport?.() }} onClick={() => {
onOpenChange(false)
onOpenImport?.()
}}
> >
Importez les contacts. Importez les contacts.
</button> </button>
</p> </p>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="text-sm font-medium text-[#1a73e8]"> <Button variant="ghost" onClick={() => onOpenChange(false)} className={CONTACTS_PAGE_LINK_BTN_CLASS}>
Non, ne rien faire Non, ne rien faire
</Button> </Button>
<Button onClick={handleCreate} disabled={!input.trim()} className="text-sm font-medium"> <Button onClick={handleCreate} disabled={!input.trim()} className="text-sm font-medium">

View File

@ -45,6 +45,25 @@ import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import { cn } from "@/lib/utils"
import {
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS,
CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS,
CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_SAVE_BTN_CLASS,
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_CARD_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_LINK_TEXT_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_POPOVER_ITEM_CLASS,
CONTACTS_PANEL_SELECT_TRIGGER_CLASS,
CONTACTS_PANEL_TAG_CLASS,
} from "@/lib/contacts-chrome-classes"
const FRENCH_MONTHS = [ const FRENCH_MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
@ -201,17 +220,17 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
<form onSubmit={handleSubmit(onSubmit)} className="mx-auto max-w-2xl px-6 py-8"> <form onSubmit={handleSubmit(onSubmit)} className="mx-auto max-w-2xl px-6 py-8">
{/* Header */} {/* Header */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full text-[#5f6368]" onClick={onBack}> <Button type="button" variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS} onClick={onBack}>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full" onClick={() => setStarred((s) => !s)}> <Button type="button" variant="ghost" size="icon" className="h-10 w-10 rounded-full" onClick={() => setStarred((s) => !s)}>
<Star className={`h-5 w-5 ${starred ? "fill-yellow-400 text-yellow-400" : "text-[#5f6368]"}`} /> <Star className={cn("h-5 w-5", starred ? "fill-yellow-400 text-yellow-400" : CONTACTS_PANEL_MUTED_ICON_CLASS)} />
</Button> </Button>
<button <button
type="submit" type="submit"
disabled={!canSave} disabled={!canSave}
className="rounded-full bg-[#f1f3f4] px-6 py-2.5 text-sm font-medium text-[#3c4043] transition-colors hover:bg-[#e8eaed] disabled:cursor-not-allowed disabled:opacity-40" className={CONTACTS_PAGE_SAVE_BTN_CLASS}
> >
Enregistrer Enregistrer
</button> </button>
@ -228,16 +247,16 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
> >
{senderInitial(displayName)} {senderInitial(displayName)}
</div> </div>
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#1a73e8] text-white shadow"> <div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</div> </div>
</div> </div>
) : ( ) : (
<div className="relative"> <div className="relative">
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-[#e8eaed]"> <div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
<User className="h-12 w-12 text-[#9aa0a6]" /> <User className="h-12 w-12" />
</div> </div>
<div className="absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-[#1a73e8] text-white shadow"> <div className={CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</div> </div>
</div> </div>
@ -249,12 +268,12 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
{currentLabels.map((labelId) => { {currentLabels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId) const row = labelRows.find((r) => r.id === labelId)
return ( return (
<span key={labelId} className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-700"> <span key={labelId} className={CONTACTS_PANEL_TAG_CLASS}>
{row && ( {row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} /> <span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
)} )}
{row?.label ?? labelId} {row?.label ?? labelId}
<button type="button" onClick={() => toggleLabel(labelId)} className="text-gray-400 hover:text-gray-600"> <button type="button" onClick={() => toggleLabel(labelId)} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</span> </span>
@ -262,20 +281,20 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
})} })}
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button type="button" className="inline-flex items-center gap-1 rounded-full border border-gray-300 px-2.5 py-0.5 text-xs text-gray-600 hover:bg-gray-50"> <button type="button" className={CONTACTS_PANEL_ADD_TAG_BTN_CLASS}>
<Plus className="h-3 w-3" /> Libellé <Plus className="h-3 w-3" /> Libellé
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-52 p-1" align="center"> <PopoverContent className="w-52 p-1" align="center">
<p className="px-2 py-1.5 text-xs font-medium text-gray-500">Libellés</p> <p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Libellés</p>
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
{availableLabels.map((row) => { {availableLabels.map((row) => {
const active = currentLabels.includes(row.id) const active = currentLabels.includes(row.id)
return ( return (
<button key={row.id} type="button" onClick={() => toggleLabel(row.id)} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100"> <button key={row.id} type="button" onClick={() => toggleLabel(row.id)} className={CONTACTS_PANEL_POPOVER_ITEM_CLASS}>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
<span className="flex-1 truncate">{row.label}</span> <span className="flex-1 truncate">{row.label}</span>
{active && <Check className="h-3.5 w-3.5 text-blue-600" />} {active && <Check className="h-3.5 w-3.5 text-primary" />}
</button> </button>
) )
})} })}
@ -285,11 +304,11 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</div> </div>
{/* Name section */} {/* Name section */}
<FormSection icon={<User className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<User className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{nameExpanded && <FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />} {nameExpanded && <FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="Prénom" {...register("firstName")} /></div> <div className="flex-1"><FloatingInput label="Prénom" {...register("firstName")} /></div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => setNameExpanded((e) => !e)}> <Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => setNameExpanded((e) => !e)}>
{nameExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {nameExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@ -305,10 +324,10 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Company section */} {/* Company section */}
<FormSection icon={<Building2 className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<Building2 className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="Entreprise" {...register("company")} /></div> <div className="flex-1"><FloatingInput label="Entreprise" {...register("company")} /></div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => setCompanyExpanded((e) => !e)}> <Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => setCompanyExpanded((e) => !e)}>
{companyExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {companyExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@ -317,13 +336,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Email section */} {/* Email section */}
<FormSection icon={<Mail className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<Mail className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{emailFields.map((field, index) => ( {emailFields.map((field, index) => (
<div key={field.id} className="space-y-2"> <div key={field.id} className="space-y-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="E-mail" type="email" {...register(`emails.${index}.value`)} /></div> <div className="flex-1"><FloatingInput label="E-mail" type="email" {...register(`emails.${index}.value`)} /></div>
{emailFields.length > 1 && ( {emailFields.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => removeEmail(index)}> <Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removeEmail(index)}>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
@ -337,14 +356,14 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Phone section */} {/* Phone section */}
<FormSection icon={<Phone className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<Phone className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{phoneFields.map((field, index) => ( {phoneFields.map((field, index) => (
<div key={field.id} className="space-y-2"> <div key={field.id} className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">🇫🇷</span> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded text-sm">🇫🇷</span>
<div className="flex-1"><FloatingInput label="Téléphone" type="tel" {...register(`phones.${index}.value`)} /></div> <div className="flex-1"><FloatingInput label="Téléphone" type="tel" {...register(`phones.${index}.value`)} /></div>
{phoneFields.length > 1 && ( {phoneFields.length > 1 && (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0 rounded-full text-gray-400" onClick={() => removePhone(index)}> <Button type="button" variant="ghost" size="icon" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removePhone(index)}>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
)} )}
@ -358,14 +377,14 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Address section */} {/* Address section */}
<FormSection icon={<MapPin className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<MapPin className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{addressFields.map((field, index) => ( {addressFields.map((field, index) => (
<div key={field.id} className="space-y-2 rounded-lg border border-gray-200 p-3"> <div key={field.id} className={CONTACTS_PANEL_CARD_CLASS}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Controller control={control} name={`addresses.${index}.label`} render={({ field: f }) => ( <Controller control={control} name={`addresses.${index}.label`} render={({ field: f }) => (
<CompactSelect value={f.value} onValueChange={f.onChange} options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))} /> <CompactSelect value={f.value} onValueChange={f.onChange} options={ADDRESS_LABELS.map((l) => ({ value: l, label: l }))} />
)} /> )} />
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 rounded-full text-gray-400" onClick={() => removeAddress(index)}> <Button type="button" variant="ghost" size="icon" className={cn("h-7 w-7 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)} onClick={() => removeAddress(index)}>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@ -384,7 +403,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Birthday section */} {/* Birthday section */}
<FormSection icon={<Cake className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<Cake className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-stretch gap-2"> <div className="flex items-stretch gap-2">
<div className="w-[72px]"><FloatingInput label="Jour" type="number" min={1} max={31} {...register("birthday.day", { valueAsNumber: true })} /></div> <div className="w-[72px]"><FloatingInput label="Jour" type="number" min={1} max={31} {...register("birthday.day", { valueAsNumber: true })} /></div>
<div className="flex-1"> <div className="flex-1">
@ -397,7 +416,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection> </FormSection>
{/* Notes section */} {/* Notes section */}
<FormSection icon={<FileText className="h-5 w-5 text-[#5f6368]" />}> <FormSection icon={<FileText className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<FloatingTextarea label="Notes" {...register("notes")} /> <FloatingTextarea label="Notes" {...register("notes")} />
</FormSection> </FormSection>
@ -417,7 +436,7 @@ function FormSection({ icon, children }: { icon: React.ReactNode; children: Reac
function AddButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { function AddButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return ( return (
<button type="button" onClick={onClick} className="flex items-center gap-2 py-1 text-sm text-[#1a73e8] hover:text-[#1557b0]"> <button type="button" onClick={onClick} className={cn("flex items-center gap-2 py-1", CONTACTS_PANEL_LINK_TEXT_CLASS, "hover:text-primary/80")}>
<Plus className="h-4 w-4" />{children} <Plus className="h-4 w-4" />{children}
</button> </button>
) )
@ -445,12 +464,12 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
return ( return (
<div className="relative"> <div className="relative">
<input ref={setRefs} id={id} {...props} defaultValue={defaultValue} <input ref={setRefs} id={id} {...props} defaultValue={defaultValue}
className={`peer h-[42px] w-full rounded border bg-white px-3 pt-4 pb-1 text-sm outline-none transition-colors ${focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"} ${className ?? ""}`} className={cn(CONTACTS_PANEL_FLOATING_INPUT_CLASS, className)}
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }} onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }} onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }} onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
/> />
<label htmlFor={id} className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm"} ${focused ? "text-blue-600" : "text-gray-500"}`}> <label htmlFor={id} className={cn(CONTACTS_PANEL_FLOATING_LABEL_CLASS, floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm", focused ? "text-primary" : "text-muted-foreground")}>
{label} {label}
</label> </label>
</div> </div>
@ -480,12 +499,12 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
return ( return (
<div className="relative"> <div className="relative">
<textarea ref={setRefs} id={id} rows={3} {...props} <textarea ref={setRefs} id={id} rows={3} {...props}
className={`peer w-full rounded border bg-white px-3 pt-5 pb-2 text-sm outline-none transition-colors resize-none ${focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"} ${className ?? ""}`} className={cn(CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS, className)}
onFocus={(e) => { setFocused(true); props.onFocus?.(e) }} onFocus={(e) => { setFocused(true); props.onFocus?.(e) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }} onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(e) }}
onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }} onChange={(e) => { setFilled(!!e.target.value); props.onChange?.(e) }}
/> />
<label htmlFor={id} className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm"} ${focused ? "text-blue-600" : "text-gray-500"}`}> <label htmlFor={id} className={cn(CONTACTS_PANEL_FLOATING_LABEL_CLASS, floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm", focused ? "text-primary" : "text-muted-foreground")}>
{label} {label}
</label> </label>
</div> </div>
@ -496,7 +515,7 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
function CompactSelect({ value, onValueChange, options, placeholder }: { value: string; onValueChange: (v: string) => void; options: { value: string; label: string }[]; placeholder?: string }) { function CompactSelect({ value, onValueChange, options, placeholder }: { value: string; onValueChange: (v: string) => void; options: { value: string; label: string }[]; placeholder?: string }) {
return ( return (
<Select value={value} onValueChange={onValueChange}> <Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="!h-[42px] !min-h-[42px] w-full rounded border border-gray-300 bg-white px-3 py-0 text-sm shadow-none data-[size=default]:!h-[42px] focus:border-blue-500 focus:ring-1 focus:ring-blue-500"> <SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder ?? "Choisir..."} /> <SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@ -21,6 +21,17 @@ import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import { downloadContactVCard } from "@/lib/contacts/export-contacts" import { downloadContactVCard } from "@/lib/contacts/export-contacts"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_ICON_BTN_CLASS,
CONTACTS_PAGE_TAG_CLASS,
CONTACTS_PANEL_DIVIDER_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
const FRENCH_MONTHS = [ const FRENCH_MONTHS = [
"janvier", "février", "mars", "avril", "mai", "juin", "janvier", "février", "mars", "avril", "mai", "juin",
@ -48,7 +59,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
if (!contact) { if (!contact) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-gray-500"> <div className={cn("flex h-full items-center justify-center text-sm", CONTACTS_MUTED_TEXT)}>
Contact introuvable Contact introuvable
</div> </div>
) )
@ -66,25 +77,24 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
} }
return ( return (
<div className="mx-auto max-w-3xl px-6 py-8"> <div className="mx-auto max-w-3xl px-6 py-8 text-foreground">
{/* Top actions */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 rounded-full text-[#5f6368]" className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={onBack} onClick={onBack}
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-full text-[#5f6368]"> <Button variant="ghost" size="icon" className={CONTACTS_PAGE_ICON_BTN_CLASS}>
<Star className="h-5 w-5" /> <Star className="h-5 w-5" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 rounded-full text-[#5f6368]" className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={() => downloadContactVCard(contact)} onClick={() => downloadContactVCard(contact)}
aria-label="Télécharger la fiche contact" aria-label="Télécharger la fiche contact"
> >
@ -93,7 +103,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 rounded-full text-[#5f6368]" className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={() => onEdit(contactId)} onClick={() => onEdit(contactId)}
> >
<Pencil className="h-5 w-5" /> <Pencil className="h-5 w-5" />
@ -101,7 +111,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-10 w-10 rounded-full text-[#5f6368]" className={CONTACTS_PAGE_ICON_BTN_CLASS}
onClick={handleDelete} onClick={handleDelete}
> >
<Trash2 className="h-5 w-5" /> <Trash2 className="h-5 w-5" />
@ -109,7 +119,6 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</div> </div>
</div> </div>
{/* Avatar + name */}
<div className="flex items-center gap-6 pb-6"> <div className="flex items-center gap-6 pb-6">
{contact.avatarUrl ? ( {contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-24 w-24 rounded-full object-cover" /> <img src={contact.avatarUrl} alt={name} className="h-24 w-24 rounded-full object-cover" />
@ -122,9 +131,9 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</div> </div>
)} )}
<div> <div>
<h1 className="text-3xl font-normal text-[#1f1f1f]">{name}</h1> <h1 className={cn("text-3xl", CONTACTS_HEADING_TEXT)}>{name}</h1>
{contact.company && ( {contact.company && (
<p className="mt-1 text-base text-[#5f6368]"> <p className={cn("mt-1 text-base", CONTACTS_MUTED_TEXT)}>
{contact.jobTitle ? `${contact.jobTitle}` : ""} {contact.jobTitle ? `${contact.jobTitle}` : ""}
{contact.company} {contact.company}
</p> </p>
@ -134,10 +143,7 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
{contact.labels.map((labelId) => { {contact.labels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId) const row = labelRows.find((r) => r.id === labelId)
return row ? ( return row ? (
<span <span key={labelId} className={CONTACTS_PAGE_TAG_CLASS}>
key={labelId}
className="inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-0.5 text-xs text-[#3c4043]"
>
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} /> <span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
{row.label} {row.label}
</span> </span>
@ -148,89 +154,82 @@ export function ContactDetailPage({ contactId, onBack, onEdit }: ContactDetailPa
</div> </div>
</div> </div>
{/* Quick actions */}
{primaryEmail && ( {primaryEmail && (
<div className="flex items-center gap-2 border-t border-gray-200 py-4"> <div className={cn("flex items-center gap-2 py-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<button <button type="button" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}>
type="button"
className="inline-flex h-9 items-center gap-2 rounded-full bg-[#d3e3fd] px-5 text-sm font-medium text-[#001d35] transition-colors hover:bg-[#c4d9fc]"
>
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
Envoyer un e-mail Envoyer un e-mail
</button> </button>
<button <button type="button" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}>
type="button"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
>
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</button> </button>
<button <button type="button" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}>
type="button"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50"
>
<Video className="h-4 w-4" /> <Video className="h-4 w-4" />
</button> </button>
</div> </div>
)} )}
{/* Details */} <div className={cn("space-y-1 pt-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<div className="space-y-1 border-t border-gray-200 pt-4">
{contact.emails.length > 0 && ( {contact.emails.length > 0 && (
<DetailRow icon={<Mail className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<Mail className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.emails.map((e, i) => ( {contact.emails.map((e, i) => (
<div key={i}> <div key={i}>
<p className="text-sm text-[#1a73e8]">{e.value}</p> <p className="text-sm text-primary">{e.value}</p>
<p className="text-xs text-[#5f6368]">{e.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{e.label}</p>
</div> </div>
))} ))}
</DetailRow> </DetailRow>
)} )}
{contact.phones.length > 0 && ( {contact.phones.length > 0 && (
<DetailRow icon={<Phone className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<Phone className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.phones.map((p, i) => ( {contact.phones.map((p, i) => (
<div key={i}> <div key={i}>
<p className="text-sm text-[#1a73e8]">{p.value}</p> <p className="text-sm text-primary">{p.value}</p>
<p className="text-xs text-[#5f6368]">{p.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{p.label}</p>
</div> </div>
))} ))}
</DetailRow> </DetailRow>
)} )}
{contact.company && ( {contact.company && (
<DetailRow icon={<Building2 className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<Building2 className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div> <div>
<p className="text-sm text-[#1f1f1f]">{contact.company}</p> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{contact.company}</p>
{contact.department && <p className="text-xs text-[#5f6368]">{contact.department}</p>} {contact.department && (
{contact.jobTitle && <p className="text-xs text-[#5f6368]">{contact.jobTitle}</p>} <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.department}</p>
)}
{contact.jobTitle && (
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.jobTitle}</p>
)}
</div> </div>
</DetailRow> </DetailRow>
)} )}
{contact.addresses && contact.addresses.length > 0 && ( {contact.addresses && contact.addresses.length > 0 && (
<DetailRow icon={<MapPin className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<MapPin className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.addresses.map((addr, i) => ( {contact.addresses.map((addr, i) => (
<div key={i}> <div key={i}>
<p className="text-sm text-[#1f1f1f]"> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country] {[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
.filter(Boolean) .filter(Boolean)
.join(", ")} .join(", ")}
</p> </p>
<p className="text-xs text-[#5f6368]">{addr.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{addr.label}</p>
</div> </div>
))} ))}
</DetailRow> </DetailRow>
)} )}
{contact.birthday && (contact.birthday.day || contact.birthday.month) && ( {contact.birthday && (contact.birthday.day || contact.birthday.month) && (
<DetailRow icon={<Cake className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<Cake className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className="text-sm text-[#1f1f1f]">{formatBirthday(contact.birthday)}</p> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{formatBirthday(contact.birthday)}</p>
</DetailRow> </DetailRow>
)} )}
{contact.notes && ( {contact.notes && (
<DetailRow icon={<FileText className="h-5 w-5 text-[#5f6368]" />}> <DetailRow icon={<FileText className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className="whitespace-pre-wrap text-sm text-[#1f1f1f]">{contact.notes}</p> <p className={cn("whitespace-pre-wrap text-sm", CONTACTS_HEADING_TEXT)}>{contact.notes}</p>
</DetailRow> </DetailRow>
)} )}
</div> </div>

View File

@ -1,6 +1,8 @@
"use client" "use client"
import { useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { cn } from "@/lib/utils"
import { useIsMobile } from "@/hooks/use-mobile"
import { ContactsSidebar } from "./contacts-sidebar" import { ContactsSidebar } from "./contacts-sidebar"
import { ContactsHeader } from "./contacts-header" import { ContactsHeader } from "./contacts-header"
import { ContactsTable } from "./contacts-table" import { ContactsTable } from "./contacts-table"
@ -10,6 +12,7 @@ import { MergeDuplicatesView } from "./merge-duplicates-view"
import { TrashView } from "./trash-view" import { TrashView } from "./trash-view"
import { BulkCreateDialog } from "./bulk-create-dialog" import { BulkCreateDialog } from "./bulk-create-dialog"
import { ImportDialog } from "./import-dialog" import { ImportDialog } from "./import-dialog"
import { CONTACTS_SHELL_CLASS } from "@/lib/contacts-chrome-classes"
export type ContactsPageView = export type ContactsPageView =
| "contacts" | "contacts"
@ -24,17 +27,31 @@ export type ContactsPageView =
| "label" | "label"
export function ContactsAppShell() { export function ContactsAppShell() {
const isMobile = useIsMobile()
const [currentView, setCurrentView] = useState<ContactsPageView>("contacts") const [currentView, setCurrentView] = useState<ContactsPageView>("contacts")
const [activeContactId, setActiveContactId] = useState<string | null>(null) const [activeContactId, setActiveContactId] = useState<string | null>(null)
const [activeLabelId, setActiveLabelId] = useState<string | null>(null) const [activeLabelId, setActiveLabelId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [bulkCreateOpen, setBulkCreateOpen] = useState(false) const [bulkCreateOpen, setBulkCreateOpen] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
useEffect(() => { useEffect(() => {
setSearchQuery("") setSearchQuery("")
}, [currentView, activeLabelId]) }, [currentView, activeLabelId])
useEffect(() => {
if (isMobile) {
setSidebarOpen(false)
} else {
setSidebarOpen(true)
}
}, [isMobile])
const closeSidebar = useCallback(() => setSidebarOpen(false), [])
const openSidebar = useCallback(() => setSidebarOpen(true), [])
const toggleSidebar = useCallback(() => setSidebarOpen((open) => !open), [])
function openContact(id: string) { function openContact(id: string) {
setActiveContactId(id) setActiveContactId(id)
setCurrentView("detail") setCurrentView("detail")
@ -55,28 +72,69 @@ export function ContactsAppShell() {
setCurrentView("contacts") setCurrentView("contacts")
} }
function goToContactsList() {
setActiveContactId(null)
setActiveLabelId(null)
setSearchQuery("")
setCurrentView("contacts")
if (isMobile) closeSidebar()
}
function handleNavigate(view: ContactsPageView) { function handleNavigate(view: ContactsPageView) {
if (view === "import") { if (view === "import") {
setImportOpen(true) setImportOpen(true)
if (isMobile) closeSidebar()
return return
} }
setCurrentView(view) setCurrentView(view)
if (isMobile) closeSidebar()
}
function handleSelectLabel(id: string) {
setActiveLabelId(id)
setCurrentView("label")
if (isMobile) closeSidebar()
}
function handleCreateContact() {
openCreate()
if (isMobile) closeSidebar()
} }
return ( return (
<div className="flex h-dvh max-h-dvh overflow-hidden bg-white"> <div
data-contacts-panel
className={cn("relative flex h-dvh max-h-dvh overflow-hidden", CONTACTS_SHELL_CLASS)}
>
{isMobile && sidebarOpen && (
<button
type="button"
aria-label="Fermer le menu"
className="absolute inset-0 z-40 bg-black/20"
onClick={closeSidebar}
/>
)}
<ContactsSidebar <ContactsSidebar
open={sidebarOpen}
overlay={isMobile}
onToggle={toggleSidebar}
onClose={closeSidebar}
currentView={currentView} currentView={currentView}
activeLabelId={activeLabelId} activeLabelId={activeLabelId}
onNavigate={handleNavigate} onNavigate={handleNavigate}
onCreateContact={openCreate} onHome={goToContactsList}
onCreateContact={handleCreateContact}
onBulkCreate={() => setBulkCreateOpen(true)} onBulkCreate={() => setBulkCreateOpen(true)}
onSelectLabel={(id) => { setActiveLabelId(id); setCurrentView("label") }} onSelectLabel={handleSelectLabel}
/> />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<ContactsHeader <ContactsHeader
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
sidebarOpen={sidebarOpen}
onOpenSidebar={openSidebar}
/> />
<main className="min-h-0 flex-1 overflow-y-auto"> <main className="min-h-0 flex-1 overflow-y-auto">
{(currentView === "contacts" || {(currentView === "contacts" ||

View File

@ -1,40 +1,67 @@
"use client" "use client"
import { Search, X } from "lucide-react" import { Menu, Search, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { HeaderAccountActions } from "@/components/gmail/header-account-actions" import { HeaderAccountActions } from "@/components/gmail/header-account-actions"
import {
CONTACTS_ICON_BTN_CLASS,
CONTACTS_SEARCH_BAR_CLASS,
CONTACTS_SEARCH_INPUT_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactsHeaderProps { interface ContactsHeaderProps {
searchQuery: string searchQuery: string
onSearchChange: (q: string) => void onSearchChange: (q: string) => void
sidebarOpen: boolean
onOpenSidebar: () => void
} }
export function ContactsHeader({ searchQuery, onSearchChange }: ContactsHeaderProps) { export function ContactsHeader({
searchQuery,
onSearchChange,
sidebarOpen,
onOpenSidebar,
}: ContactsHeaderProps) {
return ( return (
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 px-6"> <header className="flex h-16 shrink-0 items-center gap-2 border-b border-border bg-mail-surface px-3 sm:gap-4 sm:px-6">
{!sidebarOpen && (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("h-10 w-10 shrink-0 rounded-full", CONTACTS_ICON_BTN_CLASS)}
onClick={onOpenSidebar}
aria-label="Ouvrir le menu"
>
<Menu className="h-5 w-5" />
</Button>
)}
<div className="flex min-w-0 flex-1 items-center"> <div className="flex min-w-0 flex-1 items-center">
<div className="flex h-12 w-full max-w-[720px] items-center gap-3 rounded-full bg-[#edf2fc] px-4 transition-colors focus-within:bg-white focus-within:shadow-md focus-within:ring-1 focus-within:ring-gray-200"> <div className={CONTACTS_SEARCH_BAR_CLASS}>
<Search className="h-5 w-5 shrink-0 text-[#5f6368]" /> <Search className="h-5 w-5 shrink-0 text-muted-foreground" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
placeholder="Rechercher" placeholder="Rechercher"
className="flex-1 bg-transparent text-sm text-[#1f1f1f] outline-none placeholder:text-[#5f6368]" className={CONTACTS_SEARCH_INPUT_CLASS}
/> />
{searchQuery && ( {searchQuery && (
<button <button
type="button" type="button"
onClick={() => onSearchChange("")} onClick={() => onSearchChange("")}
className="rounded-full p-1 hover:bg-gray-100" className="rounded-full p-1 hover:bg-accent"
aria-label="Effacer la recherche" aria-label="Effacer la recherche"
> >
<X className="h-4 w-4 text-[#5f6368]" /> <X className="h-4 w-4 text-muted-foreground" />
</button> </button>
)} )}
</div> </div>
</div> </div>
<HeaderAccountActions className="pl-4" /> <HeaderAccountActions className="shrink-0 pl-1 sm:pl-4" />
</header> </header>
) )
} }

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useState } from "react" import { useMemo, useState } from "react"
import { import {
Users, Users,
Clock, Clock,
@ -20,23 +20,45 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import {
CONTACTS_CREATE_BTN_CLASS,
CONTACTS_FIELD_CLASS,
CONTACTS_MUTED_TEXT,
CONTACTS_NAV_ACTIVE_CLASS,
CONTACTS_NAV_ICON_MUTED,
CONTACTS_NAV_ITEM_CLASS,
CONTACTS_CREATE_BTN_LABEL_CLASS,
CONTACTS_SIDEBAR_CLASS,
} from "@/lib/contacts-chrome-classes"
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import type { ContactsPageView } from "./contacts-app-shell" import type { ContactsPageView } from "./contacts-app-shell"
interface ContactsSidebarProps { interface ContactsSidebarProps {
open: boolean
overlay: boolean
onToggle: () => void
onClose: () => void
currentView: ContactsPageView currentView: ContactsPageView
activeLabelId?: string | null activeLabelId?: string | null
onNavigate: (view: ContactsPageView) => void onNavigate: (view: ContactsPageView) => void
onHome?: () => void
onCreateContact: () => void onCreateContact: () => void
onBulkCreate?: () => void onBulkCreate?: () => void
onSelectLabel?: (id: string) => void onSelectLabel?: (id: string) => void
} }
export function ContactsSidebar({ export function ContactsSidebar({
open,
overlay,
onToggle,
onClose,
currentView, currentView,
activeLabelId, activeLabelId,
onNavigate, onNavigate,
onHome,
onCreateContact, onCreateContact,
onBulkCreate, onBulkCreate,
onSelectLabel, onSelectLabel,
@ -48,7 +70,19 @@ export function ContactsSidebar({
const [labelInput, setLabelInput] = useState("") const [labelInput, setLabelInput] = useState("")
const [showLabelInput, setShowLabelInput] = useState(false) const [showLabelInput, setShowLabelInput] = useState(false)
const availableLabels = labelRows.filter((r) => r.enabled !== false) const labelsByContactCount = useMemo(() => {
return labelRows
.filter((r) => r.enabled !== false)
.map((label) => ({
label,
count: contacts.filter((c) => c.labels?.includes(label.id)).length,
}))
.sort(
(a, b) =>
b.count - a.count ||
a.label.label.localeCompare(b.label.label, "fr")
)
}, [labelRows, contacts])
function handleAddLabel() { function handleAddLabel() {
const trimmed = labelInput.trim() const trimmed = labelInput.trim()
@ -59,17 +93,50 @@ export function ContactsSidebar({
} }
} }
function handleMenuClick() {
if (overlay && open) {
onClose()
} else {
onToggle()
}
}
if (!overlay && !open) {
return null
}
return ( return (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-200 bg-white"> <aside
{/* Logo + hamburger */} className={cn(
CONTACTS_SIDEBAR_CLASS,
overlay
? cn(
"fixed inset-y-0 left-0 z-50 shadow-xl",
open ? "translate-x-0" : "-translate-x-full pointer-events-none"
)
: "relative"
)}
aria-hidden={overlay && !open}
>
<div className="flex h-16 items-center gap-2 px-4"> <div className="flex h-16 items-center gap-2 px-4">
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-full text-gray-600"> <Button
variant="ghost"
size="icon"
className="h-10 w-10 rounded-full text-muted-foreground hover:bg-accent"
onClick={handleMenuClick}
aria-label={open ? "Fermer le menu" : "Ouvrir le menu"}
>
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
<div className="flex items-center gap-2"> <button
<Users className="h-6 w-6 text-[#5f6368]" /> type="button"
<span className="text-[22px] font-normal text-[#5f6368]">Contacts</span> onClick={onHome ?? (() => onNavigate("contacts"))}
</div> className="flex min-w-0 items-center gap-2 rounded-full px-1 py-0.5 transition-colors hover:bg-accent"
aria-label="Liste des contacts"
>
<Users className={cn("h-6 w-6", CONTACTS_NAV_ICON_MUTED)} />
<span className={cn("text-[22px] font-normal", CONTACTS_MUTED_TEXT)}>Contacts</span>
</button>
</div> </div>
{/* Create button */} {/* Create button */}
@ -78,14 +145,14 @@ export function ContactsSidebar({
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="flex h-14 w-full items-center gap-3 rounded-2xl bg-white px-4 shadow-md ring-1 ring-gray-200 transition-shadow hover:shadow-lg" className={CONTACTS_CREATE_BTN_CLASS}
> >
<Plus className="h-5 w-5 text-[#1a73e8]" /> <Plus className="h-5 w-5 text-primary" />
<span className="flex-1 text-left text-sm font-medium text-[#3c4043]">Créer un contact</span> <span className={CONTACTS_CREATE_BTN_LABEL_CLASS}>Créer un contact</span>
<ChevronDown className="h-4 w-4 text-[#5f6368]" /> <ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56"> <DropdownMenuContent align="start" className={cn("w-56", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem onClick={onCreateContact}> <DropdownMenuItem onClick={onCreateContact}>
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
Créer un contact Créer un contact
@ -98,7 +165,6 @@ export function ContactsSidebar({
</DropdownMenu> </DropdownMenu>
</div> </div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2"> <nav className="flex-1 overflow-y-auto px-2">
<NavItem <NavItem
icon={<Users className="h-5 w-5" />} icon={<Users className="h-5 w-5" />}
@ -120,9 +186,9 @@ export function ContactsSidebar({
onClick={() => onNavigate("other")} onClick={() => onNavigate("other")}
/> />
<div className="my-2 border-t border-gray-200" /> <div className="my-2 border-t border-border" />
<p className="px-3 py-2 text-xs font-medium text-[#5f6368]">Corriger et gérer</p> <p className={cn("px-3 py-2 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Corriger et gérer</p>
<NavItem <NavItem
icon={<Merge className="h-5 w-5" />} icon={<Merge className="h-5 w-5" />}
@ -144,18 +210,22 @@ export function ContactsSidebar({
onClick={() => onNavigate("trash")} onClick={() => onNavigate("trash")}
/> />
<div className="my-2 border-t border-gray-200" /> <div className="my-2 border-t border-border" />
{/* Labels section */} <div className="flex items-center gap-3 px-3 py-2">
<div className="flex items-center justify-between px-3 py-2"> <p className={cn("min-w-0 flex-1 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
<p className="text-xs font-medium text-[#5f6368]">Libellés</p> Libellés
<button </p>
type="button" <div className="flex w-6 shrink-0 justify-center">
onClick={() => setShowLabelInput(true)} <button
className="rounded-full p-1 text-[#5f6368] hover:bg-gray-100" type="button"
> onClick={() => setShowLabelInput(true)}
<Plus className="h-4 w-4" /> className="rounded-full p-1 text-muted-foreground hover:bg-accent"
</button> aria-label="Ajouter un libellé"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div> </div>
{showLabelInput && ( {showLabelInput && (
@ -166,7 +236,7 @@ export function ContactsSidebar({
onChange={(e) => setLabelInput(e.target.value)} onChange={(e) => setLabelInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddLabel()} onKeyDown={(e) => e.key === "Enter" && handleAddLabel()}
placeholder="Nom du libellé" placeholder="Nom du libellé"
className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm outline-none focus:border-blue-500" className={cn("flex-1", CONTACTS_FIELD_CLASS)}
autoFocus autoFocus
/> />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleAddLabel}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleAddLabel}>
@ -175,19 +245,16 @@ export function ContactsSidebar({
</div> </div>
)} )}
{availableLabels.map((label) => { {labelsByContactCount.map(({ label, count }) => (
const count = contacts.filter((c) => c.labels?.includes(label.id)).length <NavItem
return ( key={label.id}
<NavItem icon={<Tag className="h-5 w-5" />}
key={label.id} label={label.label}
icon={<Tag className="h-5 w-5" />} count={count}
label={label.label} active={currentView === "label" && activeLabelId === label.id}
count={count} onClick={() => onSelectLabel?.(label.id)}
active={currentView === "label" && activeLabelId === label.id} />
onClick={() => onSelectLabel?.(label.id)} ))}
/>
)
})}
</nav> </nav>
</aside> </aside>
) )
@ -212,13 +279,12 @@ function NavItem({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={`flex w-full items-center gap-3 rounded-full px-3 py-2 text-sm transition-colors ${ className={cn(
active "flex w-full items-center gap-3 rounded-full px-3 py-2 text-sm transition-colors",
? "bg-[#c2e7ff] font-medium text-[#001d35]" active ? CONTACTS_NAV_ACTIVE_CLASS : CONTACTS_NAV_ITEM_CLASS
: "text-[#1f1f1f] hover:bg-gray-100" )}
}`}
> >
<span className={active ? "text-[#001d35]" : "text-[#444746]"}>{icon}</span> <span className={active ? "text-mail-nav-selected" : CONTACTS_NAV_ICON_MUTED}>{icon}</span>
<span className="flex-1 truncate text-left">{label}</span> <span className="flex-1 truncate text-left">{label}</span>
{badge !== undefined && ( {badge !== undefined && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#ea4335] px-1.5 text-[11px] font-medium text-white"> <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-[#ea4335] px-1.5 text-[11px] font-medium text-white">
@ -226,7 +292,14 @@ function NavItem({
</span> </span>
)} )}
{count !== undefined && ( {count !== undefined && (
<span className="text-xs text-[#5f6368]">{count}</span> <span
className={cn(
"flex w-6 shrink-0 justify-center text-xs tabular-nums",
CONTACTS_MUTED_TEXT
)}
>
{count}
</span>
)} )}
</button> </button>
) )

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState, type CSSProperties } from "react"
import { Printer, Download, MoreVertical, Trash2 } from "lucide-react" import { Printer, Download, MoreVertical, Trash2 } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
@ -18,10 +18,30 @@ import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/expor
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import type { FullContact } from "@/lib/contacts/types" import type { FullContact } from "@/lib/contacts/types"
import {
contactsTableGridStyle,
isContactsColumnVisible,
useContactsTableColumns,
type ContactsTableColumn,
} from "@/hooks/use-contacts-table-columns"
import type { ContactsPageView } from "./contacts-app-shell" import type { ContactsPageView } from "./contacts-app-shell"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_ICON_BTN_CLASS,
CONTACTS_MUTED_TEXT,
CONTACTS_TABLE_HEADER_CLASS,
CONTACTS_TABLE_ROW_CLASS,
} from "@/lib/contacts-chrome-classes"
import { MAIL_SIDEBAR_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
const TABLE_GRID = const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
"grid grid-cols-[40px_minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,1.5fr)_minmax(0,1fr)] gap-2" "name",
"email",
"phone",
"job",
"labels",
]
interface ContactsTableProps { interface ContactsTableProps {
view: ContactsPageView view: ContactsPageView
@ -31,6 +51,8 @@ interface ContactsTableProps {
} }
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) { export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
const { visibleColumns, columnLabels } = useContactsTableColumns()
const gridStyle = contactsTableGridStyle(visibleColumns)
const contacts = useContactsStore((s) => s.contacts) const contacts = useContactsStore((s) => s.contacts)
const softDeleteContact = useContactsStore((s) => s.softDeleteContact) const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
@ -146,12 +168,12 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
} }
return ( return (
<div className="px-6 py-4"> <div className="px-3 py-4 sm:px-6">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-2xl font-normal text-[#1f1f1f]">{viewTitle}</h1> <h1 className={cn("truncate text-xl font-normal sm:text-2xl", CONTACTS_HEADING_TEXT)}>{viewTitle}</h1>
{selectionCount > 0 && ( {selectionCount > 0 && (
<p className="mt-0.5 text-sm text-[#5f6368]"> <p className={cn("mt-0.5 text-sm", CONTACTS_MUTED_TEXT)}>
{selectionCount} sélectionné{selectionCount > 1 ? "s" : ""} {selectionCount} sélectionné{selectionCount > 1 ? "s" : ""}
</p> </p>
)} )}
@ -161,7 +183,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-9 w-9 rounded-full text-[#5f6368]" className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
onClick={() => printContacts(filteredContacts, viewTitle)} onClick={() => printContacts(filteredContacts, viewTitle)}
aria-label="Imprimer" aria-label="Imprimer"
> >
@ -174,14 +196,14 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40" className={cn("h-9 w-9 rounded-full disabled:opacity-40", CONTACTS_ICON_BTN_CLASS)}
disabled={selectionCount === 0} disabled={selectionCount === 0}
aria-label="Exporter la sélection" aria-label="Exporter la sélection"
> >
<Download className="h-5 w-5" /> <Download className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52"> <DropdownMenuContent align="end" className={cn("w-52", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem onClick={handleExportVcf}> <DropdownMenuItem onClick={handleExportVcf}>
Exporter au format vCard (.vcf) Exporter au format vCard (.vcf)
</DropdownMenuItem> </DropdownMenuItem>
@ -195,7 +217,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-9 w-9 rounded-full text-[#5f6368] disabled:opacity-40" className={cn("h-9 w-9 rounded-full disabled:opacity-40", CONTACTS_ICON_BTN_CLASS)}
disabled={selectionCount === 0} disabled={selectionCount === 0}
onClick={handleDeleteSelected} onClick={handleDeleteSelected}
aria-label="Supprimer la sélection" aria-label="Supprimer la sélection"
@ -209,13 +231,13 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-9 w-9 rounded-full text-[#5f6368]" className={cn("h-9 w-9 rounded-full", CONTACTS_ICON_BTN_CLASS)}
aria-label="Plus d'actions" aria-label="Plus d'actions"
> >
<MoreVertical className="h-5 w-5" /> <MoreVertical className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48"> <DropdownMenuContent align="end" className={cn("w-48", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem <DropdownMenuItem
onClick={() => toggleSelectAll(true)} onClick={() => toggleSelectAll(true)}
disabled={filteredContacts.length === 0} disabled={filteredContacts.length === 0}
@ -234,26 +256,31 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
</div> </div>
<div <div
className={`${TABLE_GRID} border-b border-gray-200 py-2 text-xs font-medium text-[#5f6368]`} className={CONTACTS_TABLE_HEADER_CLASS}
style={gridStyle}
> >
<span className="flex items-center justify-center"> {isContactsColumnVisible(visibleColumns, "checkbox") && (
<Checkbox <span className="flex items-center justify-center">
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false} <Checkbox
onCheckedChange={(checked) => toggleSelectAll(checked === true)} checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
aria-label="Tout sélectionner" onCheckedChange={(checked) => toggleSelectAll(checked === true)}
/> aria-label="Tout sélectionner"
</span> />
<span>Nom</span> </span>
<span>E-mail</span> )}
<span>Numéro de téléphone</span> {DATA_COLUMNS.map((column) =>
<span>Fonction et entreprise</span> isContactsColumnVisible(visibleColumns, column) ? (
<span>Libellés</span> <span key={column}>{columnLabels[column]}</span>
) : null
)}
</div> </div>
{filteredContacts.map((contact) => ( {filteredContacts.map((contact) => (
<ContactTableRow <ContactTableRow
key={contact.id} key={contact.id}
contact={contact} contact={contact}
visibleColumns={visibleColumns}
gridStyle={gridStyle}
selected={selectedIds.has(contact.id)} selected={selectedIds.has(contact.id)}
onToggleSelect={(checked) => toggleContact(contact.id, checked)} onToggleSelect={(checked) => toggleContact(contact.id, checked)}
onOpen={() => onOpenContact(contact.id)} onOpen={() => onOpenContact(contact.id)}
@ -261,7 +288,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
))} ))}
{filteredContacts.length === 0 && ( {filteredContacts.length === 0 && (
<div className="py-12 text-center text-sm text-[#5f6368]"> <div className="py-12 text-center text-sm text-muted-foreground">
Aucun contact trouvé Aucun contact trouvé
</div> </div>
)} )}
@ -276,11 +303,15 @@ function sanitizeExportName(contact: FullContact): string {
function ContactTableRow({ function ContactTableRow({
contact, contact,
visibleColumns,
gridStyle,
selected, selected,
onToggleSelect, onToggleSelect,
onOpen, onOpen,
}: { }: {
contact: FullContact contact: FullContact
visibleColumns: ContactsTableColumn[]
gridStyle: CSSProperties
selected: boolean selected: boolean
onToggleSelect: (checked: boolean) => void onToggleSelect: (checked: boolean) => void
onOpen: () => void onOpen: () => void
@ -302,55 +333,76 @@ function ContactTableRow({
onOpen() onOpen()
} }
}} }}
className={`${TABLE_GRID} w-full cursor-pointer items-center border-b border-gray-100 py-2.5 text-left text-sm transition-colors hover:bg-[#f5f5f5] ${ className={cn(
selected ? "bg-[#e8f0fe]" : "" CONTACTS_TABLE_ROW_CLASS,
}`} selected && "bg-mail-nav-selected"
)}
style={gridStyle}
> >
<span {isContactsColumnVisible(visibleColumns, "checkbox") && (
className="flex items-center justify-center" <span
onClick={(e) => e.stopPropagation()} className="flex items-center justify-center"
onKeyDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> onKeyDown={(e) => e.stopPropagation()}
<Checkbox >
checked={selected} <Checkbox
onCheckedChange={(checked) => onToggleSelect(checked === true)} checked={selected}
aria-label={`Sélectionner ${name}`} onCheckedChange={(checked) => onToggleSelect(checked === true)}
/> aria-label={`Sélectionner ${name}`}
</span> />
</span>
)}
<span className="flex items-center gap-3"> {isContactsColumnVisible(visibleColumns, "name") && (
{contact.avatarUrl ? ( <span className="flex min-w-0 items-center gap-2 sm:gap-3">
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 rounded-full object-cover" /> {contact.avatarUrl ? (
) : ( <img src={contact.avatarUrl} alt={name} className="h-8 w-8 shrink-0 rounded-full object-cover" />
<span ) : (
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{initial}
</span>
)}
<span className="truncate text-[#1f1f1f]">{name}</span>
</span>
<span className="truncate text-[#1f1f1f]">{contact.emails[0]?.value || ""}</span>
<span className="truncate text-[#1f1f1f]">{contact.phones[0]?.value || ""}</span>
<span className="truncate text-[#1f1f1f]">
{[contact.jobTitle, contact.company].filter(Boolean).join(", ")}
</span>
<span className="flex flex-wrap gap-1">
{contact.labels?.map((labelId) => {
const row = labelRows.find((l) => l.id === labelId)
return row ? (
<span <span
key={labelId} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
className="inline-flex rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-[#3c4043]" style={{ backgroundColor: color }}
> >
{row.label} {initial}
</span> </span>
) : null )}
})} <span className="min-w-0 flex-1">
</span> <span className="block truncate text-foreground">{name}</span>
{!isContactsColumnVisible(visibleColumns, "email") && contact.emails[0]?.value && (
<span className="block truncate text-xs text-muted-foreground">{contact.emails[0].value}</span>
)}
</span>
</span>
)}
{isContactsColumnVisible(visibleColumns, "email") && (
<span className="truncate text-foreground">{contact.emails[0]?.value || ""}</span>
)}
{isContactsColumnVisible(visibleColumns, "phone") && (
<span className="truncate text-foreground">{contact.phones[0]?.value || ""}</span>
)}
{isContactsColumnVisible(visibleColumns, "job") && (
<span className="truncate text-foreground">
{[contact.jobTitle, contact.company].filter(Boolean).join(", ")}
</span>
)}
{isContactsColumnVisible(visibleColumns, "labels") && (
<span className="flex flex-wrap gap-1">
{contact.labels?.map((labelId) => {
const row = labelRows.find((l) => l.id === labelId)
return row ? (
<span
key={labelId}
className="inline-flex rounded border border-border px-1.5 py-0.5 text-[11px] text-foreground"
>
{row.label}
</span>
) : null
})}
</span>
)}
</div> </div>
) )
} }

View File

@ -11,6 +11,14 @@ import { Button } from "@/components/ui/button"
import { Info } from "lucide-react" import { Info } from "lucide-react"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { parseContactFile } from "@/lib/contacts/import-parsers" import { parseContactFile } from "@/lib/contacts/import-parsers"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ImportDialogProps { interface ImportDialogProps {
open: boolean open: boolean
@ -87,21 +95,17 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<DialogTitle>Importer des contacts</DialogTitle> <DialogTitle>Importer des contacts</DialogTitle>
<Info className="h-5 w-5 text-[#5f6368]" /> <Info className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
</div> </div>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<p className="text-sm text-[#3c4043]"> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>
Pour commencer, sélectionnez un fichier. Pour commencer, sélectionnez un fichier.
<br /> <br />
Utilisez le format CSV ou vCard (.vcf). Utilisez le format CSV ou vCard (.vcf).
</p> </p>
<Button <Button type="button" onClick={handleFileSelect} className={CONTACTS_PRIMARY_BTN_CLASS}>
type="button"
onClick={handleFileSelect}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
Sélectionner un fichier Sélectionner un fichier
</Button> </Button>
@ -114,7 +118,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
/> />
{pendingFile && previewCount > 0 && ( {pendingFile && previewCount > 0 && (
<p className="text-sm text-[#1f1f1f]"> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>
{previewCount} contact{previewCount > 1 ? "s" : ""} prêt {previewCount} contact{previewCount > 1 ? "s" : ""} prêt
{previewCount > 1 ? "s" : ""} à importer depuis{" "} {previewCount > 1 ? "s" : ""} à importer depuis{" "}
<span className="font-medium">{pendingFile.name}</span> <span className="font-medium">{pendingFile.name}</span>
@ -123,10 +127,10 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
{error && <p className="text-sm text-red-600">{error}</p>} {error && <p className="text-sm text-red-600">{error}</p>}
<p className="text-xs text-[#5f6368]"> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
Vous essayez de sauvegarder les contacts de votre mobile ? Vous essayez de sauvegarder les contacts de votre mobile ?
<br /> <br />
<span className="cursor-pointer text-[#1a73e8]">Voici comment les synchroniser.</span> <span className="cursor-pointer text-primary">Voici comment les synchroniser.</span>
</p> </p>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
@ -134,7 +138,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
className="text-sm font-medium text-[#1a73e8]" className={CONTACTS_PAGE_LINK_BTN_CLASS}
> >
Non, ne rien faire Non, ne rien faire
</Button> </Button>

View File

@ -7,6 +7,19 @@ import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/du
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { AddCoordinatesView } from "./add-coordinates-view" import { AddCoordinatesView } from "./add-coordinates-view"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_CARD_CLASS,
CONTACTS_PAGE_INFO_BANNER_CLASS,
CONTACTS_PAGE_INFO_BANNER_ICON_CLASS,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_SECTION_TITLE_CLASS,
CONTACTS_PAGE_TAB_ACTIVE_CLASS,
CONTACTS_PAGE_TAB_INACTIVE_CLASS,
CONTACTS_PRIMARY_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
type SubView = "merge" | "coordinates" type SubView = "merge" | "coordinates"
@ -63,16 +76,16 @@ export function MergeDuplicatesView() {
} }
return ( return (
<div className="px-6 py-6"> <div className="px-6 py-6 text-foreground">
<div className="mb-6 flex items-start gap-4 rounded-xl bg-[#f0f4f9] p-5"> <div className={CONTACTS_PAGE_INFO_BANNER_CLASS}>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#d3e3fd]"> <div className={CONTACTS_PAGE_INFO_BANNER_ICON_CLASS}>
<span className="text-2xl">🧹</span> <span className="text-2xl">🧹</span>
</div> </div>
<div> <div>
<h2 className="text-base font-medium text-[#1f1f1f]"> <h2 className={cn("text-base font-medium", CONTACTS_HEADING_TEXT)}>
Des méthodes simples pour nettoyer vos contacts Des méthodes simples pour nettoyer vos contacts
</h2> </h2>
<p className="mt-1 text-sm text-[#5f6368]"> <p className={cn("mt-1 text-sm", CONTACTS_MUTED_TEXT)}>
Obtenez de l'aide pour fusionner les contacts en double, ajouter des informations utiles, et bien encore Obtenez de l'aide pour fusionner les contacts en double, ajouter des informations utiles, et bien encore
</p> </p>
</div> </div>
@ -82,11 +95,7 @@ export function MergeDuplicatesView() {
<button <button
type="button" type="button"
onClick={() => setSubView("merge")} onClick={() => setSubView("merge")}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${ className={subView === "merge" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
subView === "merge"
? "bg-[#c2e7ff] text-[#001d35]"
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
}`}
> >
Fusionner les doublons Fusionner les doublons
{mergeSuggestions.length > 0 && ( {mergeSuggestions.length > 0 && (
@ -96,11 +105,7 @@ export function MergeDuplicatesView() {
<button <button
type="button" type="button"
onClick={() => setSubView("coordinates")} onClick={() => setSubView("coordinates")}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${ className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
subView === "coordinates"
? "bg-[#c2e7ff] text-[#001d35]"
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
}`}
> >
Ajouter des coordonnées Ajouter des coordonnées
{coordSuggestions.length > 0 && ( {coordSuggestions.length > 0 && (
@ -112,14 +117,14 @@ export function MergeDuplicatesView() {
{subView === "merge" && ( {subView === "merge" && (
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-normal text-[#1f1f1f]"> <h3 className={CONTACTS_PAGE_SECTION_TITLE_CLASS}>
Fusionner les doublons ({mergeSuggestions.length}) Fusionner les doublons ({mergeSuggestions.length})
</h3> </h3>
{mergeSuggestions.length > 0 && ( {mergeSuggestions.length > 0 && (
<Button <Button
onClick={handleMergeAll} onClick={handleMergeAll}
disabled={mergingAll} disabled={mergingAll}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]" className={CONTACTS_PRIMARY_BTN_CLASS}
> >
{mergingAll ? "Fusion…" : "Tout fusionner"} {mergingAll ? "Fusion…" : "Tout fusionner"}
</Button> </Button>
@ -127,7 +132,7 @@ export function MergeDuplicatesView() {
</div> </div>
{mergeSuggestions.length === 0 && ( {mergeSuggestions.length === 0 && (
<p className="py-8 text-center text-sm text-[#5f6368]"> <p className={cn("py-8 text-center text-sm", CONTACTS_MUTED_TEXT)}>
Aucun doublon détecté Aucun doublon détecté
</p> </p>
)} )}
@ -162,8 +167,8 @@ function MergeSuggestionCard({
const { contactA, contactB, reason } = suggestion const { contactA, contactB, reason } = suggestion
return ( return (
<div className="rounded-xl border border-gray-200 p-5"> <div className={CONTACTS_PAGE_CARD_CLASS}>
<p className="mb-3 text-xs font-medium text-[#5f6368]"> <p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
{REASON_LABELS[reason]} {REASON_LABELS[reason]}
</p> </p>
<div className="flex items-start gap-6"> <div className="flex items-start gap-6">
@ -174,14 +179,11 @@ function MergeSuggestionCard({
<button <button
type="button" type="button"
onClick={onIgnore} onClick={onIgnore}
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]" className={CONTACTS_PAGE_LINK_BTN_CLASS}
> >
Ignorer Ignorer
</button> </button>
<Button <Button onClick={onMerge} className={CONTACTS_PRIMARY_BTN_CLASS}>
onClick={onMerge}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
Fusionner Fusionner
</Button> </Button>
</div> </div>
@ -208,12 +210,12 @@ function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-[#1f1f1f]">{name}</p> <p className={cn("truncate text-sm font-medium", CONTACTS_HEADING_TEXT)}>{name}</p>
{contact.emails[0] && ( {contact.emails[0] && (
<p className="truncate text-xs text-[#5f6368]">{contact.emails[0].value}</p> <p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{contact.emails[0].value}</p>
)} )}
{contact.phones[0] && ( {contact.phones[0] && (
<p className="truncate text-xs text-[#5f6368]"> <p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
{contact.phones[0].value} ({contact.phones[0].label}) {contact.phones[0].value} ({contact.phones[0].label})
</p> </p>
)} )}

View File

@ -11,6 +11,17 @@ import {
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PAGE_BANNER_CLASS,
CONTACTS_PAGE_LINK_BTN_CLASS,
CONTACTS_PAGE_TITLE_CLASS,
CONTACTS_TABLE_HEADER_CLASS,
CONTACTS_TABLE_ROW_CLASS,
CONTACTS_ICON_BTN_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
export function TrashView() { export function TrashView() {
const { deletedContacts, restoreContact, emptyTrash } = useContactsStore() const { deletedContacts, restoreContact, emptyTrash } = useContactsStore()
@ -23,45 +34,42 @@ export function TrashView() {
} }
return ( return (
<div className="px-6 py-4"> <div className="px-6 py-4 text-foreground">
{/* Warning banner */}
{deletedContacts.length > 0 && ( {deletedContacts.length > 0 && (
<div className="mb-4 flex items-center justify-between rounded-lg bg-[#fef7e0] px-4 py-3"> <div className={CONTACTS_PAGE_BANNER_CLASS}>
<p className="text-sm text-[#3c4043]"> <p className="text-sm text-foreground">
Les contacts qui sont dans la corbeille depuis plus de 30 jours seront supprimés définitivement Les contacts qui sont dans la corbeille depuis plus de 30 jours seront supprimés définitivement
</p> </p>
<button <button type="button" onClick={emptyTrash} className={cn("shrink-0", CONTACTS_PAGE_LINK_BTN_CLASS)}>
type="button"
onClick={emptyTrash}
className="shrink-0 text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
>
Vider la corbeille Vider la corbeille
</button> </button>
</div> </div>
)} )}
{/* Title */} <h1 className={cn("mb-4", CONTACTS_PAGE_TITLE_CLASS)}>
<h1 className="mb-4 text-2xl font-normal text-[#1f1f1f]">
Corbeille ({deletedContacts.length}) Corbeille ({deletedContacts.length})
</h1> </h1>
{deletedContacts.length === 0 && ( {deletedContacts.length === 0 && (
<p className="py-12 text-center text-sm text-[#5f6368]"> <p className={cn("py-12 text-center text-sm", CONTACTS_MUTED_TEXT)}>
La corbeille est vide La corbeille est vide
</p> </p>
)} )}
{deletedContacts.length > 0 && ( {deletedContacts.length > 0 && (
<> <>
{/* Table header */} <div
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] gap-2 border-b border-gray-200 py-2 text-xs font-medium text-[#5f6368]"> className={cn(
"grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] gap-2",
CONTACTS_TABLE_HEADER_CLASS,
)}
>
<span>Nom</span> <span>Nom</span>
<span>Raison du placement dans la corbeille</span> <span>Raison du placement dans la corbeille</span>
<span>Date de suppression</span> <span>Date de suppression</span>
<span /> <span />
</div> </div>
{/* Rows */}
{deletedContacts.map((entry) => { {deletedContacts.map((entry) => {
const { contact, deletedAt, reason } = entry const { contact, deletedAt, reason } = entry
const displayName = fullContactDisplayName(contact) const displayName = fullContactDisplayName(contact)
@ -72,7 +80,10 @@ export function TrashView() {
return ( return (
<div <div
key={contact.id} key={contact.id}
className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] items-center gap-2 border-b border-gray-100 py-3 text-sm" className={cn(
"grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1fr)_40px] items-center gap-2 py-3 text-sm",
CONTACTS_TABLE_ROW_CLASS,
)}
> >
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
{contact.avatarUrl ? ( {contact.avatarUrl ? (
@ -85,13 +96,13 @@ export function TrashView() {
{initial} {initial}
</span> </span>
)} )}
<span className="truncate text-[#1f1f1f]">{name}</span> <span className={cn("truncate", CONTACTS_HEADING_TEXT)}>{name}</span>
</span> </span>
<span className="truncate text-[#5f6368]">{reason}</span> <span className={cn("truncate", CONTACTS_MUTED_TEXT)}>{reason}</span>
<span className="text-[#5f6368]">{formatDate(deletedAt)}</span> <span className={CONTACTS_MUTED_TEXT}>{formatDate(deletedAt)}</span>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full text-[#5f6368]"> <Button variant="ghost" size="icon" className={cn("h-8 w-8 rounded-full", CONTACTS_ICON_BTN_CLASS)}>
<MoreVertical className="h-4 w-4" /> <MoreVertical className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@ -2,7 +2,6 @@
import { useMemo } from "react" import { useMemo } from "react"
import { import {
ArrowLeft,
Pencil, Pencil,
Star, Star,
X, X,
@ -23,6 +22,21 @@ import { avatarColor, senderInitial } from "@/lib/sender-display"
import { emails as allEmails } from "@/lib/email-data" import { emails as allEmails } from "@/lib/email-data"
import { useComposeActions } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import {
CONTACTS_HEADING_TEXT,
CONTACTS_MUTED_TEXT,
CONTACTS_PANEL_DIVIDER_CLASS,
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_PRIMARY_ACTION_CLASS,
CONTACTS_PANEL_ROW_CLASS,
CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS,
CONTACTS_PANEL_SHELL_CLASS,
CONTACTS_PANEL_TAG_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { ContactsPanelLogo } from "./contacts-panel-logo"
interface ContactDetailViewProps { interface ContactDetailViewProps {
contactId: string | null contactId: string | null
@ -52,7 +66,7 @@ function formatEmailDate(iso: string): string {
} }
export function ContactDetailView({ contactId }: ContactDetailViewProps) { export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const { contacts, setView, closePanel } = useContactsStore() const { contacts, setView, showContactsList, closePanel } = useContactsStore()
const { openComposeWithInitial } = useComposeActions() const { openComposeWithInitial } = useComposeActions()
const labelRows = useNavStore((s) => s.labelRows) const labelRows = useNavStore((s) => s.labelRows)
@ -78,7 +92,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
if (!contact) { if (!contact) {
return ( return (
<div className="flex h-full items-center justify-center text-sm text-gray-500"> <div className={cn("flex h-full items-center justify-center text-sm", CONTACTS_MUTED_TEXT)}>
Contact introuvable Contact introuvable
</div> </div>
) )
@ -91,24 +105,16 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const primaryEmail = contact.emails[0]?.value const primaryEmail = contact.emails[0]?.value
return ( return (
<div className="flex h-full min-w-0 flex-col overflow-hidden"> <div className={cn("flex h-full min-w-0 flex-col overflow-hidden", CONTACTS_PANEL_SHELL_CLASS)}>
{/* Header */} {/* Header */}
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2"> <div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<Button <ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
onClick={() => setView("list")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={() => setView("edit", contactId)} onClick={() => setView("edit", contactId)}
aria-label="Modifier" aria-label="Modifier"
> >
@ -118,7 +124,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-400" className={CONTACTS_PANEL_ICON_BTN_CLASS}
> >
<Star className="h-4 w-4" /> <Star className="h-4 w-4" />
</Button> </Button>
@ -126,7 +132,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={closePanel} onClick={closePanel}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -152,11 +158,11 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
{initial} {initial}
</div> </div>
)} )}
<h2 className="mt-3 max-w-full truncate px-2 text-center text-lg font-medium text-gray-900"> <h2 className={cn("mt-3 max-w-full truncate px-2 text-center text-lg font-medium", CONTACTS_HEADING_TEXT)}>
{name} {name}
</h2> </h2>
{contact.company && ( {contact.company && (
<p className="max-w-full truncate px-2 text-center text-sm text-gray-500"> <p className={cn("max-w-full truncate px-2 text-center text-sm", CONTACTS_MUTED_TEXT)}>
{contact.jobTitle ? `${contact.jobTitle}` : ""} {contact.jobTitle ? `${contact.jobTitle}` : ""}
{contact.company} {contact.company}
</p> </p>
@ -168,7 +174,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
return ( return (
<span <span
key={labelId} key={labelId}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-700" className={CONTACTS_PANEL_TAG_CLASS}
> >
{row && ( {row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} /> <span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
@ -186,7 +192,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
<div className="flex min-w-0 flex-wrap items-center justify-center gap-2 px-4 pb-4"> <div className="flex min-w-0 flex-wrap items-center justify-center gap-2 px-4 pb-4">
<button <button
type="button" type="button"
className="inline-flex h-9 items-center gap-2 rounded-full bg-[#d3e3fd] px-5 text-sm font-medium text-[#001d35] transition-colors hover:bg-[#c4d9fc]" className={CONTACTS_PANEL_PRIMARY_ACTION_CLASS}
onClick={() => onClick={() =>
openComposeWithInitial({ openComposeWithInitial({
to: [{ name: displayName, email: primaryEmail }], to: [{ name: displayName, email: primaryEmail }],
@ -198,13 +204,13 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
</button> </button>
<button <button
type="button" type="button"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
className="flex h-9 w-9 items-center justify-center rounded-full border border-[#dadce0] text-gray-500 hover:bg-gray-50" className={CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS}
> >
<Video className="h-4 w-4" /> <Video className="h-4 w-4" />
</button> </button>
@ -212,89 +218,89 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
)} )}
{/* Contact details */} {/* Contact details */}
<div className="min-w-0 border-t border-gray-100"> <div className={cn("min-w-0", CONTACTS_PANEL_DIVIDER_CLASS)}>
{contact.emails.length > 0 && ( {contact.emails.length > 0 && (
<DetailSection icon={<Mail className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<Mail className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.emails.map((e, i) => ( {contact.emails.map((e, i) => (
<div key={i}> <div key={i}>
<p className="truncate text-sm text-[#1a73e8]">{e.value}</p> <p className="truncate text-sm text-primary">{e.value}</p>
<p className="text-xs text-gray-500">{e.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{e.label}</p>
</div> </div>
))} ))}
</DetailSection> </DetailSection>
)} )}
{contact.phones.length > 0 && ( {contact.phones.length > 0 && (
<DetailSection icon={<Phone className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<Phone className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.phones.map((p, i) => ( {contact.phones.map((p, i) => (
<div key={i}> <div key={i}>
<p className="text-sm text-[#1a73e8]">{p.value}</p> <p className="text-sm text-primary">{p.value}</p>
<p className="text-xs text-gray-500">{p.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{p.label}</p>
</div> </div>
))} ))}
</DetailSection> </DetailSection>
)} )}
{contact.company && ( {contact.company && (
<DetailSection icon={<Building2 className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<Building2 className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div> <div>
<p className="text-sm text-gray-900">{contact.company}</p> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{contact.company}</p>
{contact.department && ( {contact.department && (
<p className="text-xs text-gray-500">{contact.department}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.department}</p>
)} )}
{contact.jobTitle && ( {contact.jobTitle && (
<p className="text-xs text-gray-500">{contact.jobTitle}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.jobTitle}</p>
)} )}
</div> </div>
</DetailSection> </DetailSection>
)} )}
{contact.addresses && contact.addresses.length > 0 && ( {contact.addresses && contact.addresses.length > 0 && (
<DetailSection icon={<MapPin className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<MapPin className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{contact.addresses.map((addr, i) => ( {contact.addresses.map((addr, i) => (
<div key={i}> <div key={i}>
<p className="break-words text-sm text-gray-900 [overflow-wrap:anywhere]"> <p className={cn("break-words text-sm [overflow-wrap:anywhere]", CONTACTS_HEADING_TEXT)}>
{[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country] {[addr.street, [addr.postalCode, addr.city].filter(Boolean).join(" "), addr.region, addr.country]
.filter(Boolean) .filter(Boolean)
.join(", ")} .join(", ")}
</p> </p>
<p className="text-xs text-gray-500">{addr.label}</p> <p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{addr.label}</p>
</div> </div>
))} ))}
</DetailSection> </DetailSection>
)} )}
{contact.birthday && (contact.birthday.day || contact.birthday.month) && ( {contact.birthday && (contact.birthday.day || contact.birthday.month) && (
<DetailSection icon={<Cake className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<Cake className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className="text-sm text-gray-900">{formatBirthday(contact.birthday)}</p> <p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{formatBirthday(contact.birthday)}</p>
</DetailSection> </DetailSection>
)} )}
{contact.notes && ( {contact.notes && (
<DetailSection icon={<FileText className="h-4.5 w-4.5 text-gray-400" />}> <DetailSection icon={<FileText className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{contact.notes}</p> <p className={cn("whitespace-pre-wrap text-sm", CONTACTS_HEADING_TEXT)}>{contact.notes}</p>
</DetailSection> </DetailSection>
)} )}
</div> </div>
{/* Recent interactions */} {/* Recent interactions */}
{recentInteractions.length > 0 && ( {recentInteractions.length > 0 && (
<div className="min-w-0 overflow-hidden border-t border-gray-100 pt-3 pb-4"> <div className={cn("min-w-0 overflow-hidden pt-3 pb-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<h3 className="px-4 pb-2 text-xs font-medium uppercase text-gray-500"> <h3 className={cn("px-4 pb-2 text-xs font-medium uppercase", CONTACTS_MUTED_TEXT)}>
Interactions récentes Interactions récentes
</h3> </h3>
{recentInteractions.map((email) => ( {recentInteractions.map((email) => (
<div <div
key={email.id} key={email.id}
className="flex min-w-0 gap-3 overflow-hidden px-4 py-2 hover:bg-gray-50" className={cn("flex min-w-0 gap-3 overflow-hidden px-4 py-2", CONTACTS_PANEL_ROW_CLASS)}
> >
<Mail className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" /> <Mail className={cn("mt-0.5 h-4 w-4 shrink-0", CONTACTS_PANEL_MUTED_ICON_CLASS)} />
<div className="min-w-0 flex-1 overflow-hidden"> <div className="min-w-0 flex-1 overflow-hidden">
<p className="truncate text-sm text-gray-900">{email.subject}</p> <p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{email.subject}</p>
<p className="line-clamp-2 break-words [overflow-wrap:anywhere] text-xs text-gray-500"> <p className={cn("line-clamp-2 break-words [overflow-wrap:anywhere] text-xs", CONTACTS_MUTED_TEXT)}>
{email.preview} {email.preview}
</p> </p>
<p className="mt-0.5 text-xs text-gray-400">{formatEmailDate(email.date)}</p> <p className={cn("mt-0.5 text-xs", CONTACTS_MUTED_TEXT)}>{formatEmailDate(email.date)}</p>
</div> </div>
</div> </div>
))} ))}

View File

@ -13,7 +13,6 @@ import { useForm, useFieldArray, Controller } from "react-hook-form"
import { z } from "zod" import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { import {
ArrowLeft,
Star, Star,
X, X,
User, User,
@ -45,6 +44,26 @@ import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import { useNavStore } from "@/lib/stores/nav-store" import { useNavStore } from "@/lib/stores/nav-store"
import {
CONTACTS_PANEL_ADD_TAG_BTN_CLASS,
CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS,
CONTACTS_PANEL_CARD_CLASS,
CONTACTS_PANEL_FLOATING_INPUT_CLASS,
CONTACTS_PANEL_FLOATING_LABEL_CLASS,
CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS,
CONTACTS_PANEL_HEADER_COMPACT_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_LINK_TEXT_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_POPOVER_ITEM_CLASS,
CONTACTS_PANEL_SAVE_BTN_CLASS,
CONTACTS_PANEL_SELECT_TRIGGER_CLASS,
CONTACTS_PANEL_SHELL_CLASS,
CONTACTS_PANEL_TAG_CLASS,
CONTACTS_MUTED_TEXT,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { ContactsPanelLogo } from "./contacts-panel-logo"
const FRENCH_MONTHS = [ const FRENCH_MONTHS = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin", "Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
@ -112,6 +131,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
addContact, addContact,
updateContact, updateContact,
setView, setView,
showContactsList,
closePanel, closePanel,
createDraft, createDraft,
clearCreateDraft, clearCreateDraft,
@ -302,22 +322,10 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
return ( return (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex h-full flex-col" className={cn("flex h-full flex-col", CONTACTS_PANEL_SHELL_CLASS)}
> >
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2"> <div className={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<Button <ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
onClick={() =>
mode === "edit" && contactId
? setView("view", contactId)
: setView("list")
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@ -328,14 +336,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
onClick={() => setStarred((s) => !s)} onClick={() => setStarred((s) => !s)}
> >
<Star <Star
className={`h-4 w-4 ${starred ? "fill-yellow-400 text-yellow-400" : "text-gray-400"}`} className={cn("h-4 w-4", starred ? "fill-yellow-400 text-yellow-400" : CONTACTS_PANEL_MUTED_ICON_CLASS)}
/> />
</Button> </Button>
<button <button
type="submit" type="submit"
disabled={!canSave} disabled={!canSave}
className="rounded-full bg-[#f1f3f4] px-5 h-9 text-sm font-medium text-[#3c4043] hover:bg-[#e8eaed] disabled:opacity-40 disabled:cursor-not-allowed transition-colors" className={CONTACTS_PANEL_SAVE_BTN_CLASS}
> >
Enregistrer Enregistrer
</button> </button>
@ -344,7 +352,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={closePanel} onClick={closePanel}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -363,7 +371,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
{senderInitial(displayName)} {senderInitial(displayName)}
</div> </div>
) : ( ) : (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gray-200 text-gray-500"> <div className={CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS}>
<User className="h-8 w-8" /> <User className="h-8 w-8" />
</div> </div>
)} )}
@ -376,7 +384,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
return ( return (
<span <span
key={labelId} key={labelId}
className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 text-xs text-gray-700" className={CONTACTS_PANEL_TAG_CLASS}
> >
{row && ( {row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} /> <span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
@ -385,7 +393,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
<button <button
type="button" type="button"
onClick={() => toggleLabel(labelId)} onClick={() => toggleLabel(labelId)}
className="text-gray-400 hover:text-gray-600" className="text-muted-foreground hover:text-foreground"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
@ -396,14 +404,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex items-center gap-1 rounded-full border border-gray-300 px-2.5 py-0.5 text-xs text-gray-600 hover:bg-gray-50" className={CONTACTS_PANEL_ADD_TAG_BTN_CLASS}
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Libellé Libellé
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-52 p-1" align="center"> <PopoverContent className="w-52 p-1" align="center">
<p className="px-2 py-1.5 text-xs font-medium text-gray-500"> <p className={cn("px-2 py-1.5 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
Libellés Libellés
</p> </p>
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
@ -414,7 +422,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
key={row.id} key={row.id}
type="button" type="button"
onClick={() => toggleLabel(row.id)} onClick={() => toggleLabel(row.id)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-gray-100" className={CONTACTS_PANEL_POPOVER_ITEM_CLASS}
> >
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
<span className="flex-1 truncate">{row.label}</span> <span className="flex-1 truncate">{row.label}</span>
@ -428,7 +436,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</div> </div>
{/* Name section */} {/* Name section */}
<FormSection icon={<User className="h-5 w-5 text-gray-400" />}> <FormSection icon={<User className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{nameExpanded && ( {nameExpanded && (
<FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} /> <FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />
)} )}
@ -440,7 +448,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
onClick={() => setNameExpanded((e) => !e)} onClick={() => setNameExpanded((e) => !e)}
> >
{nameExpanded ? ( {nameExpanded ? (
@ -464,7 +472,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Company section */} {/* Company section */}
<FormSection icon={<Building2 className="h-5 w-5 text-gray-400" />}> <FormSection icon={<Building2 className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="flex-1"> <div className="flex-1">
<FloatingInput label="Entreprise" {...register("company")} /> <FloatingInput label="Entreprise" {...register("company")} />
@ -473,7 +481,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
onClick={() => setCompanyExpanded((e) => !e)} onClick={() => setCompanyExpanded((e) => !e)}
> >
{companyExpanded ? ( {companyExpanded ? (
@ -490,7 +498,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Email section */} {/* Email section */}
<FormSection icon={<Mail className="h-5 w-5 text-gray-400" />}> <FormSection icon={<Mail className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{emailFields.map((field, index) => ( {emailFields.map((field, index) => (
<div key={field.id} className="space-y-2"> <div key={field.id} className="space-y-2">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -506,7 +514,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
onClick={() => removeEmail(index)} onClick={() => removeEmail(index)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -532,7 +540,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Phone section */} {/* Phone section */}
<FormSection icon={<Phone className="h-5 w-5 text-gray-400" />}> <FormSection icon={<Phone className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{phoneFields.map((field, index) => ( {phoneFields.map((field, index) => (
<div key={field.id} className="space-y-2"> <div key={field.id} className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -551,7 +559,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 shrink-0 rounded-full text-gray-400" className={cn("h-8 w-8 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
onClick={() => removePhone(index)} onClick={() => removePhone(index)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -577,9 +585,9 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Address section */} {/* Address section */}
<FormSection icon={<MapPin className="h-5 w-5 text-gray-400" />}> <FormSection icon={<MapPin className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
{addressFields.map((field, index) => ( {addressFields.map((field, index) => (
<div key={field.id} className="space-y-2 rounded-lg border border-gray-200 p-3"> <div key={field.id} className={CONTACTS_PANEL_CARD_CLASS}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Controller <Controller
control={control} control={control}
@ -596,7 +604,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 shrink-0 rounded-full text-gray-400" className={cn("h-7 w-7 shrink-0 rounded-full", CONTACTS_PANEL_ICON_BTN_CLASS)}
onClick={() => removeAddress(index)} onClick={() => removeAddress(index)}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -632,7 +640,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Birthday section */} {/* Birthday section */}
<FormSection icon={<Cake className="h-5 w-5 text-gray-400" />}> <FormSection icon={<Cake className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<div className="flex items-stretch gap-2"> <div className="flex items-stretch gap-2">
<div className="w-[72px]"> <div className="w-[72px]">
<FloatingInput <FloatingInput
@ -673,7 +681,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection> </FormSection>
{/* Notes section */} {/* Notes section */}
<FormSection icon={<FileText className="h-5 w-5 text-gray-400" />}> <FormSection icon={<FileText className={cn("h-5 w-5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<FloatingTextarea label="Notes" {...register("notes")} /> <FloatingTextarea label="Notes" {...register("notes")} />
</FormSection> </FormSection>
@ -711,7 +719,7 @@ function AddButton({
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex items-center gap-2 py-1 text-sm text-[#1a73e8] hover:text-[#1557b0]" className={cn("flex items-center gap-2 py-1", CONTACTS_PANEL_LINK_TEXT_CLASS, "hover:text-primary/80")}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
{children} {children}
@ -755,9 +763,7 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
id={id} id={id}
{...props} {...props}
defaultValue={defaultValue} defaultValue={defaultValue}
className={`peer h-[42px] w-full rounded border bg-white px-3 pt-4 pb-1 text-sm outline-none transition-colors ${ className={cn(CONTACTS_PANEL_FLOATING_INPUT_CLASS, className)}
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
} ${className ?? ""}`}
onFocus={(e) => { onFocus={(e) => {
setFocused(true) setFocused(true)
props.onFocus?.(e) props.onFocus?.(e)
@ -774,11 +780,11 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
/> />
<label <label
htmlFor={id} htmlFor={id}
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${ className={cn(
floated CONTACTS_PANEL_FLOATING_LABEL_CLASS,
? "top-0.5 px-0.5 text-[10px] leading-tight" floated ? "top-0.5 px-0.5 text-[10px] leading-tight" : "top-[11px] text-sm",
: "top-[11px] text-sm" focused ? "text-primary" : "text-muted-foreground",
} ${focused ? "text-blue-600" : "text-gray-500"}`} )}
> >
{label} {label}
</label> </label>
@ -824,9 +830,7 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
id={id} id={id}
rows={3} rows={3}
{...props} {...props}
className={`peer w-full rounded border bg-white px-3 pt-5 pb-2 text-sm outline-none transition-colors resize-none ${ className={cn(CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS, className)}
focused ? "border-blue-500 ring-1 ring-blue-500" : "border-gray-300"
} ${className ?? ""}`}
onFocus={(e) => { onFocus={(e) => {
setFocused(true) setFocused(true)
props.onFocus?.(e) props.onFocus?.(e)
@ -843,11 +847,11 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
/> />
<label <label
htmlFor={id} htmlFor={id}
className={`pointer-events-none absolute left-3 bg-white transition-all duration-150 ${ className={cn(
floated CONTACTS_PANEL_FLOATING_LABEL_CLASS,
? "top-1 px-0.5 text-[10px] leading-tight" floated ? "top-1 px-0.5 text-[10px] leading-tight" : "top-2.5 text-sm",
: "top-2.5 text-sm" focused ? "text-primary" : "text-muted-foreground",
} ${focused ? "text-blue-600" : "text-gray-500"}`} )}
> >
{label} {label}
</label> </label>
@ -871,7 +875,7 @@ function CompactSelect({
}) { }) {
return ( return (
<Select value={value} onValueChange={onValueChange}> <Select value={value} onValueChange={onValueChange}>
<SelectTrigger className="!h-[42px] !min-h-[42px] w-full rounded border border-gray-300 bg-white px-3 py-0 text-sm shadow-none data-[size=default]:!h-[42px] focus:border-blue-500 focus:ring-1 focus:ring-blue-500"> <SelectTrigger className={CONTACTS_PANEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder ?? "Choisir..."} /> <SelectValue placeholder={placeholder ?? "Choisir..."} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@ -2,6 +2,12 @@
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types" import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
CONTACTS_PANEL_ROW_CLASS,
CONTACTS_MUTED_TEXT,
CONTACTS_HEADING_TEXT,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactRowProps { interface ContactRowProps {
contact: FullContact contact: FullContact
@ -19,27 +25,30 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex w-full items-center gap-3 px-4 h-14 hover:bg-gray-50 cursor-pointer text-left" className={cn(
"flex h-14 w-full items-center gap-3 px-4 text-left",
CONTACTS_PANEL_ROW_CLASS,
)}
> >
{contact.avatarUrl ? ( {contact.avatarUrl ? (
<img <img
src={contact.avatarUrl} src={contact.avatarUrl}
alt={name} alt={name}
className="h-10 w-10 rounded-full object-cover shrink-0" className="h-10 w-10 shrink-0 rounded-full object-cover"
/> />
) : ( ) : (
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white font-medium text-sm" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: bgColor }} style={{ backgroundColor: bgColor }}
> >
{initial} {initial}
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900">{name}</div> <p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{name}</p>
{subtitle && displayName && ( {subtitle && displayName ? (
<div className="truncate text-xs text-gray-500">{subtitle}</div> <p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{subtitle}</p>
)} ) : null}
</div> </div>
</button> </button>
) )

View File

@ -8,7 +8,21 @@ import { ScrollArea } from "@/components/ui/scroll-area"
import { useContactsStore } from "@/lib/contacts/contacts-store" import { useContactsStore } from "@/lib/contacts/contacts-store"
import { searchContacts } from "@/lib/contacts/fuzzy-search" import { searchContacts } from "@/lib/contacts/fuzzy-search"
import { fullContactDisplayName } from "@/lib/contacts/types" import { fullContactDisplayName } from "@/lib/contacts/types"
import {
CONTACTS_PANEL_CREATE_ROW_CLASS,
CONTACTS_PANEL_HEADER_CLASS,
CONTACTS_PANEL_HEADER_SEARCH_CLASS,
CONTACTS_PANEL_ICON_BTN_CLASS,
CONTACTS_PANEL_LETTER_CLASS,
CONTACTS_PANEL_LINK_TEXT_CLASS,
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_SEARCH_INPUT_CLASS,
CONTACTS_PANEL_SECTION_LABEL_CLASS,
CONTACTS_PANEL_SHELL_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
import { ContactRow } from "./contact-row" import { ContactRow } from "./contact-row"
import { ContactsPanelLogo } from "./contacts-panel-logo"
export function ContactsListView() { export function ContactsListView() {
const { const {
@ -18,6 +32,7 @@ export function ContactsListView() {
setSearchMode, setSearchMode,
setSearchQuery, setSearchQuery,
setView, setView,
showContactsList,
closePanel, closePanel,
} = useContactsStore() } = useContactsStore()
@ -61,22 +76,22 @@ export function ContactsListView() {
}, [filteredContacts]) }, [filteredContacts])
function exitSearch() { function exitSearch() {
setSearchQuery("") showContactsList()
setSearchMode(false)
} }
if (searchMode) { if (searchMode) {
return ( return (
<div className="flex h-full flex-col"> <div className={CONTACTS_PANEL_SHELL_CLASS}>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-gray-200 px-4"> <div className={cn(CONTACTS_PANEL_HEADER_SEARCH_CLASS, "gap-2")}>
<Search className="h-4 w-4 shrink-0 text-gray-500" /> <ContactsPanelLogo onClick={exitSearch} compact className="-ml-1 shrink-0" />
<Search className={`h-4 w-4 shrink-0 ${CONTACTS_PANEL_MUTED_ICON_CLASS}`} />
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Recherche..." placeholder="Recherche..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-gray-400" className={CONTACTS_PANEL_SEARCH_INPUT_CLASS}
/> />
<Button <Button
variant="ghost" variant="ghost"
@ -102,14 +117,14 @@ export function ContactsListView() {
} }
return ( return (
<div className="flex h-full flex-col"> <div className={CONTACTS_PANEL_SHELL_CLASS}>
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-4"> <div className={CONTACTS_PANEL_HEADER_CLASS}>
<span className="text-lg font-medium text-gray-900">Contacts</span> <ContactsPanelLogo onClick={showContactsList} className="-ml-1" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={() => setSearchMode(true)} onClick={() => setSearchMode(true)}
> >
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
@ -117,7 +132,7 @@ export function ContactsListView() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
asChild asChild
> >
<Link href="/contacts"> <Link href="/contacts">
@ -127,7 +142,7 @@ export function ContactsListView() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full text-gray-600" className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={closePanel} onClick={closePanel}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -136,14 +151,12 @@ export function ContactsListView() {
</div> </div>
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<CreateContactButton onClick={() => setView("create")} /> <CreateContactButton onClick={() => setView("create")} />
<div className="px-4 py-2 text-xs font-medium text-gray-500"> <div className={CONTACTS_PANEL_SECTION_LABEL_CLASS}>
Contacts ({contacts.length}) Contacts ({contacts.length})
</div> </div>
{groupedContacts.map((group) => ( {groupedContacts.map((group) => (
<div key={group.letter}> <div key={group.letter}>
<div className="px-4 py-1 text-xs font-medium uppercase text-gray-500"> <div className={CONTACTS_PANEL_LETTER_CLASS}>{group.letter}</div>
{group.letter}
</div>
{group.items.map((contact) => ( {group.items.map((contact) => (
<ContactRow <ContactRow
key={contact.id} key={contact.id}
@ -163,12 +176,12 @@ function CreateContactButton({ onClick }: { onClick: () => void }) {
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className="flex w-full items-center gap-3 px-4 h-12 hover:bg-gray-50 cursor-pointer" className={CONTACTS_PANEL_CREATE_ROW_CLASS}
> >
<div className="flex h-10 w-10 items-center justify-center"> <div className="flex h-10 w-10 items-center justify-center">
<Plus className="h-5 w-5 text-[#1a73e8]" /> <Plus className="h-5 w-5 text-primary" />
</div> </div>
<span className="text-sm font-medium text-[#1a73e8]">Créer un contact</span> <span className={CONTACTS_PANEL_LINK_TEXT_CLASS}>Créer un contact</span>
</button> </button>
) )
} }

View File

@ -0,0 +1,44 @@
"use client"
import { Users } from "lucide-react"
import { cn } from "@/lib/utils"
import {
CONTACTS_PANEL_MUTED_ICON_CLASS,
CONTACTS_PANEL_TITLE_CLASS,
} from "@/lib/contacts-chrome-classes"
type ContactsPanelLogoProps = {
onClick: () => void
className?: string
/** Titre plus compact (barre détail / formulaire). */
compact?: boolean
}
export function ContactsPanelLogo({
onClick,
className,
compact = false,
}: ContactsPanelLogoProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex min-w-0 items-center gap-2 rounded-full px-1 py-0.5 text-left transition-colors hover:bg-accent",
className,
)}
aria-label="Liste des contacts"
>
<Users
className={cn(
"shrink-0",
compact ? "h-5 w-5" : "h-6 w-6",
CONTACTS_PANEL_MUTED_ICON_CLASS,
)}
/>
<span className={cn(CONTACTS_PANEL_TITLE_CLASS, compact && "text-base")}>
Contacts
</span>
</button>
)
}

View File

@ -37,7 +37,8 @@ export function ContactsPanel() {
side="right" side="right"
hideClose hideClose
overlayClassName="bg-transparent" overlayClassName="bg-transparent"
className="w-[360px] sm:max-w-[360px] p-0 gap-0" data-contacts-panel
className="w-[360px] sm:max-w-[360px] gap-0 border-border bg-mail-surface p-0 text-foreground"
> >
<SheetTitle className="sr-only">Contacts</SheetTitle> <SheetTitle className="sr-only">Contacts</SheetTitle>
{view === "list" && <ContactsListView />} {view === "list" && <ContactsListView />}

View File

@ -24,6 +24,7 @@ import {
Star, Star,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronUp,
MoreVertical, MoreVertical,
RefreshCw, RefreshCw,
ChevronDown, ChevronDown,
@ -103,14 +104,30 @@ import {
} from "@/components/gmail/mail-label-pills" } from "@/components/gmail/mail-label-pills"
import { import {
emails, emails,
getThreadMessageCount,
type Email, type Email,
type EmailAttachment, type EmailAttachment,
} from "@/lib/email-data" } from "@/lib/email-data"
import {
getThreadMessageCount,
isListRowRead,
isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread"
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"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated" import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { useIsMd } from "@/hooks/use-md-breakpoint"
import { threadStoreId } from "@/lib/mail-settings/list-row-id"
import { resolveOpenEmailView } from "@/lib/mail-settings/resolve-open-email"
import { sortEmailsForInbox } from "@/lib/mail-settings/sort-emails"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import {
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
MAIL_MENU_SURFACE_CLASS,
MAIL_MENU_SURFACE_WIDE_CLASS,
MAIL_TOOLBAR_ICON_BTN,
} from "@/lib/mail-chrome-classes"
import { import {
emailMatchesFolder, emailMatchesFolder,
emailMatchesInboxPrimaryTab, emailMatchesInboxPrimaryTab,
@ -170,6 +187,11 @@ import type { LabelEditState } from "@/lib/stores/mail-store"
import type { MailRouteState } from "@/lib/mail-url" import type { MailRouteState } from "@/lib/mail-url"
import { readXsMatches, useIsXs } from "@/hooks/use-xs" import { readXsMatches, useIsXs } from "@/hooks/use-xs"
import { useTouchNav } from "@/hooks/use-touch-nav" import { useTouchNav } from "@/hooks/use-touch-nav"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import {
buildThreadComposePreset,
withTouchFullscreenComposePreset,
} from "@/lib/thread-compose-preset"
addCollection(mdiIcons) addCollection(mdiIcons)
@ -347,7 +369,7 @@ function inboxTabBadgeCountClass(badgeColor: string) {
function inboxTabBadgeDotClass(badgeColor: string) { function inboxTabBadgeDotClass(badgeColor: string) {
return cn( return cn(
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white", "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface",
badgeColor badgeColor
) )
} }
@ -570,6 +592,8 @@ interface EmailListProps {
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
/** Barre basse xs en lecture dun message. */
onXsViewChromeChange?: (chrome: MailXsViewChrome | null) => void
} }
function listRowCheckboxClass(circular: boolean) { function listRowCheckboxClass(circular: boolean) {
@ -581,10 +605,10 @@ function listRowCheckboxClass(circular: boolean) {
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) { function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
return isSelected return isSelected
? "bg-[#e8f0fe]" ? "bg-mail-row-selected"
: isRead : isRead
? "bg-[#f5f5f5]" ? "bg-mail-row-read"
: "bg-white" : "bg-mail-row-unread"
} }
export function EmailList({ export function EmailList({
@ -597,6 +621,7 @@ export function EmailList({
onMailRouteNavigate, onMailRouteNavigate,
onSelectFolder, onSelectFolder,
onFolderUnreadCountsChange, onFolderUnreadCountsChange,
onXsViewChromeChange,
}: EmailListProps) { }: EmailListProps) {
const isViewMode = openMailId !== null && !splitView const isViewMode = openMailId !== null && !splitView
const showSplitReadingPane = splitView && openMailId !== null const showSplitReadingPane = splitView && openMailId !== null
@ -634,6 +659,11 @@ export function EmailList({
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails] [scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
) )
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const sidebarNav = useSidebarNav() const sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>( const navMaps = useMemo<MailNavFolderMaps>(
() => ({ () => ({
@ -715,10 +745,12 @@ export function EmailList({
if (!openMailId) { if (!openMailId) {
closeAllInlineComposes() closeAllInlineComposes()
} else { } else {
pruneInlineComposesToOpenThread(openMailId) const msg = emailById.get(openMailId)
pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId)
} }
}, [ }, [
openMailId, openMailId,
emailById,
closeAllInlineComposes, closeAllInlineComposes,
pruneInlineComposesToOpenThread, pruneInlineComposesToOpenThread,
]) ])
@ -728,6 +760,10 @@ export function EmailList({
const importantEmails = useMailStore((s) => s.importantIds) const importantEmails = useMailStore((s) => s.importantIds)
const [selectedEmails, setSelectedEmails] = useState<string[]>([]) const [selectedEmails, setSelectedEmails] = useState<string[]>([])
const readOverrides = useMailStore((s) => s.readOverrides) const readOverrides = useMailStore((s) => s.readOverrides)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const density = useMailSettingsStore((s) => s.density)
const isMd = useIsMd()
const labelEdits = useMailStore((s) => s.labelEdits) const labelEdits = useMailStore((s) => s.labelEdits)
const mailActions = useRef(useMailStore.getState()).current const mailActions = useRef(useMailStore.getState()).current
const setReadOverrides = useCallback( const setReadOverrides = useCallback(
@ -995,6 +1031,31 @@ export function EmailList({
navMaps, navMaps,
]) ])
const displayListEmails = useMemo(() => {
let rows = filteredEmails
if (conversationMode) {
rows = rows.filter(isThreadHeadMessage)
}
return sortEmailsForInbox(
rows,
inboxSort,
{
readOverrides,
starredIds: starredEmails,
importantIds: importantEmails,
},
{ conversationMode, byId: emailById }
)
}, [
filteredEmails,
conversationMode,
inboxSort,
readOverrides,
starredEmails,
importantEmails,
emailById,
])
const inboxCategoryTabLabel = useMemo( const inboxCategoryTabLabel = useMemo(
() => () =>
inboxTabDisplayLabel( inboxTabDisplayLabel(
@ -1006,8 +1067,11 @@ export function EmailList({
) )
const mobileUnreadCount = useMemo( const mobileUnreadCount = useMemo(
() => filteredEmails.filter((e) => !(readOverrides[e.id] ?? e.read)).length, () =>
[filteredEmails, readOverrides] displayListEmails.filter(
(e) => !isListRowRead(e, readOverrides, emailById, conversationMode)
).length,
[displayListEmails, readOverrides, emailById, conversationMode]
) )
const mobileFolderLabel = useMemo(() => { const mobileFolderLabel = useMemo(() => {
@ -1028,21 +1092,21 @@ export function EmailList({
}, [selectedFolder, inboxTab]) }, [selectedFolder, inboxTab])
const totalPages = useMemo( const totalPages = useMemo(
() => Math.max(1, Math.ceil(filteredEmails.length / LIST_PAGE_SIZE)), () => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
[filteredEmails.length] [displayListEmails.length]
) )
const pagedEmails = useMemo(() => { const pagedEmails = useMemo(() => {
const start = (listPage - 1) * LIST_PAGE_SIZE const start = (listPage - 1) * LIST_PAGE_SIZE
return filteredEmails.slice(start, start + LIST_PAGE_SIZE) return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [filteredEmails, listPage]) }, [displayListEmails, listPage])
const listEmails = useMemo(() => { const listEmails = useMemo(() => {
if (isXs && !isViewMode) { if (isXs && !isViewMode) {
return filteredEmails.slice(0, mobileVisibleCount) return displayListEmails.slice(0, mobileVisibleCount)
} }
return pagedEmails return pagedEmails
}, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails]) }, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails]) const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
@ -1108,19 +1172,19 @@ export function EmailList({
if (!root || !isXs || isViewMode) return if (!root || !isXs || isViewMode) return
const onScroll = () => { const onScroll = () => {
if (mobileVisibleCount >= filteredEmails.length) return if (mobileVisibleCount >= displayListEmails.length) return
const nearBottom = const nearBottom =
root.scrollTop + root.clientHeight >= root.scrollHeight - 120 root.scrollTop + root.clientHeight >= root.scrollHeight - 120
if (nearBottom) { if (nearBottom) {
setMobileVisibleCount((prev) => setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, filteredEmails.length) Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
) )
} }
} }
root.addEventListener("scroll", onScroll, { passive: true }) root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll) return () => root.removeEventListener("scroll", onScroll)
}, [isXs, isViewMode, mobileVisibleCount, filteredEmails.length]) }, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length])
useEffect(() => { useEffect(() => {
const root = listViewportRef.current const root = listViewportRef.current
@ -1693,10 +1757,14 @@ export function EmailList({
const markAllInViewAsRead = useCallback(() => { const markAllInViewAsRead = useCallback(() => {
setReadOverrides((prev) => { setReadOverrides((prev) => {
const next = { ...prev } const next = { ...prev }
for (const e of filteredEmails) next[e.id] = true for (const e of displayListEmails) {
for (const id of readStateTargets(e, conversationMode)) {
next[id] = true
}
}
return next return next
}) })
}, [filteredEmails]) }, [displayListEmails, conversationMode])
const bulkMoveTo = useCallback( const bulkMoveTo = useCallback(
(targetId: string) => { (targetId: string) => {
@ -1710,30 +1778,65 @@ export function EmailList({
) )
// --- View mode helpers --- // --- View mode helpers ---
const openEmail = useMemo(() => { const openEmailView = useMemo(() => {
if (!openMailId) return null if (!openMailId) return null
const raw = allEmails.find((e) => e.id === openMailId) ?? null const resolved = resolveOpenEmailView(
if (!raw) return null openMailId,
if (raw.labels?.includes("scheduled")) return null allEmails,
return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds) conversationMode
}, [openMailId, labelEdits, allEmails, notSpamEmailIds]) )
if (!resolved) return null
if (resolved.email.labels?.includes("scheduled")) return null
const email = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.email, labelEdits),
notSpamEmailIds
)
const threadRoot = mergeEmailNotSpam(
mergeEmailLabelEdits(resolved.threadRoot, labelEdits),
notSpamEmailIds
)
return {
email,
threadRoot,
isSingleMessageView: resolved.isSingleMessageView,
}
}, [openMailId, labelEdits, allEmails, notSpamEmailIds, conversationMode])
const openEmail = openEmailView?.email ?? null
const openEmailThreadRoot = openEmailView?.threadRoot ?? null
const isSingleMessageView = openEmailView?.isSingleMessageView ?? false
const openMailIndex = useMemo( const openMailIndex = useMemo(
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1), () =>
[openMailId, filteredEmails] openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1,
[openMailId, displayListEmails]
) )
useEffect(() => { useEffect(() => {
if (!openMailId) return if (!openMailId) return
markEmailSeen(openMailId) const message = emailById.get(openMailId)
setReadOverrides((prev) => if (!message) return
prev[openMailId] !== undefined ? prev : { ...prev, [openMailId]: true } const targets = readStateTargets(message, conversationMode)
) for (const id of targets) {
}, [openMailId, markEmailSeen]) markEmailSeen(id)
}
setReadOverrides((prev) => {
let changed = false
const next = { ...prev }
for (const id of targets) {
if (next[id] === undefined) {
next[id] = true
changed = true
}
}
return changed ? next : prev
})
}, [openMailId, markEmailSeen, emailById, conversationMode])
const navigateToMail = useCallback( const navigateToMail = useCallback(
(id: string | null) => { (id: string | null) => {
if (id && splitView) { if (id && splitView) {
const idx = filteredEmails.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 / LIST_PAGE_SIZE) + 1
onMailRouteNavigate({ mailId: id, page }) onMailRouteNavigate({ mailId: id, page })
@ -1742,7 +1845,7 @@ export function EmailList({
} }
onMailRouteNavigate({ mailId: id }) onMailRouteNavigate({ mailId: id })
}, },
[splitView, filteredEmails, onMailRouteNavigate] [splitView, displayListEmails, onMailRouteNavigate]
) )
useEffect(() => { useEffect(() => {
@ -1755,13 +1858,13 @@ export function EmailList({
const pickAdjacentMailId = useCallback( const pickAdjacentMailId = useCallback(
(currentId: string) => { (currentId: string) => {
const idx = filteredEmails.findIndex((e) => e.id === currentId) const idx = displayListEmails.findIndex((e) => e.id === currentId)
if (idx < 0) return filteredEmails[0]?.id ?? null if (idx < 0) return displayListEmails[0]?.id ?? null
if (idx < filteredEmails.length - 1) return filteredEmails[idx + 1]!.id if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id
if (idx > 0) return filteredEmails[idx - 1]!.id if (idx > 0) return displayListEmails[idx - 1]!.id
return null return null
}, },
[filteredEmails] [displayListEmails]
) )
const leaveReadingPane = useCallback(() => { const leaveReadingPane = useCallback(() => {
@ -1893,21 +1996,21 @@ export function EmailList({
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
if (openMailIndex > 0) { if (openMailIndex > 0) {
const id = filteredEmails[openMailIndex - 1]!.id const id = displayListEmails[openMailIndex - 1]!.id
markEmailSeen(id) markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true })) setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id) navigateToMail(id)
} }
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen]) }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen])
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
if (openMailIndex >= 0 && openMailIndex < filteredEmails.length - 1) { if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) {
const id = filteredEmails[openMailIndex + 1]!.id const id = displayListEmails[openMailIndex + 1]!.id
markEmailSeen(id) markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true })) setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id) navigateToMail(id)
} }
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen]) }, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen])
const handleOpenEmail = useCallback( const handleOpenEmail = useCallback(
(id: string) => { (id: string) => {
@ -2016,9 +2119,40 @@ export function EmailList({
[openMailId, afterSingleMessageRemoved, moveEmailsToTarget] [openMailId, afterSingleMessageRemoved, moveEmailsToTarget]
) )
const singleReply = useCallback(() => {
if (!openEmail) return
openComposeWithInitial(
withTouchFullscreenComposePreset(buildThreadComposePreset(openEmail, "reply"))
)
}, [openEmail, openComposeWithInitial])
useEffect(() => {
if (!onXsViewChromeChange) return
if (!isXs || !isViewMode || !openEmail) {
onXsViewChromeChange(null)
return
}
onXsViewChromeChange({
onArchive: singleArchive,
onReply: singleReply,
moveTargets,
onMoveTo: singleMoveTo,
})
return () => onXsViewChromeChange(null)
}, [
onXsViewChromeChange,
isXs,
isViewMode,
openEmail,
singleArchive,
singleReply,
singleMoveTo,
moveTargets,
])
useEffect(() => { useEffect(() => {
if (!splitView) return if (!splitView) return
const firstId = filteredEmails[0]?.id ?? null const firstId = displayListEmails[0]?.id ?? null
if (!openMailId) { if (!openMailId) {
if (firstId) navigateToMail(firstId) if (firstId) navigateToMail(firstId)
return return
@ -2028,7 +2162,7 @@ export function EmailList({
navigateToMail(firstId) navigateToMail(firstId)
return return
} }
if (!filteredEmails.some((e) => e.id === openMailId)) { if (!displayListEmails.some((e) => e.id === openMailId)) {
navigateToMail(firstId) navigateToMail(firstId)
} }
}, [ }, [
@ -2036,7 +2170,7 @@ export function EmailList({
selectedFolder, selectedFolder,
inboxTab, inboxTab,
listPage, listPage,
filteredEmails, displayListEmails,
openMailId, openMailId,
navigateToMail, navigateToMail,
allEmails, allEmails,
@ -2112,8 +2246,7 @@ export function EmailList({
return () => window.removeEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler)
}, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext]) }, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext])
const dropdownSurfaceClass = const dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
"min-w-[220px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-trigger]]:gap-3 [&_[data-slot=dropdown-menu-sub-trigger]]:rounded-none [&_[data-slot=dropdown-menu-sub-trigger]]:px-3 [&_[data-slot=dropdown-menu-sub-trigger]]:py-2 [&_[data-slot=dropdown-menu-sub-trigger]]:text-sm [&_[data-slot=dropdown-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-sub-content]]:min-w-[200px] [&_[data-slot=dropdown-menu-sub-content]]:rounded-lg [&_[data-slot=dropdown-menu-sub-content]]:border [&_[data-slot=dropdown-menu-sub-content]]:border-[#dadce0] [&_[data-slot=dropdown-menu-sub-content]]:bg-white [&_[data-slot=dropdown-menu-sub-content]]:p-0 [&_[data-slot=dropdown-menu-sub-content]]:py-1 [&_[data-slot=dropdown-menu-sub-content]]:shadow-lg [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
const listToolbarMode = splitView || !isViewMode const listToolbarMode = splitView || !isViewMode
/** xs + split : icône (+ point si non lus) ; libellé uniquement sur longlet actif. */ /** xs + split : icône (+ point si non lus) ; libellé uniquement sur longlet actif. */
@ -2369,17 +2502,17 @@ export function EmailList({
mode === "list" && "max-sm:hidden sm:flex" mode === "list" && "max-sm:hidden sm:flex"
)} )}
> >
{filteredEmails.length === 0 ? ( {displayListEmails.length === 0 ? (
<span>Aucun résultat</span> <span>Aucun résultat</span>
) : mode === "view" ? ( ) : mode === "view" ? (
<span className="hidden sm:inline"> <span className="hidden sm:inline">
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {filteredEmails.length} {openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {displayListEmails.length}
</span> </span>
) : ( ) : (
<span> <span>
{(listPage - 1) * LIST_PAGE_SIZE + 1} {(listPage - 1) * LIST_PAGE_SIZE + 1}
{Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "} {Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
{filteredEmails.length} {displayListEmails.length}
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null} {totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
</span> </span>
)} )}
@ -2414,7 +2547,7 @@ export function EmailList({
size="icon" size="icon"
className={cn( className={cn(
"h-9 w-9", "h-9 w-9",
mode === "view" && openMailIndex < filteredEmails.length - 1 mode === "view" && openMailIndex < displayListEmails.length - 1
? "text-gray-600" ? "text-gray-600"
: mode === "list" && listPage < totalPages : mode === "list" && listPage < totalPages
? "text-gray-600" ? "text-gray-600"
@ -2422,7 +2555,7 @@ export function EmailList({
)} )}
disabled={ disabled={
mode === "view" mode === "view"
? openMailIndex >= filteredEmails.length - 1 ? openMailIndex >= displayListEmails.length - 1
: listPage >= totalPages : listPage >= totalPages
} }
onClick={mode === "view" ? goToNext : goListNextPage} onClick={mode === "view" ? goToNext : goListNextPage}
@ -2439,7 +2572,7 @@ export function EmailList({
) )
const mainScrollClass = const mainScrollClass =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-b-2xl border-0 bg-white shadow-none outline-none " + "min-h-0 flex-1 overflow-y-auto overflow-x-hidden border-0 bg-mail-surface shadow-none outline-none sm:rounded-b-2xl " +
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " + "[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " + "[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " + "[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
@ -2451,13 +2584,13 @@ export function EmailList({
<div className="flex h-full min-h-0 flex-1 flex-col"> <div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */} {/* Mobile xs top bar */}
{!isViewMode && ( {!isViewMode && (
<div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-4 py-2.5 sm:hidden"> <div className="relative z-20 flex shrink-0 items-center gap-2 border-b border-border bg-mail-surface dark:bg-zinc-800 px-4 py-2.5 sm:hidden">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight"> <h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel} {mobileFolderLabel}
</h1> </h1>
<p className="text-xs text-[#5f6368] leading-snug"> <p className="text-xs text-[#5f6368] leading-snug">
{filteredEmails.length} message{filteredEmails.length !== 1 ? "s" : ""} {displayListEmails.length} message{displayListEmails.length !== 1 ? "s" : ""}
{mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`} {mobileUnreadCount > 0 && ` · ${mobileUnreadCount} non lu${mobileUnreadCount !== 1 ? "s" : ""}`}
</p> </p>
</div> </div>
@ -2468,8 +2601,8 @@ export function EmailList({
className={cn( className={cn(
"shrink-0 text-[#444746]", "shrink-0 text-[#444746]",
mobileSelectionMode mobileSelectionMode
? "size-9 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur" ? "size-9 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur hover:bg-white"
: "h-9 min-h-9 gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 text-xs font-medium shadow-md backdrop-blur" : "h-9 min-h-9 gap-1.5 rounded-full border border-gray-200 bg-white/80 px-3 text-xs font-medium shadow-md backdrop-blur hover:bg-white"
)} )}
onClick={() => { onClick={() => {
setMobileSelectionMode((p) => !p) setMobileSelectionMode((p) => !p)
@ -2495,7 +2628,7 @@ export function EmailList({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur" className="size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
aria-label="Plus d'actions" aria-label="Plus d'actions"
> >
<MoreVertical className="size-[18px]" strokeWidth={1.5} /> <MoreVertical className="size-[18px]" strokeWidth={1.5} />
@ -2580,6 +2713,7 @@ export function EmailList({
</DropdownMenu> </DropdownMenu>
</div> </div>
)} )}
{/* View-mode xs nav buttons are rendered inside the scroll area below */}
{!isViewMode && touchNav && ( {!isViewMode && touchNav && (
<MobileXsBulkSheets <MobileXsBulkSheets
moveSheetOpen={isXs && mobileXsMoveSheetOpen} moveSheetOpen={isXs && mobileXsMoveSheetOpen}
@ -2611,7 +2745,7 @@ export function EmailList({
)} )}
> >
{splitView ? ( {splitView ? (
<div className="flex max-sm:hidden shrink-0 items-center gap-2 border-b border-gray-200 bg-white px-2 py-2"> <div className="flex max-sm:hidden shrink-0 items-center gap-2 border-b border-border bg-mail-surface px-2 py-2">
{onToggleSidebar ? ( {onToggleSidebar ? (
<Button <Button
type="button" type="button"
@ -2631,8 +2765,8 @@ export function EmailList({
{/* Toolbar — relative: scroll lives in sibling below */} {/* Toolbar — relative: scroll lives in sibling below */}
<div <div
className={cn( className={cn(
"relative z-20 flex shrink-0 min-h-12 gap-2 border-b border-gray-200 bg-white py-1.5 pl-2 pr-4", "relative z-20 flex shrink-0 min-h-12 gap-2 border-b border-border bg-mail-surface py-1.5 pl-2 pr-4",
splitView ? "rounded-none" : "rounded-t-2xl", splitView ? "rounded-none" : "sm:rounded-t-2xl",
isViewMode ? "items-start" : "items-center", isViewMode ? "items-start" : "items-center",
(isViewMode ? !listToolbarMode : true) && "max-sm:hidden" (isViewMode ? !listToolbarMode : true) && "max-sm:hidden"
)} )}
@ -2659,7 +2793,7 @@ export function EmailList({
<Checkbox <Checkbox
checked={selectAllChecked} checked={selectAllChecked}
onCheckedChange={handleSelectAllChange} onCheckedChange={handleSelectAllChange}
className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-white shadow-none dark:bg-white focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white data-[state=indeterminate]:border-[#1a73e8] data-[state=indeterminate]:bg-[#1a73e8] data-[state=indeterminate]:text-white" className="size-4 min-h-4 min-w-4 shrink-0 rounded-[2.5px] border-[1.5px] border-[#c2c2c2] bg-transparent shadow-none dark:bg-transparent focus-visible:ring-[#c2c2c2]/30 data-[state=checked]:border-[#1a73e8] data-[state=checked]:bg-[#1a73e8] data-[state=checked]:text-white data-[state=indeterminate]:border-[#1a73e8] data-[state=indeterminate]:bg-[#1a73e8] data-[state=indeterminate]:text-white"
/> />
</div> </div>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -2964,7 +3098,7 @@ export function EmailList({
</div> </div>
{selectedFolder === "inbox" && ( {selectedFolder === "inbox" && (
<div className="relative z-10 w-full shrink-0 bg-white after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-[#dadce0]"> <div className="relative z-10 w-full shrink-0 bg-mail-surface dark:bg-zinc-800 sm:dark:bg-mail-surface after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:z-0 after:h-px after:bg-border">
{listToolbarMode && {listToolbarMode &&
(compactInboxTabs ? ( (compactInboxTabs ? (
<CompactInboxCategoryTabs <CompactInboxCategoryTabs
@ -3017,6 +3151,7 @@ export function EmailList({
icon={tab.icon} icon={tab.icon}
className={cn( className={cn(
CATEGORY_TAB_ICON_CLASS, CATEGORY_TAB_ICON_CLASS,
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]" !isActive && "text-[#5f6368]"
)} )}
style={accentColor ? { color: accentColor } : undefined} style={accentColor ? { color: accentColor } : undefined}
@ -3037,6 +3172,7 @@ export function EmailList({
className={cn( className={cn(
CATEGORY_TAB_ICON_CLASS, CATEGORY_TAB_ICON_CLASS,
"self-center", "self-center",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]" !isActive && "text-[#5f6368]"
)} )}
style={accentColor ? { color: accentColor } : undefined} style={accentColor ? { color: accentColor } : undefined}
@ -3052,6 +3188,7 @@ export function EmailList({
<span <span
className={cn( className={cn(
"min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight", "min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#3c4043]" !isActive && "text-[#3c4043]"
)} )}
style={accentColor ? { color: accentColor } : undefined} style={accentColor ? { color: accentColor } : undefined}
@ -3069,7 +3206,12 @@ export function EmailList({
) : null} ) : null}
</div> </div>
{isExpandedTabMeta ? ( {isExpandedTabMeta ? (
<span className="block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]"> <span
className={cn(
"block min-h-4 min-w-0 truncate text-[11px] leading-snug text-[#5f6368]",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS
)}
>
{senderLine} {senderLine}
</span> </span>
) : null} ) : null}
@ -3112,34 +3254,78 @@ export function EmailList({
<div <div
ref={pullContentRef} ref={pullContentRef}
className={cn( className={cn(
!splitView && isViewMode && openEmail && "flex min-h-0 flex-1 flex-col", !splitView && isViewMode && openEmail && "relative flex min-h-0 flex-1 flex-col",
listToolbarMode && "max-sm:[transform:translateZ(0)]" listToolbarMode && "max-sm:[transform:translateZ(0)]"
)} )}
> >
{!splitView && isViewMode && openEmail ? ( {!splitView && isViewMode && openEmail ? (
/* ── EMAIL VIEW ── */ /* ── EMAIL VIEW ── */
<EmailView <>
email={openEmail} <div className="pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-2 px-3 py-2 sm:hidden">
onToggleStar={toggleStar} <Button
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred} type="button"
onNavigateToLabel={handleNavigateToLabel} variant="ghost"
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined} size="icon"
labelBgByText={listRowLabelBgByTextLower} className="pointer-events-auto size-9 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId} aria-label="Retour à la boîte de réception"
getNavItemPrefs={sidebarNav.getNavItemPrefs} onClick={goBack}
folderTree={sidebarNav.folderTree} >
labelRows={sidebarNav.labelRows} <ChevronLeft className="size-5" strokeWidth={1.5} />
currentFolderId={selectedFolder} </Button>
showLabelChip={(lab) => { <div className="pointer-events-auto flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
if (LABEL_PICKER_EXCLUDE.has(lab)) return true <Button
return mailLabelShouldShowInListStrip( type="button"
lab, variant="ghost"
sidebarNav.emailLabelToSidebarFolderId, size="icon"
sidebarNav.getNavItemPrefs, className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
sidebarNav.labelRows disabled={openMailIndex <= 0}
) onClick={goToPrev}
}} aria-label="Message plus récent"
/> >
<ChevronUp className="size-5" strokeWidth={1.5} />
</Button>
<span className="w-px shrink-0 self-stretch bg-border" aria-hidden />
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
disabled={openMailIndex >= displayListEmails.length - 1}
onClick={goToNext}
aria-label="Message plus ancien"
>
<ChevronDown className="size-5" strokeWidth={1.5} />
</Button>
</div>
</div>
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs}
folderTree={sidebarNav.folderTree}
labelRows={sidebarNav.labelRows}
currentFolderId={selectedFolder}
showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
return mailLabelShouldShowInListStrip(
lab,
sidebarNav.emailLabelToSidebarFolderId,
sidebarNav.getNavItemPrefs,
sidebarNav.labelRows
)
}}
/>
</>
) : ( ) : (
<TooltipProvider delayDuration={400}> <TooltipProvider delayDuration={400}>
<> <>
@ -3155,13 +3341,13 @@ export function EmailList({
</p> </p>
</div> </div>
)} )}
{filteredEmails.length === 0 ? ( {displayListEmails.length === 0 ? (
selectedFolder === "scheduled" ? ( selectedFolder === "scheduled" ? (
<div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center"> <div className="flex min-h-[220px] flex-col items-center justify-center px-4 py-12 text-center">
<p className="text-sm text-[#5f6368]">Aucun message planifié.</p> <p className="text-sm text-[#5f6368]">Aucun message planifié.</p>
</div> </div>
) : ( ) : (
<Empty className="min-h-[240px] flex-1 border-0 bg-white py-10 shadow-none"> <Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md"> <EmptyHeader className="max-w-md">
<EmptyMedia <EmptyMedia
variant="icon" variant="icon"
@ -3195,19 +3381,33 @@ export function EmailList({
</Empty> </Empty>
) )
) : ( ) : (
<div className="divide-y divide-[#eceff1]"> <div
className={cn(
"divide-y divide-[#eceff1]",
listToolbarMode && "sm:pb-14"
)}
>
{listEmails.map((email) => { {listEmails.map((email) => {
const isStarred = starredEmails.includes(email.id) || email.starred const rowThreadId = threadStoreId(email)
const isImportant = importantEmails.includes(email.id) || email.important const isStarred =
starredEmails.includes(rowThreadId) || email.starred
const isImportant =
importantEmails.includes(rowThreadId) || email.important
const isSpam = email.spam === true const isSpam = email.spam === true
const isDraft = email.labels?.includes("drafts") === true const isDraft = email.labels?.includes("drafts") === true
const hasThreadReplyDraft = const hasThreadReplyDraft =
savedThreadReplyDrafts[email.id] !== undefined savedThreadReplyDrafts[rowThreadId] !== undefined
const showDraftBadge = isDraft || hasThreadReplyDraft const showDraftBadge = isDraft || hasThreadReplyDraft
const isRead = const isRead = isListRowRead(
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read email,
readOverrides,
emailById,
conversationMode
)
const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail) const senderHoverEmail = resolveSenderEmail(email.sender, email.senderEmail)
const threadMessageCount = getThreadMessageCount(email) const threadMessageCount = conversationMode
? getThreadMessageCount(email)
: 0
const senderForSearch = email.sender.replace(/\s+/g, " ").trim() const senderForSearch = email.sender.replace(/\s+/g, " ").trim()
const isSelected = selectedEmails.includes(email.id) const isSelected = selectedEmails.includes(email.id)
const isSplitActiveRow = const isSplitActiveRow =
@ -3217,6 +3417,18 @@ export function EmailList({
listRowExtras.invitationById.get(email.id) ?? null listRowExtras.invitationById.get(email.id) ?? null
const attachmentList = const attachmentList =
listRowExtras.attachmentsById.get(email.id) ?? [] listRowExtras.attachmentsById.get(email.id) ?? []
const showAttachmentPills =
attachmentList.length > 0 && (!isMd || density === "default")
const showListPaperclip =
attachmentList.length > 0 && isMd && density !== "default"
const isCompactListRow = isMd && density === "compact"
const listRowPadTop = !showAttachmentPills
? isCompactListRow
? "pt-0"
: "pt-1"
: isCompactListRow
? "pt-0"
: "pt-0.5"
const isScheduled = email.labels?.includes("scheduled") === true const isScheduled = email.labels?.includes("scheduled") === true
const contextTargetIds = contextMenuTargetIdsForRow( const contextTargetIds = contextMenuTargetIdsForRow(
email.id, email.id,
@ -3294,14 +3506,19 @@ export function EmailList({
className={cn( className={cn(
"group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out", "group relative z-0 w-full cursor-pointer pl-3 pr-2 py-2 transition-[background-color,box-shadow] duration-[50ms] ease-out",
!splitView && !splitView &&
"md:flex md:items-start md:gap-2 md:px-2 md:py-1.5", "md:flex md:gap-2 md:px-2 md:py-1.5",
!splitView &&
(isCompactListRow && !showAttachmentPills
? "md:items-center"
: "md:items-start"),
isCompactListRow && "md:!py-1 md:text-[13px]",
isSplitActiveRow isSplitActiveRow
? "z-[1] bg-[#e8f0fe] shadow-[inset_3px_0_0_0_#669df6]" ? "z-[1] bg-mail-row-active-split shadow-[inset_3px_0_0_0_#669df6]"
: isSelected : isSelected
? "bg-[#e8f0fe]" ? "bg-mail-row-selected"
: isRead : isRead
? "bg-[#f5f5f5]" ? "bg-mail-row-read"
: "bg-white", : "bg-mail-row-unread",
!isSplitActiveRow && !isSplitActiveRow &&
"hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]" "hover:z-1 hover:shadow-[inset_1px_0_0_#d2d5da,inset_-1px_0_0_#d2d5da,0_4px_10px_-3px_rgba(60,64,67,.16),0_2px_5px_0_rgba(60,64,67,.09)]"
)} )}
@ -3614,7 +3831,9 @@ export function EmailList({
<div <div
className={cn( className={cn(
"w-44 shrink-0 truncate pl-2 lg:w-40", "w-44 shrink-0 truncate pl-2 lg:w-40",
attachmentList.length === 0 ? "pt-px" : "pt-0" listRowPadTop,
isCompactListRow &&
"flex min-h-7 items-center leading-tight"
)} )}
data-selectable-text data-selectable-text
> >
@ -3654,7 +3873,8 @@ export function EmailList({
<div <div
className={cn( className={cn(
"flex min-w-0 items-center gap-1", "flex min-w-0 items-center gap-1",
attachmentList.length === 0 ? "pt-1" : "pt-0.5" listRowPadTop,
isCompactListRow && "leading-tight"
)} )}
> >
{email.tag && ( {email.tag && (
@ -3683,15 +3903,18 @@ export function EmailList({
</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 text-gray-500">{email.preview}</span>
</div> </div>
{attachmentList.length > 0 && ( {showAttachmentPills && (
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} /> <EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
)} )}
</div> </div>
<div <div
className={cn( className={cn(
"flex shrink-0 flex-col items-end gap-1 self-start pr-2 text-right md:max-w-[150px] md:min-w-0", "flex shrink-0 flex-col items-end gap-1 pr-2 text-right md:max-w-[150px] md:min-w-0",
attachmentList.length === 0 ? "pt-1" : "pt-0.5" listRowPadTop,
isCompactListRow && !showAttachmentPills
? "self-center"
: "self-start"
)} )}
> >
{isScheduled ? ( {isScheduled ? (
@ -4013,6 +4236,13 @@ export function EmailList({
iconClassName="size-[18px] shrink-0" iconClassName="size-[18px] shrink-0"
/> />
) : null} ) : null}
{showListPaperclip && (
<Paperclip
className="size-[18px] shrink-0 text-[#5f6368]"
strokeWidth={1.75}
aria-label="Pièces jointes"
/>
)}
<span <span
className={cn( className={cn(
"min-w-0 truncate text-sm tabular-nums", "min-w-0 truncate text-sm tabular-nums",
@ -4203,13 +4433,13 @@ export function EmailList({
} }
}} }}
className={cn( className={cn(
"min-w-[280px] overflow-visible rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg", cn(MAIL_MENU_SURFACE_WIDE_CLASS, "overflow-visible"),
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm", "[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]", "[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-item]:focus]:text-[#3c4043]",
"[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm", "[&_[data-slot=context-menu-sub-trigger]]:gap-3 [&_[data-slot=context-menu-sub-trigger]]:rounded-none [&_[data-slot=context-menu-sub-trigger]]:px-3 [&_[data-slot=context-menu-sub-trigger]]:py-2 [&_[data-slot=context-menu-sub-trigger]]:text-sm",
"[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]", "[&_[data-slot=context-menu-sub-trigger]:focus]:bg-[#f1f3f4] [&_[data-slot=context-menu-sub-trigger]:focus]:text-[#3c4043]",
"[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]", "[&_[data-slot=context-menu-separator]]:mx-0 [&_[data-slot=context-menu-separator]]:my-1 [&_[data-slot=context-menu-separator]]:h-px [&_[data-slot=context-menu-separator]]:bg-[#eceff1]",
"[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-[#dadce0] [&_[data-slot=context-menu-sub-content]]:bg-white [&_[data-slot=context-menu-sub-content]]:shadow-lg" "[&_[data-slot=context-menu-sub-content]]:min-w-[200px] [&_[data-slot=context-menu-sub-content]]:rounded-lg [&_[data-slot=context-menu-sub-content]]:border [&_[data-slot=context-menu-sub-content]]:border-border [&_[data-slot=context-menu-sub-content]]:bg-popover [&_[data-slot=context-menu-sub-content]]:shadow-lg"
)} )}
> >
{allContextTargetsScheduled ? ( {allContextTargetsScheduled ? (
@ -4309,7 +4539,7 @@ export function EmailList({
</ContextMenuSubTrigger> </ContextMenuSubTrigger>
<ContextMenuSubContent <ContextMenuSubContent
className={cn( className={cn(
"min-w-[288px] rounded-lg border border-[#dadce0] bg-white px-4 py-3.5 text-[#3c4043] shadow-lg" "min-w-[288px] rounded-lg border border-border bg-popover px-4 py-3.5 text-[#3c4043] shadow-lg"
)} )}
> >
<div <div
@ -4454,7 +4684,7 @@ export function EmailList({
</ContextMenuSubTrigger> </ContextMenuSubTrigger>
<ContextMenuSubContent <ContextMenuSubContent
className={cn( className={cn(
"max-h-80 min-w-[200px] overflow-y-auto rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg", cn(MAIL_MENU_SURFACE_CLASS, "max-h-80 overflow-y-auto"),
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm", "[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]" "[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
)} )}
@ -4478,7 +4708,7 @@ export function EmailList({
</ContextMenuSubTrigger> </ContextMenuSubTrigger>
<ContextMenuSubContent <ContextMenuSubContent
className={cn( className={cn(
"z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden rounded-lg border border-[#dadce0] bg-white p-0 py-0 text-[#3c4043] shadow-lg", "z-[100] flex max-h-72 min-w-[260px] flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 py-0 text-[#3c4043] shadow-lg",
"[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm", "[&_[data-slot=context-menu-item]]:gap-3 [&_[data-slot=context-menu-item]]:rounded-none [&_[data-slot=context-menu-item]]:px-3 [&_[data-slot=context-menu-item]]:py-2 [&_[data-slot=context-menu-item]]:text-sm",
"[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]" "[&_[data-slot=context-menu-item]:focus]:bg-[#f1f3f4]"
)} )}
@ -4537,13 +4767,14 @@ export function EmailList({
</div> </div>
</div> </div>
{listToolbarMode ? ( {listToolbarMode ? (
<div className="hidden w-fit max-w-full shrink-0 self-start sm:block"> <div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 hidden sm:flex sm:justify-start">
<MailFolderStackIndicator <MailFolderStackIndicator
currentKey={mailNavVisitKey(selectedFolder, inboxTab)} currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree} folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel} folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows} labelRows={sidebarNav.labelRows}
onNavigate={handleBreadcrumbNavigate} onNavigate={handleBreadcrumbNavigate}
className="pointer-events-auto"
/> />
</div> </div>
) : null} ) : null}
@ -4553,7 +4784,7 @@ export function EmailList({
<button <button
type="button" type="button"
onClick={openCompose} onClick={openCompose}
className="absolute bottom-4 right-4 z-30 flex size-14 cursor-pointer items-center justify-center rounded-2xl border border-gray-200 bg-white text-[#444746] shadow-[0_1px_3px_rgba(60,64,67,.3),0_4px_8px_rgba(60,64,67,.15)] transition-[box-shadow,background-color] hover:bg-[#f6f8fc] hover:shadow-[0_1px_3px_rgba(60,64,67,.35),0_6px_12px_rgba(60,64,67,.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40" className="absolute bottom-4 right-4 z-30 flex size-14 cursor-pointer items-center justify-center rounded-2xl border border-border bg-mail-surface text-[#444746] shadow-[0_1px_3px_rgba(60,64,67,.3),0_4px_8px_rgba(60,64,67,.15)] transition-[box-shadow,background-color] hover:bg-[#f6f8fc] hover:shadow-[0_1px_3px_rgba(60,64,67,.35),0_6px_12px_rgba(60,64,67,.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/40"
aria-label="Nouveau message" aria-label="Nouveau message"
> >
<Pencil className="size-6" strokeWidth={1.5} /> <Pencil className="size-6" strokeWidth={1.5} />
@ -4561,7 +4792,7 @@ export function EmailList({
) : null} ) : null}
</div> </div>
{splitView ? ( {splitView ? (
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-white"> <section className="flex min-h-0 min-w-0 flex-1 flex-col bg-mail-surface">
{openEmail ? ( {openEmail ? (
<> <>
<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">
@ -4572,8 +4803,13 @@ export function EmailList({
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-none">
<EmailView <EmailView
email={openEmail} email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar} onToggleStar={toggleStar}
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred} isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel} onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined} onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower} labelBgByText={listRowLabelBgByTextLower}
@ -4595,7 +4831,7 @@ export function EmailList({
</div> </div>
</> </>
) : ( ) : (
<Empty className="min-h-[240px] flex-1 border-0 bg-white py-10 shadow-none"> <Empty className="min-h-[240px] flex-1 border-0 bg-mail-surface py-10 shadow-none">
<EmptyHeader className="max-w-md"> <EmptyHeader className="max-w-md">
<EmptyMedia <EmptyMedia
variant="icon" variant="icon"

View File

@ -1,6 +1,13 @@
"use client" "use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import { import {
Star, Star,
Reply, Reply,
@ -80,6 +87,24 @@ import { ComposeWindow } from "@/components/gmail/compose-modal"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview" import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import { ContactHoverCard } from "./contact-hover-card" import { ContactHoverCard } from "./contact-hover-card"
import { MailLabelPillStrip } from "./mail-label-pills" import { MailLabelPillStrip } from "./mail-label-pills"
import {
MAIL_ICON_BTN,
MAIL_INVITATION_CARD_CLASS,
MAIL_MENU_SURFACE_WIDE_CLASS,
MAIL_MESSAGE_HOVER_CLASS,
MAIL_PREVIEW_SCROLL_CLASS,
MAIL_REPLY_BAR_CLASS,
MAIL_REPLY_BUTTON_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import {
emailPreviewBaseCss,
emailPreviewDarkOverrideCss,
emailPreviewLightOverrideCss,
emailPreviewSubjectCss,
preprocessEmailHtmlForTheme,
} from "@/lib/email-preview-dark-styles"
interface EmailViewProps { interface EmailViewProps {
email: Email email: Email
@ -97,6 +122,10 @@ interface EmailViewProps {
labelRows?: readonly LabelRowItem[] labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */ /** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
currentFolderId?: string currentFolderId?: string
/** Fil complet (mode message isolé hors conversation). */
threadRoot?: Email | null
/** Affiche uniquement le message courant avec option douvrir le fil. */
isSingleMessageView?: boolean
} }
const LABEL_DISPLAY_NAMES: Record<string, string> = { const LABEL_DISPLAY_NAMES: Record<string, string> = {
@ -110,21 +139,16 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
trash: "Corbeille", trash: "Corbeille",
} }
const MESSAGE_MORE_MENU_CLASS = const MESSAGE_MORE_MENU_CLASS = MAIL_MENU_SURFACE_WIDE_CLASS
"min-w-[280px] rounded-lg border border-[#dadce0] bg-white p-0 py-1 text-[#3c4043] shadow-lg [&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none [&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm [&_[data-slot=dropdown-menu-item]:focus]:bg-[#f1f3f4] [&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1 [&_[data-slot=dropdown-menu-separator]]:bg-[#eceff1]"
/** Scroll zone du corps du message (preview remplit le panneau parent). */ const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
const EMAIL_PREVIEW_SCROLL_CLASS = display: "block",
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " + background: "transparent",
"[scrollbar-color:#9aa0a6_#ffffff] [scrollbar-width:auto] " + }
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar]:border-0 [&::-webkit-scrollbar]:bg-white " +
"[&::-webkit-scrollbar-track]:border-0 [&::-webkit-scrollbar-track]:bg-white [&::-webkit-scrollbar-track]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:rounded-none [&::-webkit-scrollbar-thumb]:border-0 [&::-webkit-scrollbar-thumb]:shadow-none " +
"[&::-webkit-scrollbar-thumb]:bg-[#9aa0a6] hover:[&::-webkit-scrollbar-thumb]:bg-[#5f6368] " +
"[&::-webkit-scrollbar-corner]:border-0 [&::-webkit-scrollbar-corner]:bg-white"
const REPLY_BAR_SURFACE_CLASS = function documentIsDark(): boolean {
"bg-[linear-gradient(to_bottom,rgba(255,255,255,0)_0%,#ffffff_0.75rem,#ffffff_100%)] pt-3" return document.documentElement.classList.contains("dark")
}
/* ── Sandboxed iframe for HTML body ── */ /* ── Sandboxed iframe for HTML body ── */
@ -142,6 +166,8 @@ function SandboxedContent({
? "allow-same-origin" ? "allow-same-origin"
: "allow-same-origin allow-popups" : "allow-same-origin allow-popups"
const { resolvedTheme } = useTheme()
const injectContent = useCallback(() => { const injectContent = useCallback(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
if (!iframe) return if (!iframe) return
@ -153,6 +179,12 @@ function SandboxedContent({
? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">` ? `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:;">`
: `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">` : `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open() doc.open()
doc.write(`<!DOCTYPE html> doc.write(`<!DOCTYPE html>
<html> <html>
@ -160,35 +192,11 @@ function SandboxedContent({
<meta charset="utf-8"> <meta charset="utf-8">
${cspMeta} ${cspMeta}
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } ${emailPreviewBaseCss(isDark)}
body { ${themeOverrides}
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: #202124;
padding: 0;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
a { color: #1a73e8; }
img { max-width: 100%; height: auto; }
blockquote {
border-left: 3px solid #dadce0;
padding-left: 12px;
margin: 8px 0;
color: #5f6368;
}
pre, code {
background: #f6f8fa;
border-radius: 3px;
font-size: 13px;
}
pre { padding: 12px; overflow-x: auto; }
code { padding: 2px 6px; }
</style> </style>
</head> </head>
<body>${html}</body> <body>${processedHtml}</body>
</html>`) </html>`)
doc.close() doc.close()
@ -205,7 +213,7 @@ function SandboxedContent({
} }
return () => resizeObserver.disconnect() return () => resizeObserver.disconnect()
}, [html, isSpam]) }, [html, isSpam, resolvedTheme])
useEffect(() => { useEffect(() => {
const cleanup = injectContent() const cleanup = injectContent()
@ -217,8 +225,8 @@ function SandboxedContent({
ref={iframeRef} ref={iframeRef}
sandbox={sandboxValue} sandbox={sandboxValue}
title="Contenu du message" title="Contenu du message"
className="w-full border-0" className="w-full border-0 bg-transparent"
style={{ height, display: "block" }} style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1} tabIndex={-1}
/> />
) )
@ -228,6 +236,7 @@ function SandboxedContent({
function SandboxedSubject({ text }: { text: string }) { function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
useEffect(() => { useEffect(() => {
const iframe = iframeRef.current const iframe = iframeRef.current
@ -235,37 +244,28 @@ function SandboxedSubject({ text }: { text: string }) {
const doc = iframe.contentDocument const doc = iframe.contentDocument
if (!doc) return if (!doc) return
const isDark = documentIsDark()
doc.open() doc.open()
doc.write(`<!DOCTYPE html> doc.write(`<!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';"> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
<style> <style>${emailPreviewSubjectCss(isDark)}</style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
line-height: 1.3;
color: #202124;
overflow: hidden;
white-space: normal;
word-wrap: break-word;
}
</style>
</head> </head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body> <body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`) </html>`)
doc.close() doc.close()
}, [text]) }, [text, resolvedTheme])
return ( return (
<iframe <iframe
ref={iframeRef} ref={iframeRef}
sandbox="allow-same-origin" sandbox="allow-same-origin"
title="Sujet du message" title="Sujet du message"
className="pointer-events-none w-full border-0" className="pointer-events-none w-full border-0 bg-transparent"
style={{ height: 32, display: "block" }} style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: "32px" }}
tabIndex={-1} tabIndex={-1}
/> />
) )
@ -274,12 +274,12 @@ function SandboxedSubject({ text }: { text: string }) {
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) { function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
return ( return (
<> <>
<div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-[#f8f9fa] to-[#eceff1]"> <div className="relative flex h-[132px] shrink-0 flex-col items-center justify-center bg-linear-to-b from-muted to-muted/70 dark:from-[#3c4043] dark:to-[#303134]">
{kind === "image" ? ( {kind === "image" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden /> <ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? ( ) : kind === "pdf" ? (
<div <div
className="rounded border border-[#dadce0] bg-white px-4 py-5 shadow-sm" className="rounded border border-border bg-mail-surface px-4 py-5 shadow-sm"
aria-hidden aria-hidden
> >
<span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span> <span className="text-[11px] font-bold leading-none text-[#d93025]">PDF</span>
@ -288,7 +288,7 @@ function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttach
<File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden /> <File className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
)} )}
</div> </div>
<div className="flex min-h-[38px] items-center gap-2 border-t border-[#eceff1] bg-[#f1f3f4] px-2 py-1.5"> <div className="flex min-h-[38px] items-center gap-2 border-t border-border bg-muted px-2 py-1.5">
{kind === "pdf" ? ( {kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden /> <FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? ( ) : kind === "image" ? (
@ -319,7 +319,7 @@ function MessageAttachmentPill({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-[#dadce0] bg-[#f8f9fa] py-1.5 pl-2.5 pr-3 text-left text-sm text-[#3c4043] shadow-sm transition hover:border-[#bdc1c6] hover:bg-white hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]" className="inline-flex max-w-[min(100%,320px)] min-w-0 shrink-0 items-center gap-2 rounded-full border border-border bg-muted py-1.5 pl-2.5 pr-3 text-left text-sm text-foreground shadow-sm transition hover:border-border hover:bg-accent hover:shadow focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
> >
{kind === "pdf" ? ( {kind === "pdf" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden /> <FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
@ -331,7 +331,7 @@ function MessageAttachmentPill({
<span className="min-w-0 truncate font-medium">{name}</span> <span className="min-w-0 truncate font-medium">{name}</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs whitespace-pre-line text-xs"> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip} {tip}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -346,9 +346,9 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
const asPills = shouldUseAttachmentPillsInPreview(attachments) const asPills = shouldUseAttachmentPillsInPreview(attachments)
return ( return (
<div className="border-t border-[#eceff1] px-4 pb-4 pl-[68px] pt-4"> <div className="mt-4 border-t border-border px-4 pb-4 pl-[68px] pt-4 max-sm:pl-4 max-sm:pr-4">
<div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2"> <div className="mb-3 flex min-w-0 flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-[#5f6368]"> <div className="flex min-w-0 max-w-[min(100%,28rem)] items-center gap-1 text-sm text-muted-foreground">
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
{summary} {summary}
<span aria-hidden> · </span> <span aria-hidden> · </span>
@ -358,13 +358,13 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-black/6" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="Informations sur l'analyse VirusTotal des pièces jointes" aria-label="Informations sur l'analyse VirusTotal des pièces jointes"
> >
<Info className="size-4" strokeWidth={1.75} /> <Info className="size-4" strokeWidth={1.75} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs"> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
VirusTotal analyse les pièces jointes et les compare à une base de signatures pour VirusTotal analyse les pièces jointes et les compare à une base de signatures pour
repérer les virus et logiciels malveillants. repérer les virus et logiciels malveillants.
</TooltipContent> </TooltipContent>
@ -372,7 +372,7 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
</div> </div>
<button <button
type="button" type="button"
className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-[#1a73e8] hover:bg-[#f6f9fe]" className="flex shrink-0 items-center gap-2 rounded-md py-1 pl-1 pr-2 text-sm font-medium text-primary hover:bg-accent"
aria-label="Ajouter à UltiDrive" aria-label="Ajouter à UltiDrive"
> >
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden /> <HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
@ -405,12 +405,12 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="flex w-[200px] flex-col overflow-hidden rounded border border-[#dadce0] bg-white text-left shadow-sm transition hover:border-[#bdc1c6] hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#1a73e8]" className="flex w-[200px] flex-col overflow-hidden rounded border border-border bg-mail-surface text-left shadow-sm transition hover:border-border hover:shadow-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
> >
<MessageAttachmentCard name={att.name} kind={kind} /> <MessageAttachmentCard name={att.name} kind={kind} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs whitespace-pre-line text-xs"> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs whitespace-pre-line text-xs")}>
{tip} {tip}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -445,7 +445,7 @@ function CollapsedMessage({
onClick() onClick()
} }
}} }}
className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[#f6f9fe]" className={cn("group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors", MAIL_MESSAGE_HOVER_CLASS)}
> >
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"
@ -456,7 +456,7 @@ function CollapsedMessage({
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text> <div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="flex min-w-0 items-center justify-between gap-2"> <div className="flex min-w-0 items-center justify-between gap-2">
<ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0"> <ContactHoverCard displayName={message.sender} email={message.senderEmail} className="min-w-0">
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span> <span className="truncate text-sm font-semibold text-foreground">{name}</span>
</ContactHoverCard> </ContactHoverCard>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<MailDateText <MailDateText
@ -537,7 +537,7 @@ function ExpandedMessage({
onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined} onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined}
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-[#202124]">{name}</span> <span className="font-semibold text-foreground">{name}</span>
<span className="text-[#5f6368]"> &lt;{senderEmail}&gt;</span> <span className="text-[#5f6368]"> &lt;{senderEmail}&gt;</span>
</ContactHoverCard> </ContactHoverCard>
</div> </div>
@ -575,11 +575,12 @@ function ExpandedMessage({
)} )}
</div> </div>
<div className="flex shrink-0 self-start items-center gap-1 pt-0.5"> <div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
<div className="flex items-center gap-1">
<MailDateText <MailDateText
iso={dateIso} iso={dateIso}
variant="preview" variant="preview"
className="text-xs text-[#5f6368]" className="hidden text-xs text-[#5f6368] sm:inline"
/> />
{onToggleStar && ( {onToggleStar && (
@ -613,14 +614,14 @@ function ExpandedMessage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]" 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()}
> >
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} /> <Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Répondre</TooltipContent> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Répondre</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu> <DropdownMenu>
@ -628,7 +629,7 @@ function ExpandedMessage({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]" className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Plus d'actions" aria-label="Plus d'actions"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@ -705,13 +706,19 @@ function ExpandedMessage({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-[#5f6368] sm:hidden"
/>
</div> </div>
</div> </div>
{/* Body */} {/* Body */}
<div <div
className={cn( className={cn(
"px-4 pl-[68px]", "px-4 pl-[68px] max-sm:pl-4 max-sm:pr-4",
attachments.length > 0 ? "pb-0" : "pb-4" attachments.length > 0 ? "pb-0" : "pb-4"
)} )}
data-selectable-text data-selectable-text
@ -730,17 +737,17 @@ function ExpandedMessage({
function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) { function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return ( return (
<div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-[#e8eaed] bg-[#f1f3f4] px-4 py-3.5"> <div className="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3"> <div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-[#3c4043]"> <p className="text-sm leading-snug text-[#3c4043]">
<span className="font-medium text-[#202124]">Pourquoi ce message est-il dans le spam ?</span>{" "} <span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé. Ce message est semblable à des messages identifiés comme spam par le passé.
</p> </p>
{onNotSpam && ( {onNotSpam && (
<button <button
type="button" type="button"
onClick={onNotSpam} onClick={onNotSpam}
className="rounded-md border border-[#dadce0] bg-white px-4 py-2 text-sm font-medium text-[#1a73e8] shadow-sm transition-colors hover:bg-[#f6f9fe]" className="rounded-md border border-border bg-mail-surface px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-accent"
> >
Signaler comme non-spam Signaler comme non-spam
</button> </button>
@ -756,7 +763,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} /> <Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="left" className="max-w-xs text-xs"> <TooltipContent side="left" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "max-w-xs text-xs")}>
Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour Les filtres peuvent se tromper. Si le message est légitime, signalez-le comme non-spam pour
l&apos;améliorer. l&apos;améliorer.
</TooltipContent> </TooltipContent>
@ -780,8 +787,22 @@ export function EmailView({
folderTree, folderTree,
labelRows, labelRows,
currentFolderId, currentFolderId,
threadRoot = null,
isSingleMessageView = false,
}: EmailViewProps) { }: EmailViewProps) {
const conversation = email.conversation ?? [] const [showFullThread, setShowFullThread] = useState(false)
const threadForReplies = threadRoot ?? email
const priorCount = Math.max(
0,
(threadForReplies.threadMessageIds?.length ?? 1) - 1
)
const showRepliesCta =
isSingleMessageView && !showFullThread && priorCount > 0
const conversation =
isSingleMessageView && !showFullThread
? []
: (showFullThread ? threadForReplies.conversation : email.conversation) ?? []
const hasConversation = conversation.length > 0 const hasConversation = conversation.length > 0
const isSpamMessage = email.spam === true const isSpamMessage = email.spam === true
@ -879,9 +900,11 @@ export function EmailView({
return ( return (
<TooltipProvider delayDuration={400}> <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={EMAIL_PREVIEW_SCROLL_CLASS}> <div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
{/* Spacer for floating nav buttons on xs */}
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
{/* Subject header */} {/* Subject header */}
<div className="flex items-start gap-3 px-6 py-4"> <div className="flex items-start gap-3 px-6 py-4 max-sm:px-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<SandboxedSubject text={email.subject} /> <SandboxedSubject text={email.subject} />
@ -915,27 +938,27 @@ export function EmailView({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]" className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Imprimer" aria-label="Imprimer"
onClick={() => openConversationPrint(email)} onClick={() => openConversationPrint(email)}
> >
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} /> <Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Imprimer tout</TooltipContent> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Imprimer tout</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]" className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Ouvrir dans une nouvelle fenêtre" aria-label="Ouvrir dans une nouvelle fenêtre"
> >
<ExternalLink className="h-[18px] w-[18px]" strokeWidth={1.5} /> <ExternalLink className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">Dans une nouvelle fenêtre</TooltipContent> <TooltipContent side="bottom" className={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Dans une nouvelle fenêtre</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -946,6 +969,20 @@ export function EmailView({
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />} {isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{showRepliesCta ? (
<div className="border-b border-[#eceff1] px-6 py-3 max-sm:px-4">
<button
type="button"
onClick={() => setShowFullThread(true)}
className="text-sm font-medium text-[#1a73e8] hover:underline"
>
{priorCount === 1
? "Afficher la réponse"
: `Afficher les ${priorCount} réponses`}
</button>
</div>
) : null}
{/* Conversation messages */} {/* Conversation messages */}
{/* Previous messages in conversation */} {/* Previous messages in conversation */}
{hasConversation && conversation.map((msg) => { {hasConversation && conversation.map((msg) => {
@ -997,39 +1034,40 @@ export function EmailView({
{showReplyForwardBar ? ( {showReplyForwardBar ? (
<div <div
className={cn( className={cn(
"sticky bottom-0 z-10 mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px]", "z-10 mt-4 hidden flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px] sm:flex",
REPLY_BAR_SURFACE_CLASS "max-sm:static sm:sticky sm:bottom-0",
MAIL_REPLY_BAR_CLASS
)} )}
> >
<button <button
type="button" type="button"
onClick={() => startThreadCompose("reply")} onClick={() => startThreadCompose("reply")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md" className={MAIL_REPLY_BUTTON_CLASS}
> >
<Reply className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} /> <Reply className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Répondre Répondre
</button> </button>
<button <button
type="button" type="button"
onClick={() => startThreadCompose("replyAll")} onClick={() => startThreadCompose("replyAll")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md" className={MAIL_REPLY_BUTTON_CLASS}
> >
<ReplyAll className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} /> <ReplyAll className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Répondre à tous Répondre à tous
</button> </button>
<button <button
type="button" type="button"
onClick={() => startThreadCompose("forward")} onClick={() => startThreadCompose("forward")}
className="inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-[#dadce0] bg-white px-6 py-2.5 text-sm font-medium text-[#3c4043] shadow-sm transition-shadow hover:bg-[#f6f9fe] hover:shadow-md" className={MAIL_REPLY_BUTTON_CLASS}
> >
<Forward className="h-[18px] w-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} /> <Forward className="h-[18px] w-[18px] shrink-0 text-muted-foreground" strokeWidth={1.5} />
Transférer Transférer
</button> </button>
</div> </div>
) : null} ) : null}
{inlineCompose ? ( {inlineCompose ? (
<div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px]"> <div ref={threadComposeAnchorRef} className="mt-6 px-4 pb-6 pl-[68px] max-sm:pl-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold text-white"

View File

@ -1,39 +1,106 @@
"use client" "use client"
import { useState, useRef, useEffect } from "react" import { useState, useRef, useEffect } from "react"
import Link from "next/link"
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 { 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 { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { MAIL_HEADER_DROPDOWN_CLASS, MAIL_ICON_BTN } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const HEADER_ICON_BTN_CLASS = cn(
"rounded-full",
MAIL_ICON_BTN,
"hover:text-accent-foreground",
)
addCollection(mdiIcons) addCollection(mdiIcons)
const googleApps = [ type FavoriteApp = {
name: string
icon: string
href?: string
/** Logos sombres : blanc en dark via invert + hue-rotate. */
whiteLogoInDark?: boolean
}
const googleApps: FavoriteApp[] = [
{ name: "Compte", icon: "/compte-mark.svg" }, { name: "Compte", icon: "/compte-mark.svg" },
{ name: "Agenda", icon: "/agenda-mark.svg" }, { name: "Agenda", icon: "/agenda-mark.svg" },
{ name: "Photos", icon: "/photos-mark.svg" }, { name: "Photos", icon: "/photos-mark.svg" },
{ name: "Ultimail", icon: "/brand/ultimail-header-icon.png" }, { name: "Ultimail", icon: "/brand/ultimail-header-icon.png", href: "/mail" },
{ name: "UltiDrive", icon: "/ultidrive-mark.svg" }, { name: "UltiDrive", icon: "/ultidrive-mark.svg" },
{ name: "UltiMeet", icon: "/ultimeet-mark.svg" }, { name: "UltiMeet", icon: "/ultimeet-mark.svg" },
{ name: "Administration", icon: "/admin-mark.svg" }, { name: "Administration", icon: "/admin-mark.svg" },
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" }, { name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
{ name: "Mistral", icon: "/mistral-mark.svg" }, { name: "Mistral", icon: "/mistral-mark.svg" },
{ name: "Qwant", icon: "/qwant-mark.svg" }, { name: "Qwant", icon: "/qwant-mark.svg", whiteLogoInDark: true },
{ name: "Ground News", icon: "/ground-news-mark.svg" }, { name: "Ground News", icon: "/ground-news-mark.svg", whiteLogoInDark: true },
] ]
const FAVORITE_TILE_CLASS =
"flex flex-col items-center gap-2 rounded-lg p-3 transition-colors hover:bg-accent"
function FavoriteAppTile({ app }: { app: FavoriteApp }) {
const content = (
<>
<div className="flex h-10 w-10 items-center justify-center">
<img
src={app.icon}
alt={app.name}
className={cn(
"h-10 w-10 object-contain",
app.whiteLogoInDark && "dark:invert dark:hue-rotate-180",
)}
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
target.parentElement!.innerHTML = `<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white">${app.name[0]}</div>`
}}
/>
</div>
<span className="w-full text-center text-xs text-muted-foreground">{app.name}</span>
</>
)
if (app.href) {
return (
<Link href={app.href} className={FAVORITE_TILE_CLASS}>
{content}
</Link>
)
}
return (
<button type="button" className={FAVORITE_TILE_CLASS}>
{content}
</button>
)
}
interface HeaderAccountActionsProps { interface HeaderAccountActionsProps {
className?: string className?: string
} }
export function HeaderAccountActions({ className }: HeaderAccountActionsProps) { export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
const [appsMenuOpen, setAppsMenuOpen] = useState(false) const [appsMenuOpen, setAppsMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const [accountMenuOpen, setAccountMenuOpen] = useState(false)
const appsMenuRef = useRef<HTMLDivElement>(null)
const accountMenuRef = useRef<HTMLDivElement>(null)
const activeAccount = useActiveAccount()
const openQuickSettings = useMailSettingsStore((s) => s.setQuickSettingsOpen)
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { if (
appsMenuRef.current &&
!appsMenuRef.current.contains(event.target as Node)
) {
setAppsMenuOpen(false) setAppsMenuOpen(false)
} }
} }
@ -43,64 +110,95 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
return ( return (
<div className={cn("flex shrink-0 items-center gap-1", className)}> <div className={cn("flex shrink-0 items-center gap-1", className)}>
<Button variant="ghost" size="icon" className="hidden text-gray-600 sm:inline-flex" aria-label="Aide"> <Button
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden /> variant="ghost"
size="icon"
className={cn("hidden sm:inline-flex", HEADER_ICON_BTN_CLASS)}
aria-label="Aide"
>
<Icon
icon="mdi:help-circle-outline"
className="size-6 shrink-0"
aria-hidden
/>
</Button> </Button>
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages"> <Button
variant="ghost"
size="icon"
className={HEADER_ICON_BTN_CLASS}
aria-label="Réglages"
onClick={() => openQuickSettings(true)}
>
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden /> <Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
</Button> </Button>
<div className="relative hidden sm:block" ref={menuRef}> <div className="relative hidden sm:block" ref={appsMenuRef}>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-gray-600" className={HEADER_ICON_BTN_CLASS}
aria-label="Applications" aria-label="Applications"
onClick={() => setAppsMenuOpen(!appsMenuOpen)} onClick={() => {
setAppsMenuOpen(!appsMenuOpen)
setAccountMenuOpen(false)
}}
> >
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden /> <Icon
icon="mdi:view-grid-outline"
className="size-6 shrink-0"
aria-hidden
/>
</Button> </Button>
{appsMenuOpen && ( {appsMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-96 rounded-2xl border border-gray-200 bg-white shadow-xl"> <div
<div className="flex items-center justify-between border-b border-gray-100 p-4"> className={cn(
<span className="text-lg font-normal text-gray-800">Vos favoris</span> "absolute right-0 top-12 z-50 w-96 rounded-2xl",
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600"> MAIL_HEADER_DROPDOWN_CLASS,
)}
>
<div className="flex items-center justify-between border-b border-border p-4">
<span className="text-lg font-normal text-foreground">
Vos favoris
</span>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", HEADER_ICON_BTN_CLASS)}
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="grid grid-cols-3 gap-1 p-3"> <div className="grid grid-cols-3 gap-1 p-3">
{googleApps.map((app) => ( {googleApps.map((app) => (
<button <FavoriteAppTile key={app.name} app={app} />
key={app.name}
type="button"
className="flex flex-col items-center gap-2 rounded-lg p-3 transition-colors hover:bg-gray-100"
>
<div className="flex h-10 w-10 items-center justify-center">
<img
src={app.icon}
alt={app.name}
className="h-10 w-10 object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = "none"
target.parentElement!.innerHTML = `<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500 font-bold text-white">${app.name[0]}</div>`
}}
/>
</div>
<span className="w-full text-center text-xs text-gray-700">{app.name}</span>
</button>
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
<Button variant="ghost" size="icon-lg" className="ml-2 size-11 overflow-hidden rounded-full p-0"> <div className="relative ml-2" ref={accountMenuRef}>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500 text-base font-bold text-white"> <Button
E variant="ghost"
</div> size="icon-lg"
</Button> className="size-11 overflow-hidden rounded-full p-0"
aria-label={`Compte : ${activeAccount.email}`}
aria-expanded={accountMenuOpen}
aria-haspopup="dialog"
onClick={() => {
setAccountMenuOpen(!accountMenuOpen)
setAppsMenuOpen(false)
}}
>
<AccountAvatar account={activeAccount} size="md" />
</Button>
<AccountSwitcherDropdown
open={accountMenuOpen}
onOpenChange={setAccountMenuOpen}
containerRef={accountMenuRef}
/>
</div>
</div> </div>
) )
} }

View File

@ -89,7 +89,7 @@ export function MailFolderStackIndicator({
className={cn( className={cn(
"flex max-w-[min(360px,calc(100vw-1rem))] items-center", "flex max-w-[min(360px,calc(100vw-1rem))] items-center",
"border-t border-r border-[#dadce0]/90", "border-t border-r border-[#dadce0]/90",
"bg-white/78 px-3.5 py-2.5 text-sm font-medium leading-snug text-[#3c4043]", "bg-mail-surface/90 px-3.5 py-2.5 text-sm font-medium leading-snug text-foreground",
"rounded-tr-2xl shadow-sm backdrop-blur-md", "rounded-tr-2xl shadow-sm backdrop-blur-md",
className className
)} )}

View File

@ -26,7 +26,7 @@ export function MailSearchBar({ className, compact = false }: MailSearchBarProps
type="text" type="text"
placeholder="Rechercher dans les messages" placeholder="Rechercher dans les messages"
className={cn( className={cn(
"h-12 w-full rounded-full border-0 bg-[#eaf1fb] text-sm focus-visible:bg-white focus-visible:ring-1 focus-visible:ring-blue-500", "h-12 w-full rounded-full border-0 bg-muted text-sm focus-visible:bg-mail-surface focus-visible:ring-1 focus-visible:ring-ring",
compact ? "pl-11 pr-11" : "pl-11 pr-12" compact ? "pl-11 pr-11" : "pl-11 pr-12"
)} )}
/> />

View File

@ -0,0 +1,43 @@
"use client"
import { useEffect } from "react"
import { useTheme } from "next-themes"
import {
mailBackgroundStyle,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
function applyMailBackground(backgroundId: string) {
const html = document.documentElement
const normalized = normalizeMailBackgroundId(backgroundId)
const { background, fallbackColor } = mailBackgroundStyle(normalized)
if (normalized === "none" || background === "none") {
delete html.dataset.mailBackground
html.style.removeProperty("--mail-bg-layer")
html.style.removeProperty("--mail-bg-fallback")
return
}
html.dataset.mailBackground = normalized
html.style.setProperty("--mail-bg-layer", background)
html.style.setProperty("--mail-bg-fallback", fallbackColor)
}
/** Applique thème clair/sombre/système et fond décoratif sur le document. */
export function MailThemeApplier() {
const themeMode = useMailSettingsStore((s) => s.themeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const { setTheme } = useTheme()
useEffect(() => {
setTheme(themeMode)
}, [themeMode, setTheme])
useEffect(() => {
applyMailBackground(backgroundId)
}, [backgroundId])
return null
}

View File

@ -0,0 +1,25 @@
"use client"
import { Toaster } from "sonner"
import { useTheme } from "next-themes"
import type { CSSProperties } from "react"
export function MailToaster() {
const { resolvedTheme } = useTheme()
return (
<Toaster
position="bottom-right"
offset={{ right: 16, bottom: 16 }}
mobileOffset={{ right: 16, left: 16, bottom: 16 }}
style={
{
["--width"]: "min(420px, calc(100vw - 2.5rem))",
} as CSSProperties
}
theme={resolvedTheme === "dark" ? "dark" : "light"}
richColors
closeButton
/>
)
}

View File

@ -1,22 +1,46 @@
"use client" "use client"
import { useState, useRef, useEffect, useCallback } from "react" import { useState, useRef, useEffect, useCallback } from "react"
import { Menu, Search, X, ChevronLeft, Pencil } from "lucide-react" import {
Menu,
Search,
X,
Pencil,
Archive,
FolderInput,
Reply,
} from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useComposeActions } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
import { MoveToDropdownItems } from "@/components/gmail/move-to-menu-items"
import { MAIL_MENU_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import { cn } from "@/lib/utils"
interface MobileBottomBarProps { interface MobileBottomBarProps {
sidebarOpen: boolean sidebarOpen: boolean
onToggleSidebar: () => void onToggleSidebar: () => void
/** Lecture message xs : barre dactions à la place du menu / recherche. */
xsViewChrome?: MailXsViewChrome | null
} }
const ROUNDED_BAR_BTN =
"size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 text-[#444746] shadow-md backdrop-blur hover:bg-white"
export function MobileBottomBar({ export function MobileBottomBar({
sidebarOpen, sidebarOpen,
onToggleSidebar, onToggleSidebar,
xsViewChrome = null,
}: MobileBottomBarProps) { }: MobileBottomBarProps) {
const [searchValue, setSearchValue] = useState("") const [searchValue, setSearchValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { openCompose } = useComposeActions() const { openCompose } = useComposeActions()
const inMailView = Boolean(xsViewChrome)
const hasSearch = searchValue.length > 0 const hasSearch = searchValue.length > 0
@ -33,51 +57,106 @@ export function MobileBottomBar({
return ( return (
<div className="fixed inset-x-0 bottom-0 z-50 flex flex-col items-center pb-[env(safe-area-inset-bottom)] sm:hidden"> <div className="fixed inset-x-0 bottom-0 z-50 flex flex-col items-center pb-[env(safe-area-inset-bottom)] sm:hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-white/95 via-white/70 to-transparent" /> <div className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-t to-transparent",
inMailView
? "from-black/90 via-black/50"
: "from-mail-surface/95 via-mail-surface/70 dark:from-background/95 dark:via-background/70"
)} />
<div className="relative z-10 flex w-full items-center gap-2 px-3 pb-3 pt-2"> <div className="relative z-10 flex w-full items-center gap-2 px-3 pb-3 pt-2">
{/* Burger / back-caret */} {inMailView && xsViewChrome ? (
<Button <div className="flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
variant="ghost" <Button
size="icon" type="button"
className="size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur" variant="ghost"
onClick={onToggleSidebar} size="icon"
aria-label={sidebarOpen ? "Fermer le menu" : "Ouvrir le menu"} className="size-11 rounded-none text-[#444746] hover:bg-[#f1f3f4]"
> onClick={xsViewChrome.onArchive}
{sidebarOpen ? ( aria-label="Archiver"
<ChevronLeft className="size-5" /> >
) : ( <Archive className="size-5" strokeWidth={1.5} />
<Menu className="size-5" /> </Button>
)} <span className="w-px shrink-0 self-stretch bg-gray-200" aria-hidden />
</Button> <DropdownMenu>
<DropdownMenuTrigger asChild>
{/* Search bar — hidden when sidebar open */} <Button
{!sidebarOpen && ( type="button"
<div className="relative flex min-w-0 flex-1 items-center"> variant="ghost"
<div className="pointer-events-none absolute left-3 z-10 flex items-center text-gray-500"> size="icon"
<Search className="size-5" /> className="size-11 rounded-none text-[#444746] hover:bg-[#f1f3f4]"
</div> aria-label="Déplacer dans un dossier"
<input >
ref={inputRef} <FolderInput className="size-5" strokeWidth={1.5} />
type="text" </Button>
value={searchValue} </DropdownMenuTrigger>
onChange={(e) => setSearchValue(e.target.value)} <DropdownMenuContent
placeholder="Rechercher" align="start"
className="h-11 w-full rounded-full border border-gray-200 bg-white/80 pl-10 pr-4 text-sm shadow-md backdrop-blur outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-blue-500/40" side="top"
/> sideOffset={8}
className={cn(MAIL_MENU_SURFACE_CLASS, "max-h-80 overflow-y-auto")}
>
<MoveToDropdownItems
targets={xsViewChrome.moveTargets}
onMoveTo={xsViewChrome.onMoveTo}
/>
</DropdownMenuContent>
</DropdownMenu>
<span className="w-px shrink-0 self-stretch bg-gray-200" aria-hidden />
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 rounded-none text-[#444746] hover:bg-[#f1f3f4]"
onClick={xsViewChrome.onReply}
aria-label="Répondre"
>
<Reply className="size-5" strokeWidth={1.5} />
</Button>
</div> </div>
) : (
<>
<Button
variant="ghost"
size="icon"
className={ROUNDED_BAR_BTN}
onClick={onToggleSidebar}
aria-label={sidebarOpen ? "Fermer le menu" : "Ouvrir le menu"}
>
{sidebarOpen ? (
<X className="size-5" />
) : (
<Menu className="size-5" />
)}
</Button>
{!sidebarOpen && (
<div className="relative flex min-w-0 flex-1 items-center">
<div className="pointer-events-none absolute left-3 z-10 flex items-center text-gray-500">
<Search className="size-5" />
</div>
<input
ref={inputRef}
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Rechercher"
className="h-11 w-full rounded-full border border-gray-200 bg-white/80 pl-10 pr-4 text-sm shadow-md backdrop-blur outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-blue-500/40"
/>
</div>
)}
</>
)} )}
{/* New-message / clear-search — hidden when sidebar open */}
{!sidebarOpen && ( {!sidebarOpen && (
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur" className={cn(ROUNDED_BAR_BTN, inMailView && "ml-auto")}
onClick={hasSearch ? handleClear : openCompose} onClick={inMailView || !hasSearch ? openCompose : handleClear}
aria-label={hasSearch ? "Effacer la recherche" : "Nouveau message"} aria-label={!inMailView && hasSearch ? "Effacer la recherche" : "Nouveau message"}
> >
{hasSearch ? ( {!inMailView && hasSearch ? (
<X className="size-5" /> <X className="size-5" />
) : ( ) : (
<Pencil className="size-5" /> <Pencil className="size-5" />

View File

@ -2,14 +2,25 @@
import { useMemo, type ReactNode } from "react" import { useMemo, type ReactNode } from "react"
import { import {
Clock,
Inbox, Inbox,
Send, Send,
FileEdit, FileEdit,
ShieldAlert, ShieldAlert,
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data" import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
export type MailMoveTargets = {
recents: MoveTarget[]
system: MoveTarget[]
folders: MoveTarget[]
}
export type MoveTarget = { export type MoveTarget = {
id: string id: string
label: string label: string
@ -82,4 +93,58 @@ export function useMoveTargets({
}, [folderTree, recentMoveTargets, currentFolderId]) }, [folderTree, recentMoveTargets, currentFolderId])
} }
export function MoveToDropdownItems({
targets,
onMoveTo,
}: {
targets: MailMoveTargets
onMoveTo: (targetId: string) => void
}) {
return (
<>
{targets.recents.length > 0 && (
<>
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Récents
</div>
{targets.recents.map((t) => (
<DropdownMenuItem key={`recent-${t.id}`} onSelect={() => onMoveTo(t.id)}>
<span className="flex items-center gap-2">
{t.icon}
<Clock className="size-3 shrink-0 text-[#9aa0a6]" strokeWidth={1.5} />
</span>
{t.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{targets.system.map((t) => (
<DropdownMenuItem key={t.id} onSelect={() => onMoveTo(t.id)}>
{t.icon}
{t.label}
</DropdownMenuItem>
))}
{targets.folders.length > 0 && (
<>
<DropdownMenuSeparator />
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Dossiers
</div>
{targets.folders.map((t) => (
<DropdownMenuItem
key={t.id}
onSelect={() => onMoveTo(t.id)}
style={{ paddingLeft: `${12 + t.depth * 16}px` }}
>
{t.icon}
{t.label}
</DropdownMenuItem>
))}
</>
)}
</>
)
}
export { SYSTEM_DESTINATIONS, flattenFolderTree } export { SYSTEM_DESTINATIONS, flattenFolderTree }

View File

@ -0,0 +1,86 @@
"use client"
import { cn } from "@/lib/utils"
type QuickSettingsOptionProps = {
name: string
label: string
checked: boolean
disabled?: boolean
onSelect: () => void
icon?: React.ReactNode
}
export function QuickSettingsOption({
name,
label,
checked,
disabled = false,
onSelect,
icon,
}: QuickSettingsOptionProps) {
return (
<label
className={cn(
"flex cursor-pointer items-center gap-3 rounded-md px-1 py-2 transition-colors",
disabled
? "cursor-not-allowed opacity-45"
: "hover:bg-mail-surface-muted"
)}
>
<input
type="radio"
name={name}
checked={checked}
disabled={disabled}
onChange={onSelect}
className="size-[18px] shrink-0 accent-[#1a73e8] disabled:cursor-not-allowed"
/>
<span
className={cn(
"min-w-0 flex-1 text-sm",
checked ? "text-[#1a73e8]" : "text-foreground"
)}
>
{label}
</span>
{icon ? <span className="shrink-0">{icon}</span> : null}
</label>
)
}
export function QuickSettingsCheckbox({
label,
checked,
onChange,
icon,
helpLabel,
}: {
label: string
checked: boolean
onChange: (checked: boolean) => void
icon?: React.ReactNode
helpLabel?: string
}) {
return (
<label className="flex cursor-pointer items-center gap-3 rounded-md px-1 py-2 hover:bg-mail-surface-muted">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="size-[18px] shrink-0 rounded-sm accent-[#1a73e8]"
/>
<span className="min-w-0 flex-1 text-sm text-foreground">{label}</span>
{helpLabel ? (
<span
className="flex size-5 shrink-0 items-center justify-center rounded-full text-xs text-[#5f6368]"
title={helpLabel}
aria-label={helpLabel}
>
?
</span>
) : null}
{icon ? <span className="shrink-0">{icon}</span> : null}
</label>
)
}

View File

@ -0,0 +1,241 @@
"use client"
import Link from "next/link"
import { X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type {
InboxSortMode,
MailDensity,
ReadingPaneMode,
} from "@/lib/mail-settings/types"
import {
QuickSettingsCheckbox,
QuickSettingsOption,
} from "@/components/gmail/quick-settings/quick-settings-option"
import {
DensityCompactIcon,
DensityDefaultIcon,
DensityNormalIcon,
InboxDefaultIcon,
InboxImportantIcon,
InboxStarredIcon,
InboxUnreadIcon,
ReadingPaneBelowIcon,
ReadingPaneNoneIcon,
ReadingPaneRightIcon,
ThemeThumbnailIcon,
} from "@/components/gmail/quick-settings/settings-preview-icons"
function SettingsSection({
title,
action,
children,
className,
}: {
title: string
action?: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<section className={cn("border-b border-border px-4 py-4", className)}>
<div className="mb-2 flex items-center justify-between gap-2">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{action}
</div>
{children}
</section>
)
}
export function QuickSettingsPanel() {
const open = useMailSettingsStore((s) => s.quickSettingsOpen)
const themeDialogOpen = useMailSettingsStore((s) => s.themeDialogOpen)
const setOpen = useMailSettingsStore((s) => s.setQuickSettingsOpen)
const setThemeDialogOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
const density = useMailSettingsStore((s) => s.density)
const setDensity = useMailSettingsStore((s) => s.setDensity)
const inboxSort = useMailSettingsStore((s) => s.inboxSort)
const setInboxSort = useMailSettingsStore((s) => s.setInboxSort)
const readingPane = useMailSettingsStore((s) => s.readingPane)
const setReadingPane = useMailSettingsStore((s) => s.setReadingPane)
const conversationMode = useMailSettingsStore((s) => s.conversationMode)
const setConversationMode = useMailSettingsStore((s) => s.setConversationMode)
if (!open) return null
const densityOptions: {
id: MailDensity
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <DensityDefaultIcon /> },
{ id: "normal", label: "Normal", icon: <DensityNormalIcon /> },
{ id: "compact", label: "Compact", icon: <DensityCompactIcon /> },
]
const inboxOptions: {
id: InboxSortMode
label: string
icon: React.ReactNode
}[] = [
{ id: "default", label: "Par défaut", icon: <InboxDefaultIcon /> },
{
id: "important",
label: "Importants d'abord",
icon: <InboxImportantIcon />,
},
{ id: "unread", label: "Non lus d'abord", icon: <InboxUnreadIcon /> },
{ id: "starred", label: "Suivis d'abord", icon: <InboxStarredIcon /> },
]
const readingPaneOptions: {
id: ReadingPaneMode
label: string
icon: React.ReactNode
disabled?: boolean
}[] = [
{
id: "none",
label: "Aucune séparation",
icon: <ReadingPaneNoneIcon />,
},
{
id: "right",
label: "À droite de la boîte de réception",
icon: <ReadingPaneRightIcon />,
},
{
id: "below",
label: "Sous la boîte de réception",
icon: <ReadingPaneBelowIcon />,
disabled: true,
},
]
return (
<>
{!themeDialogOpen && (
<button
type="button"
className="fixed inset-0 z-[60] bg-black/20"
aria-label="Fermer la configuration rapide"
onClick={() => setOpen(false)}
/>
)}
<aside
role="dialog"
aria-label="Configuration rapide"
className="fixed right-0 top-0 z-[61] flex h-full w-full max-w-[360px] flex-col border-l border-border bg-mail-surface shadow-lg"
>
<header className="flex shrink-0 items-center justify-between gap-2 px-4 pt-5 pb-3">
<h1 className="text-base font-normal text-foreground">
Configuration rapide
</h1>
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 text-muted-foreground"
aria-label="Fermer"
onClick={() => setOpen(false)}
>
<X className="size-5" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="px-4 pb-4">
<Button
variant="outline"
className="h-10 w-full rounded-full border-[#1a73e8] text-[#1a73e8] hover:bg-[#e8f0fe]/50"
asChild
onClick={() => setOpen(false)}
>
<Link href="/mail/settings">Voir tous les paramètres</Link>
</Button>
</div>
<SettingsSection title="Densité">
{densityOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="density"
label={opt.label}
checked={density === opt.id}
onSelect={() => setDensity(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection
title="Thème"
action={
<button
type="button"
className="text-sm text-[#1a73e8] hover:underline"
onClick={() => {
setThemeDialogOpen(true)
}}
>
Tout afficher
</button>
}
>
<button
type="button"
className="flex w-full items-center justify-end rounded-md py-1 hover:bg-accent"
onClick={() => setThemeDialogOpen(true)}
>
<ThemeThumbnailIcon />
</button>
</SettingsSection>
<SettingsSection title="Type de boîte de réception">
{inboxOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="inbox-sort"
label={opt.label}
checked={inboxSort === opt.id}
onSelect={() => setInboxSort(opt.id)}
icon={opt.icon}
/>
))}
</SettingsSection>
<SettingsSection title="Volet de lecture">
{readingPaneOptions.map((opt) => (
<QuickSettingsOption
key={opt.id}
name="reading-pane"
label={opt.label}
checked={readingPane === opt.id}
disabled={opt.disabled}
onSelect={() => {
if (!opt.disabled) setReadingPane(opt.id)
}}
icon={opt.icon}
/>
))}
</SettingsSection>
<section className="px-4 py-4">
<h2 className="mb-2 text-sm font-medium text-foreground">
Fils de discussion
</h2>
<QuickSettingsCheckbox
label="Mode Conversation"
checked={conversationMode}
onChange={setConversationMode}
helpLabel="Regrouper les messages d'une même conversation"
/>
</section>
</div>
</aside>
</>
)
}

View File

@ -0,0 +1,13 @@
"use client"
import { QuickSettingsPanel } from "@/components/gmail/quick-settings/quick-settings-panel"
import { ThemeSettingsDialog } from "@/components/gmail/quick-settings/theme-settings-dialog"
export function QuickSettingsRoot() {
return (
<>
<QuickSettingsPanel />
<ThemeSettingsDialog />
</>
)
}

View File

@ -0,0 +1,192 @@
import { cn } from "@/lib/utils"
const previewFrameClass =
"flex h-9 w-14 shrink-0 items-center justify-center rounded border border-[#dadce0] bg-white p-1 dark:border-[#5f6368] dark:bg-[#303134]"
const previewLineClass = "bg-[#dadce0] dark:bg-[#5f6368]"
const previewListPaneClass = "bg-[#f1f3f4] dark:bg-[#3c4043]"
const previewReadingPaneClass = "bg-[#e8f0fe] dark:bg-[#394457]"
function PreviewFrame({
children,
className,
}: {
children: React.ReactNode
className?: string
}) {
return (
<div className={cn(previewFrameClass, className)} aria-hidden>
{children}
</div>
)
}
export function DensityDefaultIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className="flex items-center gap-0.5">
<div className={cn("h-0.5 flex-1 rounded-full", previewLineClass)} />
<div className="h-1.5 w-4 shrink-0 rounded-full bg-[#1a73e8]/70 dark:bg-[#8ab4f8]/70" />
</div>
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-3/4 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function DensityNormalIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className="flex items-center gap-0.5">
<div className={cn("h-0.5 flex-1 rounded-full", previewLineClass)} />
<svg
viewBox="0 0 8 8"
className="h-2 w-2 shrink-0 text-[#5f6368] dark:text-[#9aa0a6]"
>
<path d="M2 1h4v1H5v4H4V2H2V1z" fill="currentColor" />
</svg>
</div>
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-3/4 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function DensityCompactIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-px">
<div className={cn("h-px w-full rounded-full", previewLineClass)} />
<div className={cn("h-px w-full rounded-full", previewLineClass)} />
<div className={cn("h-px w-full rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function InboxDefaultIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-4/5 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function InboxImportantIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className="flex items-center gap-0.5">
<div className={cn("h-0.5 flex-1 rounded-full", previewLineClass)} />
<div className="h-1 w-1 shrink-0 rotate-45 bg-[#f4cc70]" />
</div>
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-3/4 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function InboxUnreadIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className="flex items-center gap-0.5">
<div className={cn("h-0.5 flex-1 rounded-full", previewLineClass)} />
<div className="h-1.5 w-1.5 shrink-0 rounded-sm bg-[#1a73e8] dark:bg-[#8ab4f8]" />
</div>
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-3/4 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function InboxStarredIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className="flex items-center gap-0.5">
<div className={cn("h-0.5 flex-1 rounded-full", previewLineClass)} />
<div className="h-1.5 w-1.5 shrink-0 text-[#f4cc70]"></div>
</div>
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-3/4 rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function ReadingPaneNoneIcon() {
return (
<PreviewFrame>
<div className="flex w-full flex-col gap-0.5">
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
<div className={cn("h-0.5 w-full rounded-full", previewLineClass)} />
</div>
</PreviewFrame>
)
}
export function ReadingPaneRightIcon() {
return (
<PreviewFrame className="p-0.5">
<div className="flex h-full w-full gap-px">
<div className={cn("flex flex-1 flex-col gap-px p-0.5", previewListPaneClass)}>
<div className={cn("h-px w-full", previewLineClass)} />
<div className={cn("h-px w-full", previewLineClass)} />
</div>
<div
className={cn(
"flex w-5 flex-col gap-px p-0.5",
previewReadingPaneClass
)}
>
<div className="h-0.5 w-full rounded-full bg-[#1a73e8]/40 dark:bg-[#8ab4f8]/50" />
<div className={cn("h-px w-full", previewLineClass)} />
</div>
</div>
</PreviewFrame>
)
}
export function ReadingPaneBelowIcon() {
return (
<PreviewFrame className="p-0.5">
<div className="flex h-full w-full flex-col gap-px">
<div className={cn("flex flex-1 flex-col gap-px p-0.5", previewListPaneClass)}>
<div className={cn("h-px w-full", previewLineClass)} />
<div className={cn("h-px w-full", previewLineClass)} />
</div>
<div className={cn("h-3", previewReadingPaneClass)} />
</div>
</PreviewFrame>
)
}
export function ThemeThumbnailIcon() {
return (
<PreviewFrame className="h-10 w-16 p-0.5">
<div className="flex h-full w-full flex-col overflow-hidden rounded-sm border border-[#eceff1] dark:border-[#5f6368]">
<div className={cn("h-1.5", previewListPaneClass)} />
<div className="flex flex-1">
<div className={cn("w-2", previewReadingPaneClass)} />
<div className="flex-1 bg-white p-0.5 dark:bg-[#303134]">
<div className={cn("h-px w-full", previewLineClass)} />
</div>
</div>
</div>
</PreviewFrame>
)
}

View File

@ -0,0 +1,115 @@
"use client"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { cn } from "@/lib/utils"
import {
MAIL_BACKGROUND_PRESETS,
normalizeMailBackgroundId,
} from "@/lib/mail-settings/constants"
import type { MailBackgroundId, MailThemeMode } from "@/lib/mail-settings/types"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
const THEME_OPTIONS: { id: MailThemeMode; label: string; previewClass: string }[] =
[
{ id: "light", label: "Clair", previewClass: "bg-white" },
{ id: "dark", label: "Sombre", previewClass: "bg-[#202124]" },
{
id: "system",
label: "Système",
previewClass:
"bg-gradient-to-br from-white from-50% to-[#202124] to-50%",
},
]
export function ThemeSettingsDialog() {
const open = useMailSettingsStore((s) => s.themeDialogOpen)
const setOpen = useMailSettingsStore((s) => s.setThemeDialogOpen)
const themeMode = useMailSettingsStore((s) => s.themeMode)
const backgroundId = useMailSettingsStore((s) => s.backgroundId)
const setThemeMode = useMailSettingsStore((s) => s.setThemeMode)
const setBackgroundId = useMailSettingsStore((s) => s.setBackgroundId)
const activeBackgroundId = normalizeMailBackgroundId(backgroundId)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
overlayClassName="z-[70]"
className="z-[70] max-w-md gap-5 border-border bg-background sm:max-w-lg"
>
<DialogHeader>
<DialogTitle className="text-left text-base font-normal text-foreground">
Thème
</DialogTitle>
</DialogHeader>
<section>
<h3 className="mb-3 text-sm font-medium text-foreground">Mode</h3>
<div className="grid grid-cols-3 gap-2">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setThemeMode(opt.id)}
className={cn(
"rounded-lg border-2 p-2.5 text-left transition-colors",
themeMode === opt.id
? "border-primary bg-accent/60"
: "border-border hover:border-muted-foreground/50 hover:bg-accent/40"
)}
>
<div
className={cn(
"mb-2 h-14 rounded-md border border-border",
opt.previewClass
)}
/>
<span className="text-sm text-foreground">{opt.label}</span>
</button>
))}
</div>
</section>
<section>
<h3 className="mb-3 text-sm font-medium text-foreground">
Arrière-plan
</h3>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{MAIL_BACKGROUND_PRESETS.map((preset) => (
<button
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>
</section>
</DialogContent>
</Dialog>
)
}

View File

@ -23,6 +23,16 @@ import {
Trash2, Trash2,
} from "lucide-react" } from "lucide-react"
import { cn, formatCount } from "@/lib/utils" import { cn, formatCount } from "@/lib/utils"
import {
MAIL_SIDEBAR_COLOR_PICKER_CLASS,
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS,
MAIL_SIDEBAR_MENU_ITEM_CLASS,
MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS,
MAIL_SIDEBAR_MENU_SEPARATOR_CLASS,
MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
MAIL_SIDEBAR_MENU_SURFACE_CLASS,
mailNavRowClass,
} from "@/lib/mail-chrome-classes"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { import {
@ -43,6 +53,7 @@ import {
type SidebarNavDropPlacement, type SidebarNavDropPlacement,
} from "@/lib/sidebar-nav-dnd" } from "@/lib/sidebar-nav-dnd"
import { useComposeActions } from "@/lib/compose-context" import { useComposeActions } from "@/lib/compose-context"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -205,7 +216,7 @@ function LabelMenuOptionWithCheck({
e.stopPropagation() e.stopPropagation()
onPick() onPick()
}} }}
className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
> >
<span className="min-w-0 flex-1 text-left">{children}</span> <span className="min-w-0 flex-1 text-left">{children}</span>
<span <span
@ -213,7 +224,7 @@ function LabelMenuOptionWithCheck({
aria-hidden={!checked} aria-hidden={!checked}
> >
{checked ? ( {checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden /> <Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null} ) : null}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@ -232,7 +243,7 @@ function ContextLabelMenuOptionWithCheck({
return ( return (
<ContextMenuItem <ContextMenuItem
onClick={() => onPick()} onClick={() => onPick()}
className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm" className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
> >
<span className="min-w-0 flex-1 text-left">{children}</span> <span className="min-w-0 flex-1 text-left">{children}</span>
<span <span
@ -240,7 +251,7 @@ function ContextLabelMenuOptionWithCheck({
aria-hidden={!checked} aria-hidden={!checked}
> >
{checked ? ( {checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden /> <Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null} ) : null}
</span> </span>
</ContextMenuItem> </ContextMenuItem>
@ -466,6 +477,10 @@ function SidebarOverflowColumn({
) )
} }
/** Fond sidebar semi-transparent + flou (overlay mobile / hover). */
const SIDEBAR_PANEL_SURFACE_CLASS =
"bg-app-canvas/80 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-app-canvas/65"
const sidebarOverflowMenuButtonClass = const sidebarOverflowMenuButtonClass =
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-600 outline-none hover:bg-black/8 focus-visible:ring-2 focus-visible:ring-ring/50" "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-600 outline-none hover:bg-black/8 focus-visible:ring-2 focus-visible:ring-ring/50"
@ -540,7 +555,7 @@ function CategoryNavRow({
className={cn( className={cn(
"flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors", "flex h-8 w-full min-w-0 shrink-0 items-center pl-6 pr-2 text-gray-500 transition-colors",
isOver ? "rounded-r-full" : "rounded-r-none", isOver ? "rounded-r-full" : "rounded-r-none",
isOver && "bg-yellow-100 text-gray-900", isOver && "bg-mail-nav-drop text-foreground",
touchRowClassName touchRowClassName
)} )}
> >
@ -626,14 +641,14 @@ function CategoryNavRow({
"group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors", "group/catnav flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld), navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver : isOver
? "bg-yellow-100 text-gray-900" ? "bg-mail-nav-drop text-foreground"
: rowHoverHeld : rowHoverHeld
? "bg-gray-100 text-gray-900" ? "bg-mail-nav-hover text-foreground"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-gray-100", : "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName touchRowClassName
)} )}
> >
@ -1033,12 +1048,12 @@ export function Sidebar({
"flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 transition-colors", "flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 transition-colors",
navRowRoundedWhenActive(isSelected || isOver), navRowRoundedWhenActive(isSelected || isOver),
isSelected isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver : isOver
? "bg-yellow-100 text-gray-900" ? "bg-mail-nav-drop text-foreground"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-gray-100" : "text-gray-700 hover:bg-mail-nav-hover"
)} )}
> >
{typeof item.icon === "string" ? ( {typeof item.icon === "string" ? (
@ -1124,7 +1139,7 @@ export function Sidebar({
) )
const folderMenuSurface = const folderMenuSurface =
"min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md" MAIL_SIDEBAR_MENU_SURFACE_CLASS
const colorSub = ( const colorSub = (
subKind: "dropdown" | "context" subKind: "dropdown" | "context"
@ -1138,11 +1153,11 @@ export function Sidebar({
<Sub> <Sub>
<SubTr <SubTr
className={cn( className={cn(
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100", MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2" subKind === "context" && "flex items-center gap-2"
)} )}
> >
<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-border bg-mail-surface">
<span <span
className={cn( className={cn(
"block size-3 rounded-sm border border-black/10", "block size-3 rounded-sm border border-black/10",
@ -1153,7 +1168,7 @@ export function Sidebar({
</span> </span>
<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="min-w-[180px] border-gray-200 bg-white p-2 shadow-md"> <SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5"> <div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => ( {LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button <button
@ -1165,7 +1180,10 @@ export function Sidebar({
setMenuOpen(false) setMenuOpen(false)
}} }}
className={cn( className={cn(
"size-6 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", cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw sw
)} )}
/> />
@ -1181,10 +1199,10 @@ export function Sidebar({
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none", isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70", isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas", isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900", isSelected && "bg-mail-nav-selected font-medium text-mail-nav-selected",
!isSelected && hasUnread && "text-gray-900", !isSelected && hasUnread && "text-gray-900",
isOver && "bg-yellow-100 text-gray-900", isOver && "bg-mail-nav-drop text-foreground",
rowHoverHeld && "bg-gray-100 text-gray-900", rowHoverHeld && "bg-mail-nav-hover text-foreground",
touchRowClassName touchRowClassName
) )
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
@ -1217,8 +1235,8 @@ export function Sidebar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={folderMenuSurface}> <DropdownMenuContent align="end" className={folderMenuSurface}>
{colorSub("dropdown")} {colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers Dans la liste des dossiers
</DropdownMenuLabel> </DropdownMenuLabel>
<LabelMenuOptionWithCheck <LabelMenuOptionWithCheck
@ -1239,8 +1257,8 @@ export function Sidebar({
> >
Masquer Masquer
</LabelMenuOptionWithCheck> </LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages Dans la liste des messages
</DropdownMenuLabel> </DropdownMenuLabel>
<LabelMenuOptionWithCheck <LabelMenuOptionWithCheck
@ -1255,9 +1273,9 @@ export function Sidebar({
> >
Masquer Masquer
</LabelMenuOptionWithCheck> </LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem <DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => { onClick={() => {
setRenameDraft(node.label) setRenameDraft(node.label)
setRenameOpen(true) setRenameOpen(true)
@ -1267,7 +1285,7 @@ export function Sidebar({
Renommer Renommer
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => { onClick={() => {
setMoveParent("__root__") setMoveParent("__root__")
setMoveOpen(true) setMoveOpen(true)
@ -1277,7 +1295,7 @@ export function Sidebar({
Déplacer Déplacer
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => { onClick={() => {
setSubfolderName("") setSubfolderName("")
setSubfolderOpen(true) setSubfolderOpen(true)
@ -1288,7 +1306,7 @@ export function Sidebar({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50" className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-destructive/15"
onClick={() => { onClick={() => {
removeFolderOrLabelRow(node.id) removeFolderOrLabelRow(node.id)
setMenuOpen(false) setMenuOpen(false)
@ -1491,7 +1509,7 @@ export function Sidebar({
!isSelected && !isSelected &&
!isOver && !isOver &&
!rowHoverHeld && !rowHoverHeld &&
"rounded-r-none hover:rounded-r-full hover:bg-gray-100", "rounded-r-none hover:rounded-r-full hover:bg-mail-nav-hover",
rowHoverHeld && !isSelected && !isOver && "rounded-r-full", rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
isSelected isSelected
? "text-gray-900" ? "text-gray-900"
@ -1559,8 +1577,8 @@ export function Sidebar({
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger> <ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}> <ContextMenuContent className={folderMenuSurface}>
{colorSub("context")} {colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers Dans la liste des dossiers
</ContextMenuLabel> </ContextMenuLabel>
<ContextLabelMenuOptionWithCheck <ContextLabelMenuOptionWithCheck
@ -1581,8 +1599,8 @@ export function Sidebar({
> >
Masquer Masquer
</ContextLabelMenuOptionWithCheck> </ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages Dans la liste des messages
</ContextMenuLabel> </ContextMenuLabel>
<ContextLabelMenuOptionWithCheck <ContextLabelMenuOptionWithCheck
@ -1825,12 +1843,12 @@ export function Sidebar({
"relative flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm transition-colors", "relative flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm transition-colors",
navRowRoundedWhenActive(isHighlighted || isOver), navRowRoundedWhenActive(isHighlighted || isOver),
isHighlighted isHighlighted
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver : isOver
? "bg-yellow-100 text-gray-900" ? "bg-mail-nav-drop text-foreground"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-gray-100" : "text-gray-700 hover:bg-mail-nav-hover"
)} )}
> >
<SidebarNavIconSlot showUnreadDot={hasUnread}> <SidebarNavIconSlot showUnreadDot={hasUnread}>
@ -1901,7 +1919,7 @@ export function Sidebar({
const prefs = getNavItemPrefs(item.id) const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400" const labelDotClass = item.color ?? "bg-gray-400"
const labelMenuSurface = const labelMenuSurface =
"min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md" MAIL_SIDEBAR_MENU_SURFACE_CLASS
const colorSub = (subKind: "dropdown" | "context") => { const colorSub = (subKind: "dropdown" | "context") => {
const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
@ -1913,11 +1931,11 @@ export function Sidebar({
<Sub> <Sub>
<SubTr <SubTr
className={cn( className={cn(
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100", MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS,
subKind === "context" && "flex items-center gap-2" subKind === "context" && "flex items-center gap-2"
)} )}
> >
<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-border bg-mail-surface">
<span <span
className={cn( className={cn(
"block size-3 rounded-sm border border-black/10", "block size-3 rounded-sm border border-black/10",
@ -1928,7 +1946,7 @@ export function Sidebar({
</span> </span>
<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="min-w-[180px] border-gray-200 bg-white p-2 shadow-md"> <SubCo className={MAIL_SIDEBAR_COLOR_PICKER_CLASS}>
<div className="grid grid-cols-6 gap-1.5"> <div className="grid grid-cols-6 gap-1.5">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => ( {LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button <button
@ -1940,7 +1958,10 @@ export function Sidebar({
setMenuOpen(false) setMenuOpen(false)
}} }}
className={cn( className={cn(
"size-6 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", cn(
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2",
MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS
),
sw sw
)} )}
/> />
@ -1955,14 +1976,14 @@ export function Sidebar({
"group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors", "group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld), navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver : isOver
? "bg-yellow-100 text-gray-900" ? "bg-mail-nav-drop text-foreground"
: rowHoverHeld : rowHoverHeld
? "bg-gray-100 text-gray-900" ? "bg-mail-nav-hover text-foreground"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-gray-100", : "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName touchRowClassName
) )
@ -2050,8 +2071,8 @@ export function Sidebar({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className={labelMenuSurface}> <DropdownMenuContent align="end" className={labelMenuSurface}>
{colorSub("dropdown")} {colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés Dans la liste des libellés
</DropdownMenuLabel> </DropdownMenuLabel>
<LabelMenuOptionWithCheck <LabelMenuOptionWithCheck
@ -2072,8 +2093,8 @@ export function Sidebar({
> >
Masquer Masquer
</LabelMenuOptionWithCheck> </LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages Dans la liste des messages
</DropdownMenuLabel> </DropdownMenuLabel>
<LabelMenuOptionWithCheck <LabelMenuOptionWithCheck
@ -2088,9 +2109,9 @@ export function Sidebar({
> >
Masquer Masquer
</LabelMenuOptionWithCheck> </LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" /> <DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuItem <DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => { onClick={() => {
setRenameDraft(item.label) setRenameDraft(item.label)
setRenameOpen(true) setRenameOpen(true)
@ -2101,7 +2122,7 @@ export function Sidebar({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50" className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-destructive/15"
onClick={() => { onClick={() => {
removeFolderOrLabelRow(item.id) removeFolderOrLabelRow(item.id)
setMenuOpen(false) setMenuOpen(false)
@ -2110,7 +2131,7 @@ export function Sidebar({
Supprimer le libellé Supprimer le libellé
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" className={MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS}
onClick={() => { onClick={() => {
setSublabelName("") setSublabelName("")
setSublabelOpen(true) setSublabelOpen(true)
@ -2278,8 +2299,8 @@ export function Sidebar({
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger> <ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}> <ContextMenuContent className={labelMenuSurface}>
{colorSub("context")} {colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés Dans la liste des libellés
</ContextMenuLabel> </ContextMenuLabel>
<ContextLabelMenuOptionWithCheck <ContextLabelMenuOptionWithCheck
@ -2300,8 +2321,8 @@ export function Sidebar({
> >
Masquer Masquer
</ContextLabelMenuOptionWithCheck> </ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" /> <ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500"> <ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages Dans la liste des messages
</ContextMenuLabel> </ContextMenuLabel>
<ContextLabelMenuOptionWithCheck <ContextLabelMenuOptionWithCheck
@ -2450,7 +2471,8 @@ export function Sidebar({
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40 select-none", "absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden transition-[width,transform] duration-200 z-40 select-none",
SIDEBAR_PANEL_SURFACE_CLASS,
isExpanded ? "w-60" : "w-[68px]", isExpanded ? "w-60" : "w-[68px]",
splitView && "border-r border-gray-200", splitView && "border-r border-gray-200",
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200", !touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
@ -2460,7 +2482,8 @@ export function Sidebar({
> >
<div <div
className={cn( className={cn(
"flex shrink-0 items-center bg-app-canvas", "flex shrink-0 items-center",
SIDEBAR_PANEL_SURFACE_CLASS,
splitView splitView
? cn( ? cn(
splitViewLogoHeaderClass, splitViewLogoHeaderClass,
@ -2487,6 +2510,9 @@ export function Sidebar({
size="icon" size="icon"
className="size-9 shrink-0 text-gray-600" className="size-9 shrink-0 text-gray-600"
aria-label="Réglages" aria-label="Réglages"
onClick={() =>
useMailSettingsStore.getState().setQuickSettingsOpen(true)
}
> >
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden /> <Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button> </Button>
@ -2497,7 +2523,8 @@ export function Sidebar({
<div <div
className={cn( className={cn(
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex", "hidden shrink-0 z-10 pt-1 pb-3 pl-2 sm:flex",
SIDEBAR_PANEL_SURFACE_CLASS,
isExpanded ? "pr-3.5" : "pr-2", isExpanded ? "pr-3.5" : "pr-2",
splitView && "!hidden" splitView && "!hidden"
)} )}
@ -2508,7 +2535,7 @@ export function Sidebar({
aria-label={!isExpanded ? "Nouveau message" : undefined} aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose} onClick={openCompose}
className={cn( className={cn(
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-gray-200 bg-white text-sm font-medium text-gray-700 shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0", "inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-border bg-mail-surface text-sm font-medium text-foreground shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-accent hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
isExpanded isExpanded
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8" ? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0" : "w-[52px] justify-center px-0 py-0"
@ -2576,7 +2603,7 @@ export function Sidebar({
}) })
} }
className={cn( className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-gray-700 transition-colors hover:bg-gray-100", "flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-gray-700 transition-colors hover:bg-mail-nav-hover",
navRowRoundedWhenActive(false) navRowRoundedWhenActive(false)
)} )}
> >
@ -2651,7 +2678,10 @@ export function Sidebar({
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */} {/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
<div className="mt-3 pt-1"> <div className="mt-3 pt-1">
<div <div
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3" className={cn(
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
SIDEBAR_PANEL_SURFACE_CLASS
)}
title={!isExpanded ? "Dossiers" : undefined} title={!isExpanded ? "Dossiers" : undefined}
> >
<Icon <Icon
@ -2667,7 +2697,7 @@ export function Sidebar({
{isExpanded && ( {isExpanded && (
<button <button
type="button" type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-mail-nav-hover hover:text-gray-700"
aria-label="Ajouter un dossier" aria-label="Ajouter un dossier"
title="Ajouter un dossier" title="Ajouter un dossier"
onClick={() => { onClick={() => {
@ -2689,7 +2719,10 @@ export function Sidebar({
{/* Labels */} {/* Labels */}
<div className="mt-3 pt-1"> <div className="mt-3 pt-1">
<div <div
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3" className={cn(
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
SIDEBAR_PANEL_SURFACE_CLASS
)}
title={!isExpanded ? "Libellés" : undefined} title={!isExpanded ? "Libellés" : undefined}
> >
<Icon <Icon
@ -2705,7 +2738,7 @@ export function Sidebar({
{isExpanded && ( {isExpanded && (
<button <button
type="button" type="button"
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700" className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-mail-nav-hover hover:text-gray-700"
aria-label="Ajouter un libellé" aria-label="Ajouter un libellé"
title="Ajouter un libellé" title="Ajouter un libellé"
onClick={() => { onClick={() => {
@ -2731,7 +2764,8 @@ export function Sidebar({
{/* Sortbot */} {/* Sortbot */}
<div <div
className={cn( className={cn(
"relative z-32 mt-auto bg-app-canvas pt-2", "relative z-32 mt-auto pt-2",
SIDEBAR_PANEL_SURFACE_CLASS,
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3" "max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)} )}
> >
@ -2739,7 +2773,7 @@ export function Sidebar({
type="button" type="button"
title={!isExpanded ? "Sortbot" : undefined} title={!isExpanded ? "Sortbot" : undefined}
className={cn( className={cn(
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm text-gray-700 transition-colors hover:bg-gray-100", "flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm text-gray-700 transition-colors hover:bg-mail-nav-hover",
navRowRoundedWhenActive(false) navRowRoundedWhenActive(false)
)} )}
> >

View File

@ -0,0 +1,68 @@
import Script from 'next/script'
/** Contenu exécuté avant hydratation (thème + fond, évite flash clair). */
export const THEME_INIT_SCRIPT = `
(function () {
try {
var raw = localStorage.getItem("ultimail-mail-settings");
if (!raw) return;
var parsed = JSON.parse(raw);
var state = parsed.state || parsed;
var mode = state.themeMode || "system";
var resolved =
mode === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: mode;
document.documentElement.classList.toggle("dark", resolved === "dark");
var bgId = state.backgroundId;
if (bgId && bgId !== "none") {
var legacy = {
mountains: "photo-mountains",
ocean: "gradient-ocean",
forest: "photo-nature",
abstract: "gradient-blossom"
};
var id = legacy[bgId] || bgId;
var layers = {
"gradient-aurora": 'url("/mail-backgrounds/gradient-aurora.svg") center/cover no-repeat',
"gradient-sunset": 'url("/mail-backgrounds/gradient-sunset.svg") center/cover no-repeat',
"gradient-ocean": 'url("/mail-backgrounds/gradient-ocean.svg") center/cover no-repeat',
"gradient-blossom": 'url("/mail-backgrounds/gradient-blossom.svg") center/cover no-repeat',
"photo-mountains": 'url("https://picsum.photos/seed/ultimail-mountains/1920/1080") center/cover no-repeat',
"photo-ocean": 'url("https://picsum.photos/seed/ultimail-ocean/1920/1080") center/cover no-repeat',
"photo-city": 'url("https://picsum.photos/seed/ultimail-city/1920/1080") center/cover no-repeat',
"photo-nature": 'url("https://picsum.photos/seed/ultimail-nature/1920/1080") center/cover no-repeat'
};
var fallbacks = {
"gradient-aurora": "#667eea",
"gradient-sunset": "#e44d26",
"gradient-ocean": "#203a43",
"gradient-blossom": "#ffecd2",
"photo-mountains": "#5c6b73",
"photo-ocean": "#1a5276",
"photo-city": "#2c3e50",
"photo-nature": "#2d5016"
};
if (layers[id]) {
document.documentElement.dataset.mailBackground = id;
document.documentElement.style.setProperty("--mail-bg-layer", layers[id]);
document.documentElement.style.setProperty(
"--mail-bg-fallback",
fallbacks[id] || "#202124"
);
}
}
} catch (e) {}
})();
`.trim()
/** Script bloquant injecté par Next.js dans le <head> (compatible React 19). */
export function ThemeInitScript() {
return (
<Script id="ultimail-theme-init" strategy="beforeInteractive">
{THEME_INIT_SCRIPT}
</Script>
)
}

View File

@ -48,15 +48,17 @@ function DialogOverlay({
function DialogContent({ function DialogContent({
className, className,
overlayClassName,
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
overlayClassName?: string
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay /> <DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(

View File

@ -46,13 +46,13 @@ function TooltipContent({
data-slot="tooltip-content" data-slot="tooltip-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', 'border border-border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> <TooltipPrimitive.Arrow className="fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) )

View File

@ -1,47 +1,85 @@
"use client"
import Link from "next/link"
import { cn } from "@/lib/utils" 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 source + « Ultimail » (lisible, aligné barre). `mark` = picto seul (launcher). */
variant?: "horizontal" | "mark" variant?: "horizontal" | "mark"
/** Lien au clic ; `null` = pas de lien. Défaut : boîte de réception. */
href?: string | null
} }
/** Icône extraite du master PNG (pas le SVG VTracer, trop « M Gmail » à petite taille). */ /** 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 DEFAULT_INBOX_HREF = "/mail/inbox"
export function UltiMailLogo({
className,
variant = "horizontal",
href = DEFAULT_INBOX_HREF,
}: UltiMailLogoProps) {
const mark = (
<img
src={HEADER_ICON}
alt=""
width={288}
height={288}
draggable={false}
className={cn(
"shrink-0 object-contain object-center select-none",
variant === "mark" ? "h-10 w-10" : "h-8 w-8"
)}
aria-hidden
/>
)
export function UltiMailLogo({ className, variant = "horizontal" }: UltiMailLogoProps) {
if (variant === "mark") { if (variant === "mark") {
if (href === null) {
return <div className={cn("shrink-0", className)}>{mark}</div>
}
return ( return (
<img <Link
src={HEADER_ICON} href={href}
alt="" className={cn(
width={288} "shrink-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
height={288} className
draggable={false} )}
className={cn("h-10 w-10 shrink-0 object-contain object-center", className)} aria-label="Ultimail — Boîte de réception"
aria-hidden >
/> {mark}
</Link>
) )
} }
return ( const body = (
<div <div
role="img" role="img"
aria-label="Ultimail" aria-label="Ultimail"
className={cn("flex min-w-0 items-center gap-2.5 text-[#0f172a]", className)} className="flex min-w-0 items-center gap-2.5 text-foreground"
> >
<img {mark}
src={HEADER_ICON} <span className="min-w-0 truncate text-[1.375rem] font-semibold leading-none tracking-tight text-foreground dark:text-white">
alt=""
width={288}
height={288}
draggable={false}
className="h-8 w-8 shrink-0 object-contain object-center select-none"
aria-hidden
/>
<span className="min-w-0 truncate text-[1.375rem] font-semibold leading-none tracking-tight text-[#0f172a]">
Ultimail Ultimail
</span> </span>
</div> </div>
) )
if (href === null) {
return <div className={cn("min-w-0", className)}>{body}</div>
}
return (
<Link
href={href}
className={cn(
"flex min-w-0 items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className
)}
aria-label="Ultimail — Boîte de réception"
>
{body}
</Link>
)
} }

View File

@ -0,0 +1,77 @@
"use client"
import { useLayoutEffect, useState, type CSSProperties } from "react"
export type ContactsTableColumn =
| "checkbox"
| "name"
| "email"
| "phone"
| "job"
| "labels"
const COLUMN_WIDTHS: Record<ContactsTableColumn, string> = {
checkbox: "40px",
name: "minmax(0, 2fr)",
email: "minmax(0, 2fr)",
phone: "minmax(0, 1.5fr)",
job: "minmax(0, 1.5fr)",
labels: "minmax(0, 1fr)",
}
const COLUMN_LABELS: Record<Exclude<ContactsTableColumn, "checkbox">, string> = {
name: "Nom",
email: "E-mail",
phone: "Numéro de téléphone",
job: "Fonction et entreprise",
labels: "Libellés",
}
function columnsForWidth(width: number): ContactsTableColumn[] {
const cols: ContactsTableColumn[] = ["checkbox", "name"]
if (width >= 640) cols.push("email")
if (width >= 768) cols.push("phone")
if (width >= 1024) cols.push("job", "labels")
return cols
}
export function useContactsTableColumns() {
const [visibleColumns, setVisibleColumns] = useState<ContactsTableColumn[]>(() =>
typeof window === "undefined"
? ["checkbox", "name", "email", "phone", "job", "labels"]
: columnsForWidth(window.innerWidth)
)
useLayoutEffect(() => {
const update = () => setVisibleColumns(columnsForWidth(window.innerWidth))
update()
const mqlSm = window.matchMedia("(min-width: 640px)")
const mqlMd = window.matchMedia("(min-width: 768px)")
const mqlLg = window.matchMedia("(min-width: 1024px)")
mqlSm.addEventListener("change", update)
mqlMd.addEventListener("change", update)
mqlLg.addEventListener("change", update)
return () => {
mqlSm.removeEventListener("change", update)
mqlMd.removeEventListener("change", update)
mqlLg.removeEventListener("change", update)
}
}, [])
return { visibleColumns, columnLabels: COLUMN_LABELS }
}
export function contactsTableGridStyle(columns: ContactsTableColumn[]): CSSProperties {
return {
gridTemplateColumns: columns.map((c) => COLUMN_WIDTHS[c]).join(" "),
}
}
export function isContactsColumnVisible(
columns: ContactsTableColumn[],
column: ContactsTableColumn
): boolean {
return columns.includes(column)
}

View File

@ -0,0 +1,23 @@
import { useLayoutEffect, useState } from "react"
export const LG_MIN_PX = 1024
const LG_MQ = `(min-width: ${LG_MIN_PX}px)`
export function readLgMatches(): boolean {
if (typeof window === "undefined") return false
return window.matchMedia(LG_MQ).matches
}
export function useIsLg() {
const [matches, setMatches] = useState(false)
useLayoutEffect(() => {
const mql = window.matchMedia(LG_MQ)
const update = () => setMatches(mql.matches)
update()
mql.addEventListener("change", update)
return () => mql.removeEventListener("change", update)
}, [])
return matches
}

View File

@ -1,28 +1,34 @@
import { useLayoutEffect, useState } from "react" import { useLayoutEffect, useState } from "react"
import { readCoarsePointerMatches } from "@/hooks/use-touch-nav" import { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
import { readLgMatches } from "@/hooks/use-lg-breakpoint"
/** Tailwind `md` breakpoint — split view never applies below this width. */ import { MD_MIN_PX } from "@/hooks/use-md-breakpoint"
export const MD_MIN_PX = 768 import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import type { ReadingPaneMode } from "@/lib/mail-settings/types"
const MD_MQ = `(min-width: ${MD_MIN_PX}px)` const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
const LANDSCAPE_MQ = "(orientation: landscape)" const LANDSCAPE_MQ = "(orientation: landscape)"
/** export function readMailSplitViewMatches(
* User preference (settings UI later). When true, split view is enabled on md+ readingPane: ReadingPaneMode = "none"
* even on non-touch desktops. ): boolean {
*/
export const MAIL_SPLIT_VIEW_USER_SETTING = false
export function readMailSplitViewMatches(): boolean {
if (typeof window === "undefined") return false if (typeof window === "undefined") return false
if (!window.matchMedia(MD_MQ).matches) return false if (!window.matchMedia(MD_MQ).matches) return false
const coarse = readCoarsePointerMatches() const coarse = readCoarsePointerMatches()
const tabletLandscape = const tabletLandscape =
coarse && window.matchMedia(LANDSCAPE_MQ).matches coarse && window.matchMedia(LANDSCAPE_MQ).matches
return tabletLandscape || MAIL_SPLIT_VIEW_USER_SETTING
if (readLgMatches()) {
if (readingPane === "right") return true
if (readingPane === "none") return tabletLandscape
return false
}
return tabletLandscape
} }
export function useMailSplitView() { export function useMailSplitView() {
const readingPane = useMailSettingsStore((s) => s.readingPane)
const [splitView, setSplitView] = useState(false) const [splitView, setSplitView] = useState(false)
useLayoutEffect(() => { useLayoutEffect(() => {
@ -31,17 +37,24 @@ export function useMailSplitView() {
const mqlCoarse = window.matchMedia( const mqlCoarse = window.matchMedia(
"(hover: none) and (pointer: coarse)" "(hover: none) and (pointer: coarse)"
) )
const update = () => setSplitView(readMailSplitViewMatches()) const update = () =>
setSplitView(readMailSplitViewMatches(readingPane))
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
.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
.matchMedia(`(min-width: 1024px)`)
.removeEventListener("change", update)
} }
}, []) }, [readingPane])
return splitView return splitView
} }

View File

@ -0,0 +1,23 @@
import { useLayoutEffect, useState } from "react"
export const MD_MIN_PX = 768
const MD_MQ = `(min-width: ${MD_MIN_PX}px)`
export function readMdMatches(): boolean {
if (typeof window === "undefined") return false
return window.matchMedia(MD_MQ).matches
}
export function useIsMd() {
const [matches, setMatches] = useState(false)
useLayoutEffect(() => {
const mql = window.matchMedia(MD_MQ)
const update = () => setMatches(mql.matches)
update()
mql.addEventListener("change", update)
return () => mql.removeEventListener("change", update)
}, [])
return matches
}

View File

@ -0,0 +1,35 @@
import type { UserAccount } from "@/lib/accounts/types"
export const MOCK_USER_ACCOUNTS: UserAccount[] = [
{
id: "eliott",
email: "eliott.guillaumin@gmail.com",
displayName: "Eliott Guillaumin",
firstName: "Eliott",
},
{
id: "crippling",
email: "redeathray@gmail.com",
displayName: "C R I P P L I N G D E P R E S S I O N",
firstName: "C R I P P L I N G",
},
{
id: "blacklight",
email: "dev@bltv.fr",
displayName: "Blacklight Dev",
firstName: "Blacklight",
},
{
id: "techno",
email: "technodelio@gmail.com",
displayName: "Techno Delio",
firstName: "Techno",
},
]
export const DEFAULT_ACCOUNT_ID = MOCK_USER_ACCOUNTS[0]!.id
export const STORAGE_USAGE = {
percentUsed: 87,
totalLabel: "15 Go",
} as const

8
lib/accounts/types.ts Normal file
View File

@ -0,0 +1,8 @@
export interface UserAccount {
id: string
email: string
displayName: string
/** Used in greeting, e.g. "Bonjour Eliott !" */
firstName: string
avatarUrl?: string
}

View File

@ -0,0 +1,206 @@
import { cn } from "@/lib/utils"
/** Fond page contacts : canvas mail (#fafbfc / #202124), pas le noir `background`. */
export const CONTACTS_SHELL_CLASS = "bg-app-canvas text-foreground"
export const CONTACTS_SIDEBAR_CLASS = cn(
"flex h-full w-60 shrink-0 flex-col border-r border-border bg-mail-surface",
"transition-transform duration-200 ease-out"
)
export const CONTACTS_CREATE_BTN_CLASS = cn(
"flex h-14 w-full items-center gap-3 rounded-2xl bg-mail-surface px-4",
"shadow-md ring-1 ring-border transition-shadow hover:bg-accent hover:shadow-lg"
)
export const CONTACTS_NAV_ACTIVE_CLASS =
"bg-mail-nav-selected font-medium text-mail-nav-selected"
export const CONTACTS_NAV_ITEM_CLASS =
"text-foreground hover:bg-mail-nav-hover"
export const CONTACTS_NAV_ICON_MUTED = "text-muted-foreground"
export const CONTACTS_MUTED_TEXT = "text-muted-foreground"
export const CONTACTS_HEADING_TEXT = "text-foreground"
export const CONTACTS_SEARCH_BAR_CLASS = cn(
"flex h-10 w-full max-w-[720px] items-center gap-2 rounded-full border border-transparent bg-muted px-3",
"transition-[background-color,border-color,box-shadow]",
"focus-within:border-ring focus-within:bg-mail-surface focus-within:ring-1 focus-within:ring-ring sm:h-12 sm:gap-3 sm:px-4",
)
export const CONTACTS_SEARCH_INPUT_CLASS =
"flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
export const CONTACTS_ICON_BTN_CLASS =
"text-muted-foreground hover:bg-accent hover:text-foreground"
export const CONTACTS_TABLE_HEADER_CLASS =
"grid gap-2 border-b border-border py-2 text-xs font-medium text-muted-foreground"
export const CONTACTS_TABLE_ROW_CLASS = cn(
"grid w-full cursor-pointer items-center gap-2 border-b border-border py-2.5 text-left text-sm",
"text-foreground transition-colors hover:bg-accent/50"
)
export const CONTACTS_FIELD_CLASS = cn(
"rounded border border-mail-border bg-mail-surface px-2 py-1 text-sm text-foreground outline-none",
"focus:border-ring focus:ring-1 focus:ring-ring"
)
export const CONTACTS_PRIMARY_BTN_CLASS = cn(
"rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
)
export const CONTACTS_OUTLINE_BTN_CLASS = cn(
"inline-flex h-9 items-center gap-2 rounded-full border border-border bg-mail-surface px-5",
"text-sm font-medium text-foreground transition-colors hover:bg-accent"
)
/** Volet contacts (Sheet latéral dans la messagerie) */
export const CONTACTS_PANEL_SHELL_CLASS =
"flex h-full flex-col bg-mail-surface text-foreground"
export const CONTACTS_PANEL_HEADER_CLASS =
"flex h-12 shrink-0 items-center justify-between border-b border-border px-4"
export const CONTACTS_PANEL_HEADER_COMPACT_CLASS =
"flex h-12 shrink-0 items-center justify-between border-b border-border px-2"
export const CONTACTS_PANEL_HEADER_SEARCH_CLASS =
"flex h-12 shrink-0 items-center gap-2 border-b border-border px-4"
export const CONTACTS_PANEL_TITLE_CLASS = "text-lg font-medium text-foreground"
export const CONTACTS_PANEL_ICON_BTN_CLASS =
"h-8 w-8 rounded-full text-muted-foreground"
export const CONTACTS_PANEL_SECTION_LABEL_CLASS =
"px-4 py-2 text-xs font-medium text-muted-foreground"
export const CONTACTS_PANEL_LETTER_CLASS =
"px-4 py-1 text-xs font-medium uppercase text-muted-foreground"
export const CONTACTS_PANEL_ROW_CLASS =
"hover:bg-accent cursor-pointer"
export const CONTACTS_PANEL_CREATE_ROW_CLASS = cn(
"flex w-full items-center gap-3 px-4 h-12",
CONTACTS_PANEL_ROW_CLASS
)
export const CONTACTS_PANEL_LINK_TEXT_CLASS = "text-sm font-medium text-primary"
export const CONTACTS_PANEL_SEARCH_INPUT_CLASS = cn(
"flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
)
export const CONTACTS_PANEL_SAVE_BTN_CLASS = cn(
"rounded-full bg-muted px-5 h-9 text-sm font-medium text-foreground",
"hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
)
export const CONTACTS_PANEL_TAG_CLASS = cn(
"inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2.5 py-0.5 text-xs text-foreground"
)
export const CONTACTS_PANEL_ADD_TAG_BTN_CLASS = cn(
"inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-accent"
)
export const CONTACTS_PANEL_AVATAR_PLACEHOLDER_CLASS =
"flex h-20 w-20 items-center justify-center rounded-full bg-muted text-muted-foreground"
export const CONTACTS_PANEL_CARD_CLASS = "space-y-2 rounded-lg border border-mail-border p-3"
export const CONTACTS_PANEL_DIVIDER_CLASS = "border-t border-border"
export const CONTACTS_PANEL_MUTED_ICON_CLASS = "text-muted-foreground"
export const CONTACTS_PANEL_PRIMARY_ACTION_CLASS = cn(
"inline-flex h-9 items-center gap-2 rounded-full bg-primary/15 px-5 text-sm font-medium text-primary",
"transition-colors hover:bg-primary/25"
)
export const CONTACTS_PANEL_SECONDARY_ICON_BTN_CLASS = cn(
"flex h-9 w-9 items-center justify-center rounded-full border border-border text-muted-foreground hover:bg-accent"
)
export const CONTACTS_PANEL_FLOATING_INPUT_CLASS = cn(
"peer h-[42px] w-full rounded border border-mail-border bg-mail-surface px-3 pt-4 pb-1 text-sm text-foreground outline-none transition-colors",
"focus:border-ring focus:ring-1 focus:ring-ring"
)
export const CONTACTS_PANEL_FLOATING_TEXTAREA_CLASS = cn(
"peer w-full rounded border border-mail-border bg-mail-surface px-3 pt-5 pb-2 text-sm text-foreground outline-none transition-colors resize-none",
"focus:border-ring focus:ring-1 focus:ring-ring"
)
export const CONTACTS_PANEL_FLOATING_LABEL_CLASS =
"pointer-events-none absolute left-3 bg-mail-surface transition-all duration-150"
export const CONTACTS_PANEL_SELECT_TRIGGER_CLASS = cn(
"!h-[42px] !min-h-[42px] w-full rounded border border-mail-border bg-mail-surface px-3 py-0 text-sm text-foreground shadow-none",
"data-[size=default]:!h-[42px] focus:border-ring focus:ring-1 focus:ring-ring"
)
export const CONTACTS_PANEL_POPOVER_ITEM_CLASS =
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
/** Page /contacts (plein écran) */
export const CONTACTS_PAGE_ICON_BTN_CLASS =
"h-10 w-10 rounded-full text-muted-foreground"
export const CONTACTS_PAGE_SAVE_BTN_CLASS = cn(
CONTACTS_PANEL_SAVE_BTN_CLASS,
"px-6 py-2.5",
)
export const CONTACTS_PAGE_TITLE_CLASS = "text-2xl font-normal text-foreground"
export const CONTACTS_PAGE_SECTION_TITLE_CLASS = "text-lg font-normal text-foreground"
export const CONTACTS_PAGE_HEADING_CLASS = cn("font-normal", CONTACTS_HEADING_TEXT)
export const CONTACTS_PAGE_CARD_CLASS = "rounded-xl border border-border p-5"
export const CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS = "mt-3 border-t border-border pt-3"
export const CONTACTS_PAGE_LINK_BTN_CLASS =
"text-sm font-medium text-primary hover:text-primary/80"
export const CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS =
"flex h-28 w-28 items-center justify-center rounded-full bg-muted text-muted-foreground"
export const CONTACTS_PAGE_AVATAR_ADD_BADGE_CLASS =
"absolute -bottom-1 -right-1 flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground shadow"
export const CONTACTS_PAGE_TAG_CLASS = cn(
"inline-flex items-center gap-1 rounded border border-border px-2 py-0.5 text-xs text-foreground",
)
export const CONTACTS_PAGE_BANNER_CLASS =
"mb-4 flex items-center justify-between rounded-lg bg-muted px-4 py-3"
export const CONTACTS_PAGE_INFO_BANNER_CLASS =
"mb-6 flex items-start gap-4 rounded-xl bg-muted p-5"
export const CONTACTS_PAGE_INFO_BANNER_ICON_CLASS =
"flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary/15"
export const CONTACTS_PAGE_TAB_ACTIVE_CLASS =
"rounded-full bg-primary/20 px-4 py-2 text-sm font-medium text-foreground"
export const CONTACTS_PAGE_TAB_INACTIVE_CLASS = cn(
"rounded-full bg-muted px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent",
)
export const CONTACTS_PAGE_TEXTAREA_CLASS = cn(
CONTACTS_FIELD_CLASS,
"h-24 w-full rounded-lg px-3 py-2",
)
export const CONTACTS_CREATE_BTN_LABEL_CLASS =
"flex-1 text-left text-sm font-medium text-foreground"

View File

@ -59,6 +59,7 @@ interface ContactsActions {
openCreateContact: (draft?: ContactCreateDraft | null) => void openCreateContact: (draft?: ContactCreateDraft | null) => void
clearCreateDraft: () => void clearCreateDraft: () => void
setView: (view: ContactsView, activeContactId?: string | null) => void setView: (view: ContactsView, activeContactId?: string | null) => void
showContactsList: () => void
setSearchQuery: (q: string) => void setSearchQuery: (q: string) => void
setSearchMode: (active: boolean) => void setSearchMode: (active: boolean) => void
addContact: ( addContact: (
@ -176,6 +177,15 @@ export const useContactsStore = create<ContactsStore>()(
setView: (view, activeContactId = null) => setView: (view, activeContactId = null) =>
set({ view, activeContactId, createDraft: null }), set({ view, activeContactId, createDraft: null }),
showContactsList: () =>
set({
view: "list",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: null,
}),
setSearchQuery: (searchQuery) => set({ searchQuery }), setSearchQuery: (searchQuery) => set({ searchQuery }),
setSearchMode: (searchMode) => setSearchMode: (searchMode) =>

View File

@ -26,6 +26,8 @@ export interface ConversationMessage {
body: string body: string
preview: string preview: string
attachments?: EmailAttachment[] attachments?: EmailAttachment[]
/** Lu / non lu du message (fixtures legacy). */
read?: boolean
} }
export interface Email { export interface Email {
@ -62,16 +64,22 @@ export interface Email {
scheduledToName?: string scheduledToName?: string
/** ISO 8601 — fin de mise en attente (dossier En attente) */ /** ISO 8601 — fin de mise en attente (dossier En attente) */
snoozeWakeAt?: string snoozeWakeAt?: string
/** Id du message le plus récent du fil (tête). Égal à `id` si message seul. */
threadHeadId?: string
/** Ids de tous les messages du fil, du plus ancien au plus récent. */
threadMessageIds?: string[]
/** En mode conversation, seuls les messages tête apparaissent en liste. */
isThreadHead?: boolean
} }
/** Messages du fil : message principal + entrées `conversation`. */ export {
export function getThreadMessageCount( getThreadMessageCount,
email: Pick<Email, "conversation"> normalizeLegacyEmailCatalog,
): number { } from "@/lib/mail-thread"
return 1 + (email.conversation?.length ?? 0)
}
export const emails: Email[] = [ import { normalizeLegacyEmailCatalog } from "@/lib/mail-thread"
const legacyEmails: Email[] = [
...demoCalendarInvitationEmails, ...demoCalendarInvitationEmails,
{ {
id: "1", id: "1",
@ -102,6 +110,7 @@ export const emails: Email[] = [
sender: "ronenrozn", sender: "ronenrozn",
senderEmail: "ronenrozn@users.noreply.github.com", senderEmail: "ronenrozn@users.noreply.github.com",
date: "2026-05-12T23:15:00+02:00", date: "2026-05-12T23:15:00+02:00",
read: false,
preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...", preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p> <p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p>
@ -703,3 +712,5 @@ END:VCALENDAR`,
labels: ["inbox", "Newsletters"], labels: ["inbox", "Newsletters"],
}, },
] ]
export const emails: Email[] = normalizeLegacyEmailCatalog(legacyEmails)

View File

@ -0,0 +1,185 @@
/** CSS injecté dans les iframes daperçu mail (sujet + corps). */
const DARK_TEXT = "#e8eaed"
const DARK_LINK = "#8ab4f8"
const LIGHT_TEXT = "#202124"
const LIGHT_LINK = "#1a73e8"
export function emailPreviewBaseCss(isDark: boolean): string {
return `
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
color-scheme: ${isDark ? "dark" : "light"};
background: transparent !important;
}
html, body {
background: transparent !important;
overflow: hidden;
word-wrap: break-word;
overflow-wrap: break-word;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
color: ${isDark ? DARK_TEXT : LIGHT_TEXT} !important;
padding: 0;
}
a, a * { color: ${isDark ? DARK_LINK : LIGHT_LINK} !important; }
img { max-width: 100%; height: auto; }
blockquote {
border-left: 3px solid ${isDark ? "#5f6368" : "#dadce0"};
padding-left: 12px;
margin: 8px 0;
color: ${isDark ? "#9aa0a6" : "#5f6368"} !important;
}
pre, code {
background: ${isDark ? "#3c4043" : "#f6f8fa"} !important;
color: ${isDark ? DARK_TEXT : LIGHT_TEXT} !important;
border-radius: 3px;
font-size: 13px;
}
pre { padding: 12px; overflow-x: auto; }
code { padding: 2px 6px; }
`
}
export function emailPreviewSubjectCss(isDark: boolean): string {
return `
* { margin: 0; padding: 0; box-sizing: border-box; }
html {
color-scheme: ${isDark ? "dark" : "light"};
background: transparent !important;
}
html, body {
background: transparent !important;
overflow: hidden;
white-space: normal;
word-wrap: break-word;
}
body {
font-family: 'Google Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 22px;
line-height: 1.3;
color: ${isDark ? DARK_TEXT : LIGHT_TEXT} !important;
padding: 0;
}
`
}
/** Force le texte clair et fonds transparents sur le HTML de-mail en mode sombre. */
export function emailPreviewDarkOverrideCss(): string {
return `
:root { color-scheme: dark; }
body,
body div, body p, body span, body td, body th, body li, body font,
body h1, body h2, body h3, body h4, body h5, body h6,
body label, body strong, body b, body em, body i, body u,
body center, body table, body tbody, body thead, body tfoot, body tr {
color: ${DARK_TEXT} !important;
}
body a, body a * {
color: ${DARK_LINK} !important;
}
[bgcolor="#ffffff"], [bgcolor="#FFFFFF"], [bgcolor="white"],
[bgcolor="#f8f9fa"], [bgcolor="#F8F9FA"], [bgcolor="#f1f3f4"], [bgcolor="#F1F3F4"],
[bgcolor="#e8eaed"], [bgcolor="#E8EAED"], [bgcolor="#f6f8fc"], [bgcolor="#F6F8FC"],
[bgcolor="#fafafa"], [bgcolor="#FAFAFA"], [bgcolor="#eeeeee"], [bgcolor="#EEEEEE"],
[bgcolor="#fcfcfc"], [bgcolor="#FCFCFC"], [bgcolor="#fff"], [bgcolor="#FFF"] {
background-color: transparent !important;
background: transparent !important;
}
[color="#000000"], [color="#000"], [color="#111111"], [color="#202124"],
[color="#3c4043"], [color="#5f6368"], [color="#444746"], [color="#1f1f1f"],
[color="#333333"], [color="#333"], [color="#666666"], [color="#666"],
[color="#757575"], [color="#80868b"], [color="#9aa0a6"] {
color: ${DARK_TEXT} !important;
}
font[color] {
color: ${DARK_TEXT} !important;
}
[bgcolor="#000000"], [bgcolor="#000"], [bgcolor="#202124"], [bgcolor="#3c4043"],
[bgcolor="#1a1a1a"], [bgcolor="#2d2d2d"] {
background-color: #3c4043 !important;
}
div, td, th, p, span, li, h1, h2, h3, h4, h5, h6, table {
border-color: color-mix(in srgb, ${DARK_TEXT} 25%, transparent) !important;
}
`
}
/** Adoucit les fonds très sombres en mode clair (e-mails « dark »). */
export function emailPreviewLightOverrideCss(): string {
return `
[bgcolor="#000000"], [bgcolor="#000"], [bgcolor="#202124"], [bgcolor="#3c4043"],
[bgcolor="#1a1a1a"], [bgcolor="#2d2d2d"] {
background-color: #f1f3f4 !important;
}
[color="#ffffff"], [color="#FFFFFF"], [color="#e8eaed"], [color="#f8f9fa"],
[color="#dadce0"] {
color: ${LIGHT_TEXT} !important;
}
font[color="#ffffff"], font[color="#FFFFFF"], font[color="#e8eaed"] {
color: ${LIGHT_TEXT} !important;
}
`
}
const LIGHT_BG_STYLE =
/background(?:-color)?\s*:\s*(?:#(?:fff(?:fff)?|fefefe|f[ef][ef][ef](?:ff)?)|white|rgb\(\s*255\s*,\s*255\s*,\s*255\s*\)|rgba\(\s*255\s*,\s*255\s*,\s*255\s*,[^)]+\))/gi
const DARK_BG_STYLE =
/background(?:-color)?\s*:\s*(?:#(?:000(?:000)?|202124|3c4043|1a1a1a|2d2d2d)|black|rgb\(\s*0\s*,\s*0\s*,\s*0\s*\))/gi
/** Remplace ou supprime les couleurs de texte inline (pas background-color). */
const INLINE_COLOR_STYLE =
/(?<!background-)(?<!border-)color\s*:\s*(?:#[0-9a-f]{3,8}\b|rgb\(\s*[\d.,\s%]+\s*\)|rgba\(\s*[\d.,\s%]+\s*\)|[a-z]{3,20})\b/gi
const INLINE_BG_STYLE =
/background(?:-color)?\s*:\s*(?:#[0-9a-f]{3,8}\b|rgb\(\s*[\d.,\s%]+\s*\)|rgba\(\s*[\d.,\s%]+\s*\)|[a-z]{3,20})\b/gi
function rewriteStyleAttribute(styles: string, isDark: boolean): string {
let next = styles
if (isDark) {
next = next
.replace(INLINE_BG_STYLE, "background:transparent")
.replace(INLINE_COLOR_STYLE, `color:${DARK_TEXT}`)
} else {
next = next
.replace(DARK_BG_STYLE, "background:#f1f3f4")
.replace(
/(?<!background-)(?<!border-)color\s*:\s*(?:#(?:fff(?:fff)?|e8eaed|f8f9fa)|white|rgb\(\s*255\s*,)/gi,
`color:${LIGHT_TEXT}`
)
}
return next.replace(/;\s*;/g, ";").replace(/^;|;$/g, "").trim()
}
function rewriteInlineStyles(html: string, isDark: boolean): string {
return html.replace(
/\sstyle=(["'])([\s\S]*?)\1/gi,
(_match, quote: string, styles: string) => {
const rewritten = rewriteStyleAttribute(styles, isDark)
if (!rewritten) return ""
return ` style=${quote}${rewritten}${quote}`
}
)
}
export function preprocessEmailHtmlForTheme(html: string, isDark: boolean): string {
let next = rewriteInlineStyles(html, isDark)
if (isDark) {
next = next.replace(LIGHT_BG_STYLE, "background:transparent")
next = next.replace(/\sbgcolor=(["'])(?:#?(?:fff(?:fff)?|ffffff|white)|#f[0-9a-f]{5})\1/gi, "")
}
return next
}

View File

@ -27,7 +27,7 @@ export function inboxTabActiveAccentColor(
tabId: string, tabId: string,
badgeColor: string badgeColor: string
): string { ): string {
if (normalizeInboxTabSegment(tabId) === INBOX_ALL_TAB) return "#202124" if (normalizeInboxTabSegment(tabId) === INBOX_ALL_TAB) return "var(--foreground)"
return navFolderIconColorFromBgClass(badgeColor) return navFolderIconColorFromBgClass(badgeColor)
} }

159
lib/mail-chrome-classes.ts Normal file
View File

@ -0,0 +1,159 @@
import { cn } from "@/lib/utils"
/** Menu déroulant / contextuel Gmail (portail Radix → tokens shadcn). */
export const MAIL_MENU_SURFACE_CLASS = cn(
"min-w-[220px] rounded-lg border border-border bg-popover p-0 py-1 text-popover-foreground shadow-lg",
"[&_[data-slot=dropdown-menu-item]]:gap-3 [&_[data-slot=dropdown-menu-item]]:rounded-none",
"[&_[data-slot=dropdown-menu-item]]:px-3 [&_[data-slot=dropdown-menu-item]]:py-2 [&_[data-slot=dropdown-menu-item]]:text-sm",
"[&_[data-slot=dropdown-menu-item]]:focus:bg-accent [&_[data-slot=dropdown-menu-item]]:focus:text-accent-foreground",
"[&_[data-slot=dropdown-menu-sub-trigger]]:gap-3 [&_[data-slot=dropdown-menu-sub-trigger]]:rounded-none",
"[&_[data-slot=dropdown-menu-sub-trigger]]:px-3 [&_[data-slot=dropdown-menu-sub-trigger]]:py-2",
"[&_[data-slot=dropdown-menu-sub-trigger]]:text-sm",
"[&_[data-slot=dropdown-menu-sub-trigger]]:focus:bg-accent",
"[&_[data-slot=dropdown-menu-sub-trigger]]:data-[state=open]:bg-accent",
"[&_[data-slot=dropdown-menu-sub-content]]:min-w-[200px]",
"[&_[data-slot=dropdown-menu-sub-content]]:rounded-lg",
"[&_[data-slot=dropdown-menu-sub-content]]:border [&_[data-slot=dropdown-menu-sub-content]]:border-border",
"[&_[data-slot=dropdown-menu-sub-content]]:bg-popover",
"[&_[data-slot=dropdown-menu-sub-content]]:p-0 [&_[data-slot=dropdown-menu-sub-content]]:py-1",
"[&_[data-slot=dropdown-menu-sub-content]]:shadow-lg",
"[&_[data-slot=dropdown-menu-separator]]:mx-0 [&_[data-slot=dropdown-menu-separator]]:my-1",
"[&_[data-slot=dropdown-menu-separator]]:bg-border",
"[&_[data-slot=context-menu-item]]:focus:bg-accent [&_[data-slot=context-menu-item]]:focus:text-accent-foreground",
"[&_[data-slot=context-menu-sub-trigger]]:focus:bg-accent",
"[&_[data-slot=context-menu-sub-content]]:border-border [&_[data-slot=context-menu-sub-content]]:bg-popover"
)
export const MAIL_MENU_SURFACE_WIDE_CLASS = cn(
MAIL_MENU_SURFACE_CLASS,
"min-w-[280px]"
)
export const MAIL_SIDEBAR_MENU_SURFACE_CLASS = cn(
"min-w-[240px] border-border bg-popover p-0 py-1.5 text-popover-foreground shadow-md",
"[&_[data-slot=dropdown-menu-label]]:text-muted-foreground",
"[&_[data-slot=dropdown-menu-item]]:text-popover-foreground",
"[&_[data-slot=dropdown-menu-item]]:focus:bg-accent [&_[data-slot=dropdown-menu-item]]:focus:text-accent-foreground",
"[&_[data-slot=dropdown-menu-sub-trigger]]:text-popover-foreground",
"[&_[data-slot=dropdown-menu-sub-trigger]]:focus:bg-accent",
"[&_[data-slot=dropdown-menu-sub-trigger]]:data-[state=open]:bg-accent",
"[&_[data-slot=context-menu-label]]:text-muted-foreground",
"[&_[data-slot=context-menu-item]]:text-popover-foreground",
"[&_[data-slot=context-menu-item]]:focus:bg-accent [&_[data-slot=context-menu-item]]:focus:text-accent-foreground",
"[&_[data-slot=context-menu-sub-trigger]]:text-popover-foreground",
"[&_[data-slot=context-menu-sub-trigger]]:focus:bg-accent",
"[&_[data-slot=context-menu-sub-trigger]]:data-[state=open]:bg-accent"
)
export const MAIL_SIDEBAR_MENU_ITEM_CLASS = cn(
"mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm text-popover-foreground",
"focus:bg-accent focus:text-accent-foreground"
)
export const MAIL_SIDEBAR_MENU_SUB_TRIGGER_CLASS = cn(
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-popover-foreground",
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent"
)
export const MAIL_SIDEBAR_MENU_PLAIN_ITEM_CLASS = cn(
"mx-1 cursor-pointer px-3 py-2 text-sm text-popover-foreground focus:bg-accent focus:text-accent-foreground"
)
export const MAIL_SIDEBAR_MENU_SEPARATOR_CLASS = "my-1.5 bg-border"
export const MAIL_SIDEBAR_COLOR_SWATCH_RING_CLASS =
"border-border bg-mail-surface ring-offset-background hover:ring-muted-foreground focus-visible:ring-ring"
export const MAIL_SIDEBAR_COLOR_PICKER_CLASS = cn(
"min-w-[180px] border-border bg-popover p-2 text-popover-foreground shadow-md"
)
export const MAIL_ICON_BTN =
"text-muted-foreground hover:bg-accent hover:text-accent-foreground"
/** Panneaux header (favoris, comptes) — gris mail, pas le noir `popover`. */
export const MAIL_HEADER_DROPDOWN_CLASS = cn(
"border border-border bg-mail-surface-elevated text-foreground shadow-xl",
)
export const MAIL_TOOLBAR_ICON_BTN = cn(
"h-9 w-9 shrink-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)
/** Onglets catégorie boîte — libellés / icônes blancs en dark ; le soulignement garde la couleur daccent. */
export const MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS = "dark:!text-white"
export const MAIL_PREVIEW_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " +
"[scrollbar-color:color-mix(in_srgb,var(--muted-foreground)_55%,transparent)_transparent] [scrollbar-width:auto] " +
"[&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted-foreground/45"
export const MAIL_REPLY_BAR_CLASS =
"bg-gradient-to-b from-transparent via-mail-surface/90 to-mail-surface pt-3"
export const MAIL_REPLY_BUTTON_CLASS = cn(
"inline-flex shrink-0 items-center gap-2 whitespace-nowrap rounded-full border border-border",
"bg-mail-surface px-6 py-2.5 text-sm font-medium text-foreground shadow-sm",
"transition-shadow hover:bg-accent hover:shadow-md"
)
export const MAIL_INVITATION_CARD_CLASS = cn(
"mx-6 mb-4 rounded-xl border border-border bg-mail-invitation px-4 py-3 shadow-sm"
)
export const MAIL_MESSAGE_HOVER_CLASS = "hover:bg-accent/60"
export const MAIL_COMPOSE_TITLEBAR_CLASS = cn(
"flex h-10 shrink-0 cursor-pointer items-center rounded-t-lg bg-muted px-3",
"dark:bg-[#2d2e30]"
)
export const MAIL_COMPOSE_POPOVER_CLASS = cn(
"border-border bg-popover p-3 text-popover-foreground shadow-lg"
)
export const MAIL_COMPOSE_MENU_SELECTED_CLASS = "bg-accent text-accent-foreground"
/** Bouton pilule xs (barres flottantes liste / lecture). */
export const XS_FLOATING_CONTROL_BTN = cn(
"pointer-events-auto size-9 shrink-0 rounded-full border border-border",
"bg-mail-surface/80 text-muted-foreground shadow-md backdrop-blur",
"hover:bg-accent hover:text-accent-foreground"
)
/** Barre overlay xs : pas de fond opaque, contrôles flottants seulement. */
export const XS_FLOATING_CHROME_BAR =
"pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center gap-2 px-3 pt-2 sm:hidden"
export const MAIL_TOOLTIP_CONTENT_CLASS =
"border border-border bg-popover text-popover-foreground shadow-md"
/** En-tête sujet : pas de fond propre (aligné sur le panneau). */
export const MAIL_PREVIEW_SUBJECT_HEADER_CLASS = ""
export const MAIL_TOAST_SURFACE_CLASS = cn(
"relative box-border w-full max-w-full overflow-hidden rounded-xl border border-border",
"bg-mail-surface text-foreground shadow-md ring-1 ring-primary/15"
)
export function mailNavRowClass(opts: {
isSelected: boolean
isOver?: boolean
rowHoverHeld?: boolean
hasUnread?: boolean
extra?: string
}) {
return cn(
"transition-colors",
opts.isSelected
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: opts.isOver
? "bg-mail-nav-drop text-foreground"
: opts.rowHoverHeld
? "bg-mail-nav-hover text-foreground"
: opts.hasUnread
? "text-foreground hover:bg-mail-nav-hover"
: "text-muted-foreground hover:bg-mail-nav-hover",
opts.extra
)
}

View File

@ -31,7 +31,7 @@ export function parseMailDate(iso: string): Dayjs | null {
return d.isValid() ? d : null return d.isValid() ? d : null
} }
export type MailDateDisplayVariant = "list" | "preview" | "detail" export type MailDateDisplayVariant = "list" | "preview" | "previewShort" | "detail"
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000 const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000
@ -97,6 +97,7 @@ export function formatMailDetailDate(iso: string, now: Dayjs = dayjs()): string
export function formatMailDate(iso: string, variant: MailDateDisplayVariant): string { export function formatMailDate(iso: string, variant: MailDateDisplayVariant): string {
switch (variant) { switch (variant) {
case "list": case "list":
case "previewShort":
return formatMailListDate(iso) return formatMailListDate(iso)
case "preview": case "preview":
return formatMailPreviewDate(iso) return formatMailPreviewDate(iso)

View File

@ -0,0 +1,101 @@
import type { MailBackgroundId } from "@/lib/mail-settings/types"
export type MailBackgroundPreset = {
id: MailBackgroundId
label: string
/** Valeur CSS pour `background` (image, gradient, ou combinaison). */
background: string
/** Couleur de repli sous limage. */
fallbackColor: string
}
/** Anciens ids persistés → nouveaux presets. */
const LEGACY_BACKGROUND_IDS: Record<string, MailBackgroundId> = {
mountains: "photo-mountains",
ocean: "gradient-ocean",
forest: "photo-nature",
abstract: "gradient-blossom",
}
export const MAIL_BACKGROUND_PRESETS: MailBackgroundPreset[] = [
{
id: "none",
label: "Aucun",
background: "none",
fallbackColor: "var(--app-canvas)",
},
{
id: "gradient-aurora",
label: "Aurore",
background: `url("/mail-backgrounds/gradient-aurora.svg") center / cover no-repeat`,
fallbackColor: "#667eea",
},
{
id: "gradient-sunset",
label: "Coucher de soleil",
background: `url("/mail-backgrounds/gradient-sunset.svg") center / cover no-repeat`,
fallbackColor: "#e44d26",
},
{
id: "gradient-ocean",
label: "Océan",
background: `url("/mail-backgrounds/gradient-ocean.svg") center / cover no-repeat`,
fallbackColor: "#203a43",
},
{
id: "gradient-blossom",
label: "Floral",
background: `url("/mail-backgrounds/gradient-blossom.svg") center / cover no-repeat`,
fallbackColor: "#ffecd2",
},
{
id: "photo-mountains",
label: "Montagnes",
background: `url("https://picsum.photos/seed/ultimail-mountains/1920/1080") center / cover no-repeat`,
fallbackColor: "#5c6b73",
},
{
id: "photo-ocean",
label: "Mer",
background: `url("https://picsum.photos/seed/ultimail-ocean/1920/1080") center / cover no-repeat`,
fallbackColor: "#1a5276",
},
{
id: "photo-city",
label: "Ville",
background: `url("https://picsum.photos/seed/ultimail-city/1920/1080") center / cover no-repeat`,
fallbackColor: "#2c3e50",
},
{
id: "photo-nature",
label: "Nature",
background: `url("https://picsum.photos/seed/ultimail-nature/1920/1080") center / cover no-repeat`,
fallbackColor: "#2d5016",
},
]
export function normalizeMailBackgroundId(id: string): MailBackgroundId {
if (MAIL_BACKGROUND_PRESETS.some((p) => p.id === id)) {
return id as MailBackgroundId
}
return LEGACY_BACKGROUND_IDS[id] ?? "none"
}
export function getMailBackgroundPreset(id: string): MailBackgroundPreset {
const normalized = normalizeMailBackgroundId(id)
return (
MAIL_BACKGROUND_PRESETS.find((p) => p.id === normalized) ??
MAIL_BACKGROUND_PRESETS[0]!
)
}
export function mailBackgroundStyle(id: string): {
background: string
fallbackColor: string
} {
const preset = getMailBackgroundPreset(id)
return {
background: preset.background,
fallbackColor: preset.fallbackColor,
}
}

View File

@ -0,0 +1,8 @@
import { threadStoreId } from "@/lib/mail-thread"
/** @deprecated Utiliser `threadStoreId` ou lid message directement selon le cas. */
export function listRowStoreId(listId: string): string {
return listId
}
export { threadStoreId }

View File

@ -0,0 +1,36 @@
import type { Email } from "@/lib/email-data"
import {
buildThreadViewEmail,
isThreadHeadMessage,
} from "@/lib/mail-thread"
/** Résout lemail affiché dans laperçu. */
export function resolveOpenEmailView(
mailId: string,
allEmails: Email[],
conversationMode: boolean
): { email: Email; threadRoot: Email; isSingleMessageView: boolean } | null {
const byId = new Map(allEmails.map((e) => [e.id, e]))
const message = byId.get(mailId)
if (!message) return null
const threadRoot = buildThreadViewEmail(message, byId)
if (conversationMode && isThreadHeadMessage(message)) {
return {
email: threadRoot,
threadRoot,
isSingleMessageView: false,
}
}
if (conversationMode) {
return null
}
return {
email: { ...message, conversation: undefined },
threadRoot,
isSingleMessageView: (message.threadMessageIds?.length ?? 1) > 1,
}
}

View File

@ -0,0 +1,80 @@
import type { Email } from "@/lib/email-data"
import type { InboxSortMode } from "@/lib/mail-settings/types"
import { isListRowRead } from "@/lib/mail-thread"
export type MailSortContext = {
readOverrides: Record<string, boolean>
starredIds: string[]
importantIds: string[]
}
export type MailSortOptions = {
conversationMode: boolean
byId: Map<string, Email>
}
function mailTimestamp(email: Email): number {
const t = new Date(email.date).getTime()
return Number.isFinite(t) ? t : 0
}
function compareByDateDesc(a: Email, b: Email): number {
return mailTimestamp(b) - mailTimestamp(a)
}
function isUnread(
email: Email,
ctx: MailSortContext,
opts: MailSortOptions
): boolean {
return !isListRowRead(
email,
ctx.readOverrides,
opts.byId,
opts.conversationMode
)
}
function isStarred(email: Email, ctx: MailSortContext): boolean {
const storeId = email.threadHeadId ?? email.id
return ctx.starredIds.includes(storeId) || email.starred
}
function isImportant(email: Email, ctx: MailSortContext): boolean {
const storeId = email.threadHeadId ?? email.id
return ctx.importantIds.includes(storeId) || email.important
}
export function sortEmailsForInbox(
rows: Email[],
mode: InboxSortMode,
ctx: MailSortContext,
opts: MailSortOptions
): Email[] {
const sorted = [...rows]
if (mode === "default") {
sorted.sort(compareByDateDesc)
return sorted
}
sorted.sort((a, b) => {
let primary = 0
switch (mode) {
case "important":
primary =
Number(isImportant(b, ctx)) - Number(isImportant(a, ctx))
break
case "unread":
primary = Number(isUnread(b, ctx, opts)) - Number(isUnread(a, ctx, opts))
break
case "starred":
primary = Number(isStarred(b, ctx)) - Number(isStarred(a, ctx))
break
default:
break
}
if (primary !== 0) return primary
return compareByDateDesc(a, b)
})
return sorted
}

View File

@ -0,0 +1,22 @@
export type MailDensity = "default" | "normal" | "compact"
export type MailThemeMode = "light" | "dark" | "system"
export type MailBackgroundId =
| "none"
| "gradient-aurora"
| "gradient-sunset"
| "gradient-ocean"
| "gradient-blossom"
| "photo-mountains"
| "photo-ocean"
| "photo-city"
| "photo-nature"
export type InboxSortMode =
| "default"
| "important"
| "unread"
| "starred"
export type ReadingPaneMode = "none" | "right" | "below"

170
lib/mail-thread/index.ts Normal file
View File

@ -0,0 +1,170 @@
import type { ConversationMessage, Email } from "@/lib/email-data"
/** Id fil pour étoile, important, libellés, composition. */
export function threadStoreId(email: Pick<Email, "id" | "threadHeadId">): string {
return email.threadHeadId ?? email.id
}
/** Message affiché en tête de fil en mode conversation. */
export function isThreadHeadMessage(
email: Pick<Email, "id" | "isThreadHead" | "threadHeadId">
): boolean {
if (email.isThreadHead === false) return false
if (email.isThreadHead === true) return true
return !email.threadHeadId || email.threadHeadId === email.id
}
function threadMetaFromHead(head: Email): Pick<
Email,
| "subject"
| "labels"
| "starred"
| "important"
| "spam"
| "deleted"
| "tag"
| "hasInvitation"
| "calendarInvitation"
| "scheduledSendAt"
| "scheduledToName"
| "snoozeWakeAt"
> {
return {
subject: head.subject,
labels: head.labels,
starred: head.starred,
important: head.important,
spam: head.spam,
deleted: head.deleted,
tag: head.tag,
hasInvitation: head.hasInvitation,
calendarInvitation: head.calendarInvitation,
scheduledSendAt: head.scheduledSendAt,
scheduledToName: head.scheduledToName,
snoozeWakeAt: head.snoozeWakeAt,
}
}
function priorToEmail(
msg: ConversationMessage,
head: Email,
threadMessageIds: string[]
): Email {
return {
...threadMetaFromHead(head),
id: msg.id,
sender: msg.sender,
senderEmail: msg.senderEmail,
date: msg.date,
preview: msg.preview,
body: msg.body,
attachments: msg.attachments,
hasAttachment: (msg.attachments?.length ?? 0) > 0,
read: msg.read ?? true,
threadHeadId: head.id,
threadMessageIds,
isThreadHead: false,
}
}
/** Découpe les fixtures legacy (`conversation[]`) en messages autonomes. */
export function normalizeLegacyEmailCatalog(raw: Email[]): Email[] {
const out: Email[] = []
for (const root of raw) {
const conv = root.conversation ?? []
if (conv.length === 0) {
out.push({
...root,
conversation: undefined,
threadHeadId: root.id,
threadMessageIds: [root.id],
isThreadHead: true,
})
continue
}
const threadMessageIds = [...conv.map((m) => m.id), root.id]
for (const msg of conv) {
out.push(priorToEmail(msg, root, threadMessageIds))
}
out.push({
...root,
conversation: undefined,
threadHeadId: root.id,
threadMessageIds,
isThreadHead: true,
})
}
return out
}
/** Reconstruit la vue fil (conversation[]) pour laperçu / réponse. */
export function buildThreadViewEmail(
message: Email,
byId: Map<string, Email>
): Email {
const headId = message.threadHeadId ?? message.id
const head = byId.get(headId) ?? message
const ids = head.threadMessageIds ?? [headId]
const priorIds = ids.slice(0, -1)
const conversation: ConversationMessage[] = priorIds.map((id) => {
const m = byId.get(id)!
return {
id: m.id,
sender: m.sender,
senderEmail: m.senderEmail ?? "",
date: m.date,
body: m.body ?? "",
preview: m.preview,
attachments: m.attachments,
}
})
return { ...head, conversation }
}
export function getThreadMessageCount(
email: Pick<Email, "threadMessageIds" | "conversation">
): number {
if (email.threadMessageIds?.length) return email.threadMessageIds.length
return 1 + (email.conversation?.length ?? 0)
}
/** Lu en liste : fil entier en mode conversation, message seul sinon. */
export function isListRowRead(
email: Email,
readOverrides: Record<string, boolean>,
byId: Map<string, Email>,
conversationMode: boolean
): boolean {
if (
conversationMode &&
email.threadMessageIds &&
email.threadMessageIds.length > 1
) {
return email.threadMessageIds.every((id) => {
const m = byId.get(id)
if (!m) return true
return readOverrides[id] ?? m.read
})
}
return readOverrides[email.id] ?? email.read
}
/** Marque lu / non lu (un message ou tout le fil). */
export function readStateTargets(
email: Email,
conversationMode: boolean
): string[] {
if (
conversationMode &&
email.threadMessageIds &&
email.threadMessageIds.length > 1
) {
return [...email.threadMessageIds]
}
return [email.id]
}

View File

@ -0,0 +1,9 @@
import type { MailMoveTargets } from "@/components/gmail/move-to-menu-items"
/** Actions barre basse xs quand un message est ouvert (piloté par EmailList). */
export type MailXsViewChrome = {
onArchive: () => void
onReply: () => void
moveTargets: MailMoveTargets
onMoveTo: (targetId: string) => void
}

View File

@ -3,6 +3,8 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Ban, Loader2, Send } from "lucide-react" import { Ban, Loader2, Send } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { MAIL_TOAST_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
const DEFAULT_DURATION_MS = 3000 const DEFAULT_DURATION_MS = 3000
@ -119,17 +121,17 @@ function PendingSendToastBody({
} }
return ( return (
<div className="relative box-border w-full max-w-full overflow-hidden rounded-xl border border-[#dadce0] bg-linear-to-b from-[#f8fbff] to-white text-[#202124] shadow-md ring-1 ring-[#1a73e8]/8 backdrop-blur-sm"> <div className={cn(MAIL_TOAST_SURFACE_CLASS, "backdrop-blur-sm")}>
<div className="px-3.5 pb-2.5 pt-3"> <div className="px-3.5 pb-2.5 pt-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[#e8f0fe] text-[#1a73e8]" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary"
aria-hidden aria-hidden
> >
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={2} /> <Loader2 className="h-4 w-4 animate-spin" strokeWidth={2} />
</span> </span>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-[13px] font-semibold leading-snug tracking-tight text-[#3c4043]"> <p className="text-[13px] font-semibold leading-snug tracking-tight text-foreground">
Envoi en cours Envoi en cours
</p> </p>
</div> </div>
@ -146,14 +148,14 @@ function PendingSendToastBody({
<button <button
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
className="inline-flex min-h-9 min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-[#dadce0] bg-white px-2.5 py-2 text-xs font-semibold leading-tight text-[#5f6368] shadow-sm transition-colors hover:border-[#bdc1c6] hover:bg-[#f8f9fa] hover:text-[#3c4043]" className="inline-flex min-h-9 min-w-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg border border-border bg-mail-surface px-2.5 py-2 text-xs font-semibold leading-tight text-muted-foreground shadow-sm transition-colors hover:border-border hover:bg-accent hover:text-foreground"
> >
<Ban className="h-3.5 w-3.5 shrink-0" strokeWidth={2} aria-hidden /> <Ban className="h-3.5 w-3.5 shrink-0" strokeWidth={2} aria-hidden />
<span>Annuler l&apos;envoi</span> <span>Annuler l&apos;envoi</span>
</button> </button>
</div> </div>
</div> </div>
<div className="relative h-1 w-full shrink-0 bg-[#e8eaed]"> <div className="relative h-1 w-full shrink-0 bg-muted">
<div <div
className="absolute inset-y-0 left-0 bg-linear-to-r from-[#1a73e8] to-[#4285f4]" className="absolute inset-y-0 left-0 bg-linear-to-r from-[#1a73e8] to-[#4285f4]"
style={ style={

View File

@ -0,0 +1,59 @@
"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import {
DEFAULT_ACCOUNT_ID,
MOCK_USER_ACCOUNTS,
} from "@/lib/accounts/mock-accounts"
import type { UserAccount } from "@/lib/accounts/types"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
type AccountStoreState = {
activeAccountId: string
otherAccountsExpanded: boolean
}
type AccountStoreActions = {
setActiveAccount: (id: string) => void
setOtherAccountsExpanded: (expanded: boolean) => void
toggleOtherAccountsExpanded: () => void
signOutAll: () => void
}
export function getAccountById(id: string): UserAccount | undefined {
return MOCK_USER_ACCOUNTS.find((a) => a.id === id)
}
export function useActiveAccount(): UserAccount {
const activeAccountId = useAccountStore((s) => s.activeAccountId)
return getAccountById(activeAccountId) ?? MOCK_USER_ACCOUNTS[0]!
}
export const useAccountStore = create<AccountStoreState & AccountStoreActions>()(
persist(
(set) => ({
activeAccountId: DEFAULT_ACCOUNT_ID,
otherAccountsExpanded: true,
setActiveAccount: (id) => set({ activeAccountId: id }),
setOtherAccountsExpanded: (expanded) =>
set({ otherAccountsExpanded: expanded }),
toggleOtherAccountsExpanded: () =>
set((s) => ({ otherAccountsExpanded: !s.otherAccountsExpanded })),
signOutAll: () =>
set({ activeAccountId: DEFAULT_ACCOUNT_ID, otherAccountsExpanded: true }),
}),
{
name: "ultimail-accounts",
storage: debouncedPersistJSONStorage,
partialize: (s) => ({
activeAccountId: s.activeAccountId,
otherAccountsExpanded: s.otherAccountsExpanded,
}),
},
),
)

View File

@ -0,0 +1,88 @@
"use client"
import { create } from "zustand"
import { persist } from "zustand/middleware"
import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage"
import { normalizeMailBackgroundId } from "@/lib/mail-settings/constants"
import type {
InboxSortMode,
MailBackgroundId,
MailDensity,
MailThemeMode,
ReadingPaneMode,
} from "@/lib/mail-settings/types"
type MailSettingsState = {
quickSettingsOpen: boolean
themeDialogOpen: boolean
density: MailDensity
themeMode: MailThemeMode
backgroundId: MailBackgroundId
inboxSort: InboxSortMode
readingPane: ReadingPaneMode
conversationMode: boolean
}
type MailSettingsActions = {
setQuickSettingsOpen: (open: boolean) => void
setThemeDialogOpen: (open: boolean) => void
setDensity: (density: MailDensity) => void
setThemeMode: (mode: MailThemeMode) => void
setBackgroundId: (id: MailBackgroundId) => void
setInboxSort: (sort: InboxSortMode) => void
setReadingPane: (mode: ReadingPaneMode) => void
setConversationMode: (enabled: boolean) => void
}
const defaults: MailSettingsState = {
quickSettingsOpen: false,
themeDialogOpen: false,
density: "default",
themeMode: "system",
backgroundId: "none",
inboxSort: "default",
readingPane: "none",
conversationMode: true,
}
export const useMailSettingsStore = create<
MailSettingsState & MailSettingsActions
>()(
persist(
(set) => ({
...defaults,
setQuickSettingsOpen: (open) => set({ quickSettingsOpen: open }),
setThemeDialogOpen: (open) => set({ themeDialogOpen: open }),
setDensity: (density) => set({ density }),
setThemeMode: (themeMode) => set({ themeMode }),
setBackgroundId: (backgroundId) => set({ backgroundId }),
setInboxSort: (inboxSort) => set({ inboxSort }),
setReadingPane: (readingPane) => set({ readingPane }),
setConversationMode: (conversationMode) => set({ conversationMode }),
}),
{
name: "ultimail-mail-settings",
storage: debouncedPersistJSONStorage,
partialize: (s) => ({
density: s.density,
themeMode: s.themeMode,
backgroundId: s.backgroundId,
inboxSort: s.inboxSort,
readingPane: s.readingPane,
conversationMode: s.conversationMode,
}),
merge: (persisted, current) => {
const p = persisted as Partial<MailSettingsState> | undefined
if (!p) return current
return {
...current,
...p,
backgroundId: normalizeMailBackgroundId(
(p.backgroundId as string) ?? "none"
),
}
},
}
)
)

View File

@ -186,11 +186,7 @@ function forwardBodyHtml(email: Email): string {
export function withTouchFullscreenComposePreset( export function withTouchFullscreenComposePreset(
preset: ComposeOpenPreset preset: ComposeOpenPreset
): ComposeOpenPreset { ): ComposeOpenPreset {
if ( if (typeof window === "undefined" || !readCoarsePointerMatches()) {
typeof window === "undefined" ||
!readCoarsePointerMatches() ||
readXsMatches()
) {
return preset return preset
} }
return { return {

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="a" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#667eea"/>
<stop offset="35%" stop-color="#764ba2"/>
<stop offset="70%" stop-color="#f093fb"/>
<stop offset="100%" stop-color="#f5576c"/>
</linearGradient>
<radialGradient id="b" cx="20%" cy="80%" r="55%">
<stop offset="0%" stop-color="#4facfe" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#4facfe" stop-opacity="0"/>
</radialGradient>
<radialGradient id="c" cx="85%" cy="15%" r="50%">
<stop offset="0%" stop-color="#43e97b" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#43e97b" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1920" height="1080" fill="url(#a)"/>
<rect width="1920" height="1080" fill="url(#b)"/>
<rect width="1920" height="1080" fill="url(#c)"/>
</svg>

After

Width:  |  Height:  |  Size: 980 B

View File

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffecd2"/>
<stop offset="50%" stop-color="#fcb69f"/>
<stop offset="100%" stop-color="#ff9a9e"/>
</linearGradient>
<radialGradient id="petal1" cx="15%" cy="30%" r="40%">
<stop offset="0%" stop-color="#fbc2eb" stop-opacity="0.7"/>
<stop offset="100%" stop-color="#fbc2eb" stop-opacity="0"/>
</radialGradient>
<radialGradient id="petal2" cx="80%" cy="70%" r="45%">
<stop offset="0%" stop-color="#a18cd1" stop-opacity="0.45"/>
<stop offset="100%" stop-color="#a18cd1" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<rect width="1920" height="1080" fill="url(#petal1)"/>
<rect width="1920" height="1080" fill="url(#petal2)"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="deep" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f2027"/>
<stop offset="40%" stop-color="#203a43"/>
<stop offset="100%" stop-color="#2c5364"/>
</linearGradient>
<linearGradient id="wave" x1="0%" y1="100%" x2="0%" y2="40%">
<stop offset="0%" stop-color="#1a73e8" stop-opacity="0.9"/>
<stop offset="50%" stop-color="#34a853" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#81d4fa" stop-opacity="0"/>
</linearGradient>
<radialGradient id="light" cx="70%" cy="25%" r="45%">
<stop offset="0%" stop-color="#e0f7fa" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#e0f7fa" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1920" height="1080" fill="url(#deep)"/>
<rect width="1920" height="1080" fill="url(#wave)"/>
<rect width="1920" height="1080" fill="url(#light)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="sky" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#2c3e50"/>
<stop offset="25%" stop-color="#e44d26"/>
<stop offset="55%" stop-color="#f7971e"/>
<stop offset="80%" stop-color="#ffd200"/>
<stop offset="100%" stop-color="#fceabb"/>
</linearGradient>
<linearGradient id="haze" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#ff6b6b" stop-opacity="0.25"/>
<stop offset="100%" stop-color="#f8f9fa" stop-opacity="0"/>
</linearGradient>
</defs>
<rect width="1920" height="1080" fill="url(#sky)"/>
<rect width="1920" height="1080" fill="url(#haze)"/>
</svg>

After

Width:  |  Height:  |  Size: 785 B

File diff suppressed because one or more lines are too long