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);
/** Fond chrome (layout mail : header, rails, sidebar). */
--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 {
--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);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
@ -116,6 +155,12 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--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 {
@ -295,3 +340,262 @@ body {
animation: long-press-ack 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
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 { Analytics } from '@vercel/analytics/next'
import './globals.css'
import { ThemeInitScript } from '@/components/theme-init-script'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
@ -27,8 +28,9 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" className="h-dvh max-h-dvh overflow-hidden bg-white">
<body className="h-dvh max-h-dvh overflow-hidden font-sans antialiased touch-manipulation">
<html lang="fr" suppressHydrationWarning className="h-dvh max-h-dvh overflow-hidden">
<body className="h-dvh max-h-dvh overflow-hidden bg-background font-sans antialiased touch-manipulation">
<ThemeInitScript />
{children}
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>

View File

@ -7,13 +7,13 @@ import {
useLayoutEffect,
useMemo,
useState,
type CSSProperties,
} from "react"
import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view"
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 { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header"
@ -35,6 +35,9 @@ import {
type MailRouteState,
} from "@/lib/mail-url"
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 {
if (!pathname?.startsWith("/mail")) return undefined
@ -70,6 +73,7 @@ function MailAppInner() {
const [folderUnreadCounts, setFolderUnreadCounts] = useState<
Record<string, number>
>({})
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => {
@ -125,7 +129,7 @@ function MailAppInner() {
<div
className={cn(
"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 && (
@ -154,8 +158,10 @@ function MailAppInner() {
/>
<main
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden bg-white",
splitView ? "rounded-none shadow-none" : "rounded-none shadow-sm sm:rounded-2xl"
"flex min-h-0 flex-1 flex-col overflow-hidden bg-mail-surface",
splitView
? "rounded-none shadow-none"
: "rounded-none shadow-none sm:rounded-2xl sm:shadow-sm"
)}
>
<Suspense>
@ -169,6 +175,7 @@ function MailAppInner() {
onMailRouteNavigate={navigateRoute}
onSelectFolder={handleSelectFolder}
onFolderUnreadCountsChange={setFolderUnreadCounts}
onXsViewChromeChange={setXsViewChrome}
/>
</Suspense>
</main>
@ -186,6 +193,7 @@ function MailAppInner() {
<MobileBottomBar
sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
xsViewChrome={xsViewChrome}
/>
) : null}
</div>
@ -211,6 +219,7 @@ export function MailAppShell({
}, [])
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ComposeProvider>
<ScheduledMailProvider>
<EmailDragProvider>
@ -224,24 +233,14 @@ export function MailAppShell({
>
<MailAppInner />
</Suspense>
<MailThemeApplier />
<QuickSettingsRoot />
<MoveDragIndicator />
<ComposeModalManager />
<Toaster
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
/>
<MailToaster />
</EmailDragProvider>
</ScheduledMailProvider>
</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"
import { ensureVcLogosCollection } from "@/lib/register-vc-logos"
import { cn } from "@/lib/utils"
import { MAIL_INVITATION_CARD_CLASS } from "@/lib/mail-chrome-classes"
function attendeeDisplayList(inv: ParsedCalendarInvitation): {
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]"
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({
invitation,
@ -63,35 +64,35 @@ export function CalendarInvitationPreview({
return (
<div
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
)}
>
<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="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 />
<InvitationTimeChipText
start={invitation.start}
end={invitation.end}
/>
</div>
<h2 className="text-xl font-normal leading-snug text-[#202124]">
<h2 className="text-xl font-normal leading-snug text-foreground">
{invitation.summary}
</h2>
{organizerLine && (
<p className="text-sm text-[#3c4043]">{organizerLine}</p>
<p className="text-sm text-foreground/90">{organizerLine}</p>
)}
{othersLine && (
<p className="flex flex-wrap items-start gap-1.5 text-sm text-[#3c4043]">
<Users className="mt-0.5 size-4 shrink-0 text-[#5f6368]" aria-hidden />
<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-muted-foreground" aria-hidden />
<span>{othersLine}</span>
</p>
)}
</div>
<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
src="/agenda-mark.svg"
alt=""
@ -99,8 +100,8 @@ export function CalendarInvitationPreview({
aria-hidden
/>
</div>
<div className="min-w-0 text-right text-sm leading-snug text-[#5f6368]">
<p className="font-medium text-[#3c4043]">Dans votre agenda</p>
<div className="min-w-0 text-right text-sm leading-snug text-muted-foreground">
<p className="font-medium text-foreground">Dans votre agenda</p>
<p className="mt-0.5">Aucun autre événement à cette date</p>
</div>
</div>
@ -122,14 +123,14 @@ export function CalendarInvitationPreview({
</button>
<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"
>
<MoreVertical className="size-[18px]" strokeWidth={1.5} />
</button>
</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>
<div className="flex items-center gap-2">
<span>Correct ?</span>

View File

@ -3,6 +3,9 @@
import { memo, useEffect, useLayoutEffect, useRef, useState } from "react"
import { Icon } from "@iconify/react"
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 { cn } from "@/lib/utils"
@ -17,7 +20,7 @@ const TAB_ICON_CLASS = "h-4 w-4 shrink-0"
function inboxTabBadgeDotClass(badgeColor: string) {
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
)
}
@ -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",
"transition-colors duration-200 motion-reduce:transition-none",
isActive ? "shrink-0 flex-none" : "min-w-0 flex-1 overflow-hidden",
!isActive && "hover:bg-[#f1f3f4]"
!isActive && "hover:bg-mail-nav-hover"
)}
>
<div
@ -145,7 +148,8 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
className={cn(
TAB_ICON_CLASS,
"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}
aria-hidden
@ -159,8 +163,12 @@ export const CompactInboxCategoryTabs = memo(function CompactInboxCategoryTabs({
</div>
{isActive ? (
<span
className="shrink-0 whitespace-nowrap text-[13px] font-semibold leading-tight"
style={{ color: accentColor }}
className={cn(
"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}
</span>

View File

@ -81,6 +81,14 @@ import {
import { toast } from "sonner"
import { showPendingSendToast } from "@/lib/pending-send-toast"
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 {
DropdownMenu,
DropdownMenuContent,
@ -101,13 +109,14 @@ import data from "@emoji-mart/data"
const LazyPicker = lazy(() => import("@emoji-mart/react"))
function EmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-[#5f6368]">Chargement</div>}>
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}
locale="fr"
theme="light"
theme={resolvedTheme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="search"
set="native"
@ -349,7 +358,7 @@ function RecipientField({
/>
</div>
{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) => (
<button
key={s.email}
@ -421,19 +430,19 @@ function AlignmentDropdown({
>
<DropdownMenuItem
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
</DropdownMenuItem>
<DropdownMenuItem
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
</DropdownMenuItem>
<DropdownMenuItem
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
</DropdownMenuItem>
@ -649,7 +658,7 @@ function FormattingToolbar({
const sep = <span className="mx-0.5 h-5 w-px bg-[#dadce0]" aria-hidden />
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 */}
<button
type="button"
@ -792,7 +801,7 @@ function EmojiButton({
<PopoverContent
align="start"
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()}
>
<EmojiPicker onSelect={handleSelect} />
@ -912,25 +921,25 @@ function LinkButton({
<PopoverContent
align="start"
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()}
>
<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"}
</div>
<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
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
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 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
type="text"
value={url}
@ -942,7 +951,7 @@ function LinkButton({
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
/>
</div>
@ -962,7 +971,7 @@ function LinkButton({
<button
type="button"
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
</button>
@ -1028,7 +1037,7 @@ function SignatureButton({
<DropdownMenuContent
align="start"
side="top"
className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
@ -1045,7 +1054,7 @@ function SignatureButton({
<DropdownMenuSeparator />
<DropdownMenuItem
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">
{!compose.signatureId && <span className="text-xs"></span>}
@ -1056,7 +1065,7 @@ function SignatureButton({
<DropdownMenuItem
key={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">
{compose.signatureId === sig.id && <span className="text-xs"></span>}
@ -1689,10 +1698,11 @@ export function ComposeWindow({
const modalContent = (
<div
data-compose-window
className={cn(
"relative flex flex-col overflow-hidden bg-white",
"relative flex flex-col overflow-hidden bg-mail-surface text-foreground",
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
? "h-full min-h-0 w-full max-w-none flex-1 rounded-none shadow-none"
: cn(
@ -1747,7 +1757,7 @@ export function ComposeWindow({
{isInline ? (
<div ref={inlineRecipientShellRef} className="flex shrink-0 flex-col">
<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={
compose.threading
? `In-Reply-To: ${compose.threading.inReplyTo}\nReferences: ${compose.threading.references.join(" ")}`
@ -1864,17 +1874,17 @@ export function ComposeWindow({
) : isXsSheet ? (
<div
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))]"
)}
>
<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}
</span>
<button
type="button"
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"
>
<X className="h-4 w-4" />
@ -1884,10 +1894,10 @@ export function ComposeWindow({
<>
{/* Title bar */}
<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)}
>
<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}
</span>
<div className="flex items-center gap-0.5">
@ -1966,14 +1976,14 @@ export function ComposeWindow({
{compose.attachments.map((att) => (
<div
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/") ? (
<ImageIcon className="h-4 w-4 shrink-0 text-[#1a73e8]" />
) : (
<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}
</span>
<span className="shrink-0 text-xs text-[#80868b]">
@ -2182,10 +2192,13 @@ export function ComposeWindow({
if (compose.minimized && !isInline && !isXsSheet) {
return (
<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)}
>
<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}
</span>
<div className="flex items-center gap-0.5">
@ -2195,7 +2208,7 @@ export function ComposeWindow({
e.stopPropagation()
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" />
</button>
@ -2205,7 +2218,7 @@ export function ComposeWindow({
e.stopPropagation()
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" />
</button>

View File

@ -170,7 +170,7 @@ export function ContactHoverCard({
align={align}
sideOffset={8}
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"
)}
>
@ -220,21 +220,21 @@ export function ContactHoverCard({
</button>
<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"
>
<MessageSquare className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
<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"
>
<Video className="h-[18px] w-[18px]" strokeWidth={1.5} />
</button>
<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"
>
<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 { fullContactDisplayName } from "@/lib/contacts/types"
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() {
const { getCoordinateSuggestions, updateContact } = useContactsStore()
@ -32,21 +42,18 @@ export function AddCoordinatesView() {
return (
<div>
<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})
</h3>
{visible.length > 0 && (
<Button
onClick={handleAddAll}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
<Button onClick={handleAddAll} className={CONTACTS_PRIMARY_BTN_CLASS}>
Ajouter tous les détails
</Button>
)}
</div>
{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
</p>
)}
@ -60,8 +67,8 @@ export function AddCoordinatesView() {
const initial = senderInitial(name)
return (
<div key={contact.id} className="rounded-xl border border-gray-200 p-5">
<p className="mb-2 text-xs font-medium text-[#5f6368]">Contact à modifier</p>
<div key={contact.id} className={CONTACTS_PAGE_CARD_CLASS}>
<p className={cn("mb-2 text-xs font-medium", CONTACTS_MUTED_TEXT)}>Contact à modifier</p>
<div className="flex items-start gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-10 w-10 rounded-full object-cover" />
@ -74,32 +81,30 @@ export function AddCoordinatesView() {
</div>
)}
<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] && (
<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] && (
<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 className="mt-3 border-t border-gray-100 pt-3">
<p className="text-xs font-medium text-[#5f6368]">Détails à ajouter</p>
<p className="mt-1 text-sm text-[#1f1f1f]">{suggestedValue}</p>
<div className={CONTACTS_PAGE_CARD_INNER_DIVIDER_CLASS}>
<p className={cn("text-xs font-medium", CONTACTS_MUTED_TEXT)}>Détails à ajouter</p>
<p className={cn("mt-1 text-sm", CONTACTS_HEADING_TEXT)}>{suggestedValue}</p>
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => handleIgnore(contact.id)}
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
>
<button type="button" onClick={() => handleIgnore(contact.id)} className={CONTACTS_PAGE_LINK_BTN_CLASS}>
Ignorer
</button>
<Button
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
</Button>

View File

@ -10,6 +10,12 @@ import {
import { Button } from "@/components/ui/button"
import { useContactsStore } from "@/lib/contacts/contacts-store"
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 {
open: boolean
@ -37,28 +43,31 @@ export function BulkCreateDialog({ open, onOpenChange, onOpenImport }: BulkCreat
<DialogTitle>Créer plusieurs contacts</DialogTitle>
</DialogHeader>
<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
</p>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
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 ?{" "}
<button
type="button"
className="cursor-pointer text-[#1a73e8] hover:underline"
onClick={() => { onOpenChange(false); onOpenImport?.() }}
className="cursor-pointer text-primary hover:underline"
onClick={() => {
onOpenChange(false)
onOpenImport?.()
}}
>
Importez les contacts.
</button>
</p>
</div>
<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
</Button>
<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 { avatarColor, senderInitial } from "@/lib/sender-display"
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 = [
"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">
{/* Header */}
<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" />
</Button>
<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)}>
<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
type="submit"
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
</button>
@ -228,16 +247,16 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
>
{senderInitial(displayName)}
</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" />
</div>
</div>
) : (
<div className="relative">
<div className="flex h-28 w-28 items-center justify-center rounded-full bg-[#e8eaed]">
<User className="h-12 w-12 text-[#9aa0a6]" />
<div className={CONTACTS_PAGE_AVATAR_PLACEHOLDER_LARGE_CLASS}>
<User className="h-12 w-12" />
</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" />
</div>
</div>
@ -249,12 +268,12 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
{currentLabels.map((labelId) => {
const row = labelRows.find((r) => r.id === labelId)
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 && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
)}
{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" />
</button>
</span>
@ -262,20 +281,20 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
})}
<Popover>
<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é
</button>
</PopoverTrigger>
<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">
{availableLabels.map((row) => {
const active = currentLabels.includes(row.id)
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="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>
)
})}
@ -285,11 +304,11 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</div>
{/* 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")} />}
<div className="flex items-center gap-1">
<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" />}
</Button>
</div>
@ -305,10 +324,10 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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-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" />}
</Button>
</div>
@ -317,13 +336,13 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-1">
<div className="flex-1"><FloatingInput label="E-mail" type="email" {...register(`emails.${index}.value`)} /></div>
{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" />
</Button>
)}
@ -337,14 +356,14 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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) => (
<div key={field.id} className="space-y-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>
<div className="flex-1"><FloatingInput label="Téléphone" type="tel" {...register(`phones.${index}.value`)} /></div>
{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" />
</Button>
)}
@ -358,14 +377,14 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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) => (
<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">
<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 }))} />
)} />
<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" />
</Button>
</div>
@ -384,7 +403,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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="w-[72px]"><FloatingInput label="Jour" type="number" min={1} max={31} {...register("birthday.day", { valueAsNumber: true })} /></div>
<div className="flex-1">
@ -397,7 +416,7 @@ export function ContactCreatePage({ mode, contactId, onBack, onSaved }: ContactC
</FormSection>
{/* 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")} />
</FormSection>
@ -417,7 +436,7 @@ function FormSection({ icon, children }: { icon: React.ReactNode; children: Reac
function AddButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
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}
</button>
)
@ -445,12 +464,12 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
return (
<div className="relative">
<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) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(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>
</div>
@ -480,12 +499,12 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
return (
<div className="relative">
<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) }}
onBlur={(e) => { setFocused(false); setFilled(!!e.target.value); props.onBlur?.(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>
</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 }) {
return (
<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..."} />
</SelectTrigger>
<SelectContent>

View File

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

View File

@ -1,6 +1,8 @@
"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 { ContactsHeader } from "./contacts-header"
import { ContactsTable } from "./contacts-table"
@ -10,6 +12,7 @@ import { MergeDuplicatesView } from "./merge-duplicates-view"
import { TrashView } from "./trash-view"
import { BulkCreateDialog } from "./bulk-create-dialog"
import { ImportDialog } from "./import-dialog"
import { CONTACTS_SHELL_CLASS } from "@/lib/contacts-chrome-classes"
export type ContactsPageView =
| "contacts"
@ -24,17 +27,31 @@ export type ContactsPageView =
| "label"
export function ContactsAppShell() {
const isMobile = useIsMobile()
const [currentView, setCurrentView] = useState<ContactsPageView>("contacts")
const [activeContactId, setActiveContactId] = useState<string | null>(null)
const [activeLabelId, setActiveLabelId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState("")
const [importOpen, setImportOpen] = useState(false)
const [bulkCreateOpen, setBulkCreateOpen] = useState(false)
const [sidebarOpen, setSidebarOpen] = useState(false)
useEffect(() => {
setSearchQuery("")
}, [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) {
setActiveContactId(id)
setCurrentView("detail")
@ -55,28 +72,69 @@ export function ContactsAppShell() {
setCurrentView("contacts")
}
function goToContactsList() {
setActiveContactId(null)
setActiveLabelId(null)
setSearchQuery("")
setCurrentView("contacts")
if (isMobile) closeSidebar()
}
function handleNavigate(view: ContactsPageView) {
if (view === "import") {
setImportOpen(true)
if (isMobile) closeSidebar()
return
}
setCurrentView(view)
if (isMobile) closeSidebar()
}
function handleSelectLabel(id: string) {
setActiveLabelId(id)
setCurrentView("label")
if (isMobile) closeSidebar()
}
function handleCreateContact() {
openCreate()
if (isMobile) closeSidebar()
}
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
open={sidebarOpen}
overlay={isMobile}
onToggle={toggleSidebar}
onClose={closeSidebar}
currentView={currentView}
activeLabelId={activeLabelId}
onNavigate={handleNavigate}
onCreateContact={openCreate}
onHome={goToContactsList}
onCreateContact={handleCreateContact}
onBulkCreate={() => setBulkCreateOpen(true)}
onSelectLabel={(id) => { setActiveLabelId(id); setCurrentView("label") }}
onSelectLabel={handleSelectLabel}
/>
<div className="flex min-w-0 flex-1 flex-col">
<ContactsHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
sidebarOpen={sidebarOpen}
onOpenSidebar={openSidebar}
/>
<main className="min-h-0 flex-1 overflow-y-auto">
{(currentView === "contacts" ||

View File

@ -1,40 +1,67 @@
"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 {
CONTACTS_ICON_BTN_CLASS,
CONTACTS_SEARCH_BAR_CLASS,
CONTACTS_SEARCH_INPUT_CLASS,
} from "@/lib/contacts-chrome-classes"
import { cn } from "@/lib/utils"
interface ContactsHeaderProps {
searchQuery: string
onSearchChange: (q: string) => void
sidebarOpen: boolean
onOpenSidebar: () => void
}
export function ContactsHeader({ searchQuery, onSearchChange }: ContactsHeaderProps) {
export function ContactsHeader({
searchQuery,
onSearchChange,
sidebarOpen,
onOpenSidebar,
}: ContactsHeaderProps) {
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 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">
<Search className="h-5 w-5 shrink-0 text-[#5f6368]" />
<div className={CONTACTS_SEARCH_BAR_CLASS}>
<Search className="h-5 w-5 shrink-0 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Rechercher"
className="flex-1 bg-transparent text-sm text-[#1f1f1f] outline-none placeholder:text-[#5f6368]"
className={CONTACTS_SEARCH_INPUT_CLASS}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="rounded-full p-1 hover:bg-gray-100"
className="rounded-full p-1 hover:bg-accent"
aria-label="Effacer la recherche"
>
<X className="h-4 w-4 text-[#5f6368]" />
<X className="h-4 w-4 text-muted-foreground" />
</button>
)}
</div>
</div>
<HeaderAccountActions className="pl-4" />
<HeaderAccountActions className="shrink-0 pl-1 sm:pl-4" />
</header>
)
}

View File

@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useMemo, useState } from "react"
import {
Users,
Clock,
@ -20,23 +20,45 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} 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 { useNavStore } from "@/lib/stores/nav-store"
import type { ContactsPageView } from "./contacts-app-shell"
interface ContactsSidebarProps {
open: boolean
overlay: boolean
onToggle: () => void
onClose: () => void
currentView: ContactsPageView
activeLabelId?: string | null
onNavigate: (view: ContactsPageView) => void
onHome?: () => void
onCreateContact: () => void
onBulkCreate?: () => void
onSelectLabel?: (id: string) => void
}
export function ContactsSidebar({
open,
overlay,
onToggle,
onClose,
currentView,
activeLabelId,
onNavigate,
onHome,
onCreateContact,
onBulkCreate,
onSelectLabel,
@ -48,7 +70,19 @@ export function ContactsSidebar({
const [labelInput, setLabelInput] = useState("")
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() {
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 (
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-gray-200 bg-white">
{/* Logo + hamburger */}
<aside
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">
<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" />
</Button>
<div className="flex items-center gap-2">
<Users className="h-6 w-6 text-[#5f6368]" />
<span className="text-[22px] font-normal text-[#5f6368]">Contacts</span>
</div>
<button
type="button"
onClick={onHome ?? (() => onNavigate("contacts"))}
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>
{/* Create button */}
@ -78,14 +145,14 @@ export function ContactsSidebar({
<DropdownMenuTrigger asChild>
<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]" />
<span className="flex-1 text-left text-sm font-medium text-[#3c4043]">Créer un contact</span>
<ChevronDown className="h-4 w-4 text-[#5f6368]" />
<Plus className="h-5 w-5 text-primary" />
<span className={CONTACTS_CREATE_BTN_LABEL_CLASS}>Créer un contact</span>
<ChevronDown className={cn("h-4 w-4", CONTACTS_NAV_ICON_MUTED)} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuContent align="start" className={cn("w-56", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem onClick={onCreateContact}>
<UserPlus className="mr-2 h-4 w-4" />
Créer un contact
@ -98,7 +165,6 @@ export function ContactsSidebar({
</DropdownMenu>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2">
<NavItem
icon={<Users className="h-5 w-5" />}
@ -120,9 +186,9 @@ export function ContactsSidebar({
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
icon={<Merge className="h-5 w-5" />}
@ -144,18 +210,22 @@ export function ContactsSidebar({
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 justify-between px-3 py-2">
<p className="text-xs font-medium text-[#5f6368]">Libellés</p>
<button
type="button"
onClick={() => setShowLabelInput(true)}
className="rounded-full p-1 text-[#5f6368] hover:bg-gray-100"
>
<Plus className="h-4 w-4" />
</button>
<div className="flex items-center gap-3 px-3 py-2">
<p className={cn("min-w-0 flex-1 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
Libellés
</p>
<div className="flex w-6 shrink-0 justify-center">
<button
type="button"
onClick={() => setShowLabelInput(true)}
className="rounded-full p-1 text-muted-foreground hover:bg-accent"
aria-label="Ajouter un libellé"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{showLabelInput && (
@ -166,7 +236,7 @@ export function ContactsSidebar({
onChange={(e) => setLabelInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddLabel()}
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
/>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleAddLabel}>
@ -175,19 +245,16 @@ export function ContactsSidebar({
</div>
)}
{availableLabels.map((label) => {
const count = contacts.filter((c) => c.labels?.includes(label.id)).length
return (
<NavItem
key={label.id}
icon={<Tag className="h-5 w-5" />}
label={label.label}
count={count}
active={currentView === "label" && activeLabelId === label.id}
onClick={() => onSelectLabel?.(label.id)}
/>
)
})}
{labelsByContactCount.map(({ label, count }) => (
<NavItem
key={label.id}
icon={<Tag className="h-5 w-5" />}
label={label.label}
count={count}
active={currentView === "label" && activeLabelId === label.id}
onClick={() => onSelectLabel?.(label.id)}
/>
))}
</nav>
</aside>
)
@ -212,13 +279,12 @@ function NavItem({
<button
type="button"
onClick={onClick}
className={`flex w-full items-center gap-3 rounded-full px-3 py-2 text-sm transition-colors ${
active
? "bg-[#c2e7ff] font-medium text-[#001d35]"
: "text-[#1f1f1f] hover:bg-gray-100"
}`}
className={cn(
"flex w-full items-center gap-3 rounded-full px-3 py-2 text-sm transition-colors",
active ? CONTACTS_NAV_ACTIVE_CLASS : CONTACTS_NAV_ITEM_CLASS
)}
>
<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>
{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">
@ -226,7 +292,14 @@ function NavItem({
</span>
)}
{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>
)

View File

@ -1,6 +1,6 @@
"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 { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
@ -18,10 +18,30 @@ import { downloadContactsCsv, downloadContactsVCard } from "@/lib/contacts/expor
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
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 {
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 =
"grid grid-cols-[40px_minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,1.5fr)_minmax(0,1fr)] gap-2"
const DATA_COLUMNS: Exclude<ContactsTableColumn, "checkbox">[] = [
"name",
"email",
"phone",
"job",
"labels",
]
interface ContactsTableProps {
view: ContactsPageView
@ -31,6 +51,8 @@ interface ContactsTableProps {
}
export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact }: ContactsTableProps) {
const { visibleColumns, columnLabels } = useContactsTableColumns()
const gridStyle = contactsTableGridStyle(visibleColumns)
const contacts = useContactsStore((s) => s.contacts)
const softDeleteContact = useContactsStore((s) => s.softDeleteContact)
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
@ -146,12 +168,12 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
}
return (
<div className="px-6 py-4">
<div className="mb-2 flex items-center justify-between">
<div className="px-3 py-4 sm:px-6">
<div className="mb-2 flex items-center justify-between gap-2">
<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 && (
<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" : ""}
</p>
)}
@ -161,7 +183,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button"
variant="ghost"
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)}
aria-label="Imprimer"
>
@ -174,14 +196,14 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button"
variant="ghost"
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}
aria-label="Exporter la sélection"
>
<Download className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuContent align="end" className={cn("w-52", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem onClick={handleExportVcf}>
Exporter au format vCard (.vcf)
</DropdownMenuItem>
@ -195,7 +217,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button"
variant="ghost"
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}
onClick={handleDeleteSelected}
aria-label="Supprimer la sélection"
@ -209,13 +231,13 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
type="button"
variant="ghost"
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"
>
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuContent align="end" className={cn("w-48", MAIL_SIDEBAR_MENU_SURFACE_CLASS)}>
<DropdownMenuItem
onClick={() => toggleSelectAll(true)}
disabled={filteredContacts.length === 0}
@ -234,26 +256,31 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
</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">
<Checkbox
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
aria-label="Tout sélectionner"
/>
</span>
<span>Nom</span>
<span>E-mail</span>
<span>Numéro de téléphone</span>
<span>Fonction et entreprise</span>
<span>Libellés</span>
{isContactsColumnVisible(visibleColumns, "checkbox") && (
<span className="flex items-center justify-center">
<Checkbox
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
onCheckedChange={(checked) => toggleSelectAll(checked === true)}
aria-label="Tout sélectionner"
/>
</span>
)}
{DATA_COLUMNS.map((column) =>
isContactsColumnVisible(visibleColumns, column) ? (
<span key={column}>{columnLabels[column]}</span>
) : null
)}
</div>
{filteredContacts.map((contact) => (
<ContactTableRow
key={contact.id}
contact={contact}
visibleColumns={visibleColumns}
gridStyle={gridStyle}
selected={selectedIds.has(contact.id)}
onToggleSelect={(checked) => toggleContact(contact.id, checked)}
onOpen={() => onOpenContact(contact.id)}
@ -261,7 +288,7 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
))}
{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é
</div>
)}
@ -276,11 +303,15 @@ function sanitizeExportName(contact: FullContact): string {
function ContactTableRow({
contact,
visibleColumns,
gridStyle,
selected,
onToggleSelect,
onOpen,
}: {
contact: FullContact
visibleColumns: ContactsTableColumn[]
gridStyle: CSSProperties
selected: boolean
onToggleSelect: (checked: boolean) => void
onOpen: () => void
@ -302,55 +333,76 @@ function ContactTableRow({
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] ${
selected ? "bg-[#e8f0fe]" : ""
}`}
className={cn(
CONTACTS_TABLE_ROW_CLASS,
selected && "bg-mail-nav-selected"
)}
style={gridStyle}
>
<span
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onToggleSelect(checked === true)}
aria-label={`Sélectionner ${name}`}
/>
</span>
{isContactsColumnVisible(visibleColumns, "checkbox") && (
<span
className="flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onToggleSelect(checked === true)}
aria-label={`Sélectionner ${name}`}
/>
</span>
)}
<span className="flex items-center gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 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 ? (
{isContactsColumnVisible(visibleColumns, "name") && (
<span className="flex min-w-0 items-center gap-2 sm:gap-3">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 shrink-0 rounded-full object-cover" />
) : (
<span
key={labelId}
className="inline-flex rounded border border-gray-300 px-1.5 py-0.5 text-[11px] text-[#3c4043]"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: color }}
>
{row.label}
{initial}
</span>
) : null
})}
</span>
)}
<span className="min-w-0 flex-1">
<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>
)
}

View File

@ -11,6 +11,14 @@ import { Button } from "@/components/ui/button"
import { Info } from "lucide-react"
import { useContactsStore } from "@/lib/contacts/contacts-store"
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 {
open: boolean
@ -87,21 +95,17 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
<DialogHeader>
<div className="flex items-center justify-between">
<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>
</DialogHeader>
<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.
<br />
Utilisez le format CSV ou vCard (.vcf).
</p>
<Button
type="button"
onClick={handleFileSelect}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
<Button type="button" onClick={handleFileSelect} className={CONTACTS_PRIMARY_BTN_CLASS}>
Sélectionner un fichier
</Button>
@ -114,7 +118,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
/>
{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 > 1 ? "s" : ""} à importer depuis{" "}
<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>}
<p className="text-xs text-[#5f6368]">
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>
Vous essayez de sauvegarder les contacts de votre mobile ?
<br />
<span className="cursor-pointer text-[#1a73e8]">Voici comment les synchroniser.</span>
<span className="cursor-pointer text-primary">Voici comment les synchroniser.</span>
</p>
</div>
<div className="flex justify-end gap-3">
@ -134,7 +138,7 @@ export function ImportDialog({ open, onOpenChange }: ImportDialogProps) {
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
className="text-sm font-medium text-[#1a73e8]"
className={CONTACTS_PAGE_LINK_BTN_CLASS}
>
Non, ne rien faire
</Button>

View File

@ -7,6 +7,19 @@ import { findDuplicatePairs, type DuplicateMatchReason } from "@/lib/contacts/du
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
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"
@ -63,16 +76,16 @@ export function MergeDuplicatesView() {
}
return (
<div className="px-6 py-6">
<div className="mb-6 flex items-start gap-4 rounded-xl bg-[#f0f4f9] p-5">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#d3e3fd]">
<div className="px-6 py-6 text-foreground">
<div className={CONTACTS_PAGE_INFO_BANNER_CLASS}>
<div className={CONTACTS_PAGE_INFO_BANNER_ICON_CLASS}>
<span className="text-2xl">🧹</span>
</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
</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
</p>
</div>
@ -82,11 +95,7 @@ export function MergeDuplicatesView() {
<button
type="button"
onClick={() => setSubView("merge")}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
subView === "merge"
? "bg-[#c2e7ff] text-[#001d35]"
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
}`}
className={subView === "merge" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Fusionner les doublons
{mergeSuggestions.length > 0 && (
@ -96,11 +105,7 @@ export function MergeDuplicatesView() {
<button
type="button"
onClick={() => setSubView("coordinates")}
className={`rounded-full px-4 py-2 text-sm font-medium transition-colors ${
subView === "coordinates"
? "bg-[#c2e7ff] text-[#001d35]"
: "bg-[#f0f4f9] text-[#1f1f1f] hover:bg-[#e3e8ed]"
}`}
className={subView === "coordinates" ? CONTACTS_PAGE_TAB_ACTIVE_CLASS : CONTACTS_PAGE_TAB_INACTIVE_CLASS}
>
Ajouter des coordonnées
{coordSuggestions.length > 0 && (
@ -112,14 +117,14 @@ export function MergeDuplicatesView() {
{subView === "merge" && (
<div>
<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})
</h3>
{mergeSuggestions.length > 0 && (
<Button
onClick={handleMergeAll}
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"}
</Button>
@ -127,7 +132,7 @@ export function MergeDuplicatesView() {
</div>
{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é
</p>
)}
@ -162,8 +167,8 @@ function MergeSuggestionCard({
const { contactA, contactB, reason } = suggestion
return (
<div className="rounded-xl border border-gray-200 p-5">
<p className="mb-3 text-xs font-medium text-[#5f6368]">
<div className={CONTACTS_PAGE_CARD_CLASS}>
<p className={cn("mb-3 text-xs font-medium", CONTACTS_MUTED_TEXT)}>
{REASON_LABELS[reason]}
</p>
<div className="flex items-start gap-6">
@ -174,14 +179,11 @@ function MergeSuggestionCard({
<button
type="button"
onClick={onIgnore}
className="text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
className={CONTACTS_PAGE_LINK_BTN_CLASS}
>
Ignorer
</button>
<Button
onClick={onMerge}
className="rounded-full bg-[#1a73e8] px-5 text-sm font-medium text-white hover:bg-[#1557b0]"
>
<Button onClick={onMerge} className={CONTACTS_PRIMARY_BTN_CLASS}>
Fusionner
</Button>
</div>
@ -208,12 +210,12 @@ function ContactMiniCard({ contact }: { contact: import("@/lib/contacts/types").
</div>
)}
<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] && (
<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] && (
<p className="truncate text-xs text-[#5f6368]">
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>
{contact.phones[0].value} ({contact.phones[0].label})
</p>
)}

View File

@ -11,6 +11,17 @@ import {
import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types"
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() {
const { deletedContacts, restoreContact, emptyTrash } = useContactsStore()
@ -23,45 +34,42 @@ export function TrashView() {
}
return (
<div className="px-6 py-4">
{/* Warning banner */}
<div className="px-6 py-4 text-foreground">
{deletedContacts.length > 0 && (
<div className="mb-4 flex items-center justify-between rounded-lg bg-[#fef7e0] px-4 py-3">
<p className="text-sm text-[#3c4043]">
<div className={CONTACTS_PAGE_BANNER_CLASS}>
<p className="text-sm text-foreground">
Les contacts qui sont dans la corbeille depuis plus de 30 jours seront supprimés définitivement
</p>
<button
type="button"
onClick={emptyTrash}
className="shrink-0 text-sm font-medium text-[#1a73e8] hover:text-[#1557b0]"
>
<button type="button" onClick={emptyTrash} className={cn("shrink-0", CONTACTS_PAGE_LINK_BTN_CLASS)}>
Vider la corbeille
</button>
</div>
)}
{/* Title */}
<h1 className="mb-4 text-2xl font-normal text-[#1f1f1f]">
<h1 className={cn("mb-4", CONTACTS_PAGE_TITLE_CLASS)}>
Corbeille ({deletedContacts.length})
</h1>
{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
</p>
)}
{deletedContacts.length > 0 && (
<>
{/* Table header */}
<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]">
<div
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>Raison du placement dans la corbeille</span>
<span>Date de suppression</span>
<span />
</div>
{/* Rows */}
{deletedContacts.map((entry) => {
const { contact, deletedAt, reason } = entry
const displayName = fullContactDisplayName(contact)
@ -72,7 +80,10 @@ export function TrashView() {
return (
<div
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">
{contact.avatarUrl ? (
@ -85,13 +96,13 @@ export function TrashView() {
{initial}
</span>
)}
<span className="truncate text-[#1f1f1f]">{name}</span>
<span className={cn("truncate", CONTACTS_HEADING_TEXT)}>{name}</span>
</span>
<span className="truncate text-[#5f6368]">{reason}</span>
<span className="text-[#5f6368]">{formatDate(deletedAt)}</span>
<span className={cn("truncate", CONTACTS_MUTED_TEXT)}>{reason}</span>
<span className={CONTACTS_MUTED_TEXT}>{formatDate(deletedAt)}</span>
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>

View File

@ -2,7 +2,6 @@
import { useMemo } from "react"
import {
ArrowLeft,
Pencil,
Star,
X,
@ -23,6 +22,21 @@ import { avatarColor, senderInitial } from "@/lib/sender-display"
import { emails as allEmails } from "@/lib/email-data"
import { useComposeActions } from "@/lib/compose-context"
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 {
contactId: string | null
@ -52,7 +66,7 @@ function formatEmailDate(iso: string): string {
}
export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const { contacts, setView, closePanel } = useContactsStore()
const { contacts, setView, showContactsList, closePanel } = useContactsStore()
const { openComposeWithInitial } = useComposeActions()
const labelRows = useNavStore((s) => s.labelRows)
@ -78,7 +92,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
if (!contact) {
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
</div>
)
@ -91,24 +105,16 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
const primaryEmail = contact.emails[0]?.value
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 */}
<div className="flex h-12 shrink-0 items-center justify-between border-b border-gray-200 px-2">
<Button
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={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={() => setView("edit", contactId)}
aria-label="Modifier"
>
@ -118,7 +124,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-400"
className={CONTACTS_PANEL_ICON_BTN_CLASS}
>
<Star className="h-4 w-4" />
</Button>
@ -126,7 +132,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={closePanel}
>
<X className="h-4 w-4" />
@ -152,11 +158,11 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
{initial}
</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}
</h2>
{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.company}
</p>
@ -168,7 +174,7 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
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 font-medium text-gray-700"
className={CONTACTS_PANEL_TAG_CLASS}
>
{row && (
<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">
<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={() =>
openComposeWithInitial({
to: [{ name: displayName, email: primaryEmail }],
@ -198,13 +204,13 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
</button>
<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" />
</button>
<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" />
</button>
@ -212,89 +218,89 @@ export function ContactDetailView({ contactId }: ContactDetailViewProps) {
)}
{/* 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 && (
<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) => (
<div key={i}>
<p className="truncate text-sm text-[#1a73e8]">{e.value}</p>
<p className="text-xs text-gray-500">{e.label}</p>
<p className="truncate text-sm text-primary">{e.value}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{e.label}</p>
</div>
))}
</DetailSection>
)}
{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) => (
<div key={i}>
<p className="text-sm text-[#1a73e8]">{p.value}</p>
<p className="text-xs text-gray-500">{p.label}</p>
<p className="text-sm text-primary">{p.value}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{p.label}</p>
</div>
))}
</DetailSection>
)}
{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>
<p className="text-sm text-gray-900">{contact.company}</p>
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{contact.company}</p>
{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 && (
<p className="text-xs text-gray-500">{contact.jobTitle}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{contact.jobTitle}</p>
)}
</div>
</DetailSection>
)}
{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) => (
<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]
.filter(Boolean)
.join(", ")}
</p>
<p className="text-xs text-gray-500">{addr.label}</p>
<p className={cn("text-xs", CONTACTS_MUTED_TEXT)}>{addr.label}</p>
</div>
))}
</DetailSection>
)}
{contact.birthday && (contact.birthday.day || contact.birthday.month) && (
<DetailSection icon={<Cake className="h-4.5 w-4.5 text-gray-400" />}>
<p className="text-sm text-gray-900">{formatBirthday(contact.birthday)}</p>
<DetailSection icon={<Cake className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className={cn("text-sm", CONTACTS_HEADING_TEXT)}>{formatBirthday(contact.birthday)}</p>
</DetailSection>
)}
{contact.notes && (
<DetailSection icon={<FileText className="h-4.5 w-4.5 text-gray-400" />}>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{contact.notes}</p>
<DetailSection icon={<FileText className={cn("h-4.5 w-4.5", CONTACTS_PANEL_MUTED_ICON_CLASS)} />}>
<p className={cn("whitespace-pre-wrap text-sm", CONTACTS_HEADING_TEXT)}>{contact.notes}</p>
</DetailSection>
)}
</div>
{/* Recent interactions */}
{recentInteractions.length > 0 && (
<div className="min-w-0 overflow-hidden border-t border-gray-100 pt-3 pb-4">
<h3 className="px-4 pb-2 text-xs font-medium uppercase text-gray-500">
<div className={cn("min-w-0 overflow-hidden pt-3 pb-4", CONTACTS_PANEL_DIVIDER_CLASS)}>
<h3 className={cn("px-4 pb-2 text-xs font-medium uppercase", CONTACTS_MUTED_TEXT)}>
Interactions récentes
</h3>
{recentInteractions.map((email) => (
<div
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">
<p className="truncate text-sm text-gray-900">{email.subject}</p>
<p className="line-clamp-2 break-words [overflow-wrap:anywhere] text-xs text-gray-500">
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{email.subject}</p>
<p className={cn("line-clamp-2 break-words [overflow-wrap:anywhere] text-xs", CONTACTS_MUTED_TEXT)}>
{email.preview}
</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>
))}

View File

@ -13,7 +13,6 @@ import { useForm, useFieldArray, Controller } from "react-hook-form"
import { z } from "zod"
import { zodResolver } from "@hookform/resolvers/zod"
import {
ArrowLeft,
Star,
X,
User,
@ -45,6 +44,26 @@ import { useContactsStore } from "@/lib/contacts/contacts-store"
import { fullContactDisplayName } from "@/lib/contacts/types"
import { avatarColor, senderInitial } from "@/lib/sender-display"
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 = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
@ -112,6 +131,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
addContact,
updateContact,
setView,
showContactsList,
closePanel,
createDraft,
clearCreateDraft,
@ -302,22 +322,10 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
return (
<form
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">
<Button
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={CONTACTS_PANEL_HEADER_COMPACT_CLASS}>
<ContactsPanelLogo onClick={showContactsList} compact className="-ml-1" />
<div className="flex items-center gap-1">
<Button
@ -328,14 +336,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
onClick={() => setStarred((s) => !s)}
>
<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
type="submit"
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
</button>
@ -344,7 +352,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-gray-600"
className={CONTACTS_PANEL_ICON_BTN_CLASS}
onClick={closePanel}
>
<X className="h-4 w-4" />
@ -363,7 +371,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
{senderInitial(displayName)}
</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" />
</div>
)}
@ -376,7 +384,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
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"
className={CONTACTS_PANEL_TAG_CLASS}
>
{row && (
<span className={`inline-block h-2 w-2 rounded-full ${row.color}`} />
@ -385,7 +393,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
<button
type="button"
onClick={() => toggleLabel(labelId)}
className="text-gray-400 hover:text-gray-600"
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
@ -396,14 +404,14 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
<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"
className={CONTACTS_PANEL_ADD_TAG_BTN_CLASS}
>
<Plus className="h-3 w-3" />
Libellé
</button>
</PopoverTrigger>
<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
</p>
<div className="max-h-48 overflow-y-auto">
@ -414,7 +422,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
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"
className={CONTACTS_PANEL_POPOVER_ITEM_CLASS}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${row.color}`} />
<span className="flex-1 truncate">{row.label}</span>
@ -428,7 +436,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</div>
{/* 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 && (
<FloatingInput label="Titre (M., Mme...)" {...register("namePrefix")} />
)}
@ -440,7 +448,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
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)}
>
{nameExpanded ? (
@ -464,7 +472,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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-1">
<FloatingInput label="Entreprise" {...register("company")} />
@ -473,7 +481,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
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)}
>
{companyExpanded ? (
@ -490,7 +498,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-1">
@ -506,7 +514,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
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)}
>
<X className="h-3.5 w-3.5" />
@ -532,7 +540,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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) => (
<div key={field.id} className="space-y-2">
<div className="flex items-center gap-2">
@ -551,7 +559,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
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)}
>
<X className="h-3.5 w-3.5" />
@ -577,9 +585,9 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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) => (
<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">
<Controller
control={control}
@ -596,7 +604,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
type="button"
variant="ghost"
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)}
>
<X className="h-3.5 w-3.5" />
@ -632,7 +640,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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="w-[72px]">
<FloatingInput
@ -673,7 +681,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
</FormSection>
{/* 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")} />
</FormSection>
@ -711,7 +719,7 @@ function AddButton({
<button
type="button"
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" />
{children}
@ -755,9 +763,7 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
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)
@ -774,11 +780,11 @@ const FloatingInput = forwardRef<HTMLInputElement, FloatingInputProps>(
/>
<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"}`}
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>
@ -824,9 +830,7 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
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)
@ -843,11 +847,11 @@ const FloatingTextarea = forwardRef<HTMLTextAreaElement, FloatingTextareaProps>(
/>
<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"}`}
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>
@ -871,7 +875,7 @@ function CompactSelect({
}) {
return (
<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..."} />
</SelectTrigger>
<SelectContent>

View File

@ -2,6 +2,12 @@
import { type FullContact, fullContactDisplayName } from "@/lib/contacts/types"
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 {
contact: FullContact
@ -19,27 +25,30 @@ export function ContactRow({ contact, onClick }: ContactRowProps) {
<button
type="button"
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 ? (
<img
src={contact.avatarUrl}
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
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 }}
>
{initial}
</div>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-gray-900">{name}</div>
{subtitle && displayName && (
<div className="truncate text-xs text-gray-500">{subtitle}</div>
)}
<p className={cn("truncate text-sm", CONTACTS_HEADING_TEXT)}>{name}</p>
{subtitle && displayName ? (
<p className={cn("truncate text-xs", CONTACTS_MUTED_TEXT)}>{subtitle}</p>
) : null}
</div>
</button>
)

View File

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

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"
hideClose
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>
{view === "list" && <ContactsListView />}

View File

@ -24,6 +24,7 @@ import {
Star,
ChevronLeft,
ChevronRight,
ChevronUp,
MoreVertical,
RefreshCw,
ChevronDown,
@ -103,14 +104,30 @@ import {
} from "@/components/gmail/mail-label-pills"
import {
emails,
getThreadMessageCount,
type Email,
type EmailAttachment,
} from "@/lib/email-data"
import {
getThreadMessageCount,
isListRowRead,
isThreadHeadMessage,
readStateTargets,
} from "@/lib/mail-thread"
import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store"
import { useScheduledStore } from "@/lib/stores/scheduled-store"
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 {
emailMatchesFolder,
emailMatchesInboxPrimaryTab,
@ -170,6 +187,11 @@ import type { LabelEditState } from "@/lib/stores/mail-store"
import type { MailRouteState } from "@/lib/mail-url"
import { readXsMatches, useIsXs } from "@/hooks/use-xs"
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)
@ -347,7 +369,7 @@ function inboxTabBadgeCountClass(badgeColor: string) {
function inboxTabBadgeDotClass(badgeColor: string) {
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
)
}
@ -570,6 +592,8 @@ interface EmailListProps {
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
/** Barre basse xs en lecture dun message. */
onXsViewChromeChange?: (chrome: MailXsViewChrome | null) => void
}
function listRowCheckboxClass(circular: boolean) {
@ -581,10 +605,10 @@ function listRowCheckboxClass(circular: boolean) {
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
return isSelected
? "bg-[#e8f0fe]"
? "bg-mail-row-selected"
: isRead
? "bg-[#f5f5f5]"
: "bg-white"
? "bg-mail-row-read"
: "bg-mail-row-unread"
}
export function EmailList({
@ -597,6 +621,7 @@ export function EmailList({
onMailRouteNavigate,
onSelectFolder,
onFolderUnreadCountsChange,
onXsViewChromeChange,
}: EmailListProps) {
const isViewMode = openMailId !== null && !splitView
const showSplitReadingPane = splitView && openMailId !== null
@ -634,6 +659,11 @@ export function EmailList({
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
)
const emailById = useMemo(
() => new Map(allEmails.map((e) => [e.id, e])),
[allEmails]
)
const sidebarNav = useSidebarNav()
const navMaps = useMemo<MailNavFolderMaps>(
() => ({
@ -715,10 +745,12 @@ export function EmailList({
if (!openMailId) {
closeAllInlineComposes()
} else {
pruneInlineComposesToOpenThread(openMailId)
const msg = emailById.get(openMailId)
pruneInlineComposesToOpenThread(msg ? threadStoreId(msg) : openMailId)
}
}, [
openMailId,
emailById,
closeAllInlineComposes,
pruneInlineComposesToOpenThread,
])
@ -728,6 +760,10 @@ export function EmailList({
const importantEmails = useMailStore((s) => s.importantIds)
const [selectedEmails, setSelectedEmails] = useState<string[]>([])
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 mailActions = useRef(useMailStore.getState()).current
const setReadOverrides = useCallback(
@ -995,6 +1031,31 @@ export function EmailList({
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(
() =>
inboxTabDisplayLabel(
@ -1006,8 +1067,11 @@ export function EmailList({
)
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(() => {
@ -1028,21 +1092,21 @@ export function EmailList({
}, [selectedFolder, inboxTab])
const totalPages = useMemo(
() => Math.max(1, Math.ceil(filteredEmails.length / LIST_PAGE_SIZE)),
[filteredEmails.length]
() => Math.max(1, Math.ceil(displayListEmails.length / LIST_PAGE_SIZE)),
[displayListEmails.length]
)
const pagedEmails = useMemo(() => {
const start = (listPage - 1) * LIST_PAGE_SIZE
return filteredEmails.slice(start, start + LIST_PAGE_SIZE)
}, [filteredEmails, listPage])
return displayListEmails.slice(start, start + LIST_PAGE_SIZE)
}, [displayListEmails, listPage])
const listEmails = useMemo(() => {
if (isXs && !isViewMode) {
return filteredEmails.slice(0, mobileVisibleCount)
return displayListEmails.slice(0, mobileVisibleCount)
}
return pagedEmails
}, [isXs, isViewMode, filteredEmails, mobileVisibleCount, pagedEmails])
}, [isXs, isViewMode, displayListEmails, mobileVisibleCount, pagedEmails])
const listMailIndex = useMemo(() => buildListMailIndex(allEmails), [allEmails])
@ -1108,19 +1172,19 @@ export function EmailList({
if (!root || !isXs || isViewMode) return
const onScroll = () => {
if (mobileVisibleCount >= filteredEmails.length) return
if (mobileVisibleCount >= displayListEmails.length) return
const nearBottom =
root.scrollTop + root.clientHeight >= root.scrollHeight - 120
if (nearBottom) {
setMobileVisibleCount((prev) =>
Math.min(prev + LIST_PAGE_SIZE, filteredEmails.length)
Math.min(prev + LIST_PAGE_SIZE, displayListEmails.length)
)
}
}
root.addEventListener("scroll", onScroll, { passive: true })
return () => root.removeEventListener("scroll", onScroll)
}, [isXs, isViewMode, mobileVisibleCount, filteredEmails.length])
}, [isXs, isViewMode, mobileVisibleCount, displayListEmails.length])
useEffect(() => {
const root = listViewportRef.current
@ -1693,10 +1757,14 @@ export function EmailList({
const markAllInViewAsRead = useCallback(() => {
setReadOverrides((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
})
}, [filteredEmails])
}, [displayListEmails, conversationMode])
const bulkMoveTo = useCallback(
(targetId: string) => {
@ -1710,30 +1778,65 @@ export function EmailList({
)
// --- View mode helpers ---
const openEmail = useMemo(() => {
const openEmailView = useMemo(() => {
if (!openMailId) return null
const raw = allEmails.find((e) => e.id === openMailId) ?? null
if (!raw) return null
if (raw.labels?.includes("scheduled")) return null
return mergeEmailNotSpam(mergeEmailLabelEdits(raw, labelEdits), notSpamEmailIds)
}, [openMailId, labelEdits, allEmails, notSpamEmailIds])
const resolved = resolveOpenEmailView(
openMailId,
allEmails,
conversationMode
)
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(
() => (openMailId ? filteredEmails.findIndex((e) => e.id === openMailId) : -1),
[openMailId, filteredEmails]
() =>
openMailId ? displayListEmails.findIndex((e) => e.id === openMailId) : -1,
[openMailId, displayListEmails]
)
useEffect(() => {
if (!openMailId) return
markEmailSeen(openMailId)
setReadOverrides((prev) =>
prev[openMailId] !== undefined ? prev : { ...prev, [openMailId]: true }
)
}, [openMailId, markEmailSeen])
const message = emailById.get(openMailId)
if (!message) return
const targets = readStateTargets(message, conversationMode)
for (const id of targets) {
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(
(id: string | null) => {
if (id && splitView) {
const idx = filteredEmails.findIndex((e) => e.id === id)
const idx = displayListEmails.findIndex((e) => e.id === id)
if (idx >= 0) {
const page = Math.floor(idx / LIST_PAGE_SIZE) + 1
onMailRouteNavigate({ mailId: id, page })
@ -1742,7 +1845,7 @@ export function EmailList({
}
onMailRouteNavigate({ mailId: id })
},
[splitView, filteredEmails, onMailRouteNavigate]
[splitView, displayListEmails, onMailRouteNavigate]
)
useEffect(() => {
@ -1755,13 +1858,13 @@ export function EmailList({
const pickAdjacentMailId = useCallback(
(currentId: string) => {
const idx = filteredEmails.findIndex((e) => e.id === currentId)
if (idx < 0) return filteredEmails[0]?.id ?? null
if (idx < filteredEmails.length - 1) return filteredEmails[idx + 1]!.id
if (idx > 0) return filteredEmails[idx - 1]!.id
const idx = displayListEmails.findIndex((e) => e.id === currentId)
if (idx < 0) return displayListEmails[0]?.id ?? null
if (idx < displayListEmails.length - 1) return displayListEmails[idx + 1]!.id
if (idx > 0) return displayListEmails[idx - 1]!.id
return null
},
[filteredEmails]
[displayListEmails]
)
const leaveReadingPane = useCallback(() => {
@ -1893,21 +1996,21 @@ export function EmailList({
const goToPrev = useCallback(() => {
if (openMailIndex > 0) {
const id = filteredEmails[openMailIndex - 1]!.id
const id = displayListEmails[openMailIndex - 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen])
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen])
const goToNext = useCallback(() => {
if (openMailIndex >= 0 && openMailIndex < filteredEmails.length - 1) {
const id = filteredEmails[openMailIndex + 1]!.id
if (openMailIndex >= 0 && openMailIndex < displayListEmails.length - 1) {
const id = displayListEmails[openMailIndex + 1]!.id
markEmailSeen(id)
setReadOverrides((prev) => ({ ...prev, [id]: true }))
navigateToMail(id)
}
}, [openMailIndex, filteredEmails, navigateToMail, markEmailSeen])
}, [openMailIndex, displayListEmails, navigateToMail, markEmailSeen])
const handleOpenEmail = useCallback(
(id: string) => {
@ -2016,9 +2119,40 @@ export function EmailList({
[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(() => {
if (!splitView) return
const firstId = filteredEmails[0]?.id ?? null
const firstId = displayListEmails[0]?.id ?? null
if (!openMailId) {
if (firstId) navigateToMail(firstId)
return
@ -2028,7 +2162,7 @@ export function EmailList({
navigateToMail(firstId)
return
}
if (!filteredEmails.some((e) => e.id === openMailId)) {
if (!displayListEmails.some((e) => e.id === openMailId)) {
navigateToMail(firstId)
}
}, [
@ -2036,7 +2170,7 @@ export function EmailList({
selectedFolder,
inboxTab,
listPage,
filteredEmails,
displayListEmails,
openMailId,
navigateToMail,
allEmails,
@ -2112,8 +2246,7 @@ export function EmailList({
return () => window.removeEventListener("keydown", handler)
}, [isViewMode, showSplitReadingPane, splitView, goBack, goToPrev, goToNext])
const dropdownSurfaceClass =
"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 dropdownSurfaceClass = MAIL_MENU_SURFACE_CLASS
const listToolbarMode = splitView || !isViewMode
/** 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"
)}
>
{filteredEmails.length === 0 ? (
{displayListEmails.length === 0 ? (
<span>Aucun résultat</span>
) : mode === "view" ? (
<span className="hidden sm:inline">
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {filteredEmails.length}
{openMailIndex >= 0 ? openMailIndex + 1 : ""} sur {displayListEmails.length}
</span>
) : (
<span>
{(listPage - 1) * LIST_PAGE_SIZE + 1}
{Math.min(listPage * LIST_PAGE_SIZE, filteredEmails.length)} sur{" "}
{filteredEmails.length}
{Math.min(listPage * LIST_PAGE_SIZE, displayListEmails.length)} sur{" "}
{displayListEmails.length}
{totalPages > 1 ? ` · p. ${listPage}/${totalPages}` : null}
</span>
)}
@ -2414,7 +2547,7 @@ export function EmailList({
size="icon"
className={cn(
"h-9 w-9",
mode === "view" && openMailIndex < filteredEmails.length - 1
mode === "view" && openMailIndex < displayListEmails.length - 1
? "text-gray-600"
: mode === "list" && listPage < totalPages
? "text-gray-600"
@ -2422,7 +2555,7 @@ export function EmailList({
)}
disabled={
mode === "view"
? openMailIndex >= filteredEmails.length - 1
? openMailIndex >= displayListEmails.length - 1
: listPage >= totalPages
}
onClick={mode === "view" ? goToNext : goListNextPage}
@ -2439,7 +2572,7 @@ export function EmailList({
)
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] " +
"[&::-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 " +
@ -2451,13 +2584,13 @@ export function EmailList({
<div className="flex h-full min-h-0 flex-1 flex-col">
{/* Mobile xs top bar */}
{!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">
<h1 className="truncate text-base font-semibold text-[#1f1f1f] leading-tight">
{mobileFolderLabel}
</h1>
<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" : ""}`}
</p>
</div>
@ -2468,8 +2601,8 @@ export function EmailList({
className={cn(
"shrink-0 text-[#444746]",
mobileSelectionMode
? "size-9 rounded-full border border-gray-200 bg-white/80 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"
? "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 hover:bg-white"
)}
onClick={() => {
setMobileSelectionMode((p) => !p)
@ -2495,7 +2628,7 @@ export function EmailList({
type="button"
variant="ghost"
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"
>
<MoreVertical className="size-[18px]" strokeWidth={1.5} />
@ -2580,6 +2713,7 @@ export function EmailList({
</DropdownMenu>
</div>
)}
{/* View-mode xs nav buttons are rendered inside the scroll area below */}
{!isViewMode && touchNav && (
<MobileXsBulkSheets
moveSheetOpen={isXs && mobileXsMoveSheetOpen}
@ -2611,7 +2745,7 @@ export function EmailList({
)}
>
{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 ? (
<Button
type="button"
@ -2631,8 +2765,8 @@ export function EmailList({
{/* Toolbar — relative: scroll lives in sibling below */}
<div
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",
splitView ? "rounded-none" : "rounded-t-2xl",
"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" : "sm:rounded-t-2xl",
isViewMode ? "items-start" : "items-center",
(isViewMode ? !listToolbarMode : true) && "max-sm:hidden"
)}
@ -2659,7 +2793,7 @@ export function EmailList({
<Checkbox
checked={selectAllChecked}
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>
<DropdownMenuTrigger asChild>
@ -2964,7 +3098,7 @@ export function EmailList({
</div>
{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 &&
(compactInboxTabs ? (
<CompactInboxCategoryTabs
@ -3017,6 +3151,7 @@ export function EmailList({
icon={tab.icon}
className={cn(
CATEGORY_TAB_ICON_CLASS,
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
@ -3037,6 +3172,7 @@ export function EmailList({
className={cn(
CATEGORY_TAB_ICON_CLASS,
"self-center",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#5f6368]"
)}
style={accentColor ? { color: accentColor } : undefined}
@ -3052,6 +3188,7 @@ export function EmailList({
<span
className={cn(
"min-w-0 flex-1 truncate text-[13px] font-semibold leading-tight",
MAIL_INBOX_CATEGORY_TAB_CONTENT_DARK_CLASS,
!isActive && "text-[#3c4043]"
)}
style={accentColor ? { color: accentColor } : undefined}
@ -3069,7 +3206,12 @@ export function EmailList({
) : null}
</div>
{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}
</span>
) : null}
@ -3112,34 +3254,78 @@ export function EmailList({
<div
ref={pullContentRef}
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)]"
)}
>
{!splitView && isViewMode && openEmail ? (
/* ── EMAIL VIEW ── */
<EmailView
email={openEmail}
onToggleStar={toggleStar}
isStarred={starredEmails.includes(openEmail.id) || 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
)
}}
/>
<>
<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">
<Button
type="button"
variant="ghost"
size="icon"
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"
aria-label="Retour à la boîte de réception"
onClick={goBack}
>
<ChevronLeft className="size-5" strokeWidth={1.5} />
</Button>
<div className="pointer-events-auto flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className="size-9 rounded-none text-[#444746] hover:bg-[#f1f3f4] disabled:opacity-40"
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}>
<>
@ -3155,13 +3341,13 @@ export function EmailList({
</p>
</div>
)}
{filteredEmails.length === 0 ? (
{displayListEmails.length === 0 ? (
selectedFolder === "scheduled" ? (
<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>
</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">
<EmptyMedia
variant="icon"
@ -3195,19 +3381,33 @@ export function EmailList({
</Empty>
)
) : (
<div className="divide-y divide-[#eceff1]">
<div
className={cn(
"divide-y divide-[#eceff1]",
listToolbarMode && "sm:pb-14"
)}
>
{listEmails.map((email) => {
const isStarred = starredEmails.includes(email.id) || email.starred
const isImportant = importantEmails.includes(email.id) || email.important
const rowThreadId = threadStoreId(email)
const isStarred =
starredEmails.includes(rowThreadId) || email.starred
const isImportant =
importantEmails.includes(rowThreadId) || email.important
const isSpam = email.spam === true
const isDraft = email.labels?.includes("drafts") === true
const hasThreadReplyDraft =
savedThreadReplyDrafts[email.id] !== undefined
savedThreadReplyDrafts[rowThreadId] !== undefined
const showDraftBadge = isDraft || hasThreadReplyDraft
const isRead =
readOverrides[email.id] !== undefined ? readOverrides[email.id]! : email.read
const isRead = isListRowRead(
email,
readOverrides,
emailById,
conversationMode
)
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 isSelected = selectedEmails.includes(email.id)
const isSplitActiveRow =
@ -3217,6 +3417,18 @@ export function EmailList({
listRowExtras.invitationById.get(email.id) ?? null
const attachmentList =
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 contextTargetIds = contextMenuTargetIdsForRow(
email.id,
@ -3294,14 +3506,19 @@ export function EmailList({
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",
!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
? "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
? "bg-[#e8f0fe]"
? "bg-mail-row-selected"
: isRead
? "bg-[#f5f5f5]"
: "bg-white",
? "bg-mail-row-read"
: "bg-mail-row-unread",
!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)]"
)}
@ -3614,7 +3831,9 @@ export function EmailList({
<div
className={cn(
"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
>
@ -3654,7 +3873,8 @@ export function EmailList({
<div
className={cn(
"flex min-w-0 items-center gap-1",
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
listRowPadTop,
isCompactListRow && "leading-tight"
)}
>
{email.tag && (
@ -3683,15 +3903,18 @@ export function EmailList({
</span>
<span className="min-w-0 flex-1 truncate text-sm text-gray-500">{email.preview}</span>
</div>
{attachmentList.length > 0 && (
{showAttachmentPills && (
<EmailListAttachmentRow emailId={email.id} attachments={attachmentList} />
)}
</div>
<div
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",
attachmentList.length === 0 ? "pt-1" : "pt-0.5"
"flex shrink-0 flex-col items-end gap-1 pr-2 text-right md:max-w-[150px] md:min-w-0",
listRowPadTop,
isCompactListRow && !showAttachmentPills
? "self-center"
: "self-start"
)}
>
{isScheduled ? (
@ -4013,6 +4236,13 @@ export function EmailList({
iconClassName="size-[18px] shrink-0"
/>
) : null}
{showListPaperclip && (
<Paperclip
className="size-[18px] shrink-0 text-[#5f6368]"
strokeWidth={1.75}
aria-label="Pièces jointes"
/>
)}
<span
className={cn(
"min-w-0 truncate text-sm tabular-nums",
@ -4203,13 +4433,13 @@ export function EmailList({
}
}}
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]: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]: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-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 ? (
@ -4309,7 +4539,7 @@ export function EmailList({
</ContextMenuSubTrigger>
<ContextMenuSubContent
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
@ -4454,7 +4684,7 @@ export function EmailList({
</ContextMenuSubTrigger>
<ContextMenuSubContent
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]:focus]:bg-[#f1f3f4]"
)}
@ -4478,7 +4708,7 @@ export function EmailList({
</ContextMenuSubTrigger>
<ContextMenuSubContent
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]:focus]:bg-[#f1f3f4]"
)}
@ -4537,13 +4767,14 @@ export function EmailList({
</div>
</div>
{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
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows}
onNavigate={handleBreadcrumbNavigate}
className="pointer-events-auto"
/>
</div>
) : null}
@ -4553,7 +4784,7 @@ export function EmailList({
<button
type="button"
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"
>
<Pencil className="size-6" strokeWidth={1.5} />
@ -4561,7 +4792,7 @@ export function EmailList({
) : null}
</div>
{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 ? (
<>
<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">
<EmailView
email={openEmail}
threadRoot={openEmailThreadRoot}
isSingleMessageView={isSingleMessageView}
onToggleStar={toggleStar}
isStarred={starredEmails.includes(openEmail.id) || openEmail.starred}
isStarred={
starredEmails.includes(threadStoreId(openEmail)) ||
openEmail.starred
}
onNavigateToLabel={handleNavigateToLabel}
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
labelBgByText={listRowLabelBgByTextLower}
@ -4595,7 +4831,7 @@ export function EmailList({
</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">
<EmptyMedia
variant="icon"

View File

@ -1,6 +1,13 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react"
import {
Star,
Reply,
@ -80,6 +87,24 @@ import { ComposeWindow } from "@/components/gmail/compose-modal"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import { ContactHoverCard } from "./contact-hover-card"
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 {
email: Email
@ -97,6 +122,10 @@ interface EmailViewProps {
labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
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> = {
@ -110,21 +139,16 @@ const LABEL_DISPLAY_NAMES: Record<string, string> = {
trash: "Corbeille",
}
const MESSAGE_MORE_MENU_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]"
const MESSAGE_MORE_MENU_CLASS = MAIL_MENU_SURFACE_WIDE_CLASS
/** Scroll zone du corps du message (preview remplit le panneau parent). */
const EMAIL_PREVIEW_SCROLL_CLASS =
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-y-contain outline-none " +
"[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 EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
display: "block",
background: "transparent",
}
const REPLY_BAR_SURFACE_CLASS =
"bg-[linear-gradient(to_bottom,rgba(255,255,255,0)_0%,#ffffff_0.75rem,#ffffff_100%)] pt-3"
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
/* ── Sandboxed iframe for HTML body ── */
@ -142,6 +166,8 @@ function SandboxedContent({
? "allow-same-origin"
: "allow-same-origin allow-popups"
const { resolvedTheme } = useTheme()
const injectContent = useCallback(() => {
const iframe = iframeRef.current
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 https: data:;">`
const isDark = documentIsDark()
const processedHtml = preprocessEmailHtmlForTheme(html, isDark)
const themeOverrides = isDark
? emailPreviewDarkOverrideCss()
: emailPreviewLightOverrideCss()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
@ -160,35 +192,11 @@ function SandboxedContent({
<meta charset="utf-8">
${cspMeta}
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
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; }
${emailPreviewBaseCss(isDark)}
${themeOverrides}
</style>
</head>
<body>${html}</body>
<body>${processedHtml}</body>
</html>`)
doc.close()
@ -205,7 +213,7 @@ function SandboxedContent({
}
return () => resizeObserver.disconnect()
}, [html, isSpam])
}, [html, isSpam, resolvedTheme])
useEffect(() => {
const cleanup = injectContent()
@ -217,8 +225,8 @@ function SandboxedContent({
ref={iframeRef}
sandbox={sandboxValue}
title="Contenu du message"
className="w-full border-0"
style={{ height, display: "block" }}
className="w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: `${height}px` }}
tabIndex={-1}
/>
)
@ -228,6 +236,7 @@ function SandboxedContent({
function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
useEffect(() => {
const iframe = iframeRef.current
@ -235,37 +244,28 @@ function SandboxedSubject({ text }: { text: string }) {
const doc = iframe.contentDocument
if (!doc) return
const isDark = documentIsDark()
doc.open()
doc.write(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline';">
<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>
<style>${emailPreviewSubjectCss(isDark)}</style>
</head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`)
doc.close()
}, [text])
}, [text, resolvedTheme])
return (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
title="Sujet du message"
className="pointer-events-none w-full border-0"
style={{ height: 32, display: "block" }}
className="pointer-events-none w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: "32px" }}
tabIndex={-1}
/>
)
@ -274,12 +274,12 @@ function SandboxedSubject({ text }: { text: string }) {
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
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" ? (
<ImageIcon className="size-11 text-[#9aa0a6]" strokeWidth={1.25} aria-hidden />
) : kind === "pdf" ? (
<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
>
<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 />
)}
</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" ? (
<FileText className="size-4 shrink-0 text-[#d93025]" strokeWidth={1.5} aria-hidden />
) : kind === "image" ? (
@ -319,7 +319,7 @@ function MessageAttachmentPill({
<TooltipTrigger asChild>
<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" ? (
<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>
</button>
</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}
</TooltipContent>
</Tooltip>
@ -346,9 +346,9 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
const asPills = shouldUseAttachmentPillsInPreview(attachments)
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="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">
{summary}
<span aria-hidden> · </span>
@ -358,13 +358,13 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
<TooltipTrigger asChild>
<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"
>
<Info className="size-4" strokeWidth={1.75} />
</button>
</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
repérer les virus et logiciels malveillants.
</TooltipContent>
@ -372,7 +372,7 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
</div>
<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"
>
<HardDrive className="size-[18px] shrink-0" strokeWidth={1.5} aria-hidden />
@ -405,12 +405,12 @@ function MessageAttachmentsSection({ attachments }: { attachments: EmailAttachme
<TooltipTrigger asChild>
<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} />
</button>
</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}
</TooltipContent>
</Tooltip>
@ -445,7 +445,7 @@ function CollapsedMessage({
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
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="flex min-w-0 items-center justify-between gap-2">
<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>
<div className="flex shrink-0 items-center gap-1">
<MailDateText
@ -537,7 +537,7 @@ function ExpandedMessage({
onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined}
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>
</ContactHoverCard>
</div>
@ -575,11 +575,12 @@ function ExpandedMessage({
)}
</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
iso={dateIso}
variant="preview"
className="text-xs text-[#5f6368]"
className="hidden text-xs text-[#5f6368] sm:inline"
/>
{onToggleStar && (
@ -613,14 +614,14 @@ function ExpandedMessage({
<Button
variant="ghost"
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"
onClick={(e) => e.stopPropagation()}
>
<Reply className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</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>
<DropdownMenu>
@ -628,7 +629,7 @@ function ExpandedMessage({
<Button
variant="ghost"
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"
onClick={(e) => e.stopPropagation()}
>
@ -705,13 +706,19 @@ function ExpandedMessage({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-[#5f6368] sm:hidden"
/>
</div>
</div>
{/* Body */}
<div
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"
)}
data-selectable-text
@ -730,17 +737,17 @@ function ExpandedMessage({
function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
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">
<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é.
</p>
{onNotSpam && (
<button
type="button"
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
</button>
@ -756,7 +763,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</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
l&apos;améliorer.
</TooltipContent>
@ -780,8 +787,22 @@ export function EmailView({
folderTree,
labelRows,
currentFolderId,
threadRoot = null,
isSingleMessageView = false,
}: 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 isSpamMessage = email.spam === true
@ -879,9 +900,11 @@ export function EmailView({
return (
<TooltipProvider delayDuration={400}>
<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 */}
<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="flex flex-wrap items-center gap-2">
<SandboxedSubject text={email.subject} />
@ -915,27 +938,27 @@ export function EmailView({
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-[#5f6368] hover:bg-[#f1f3f4]"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Imprimer"
onClick={() => openConversationPrint(email)}
>
<Printer className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</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>
<TooltipTrigger asChild>
<Button
variant="ghost"
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"
>
<ExternalLink className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</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>
</div>
</div>
@ -946,6 +969,20 @@ export function EmailView({
{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 */}
{/* Previous messages in conversation */}
{hasConversation && conversation.map((msg) => {
@ -997,39 +1034,40 @@ export function EmailView({
{showReplyForwardBar ? (
<div
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]",
REPLY_BAR_SURFACE_CLASS
"z-10 mt-4 hidden flex-wrap items-center gap-x-3 gap-y-2 px-4 pb-6 pl-[68px] sm:flex",
"max-sm:static sm:sticky sm:bottom-0",
MAIL_REPLY_BAR_CLASS
)}
>
<button
type="button"
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
</button>
<button
type="button"
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
</button>
<button
type="button"
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
</button>
</div>
) : null}
{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 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"
import { useState, useRef, useEffect } from "react"
import Link from "next/link"
import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi"
import { Pencil } from "lucide-react"
import { AccountAvatar } from "@/components/gmail/account-avatar"
import { AccountSwitcherDropdown } from "@/components/gmail/account-switcher-dropdown"
import { Button } from "@/components/ui/button"
import { useActiveAccount } from "@/lib/stores/account-store"
import { 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"
const HEADER_ICON_BTN_CLASS = cn(
"rounded-full",
MAIL_ICON_BTN,
"hover:text-accent-foreground",
)
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: "Agenda", icon: "/agenda-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: "UltiMeet", icon: "/ultimeet-mark.svg" },
{ name: "Administration", icon: "/admin-mark.svg" },
{ name: "OpenMaps", icon: "/openstreetmap-mark.svg" },
{ name: "Mistral", icon: "/mistral-mark.svg" },
{ name: "Qwant", icon: "/qwant-mark.svg" },
{ name: "Ground News", icon: "/ground-news-mark.svg" },
{ name: "Qwant", icon: "/qwant-mark.svg", whiteLogoInDark: true },
{ 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 {
className?: string
}
export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
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(() => {
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)
}
}
@ -43,64 +110,95 @@ export function HeaderAccountActions({ className }: HeaderAccountActionsProps) {
return (
<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">
<Icon icon="mdi:help-circle-outline" className="size-6 shrink-0" aria-hidden />
<Button
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 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 />
</Button>
<div className="relative hidden sm:block" ref={menuRef}>
<div className="relative hidden sm:block" ref={appsMenuRef}>
<Button
variant="ghost"
size="icon"
className="text-gray-600"
className={HEADER_ICON_BTN_CLASS}
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>
{appsMenuOpen && (
<div className="absolute right-0 top-12 z-50 w-96 rounded-2xl border border-gray-200 bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-gray-100 p-4">
<span className="text-lg font-normal text-gray-800">Vos favoris</span>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-600">
<div
className={cn(
"absolute right-0 top-12 z-50 w-96 rounded-2xl",
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" />
</Button>
</div>
<div className="grid grid-cols-3 gap-1 p-3">
{googleApps.map((app) => (
<button
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>
<FavoriteAppTile key={app.name} app={app} />
))}
</div>
</div>
)}
</div>
<Button variant="ghost" size="icon-lg" className="ml-2 size-11 overflow-hidden rounded-full p-0">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500 text-base font-bold text-white">
E
</div>
</Button>
<div className="relative ml-2" ref={accountMenuRef}>
<Button
variant="ghost"
size="icon-lg"
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>
)
}

View File

@ -89,7 +89,7 @@ export function MailFolderStackIndicator({
className={cn(
"flex max-w-[min(360px,calc(100vw-1rem))] items-center",
"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",
className
)}

View File

@ -26,7 +26,7 @@ export function MailSearchBar({ className, compact = false }: MailSearchBarProps
type="text"
placeholder="Rechercher dans les messages"
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"
)}
/>

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"
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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 {
sidebarOpen: boolean
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({
sidebarOpen,
onToggleSidebar,
xsViewChrome = null,
}: MobileBottomBarProps) {
const [searchValue, setSearchValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
const { openCompose } = useComposeActions()
const inMailView = Boolean(xsViewChrome)
const hasSearch = searchValue.length > 0
@ -33,51 +57,106 @@ export function MobileBottomBar({
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="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">
{/* Burger / back-caret */}
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur"
onClick={onToggleSidebar}
aria-label={sidebarOpen ? "Fermer le menu" : "Ouvrir le menu"}
>
{sidebarOpen ? (
<ChevronLeft className="size-5" />
) : (
<Menu className="size-5" />
)}
</Button>
{/* Search bar — hidden when sidebar open */}
{!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"
/>
{inMailView && xsViewChrome ? (
<div className="flex shrink-0 overflow-hidden rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 rounded-none text-[#444746] hover:bg-[#f1f3f4]"
onClick={xsViewChrome.onArchive}
aria-label="Archiver"
>
<Archive className="size-5" strokeWidth={1.5} />
</Button>
<span className="w-px shrink-0 self-stretch bg-gray-200" aria-hidden />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 rounded-none text-[#444746] hover:bg-[#f1f3f4]"
aria-label="Déplacer dans un dossier"
>
<FolderInput className="size-5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
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>
) : (
<>
<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 && (
<Button
variant="ghost"
size="icon"
className="size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur"
onClick={hasSearch ? handleClear : openCompose}
aria-label={hasSearch ? "Effacer la recherche" : "Nouveau message"}
className={cn(ROUNDED_BAR_BTN, inMailView && "ml-auto")}
onClick={inMailView || !hasSearch ? openCompose : handleClear}
aria-label={!inMailView && hasSearch ? "Effacer la recherche" : "Nouveau message"}
>
{hasSearch ? (
{!inMailView && hasSearch ? (
<X className="size-5" />
) : (
<Pencil className="size-5" />

View File

@ -2,14 +2,25 @@
import { useMemo, type ReactNode } from "react"
import {
Clock,
Inbox,
Send,
FileEdit,
ShieldAlert,
Trash2,
} from "lucide-react"
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
export type MailMoveTargets = {
recents: MoveTarget[]
system: MoveTarget[]
folders: MoveTarget[]
}
export type MoveTarget = {
id: string
label: string
@ -82,4 +93,58 @@ export function useMoveTargets({
}, [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 }

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,
} from "lucide-react"
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 { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import {
@ -43,6 +53,7 @@ import {
type SidebarNavDropPlacement,
} from "@/lib/sidebar-nav-dnd"
import { useComposeActions } from "@/lib/compose-context"
import { useMailSettingsStore } from "@/lib/stores/mail-settings-store"
import {
DropdownMenu,
DropdownMenuContent,
@ -205,7 +216,7 @@ function LabelMenuOptionWithCheck({
e.stopPropagation()
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
@ -213,7 +224,7 @@ function LabelMenuOptionWithCheck({
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden />
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</DropdownMenuItem>
@ -232,7 +243,7 @@ function ContextLabelMenuOptionWithCheck({
return (
<ContextMenuItem
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
@ -240,7 +251,7 @@ function ContextLabelMenuOptionWithCheck({
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-gray-900" strokeWidth={2} aria-hidden />
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</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 =
"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(
"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 && "bg-yellow-100 text-gray-900",
isOver && "bg-mail-nav-drop text-foreground",
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",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-gray-100 text-gray-900"
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100",
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
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",
navRowRoundedWhenActive(isSelected || isOver),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
{typeof item.icon === "string" ? (
@ -1124,7 +1139,7 @@ export function Sidebar({
)
const folderMenuSurface =
"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"
@ -1138,11 +1153,11 @@ export function Sidebar({
<Sub>
<SubTr
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"
)}
>
<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
className={cn(
"block size-3 rounded-sm border border-black/10",
@ -1153,7 +1168,7 @@ export function Sidebar({
</span>
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
</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">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
@ -1165,7 +1180,10 @@ export function Sidebar({
setMenuOpen(false)
}}
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
)}
/>
@ -1181,10 +1199,10 @@ export function Sidebar({
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70",
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",
isOver && "bg-yellow-100 text-gray-900",
rowHoverHeld && "bg-gray-100 text-gray-900",
isOver && "bg-mail-nav-drop text-foreground",
rowHoverHeld && "bg-mail-nav-hover text-foreground",
touchRowClassName
)
const rowStyle: CSSProperties = {
@ -1217,8 +1235,8 @@ export function Sidebar({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={folderMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
@ -1239,8 +1257,8 @@ export function Sidebar({
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
@ -1255,9 +1273,9 @@ export function Sidebar({
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<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={() => {
setRenameDraft(node.label)
setRenameOpen(true)
@ -1267,7 +1285,7 @@ export function Sidebar({
Renommer
</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={() => {
setMoveParent("__root__")
setMoveOpen(true)
@ -1277,7 +1295,7 @@ export function Sidebar({
Déplacer
</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={() => {
setSubfolderName("")
setSubfolderOpen(true)
@ -1288,7 +1306,7 @@ export function Sidebar({
</DropdownMenuItem>
<DropdownMenuItem
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={() => {
removeFolderOrLabelRow(node.id)
setMenuOpen(false)
@ -1491,7 +1509,7 @@ export function Sidebar({
!isSelected &&
!isOver &&
!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",
isSelected
? "text-gray-900"
@ -1559,8 +1577,8 @@ export function Sidebar({
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
<ContextMenuContent className={folderMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des dossiers
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
@ -1581,8 +1599,8 @@ export function Sidebar({
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</ContextMenuLabel>
<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",
navRowRoundedWhenActive(isHighlighted || isOver),
isHighlighted
? "bg-[#d3e3fd] text-gray-900 font-medium"
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
? "bg-mail-nav-drop text-foreground"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
@ -1901,7 +1919,7 @@ export function Sidebar({
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
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 Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub
@ -1913,11 +1931,11 @@ export function Sidebar({
<Sub>
<SubTr
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"
)}
>
<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
className={cn(
"block size-3 rounded-sm border border-black/10",
@ -1928,7 +1946,7 @@ export function Sidebar({
</span>
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
</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">
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
<button
@ -1940,7 +1958,10 @@ export function Sidebar({
setMenuOpen(false)
}}
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
)}
/>
@ -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",
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
? "bg-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-gray-100 text-gray-900"
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100",
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName
)
@ -2050,8 +2071,8 @@ export function Sidebar({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={labelMenuSurface}>
{colorSub("dropdown")}
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
@ -2072,8 +2093,8 @@ export function Sidebar({
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</DropdownMenuLabel>
<LabelMenuOptionWithCheck
@ -2088,9 +2109,9 @@ export function Sidebar({
>
Masquer
</LabelMenuOptionWithCheck>
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
<DropdownMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<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={() => {
setRenameDraft(item.label)
setRenameOpen(true)
@ -2101,7 +2122,7 @@ export function Sidebar({
</DropdownMenuItem>
<DropdownMenuItem
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={() => {
removeFolderOrLabelRow(item.id)
setMenuOpen(false)
@ -2110,7 +2131,7 @@ export function Sidebar({
Supprimer le libellé
</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={() => {
setSublabelName("")
setSublabelOpen(true)
@ -2278,8 +2299,8 @@ export function Sidebar({
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
<ContextMenuContent className={labelMenuSurface}>
{colorSub("context")}
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des libellés
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
@ -2300,8 +2321,8 @@ export function Sidebar({
>
Masquer
</ContextLabelMenuOptionWithCheck>
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
<ContextMenuSeparator className={MAIL_SIDEBAR_MENU_SEPARATOR_CLASS} />
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-muted-foreground">
Dans la liste des messages
</ContextMenuLabel>
<ContextLabelMenuOptionWithCheck
@ -2450,7 +2471,8 @@ export function Sidebar({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
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]",
splitView && "border-r border-gray-200",
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
@ -2460,7 +2482,8 @@ export function Sidebar({
>
<div
className={cn(
"flex shrink-0 items-center bg-app-canvas",
"flex shrink-0 items-center",
SIDEBAR_PANEL_SURFACE_CLASS,
splitView
? cn(
splitViewLogoHeaderClass,
@ -2487,6 +2510,9 @@ export function Sidebar({
size="icon"
className="size-9 shrink-0 text-gray-600"
aria-label="Réglages"
onClick={() =>
useMailSettingsStore.getState().setQuickSettingsOpen(true)
}
>
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
</Button>
@ -2497,7 +2523,8 @@ export function Sidebar({
<div
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",
splitView && "!hidden"
)}
@ -2508,7 +2535,7 @@ export function Sidebar({
aria-label={!isExpanded ? "Nouveau message" : undefined}
onClick={openCompose}
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
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
: "w-[52px] justify-center px-0 py-0"
@ -2576,7 +2603,7 @@ export function Sidebar({
})
}
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)
)}
>
@ -2651,7 +2678,10 @@ export function Sidebar({
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
<div className="mt-3 pt-1">
<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}
>
<Icon
@ -2667,7 +2697,7 @@ export function Sidebar({
{isExpanded && (
<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"
title="Ajouter un dossier"
onClick={() => {
@ -2689,7 +2719,10 @@ export function Sidebar({
{/* Labels */}
<div className="mt-3 pt-1">
<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}
>
<Icon
@ -2705,7 +2738,7 @@ export function Sidebar({
{isExpanded && (
<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é"
title="Ajouter un libellé"
onClick={() => {
@ -2731,7 +2764,8 @@ export function Sidebar({
{/* Sortbot */}
<div
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"
)}
>
@ -2739,7 +2773,7 @@ export function Sidebar({
type="button"
title={!isExpanded ? "Sortbot" : undefined}
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)
)}
>

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({
className,
overlayClassName,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
overlayClassName?: string
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(

View File

@ -46,13 +46,13 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
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,
)}
{...props}
>
{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.Portal>
)

View File

@ -1,47 +1,85 @@
"use client"
import Link from "next/link"
import { cn } from "@/lib/utils"
type UltiMailLogoProps = {
className?: string
/** `horizontal` = picto source + « Ultimail » (lisible, aligné barre). `mark` = picto seul (launcher). */
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). */
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 (href === null) {
return <div className={cn("shrink-0", className)}>{mark}</div>
}
return (
<img
src={HEADER_ICON}
alt=""
width={288}
height={288}
draggable={false}
className={cn("h-10 w-10 shrink-0 object-contain object-center", className)}
aria-hidden
/>
<Link
href={href}
className={cn(
"shrink-0 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
className
)}
aria-label="Ultimail — Boîte de réception"
>
{mark}
</Link>
)
}
return (
const body = (
<div
role="img"
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
src={HEADER_ICON}
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]">
{mark}
<span className="min-w-0 truncate text-[1.375rem] font-semibold leading-none tracking-tight text-foreground dark:text-white">
Ultimail
</span>
</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 { readCoarsePointerMatches } from "@/hooks/use-touch-nav"
/** Tailwind `md` breakpoint — split view never applies below this width. */
export const MD_MIN_PX = 768
import { readLgMatches } from "@/hooks/use-lg-breakpoint"
import { MD_MIN_PX } from "@/hooks/use-md-breakpoint"
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 LANDSCAPE_MQ = "(orientation: landscape)"
/**
* User preference (settings UI later). When true, split view is enabled on md+
* even on non-touch desktops.
*/
export const MAIL_SPLIT_VIEW_USER_SETTING = false
export function readMailSplitViewMatches(): boolean {
export function readMailSplitViewMatches(
readingPane: ReadingPaneMode = "none"
): boolean {
if (typeof window === "undefined") return false
if (!window.matchMedia(MD_MQ).matches) return false
const coarse = readCoarsePointerMatches()
const tabletLandscape =
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() {
const readingPane = useMailSettingsStore((s) => s.readingPane)
const [splitView, setSplitView] = useState(false)
useLayoutEffect(() => {
@ -31,17 +37,24 @@ export function useMailSplitView() {
const mqlCoarse = window.matchMedia(
"(hover: none) and (pointer: coarse)"
)
const update = () => setSplitView(readMailSplitViewMatches())
const update = () =>
setSplitView(readMailSplitViewMatches(readingPane))
update()
mqlMd.addEventListener("change", update)
mqlLandscape.addEventListener("change", update)
mqlCoarse.addEventListener("change", update)
window
.matchMedia(`(min-width: 1024px)`)
.addEventListener("change", update)
return () => {
mqlMd.removeEventListener("change", update)
mqlLandscape.removeEventListener("change", update)
mqlCoarse.removeEventListener("change", update)
window
.matchMedia(`(min-width: 1024px)`)
.removeEventListener("change", update)
}
}, [])
}, [readingPane])
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
clearCreateDraft: () => void
setView: (view: ContactsView, activeContactId?: string | null) => void
showContactsList: () => void
setSearchQuery: (q: string) => void
setSearchMode: (active: boolean) => void
addContact: (
@ -176,6 +177,15 @@ export const useContactsStore = create<ContactsStore>()(
setView: (view, activeContactId = null) =>
set({ view, activeContactId, createDraft: null }),
showContactsList: () =>
set({
view: "list",
activeContactId: null,
searchQuery: "",
searchMode: false,
createDraft: null,
}),
setSearchQuery: (searchQuery) => set({ searchQuery }),
setSearchMode: (searchMode) =>

View File

@ -26,6 +26,8 @@ export interface ConversationMessage {
body: string
preview: string
attachments?: EmailAttachment[]
/** Lu / non lu du message (fixtures legacy). */
read?: boolean
}
export interface Email {
@ -62,16 +64,22 @@ export interface Email {
scheduledToName?: string
/** ISO 8601 — fin de mise en attente (dossier En attente) */
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 function getThreadMessageCount(
email: Pick<Email, "conversation">
): number {
return 1 + (email.conversation?.length ?? 0)
}
export {
getThreadMessageCount,
normalizeLegacyEmailCatalog,
} from "@/lib/mail-thread"
export const emails: Email[] = [
import { normalizeLegacyEmailCatalog } from "@/lib/mail-thread"
const legacyEmails: Email[] = [
...demoCalendarInvitationEmails,
{
id: "1",
@ -102,6 +110,7 @@ export const emails: Email[] = [
sender: "ronenrozn",
senderEmail: "ronenrozn@users.noreply.github.com",
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...",
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>
@ -703,3 +712,5 @@ END:VCALENDAR`,
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,
badgeColor: string
): string {
if (normalizeInboxTabSegment(tabId) === INBOX_ALL_TAB) return "#202124"
if (normalizeInboxTabSegment(tabId) === INBOX_ALL_TAB) return "var(--foreground)"
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
}
export type MailDateDisplayVariant = "list" | "preview" | "detail"
export type MailDateDisplayVariant = "list" | "preview" | "previewShort" | "detail"
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 {
switch (variant) {
case "list":
case "previewShort":
return formatMailListDate(iso)
case "preview":
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 { Ban, Loader2, Send } from "lucide-react"
import { toast } from "sonner"
import { MAIL_TOAST_SURFACE_CLASS } from "@/lib/mail-chrome-classes"
import { cn } from "@/lib/utils"
const DEFAULT_DURATION_MS = 3000
@ -119,17 +121,17 @@ function PendingSendToastBody({
}
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="flex items-center gap-3">
<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
>
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={2} />
</span>
<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
</p>
</div>
@ -146,14 +148,14 @@ function PendingSendToastBody({
<button
type="button"
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 />
<span>Annuler l&apos;envoi</span>
</button>
</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
className="absolute inset-y-0 left-0 bg-linear-to-r from-[#1a73e8] to-[#4285f4]"
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(
preset: ComposeOpenPreset
): ComposeOpenPreset {
if (
typeof window === "undefined" ||
!readCoarsePointerMatches() ||
readXsMatches()
) {
if (typeof window === "undefined" || !readCoarsePointerMatches()) {
return preset
}
return {

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// 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