huhu
This commit is contained in:
parent
77f99d8d8a
commit
9266aa34cd
304
app/globals.css
304
app/globals.css
@ -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 l’UI) ── */
|
||||
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 d’aperç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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
10
app/mail/settings/page.tsx
Normal file
10
app/mail/settings/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
components/gmail/account-avatar.tsx
Normal file
57
components/gmail/account-avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
components/gmail/account-switcher-dropdown.tsx
Normal file
219
components/gmail/account-switcher-dropdown.tsx
Normal 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'utilisation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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 d’options"
|
||||
>
|
||||
<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>D’après cet e-mail</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Correct ?</span>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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,19 +210,23 @@ 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>
|
||||
<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-[#5f6368] hover:bg-gray-100"
|
||||
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 && (
|
||||
<div className="flex items-center gap-1 px-3 pb-2">
|
||||
@ -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,9 +245,7 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableLabels.map((label) => {
|
||||
const count = contacts.filter((c) => c.labels?.includes(label.id)).length
|
||||
return (
|
||||
{labelsByContactCount.map(({ label, count }) => (
|
||||
<NavItem
|
||||
key={label.id}
|
||||
icon={<Tag className="h-5 w-5" />}
|
||||
@ -186,8 +254,7 @@ export function ContactsSidebar({
|
||||
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>
|
||||
)
|
||||
|
||||
@ -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,8 +256,10 @@ 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}
|
||||
>
|
||||
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
||||
<span className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={allFilteredSelected ? true : someFilteredSelected ? "indeterminate" : false}
|
||||
@ -243,17 +267,20 @@ export function ContactsTable({ view, searchQuery, activeLabelId, onOpenContact
|
||||
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>
|
||||
)}
|
||||
{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,10 +333,13 @@ 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}
|
||||
>
|
||||
{isContactsColumnVisible(visibleColumns, "checkbox") && (
|
||||
<span
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@ -317,10 +351,12 @@ function ContactTableRow({
|
||||
aria-label={`Sélectionner ${name}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="flex items-center gap-3">
|
||||
{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 rounded-full object-cover" />
|
||||
<img src={contact.avatarUrl} alt={name} className="h-8 w-8 shrink-0 rounded-full object-cover" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium text-white"
|
||||
@ -329,28 +365,44 @@ function ContactTableRow({
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[#1f1f1f]">{name}</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>
|
||||
)}
|
||||
|
||||
<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]">
|
||||
{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-gray-300 px-1.5 py-0.5 text-[11px] text-[#3c4043]"
|
||||
className="inline-flex rounded border border-border px-1.5 py-0.5 text-[11px] text-foreground"
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
) : null
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
44
components/gmail/contacts/contacts-panel-logo.tsx
Normal file
44
components/gmail/contacts/contacts-panel-logo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
@ -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 d’un 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 l’onglet 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,16 +3254,59 @@ 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 ── */
|
||||
<>
|
||||
<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(openEmail.id) || openEmail.starred}
|
||||
isStarred={
|
||||
starredEmails.includes(threadStoreId(openEmail)) ||
|
||||
openEmail.starred
|
||||
}
|
||||
onNavigateToLabel={handleNavigateToLabel}
|
||||
onNotSpam={openEmail.spam === true ? singleNotSpam : undefined}
|
||||
labelBgByText={listRowLabelBgByTextLower}
|
||||
@ -3140,6 +3325,7 @@ export function EmailList({
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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"
|
||||
|
||||
@ -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 d’ouvrir 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, "<").replace(/>/g, ">")}</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]"> <{senderEmail}></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()}
|
||||
>
|
||||
@ -706,12 +707,18 @@ function ExpandedMessage({
|
||||
</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'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"
|
||||
|
||||
@ -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>
|
||||
<Button variant="ghost" size="icon" className="text-gray-600" aria-label="Réglages">
|
||||
<Icon icon="mdi:cog-outline" className="size-6 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<div className="relative hidden sm:block" ref={menuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-gray-600"
|
||||
aria-label="Applications"
|
||||
onClick={() => setAppsMenuOpen(!appsMenuOpen)}
|
||||
className={cn("hidden sm:inline-flex", HEADER_ICON_BTN_CLASS)}
|
||||
aria-label="Aide"
|
||||
>
|
||||
<Icon icon="mdi:view-grid-outline" className="size-6 shrink-0" aria-hidden />
|
||||
<Icon
|
||||
icon="mdi:help-circle-outline"
|
||||
className="size-6 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
<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={appsMenuRef}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={HEADER_ICON_BTN_CLASS}
|
||||
aria-label="Applications"
|
||||
onClick={() => {
|
||||
setAppsMenuOpen(!appsMenuOpen)
|
||||
setAccountMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
)}
|
||||
/>
|
||||
|
||||
43
components/gmail/mail-theme-applier.tsx
Normal file
43
components/gmail/mail-theme-applier.tsx
Normal 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
|
||||
}
|
||||
25
components/gmail/mail-toaster.tsx
Normal file
25
components/gmail/mail-toaster.tsx
Normal 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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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 d’actions à 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,25 +57,79 @@ 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 */}
|
||||
{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="size-11 shrink-0 rounded-full border border-gray-200 bg-white/80 shadow-md backdrop-blur"
|
||||
className={ROUNDED_BAR_BTN}
|
||||
onClick={onToggleSidebar}
|
||||
aria-label={sidebarOpen ? "Fermer le menu" : "Ouvrir le menu"}
|
||||
>
|
||||
{sidebarOpen ? (
|
||||
<ChevronLeft className="size-5" />
|
||||
<X 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">
|
||||
@ -67,17 +145,18 @@ export function MobileBottomBar({
|
||||
/>
|
||||
</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" />
|
||||
|
||||
@ -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 }
|
||||
|
||||
86
components/gmail/quick-settings/quick-settings-option.tsx
Normal file
86
components/gmail/quick-settings/quick-settings-option.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
components/gmail/quick-settings/quick-settings-panel.tsx
Normal file
241
components/gmail/quick-settings/quick-settings-panel.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
components/gmail/quick-settings/quick-settings-root.tsx
Normal file
13
components/gmail/quick-settings/quick-settings-root.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
192
components/gmail/quick-settings/settings-preview-icons.tsx
Normal file
192
components/gmail/quick-settings/settings-preview-icons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
components/gmail/quick-settings/theme-settings-dialog.tsx
Normal file
115
components/gmail/quick-settings/theme-settings-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
)}
|
||||
>
|
||||
|
||||
68
components/theme-init-script.tsx
Normal file
68
components/theme-init-script.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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" }: UltiMailLogoProps) {
|
||||
if (variant === "mark") {
|
||||
return (
|
||||
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("h-10 w-10 shrink-0 object-contain object-center", className)}
|
||||
className={cn(
|
||||
"shrink-0 object-contain object-center select-none",
|
||||
variant === "mark" ? "h-10 w-10" : "h-8 w-8"
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
|
||||
if (variant === "mark") {
|
||||
if (href === null) {
|
||||
return <div className={cn("shrink-0", className)}>{mark}</div>
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
77
hooks/use-contacts-table-columns.ts
Normal file
77
hooks/use-contacts-table-columns.ts
Normal 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)
|
||||
}
|
||||
23
hooks/use-lg-breakpoint.ts
Normal file
23
hooks/use-lg-breakpoint.ts
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
23
hooks/use-md-breakpoint.ts
Normal file
23
hooks/use-md-breakpoint.ts
Normal 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
|
||||
}
|
||||
35
lib/accounts/mock-accounts.ts
Normal file
35
lib/accounts/mock-accounts.ts
Normal 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
8
lib/accounts/types.ts
Normal 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
|
||||
}
|
||||
206
lib/contacts-chrome-classes.ts
Normal file
206
lib/contacts-chrome-classes.ts
Normal 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"
|
||||
@ -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) =>
|
||||
|
||||
@ -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)
|
||||
|
||||
185
lib/email-preview-dark-styles.ts
Normal file
185
lib/email-preview-dark-styles.ts
Normal file
@ -0,0 +1,185 @@
|
||||
/** CSS injecté dans les iframes d’aperç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 d’e-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
|
||||
}
|
||||
@ -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
159
lib/mail-chrome-classes.ts
Normal 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 d’accent. */
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
101
lib/mail-settings/constants.ts
Normal file
101
lib/mail-settings/constants.ts
Normal 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 l’image. */
|
||||
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,
|
||||
}
|
||||
}
|
||||
8
lib/mail-settings/list-row-id.ts
Normal file
8
lib/mail-settings/list-row-id.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { threadStoreId } from "@/lib/mail-thread"
|
||||
|
||||
/** @deprecated Utiliser `threadStoreId` ou l’id message directement selon le cas. */
|
||||
export function listRowStoreId(listId: string): string {
|
||||
return listId
|
||||
}
|
||||
|
||||
export { threadStoreId }
|
||||
36
lib/mail-settings/resolve-open-email.ts
Normal file
36
lib/mail-settings/resolve-open-email.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { Email } from "@/lib/email-data"
|
||||
import {
|
||||
buildThreadViewEmail,
|
||||
isThreadHeadMessage,
|
||||
} from "@/lib/mail-thread"
|
||||
|
||||
/** Résout l’email affiché dans l’aperç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,
|
||||
}
|
||||
}
|
||||
80
lib/mail-settings/sort-emails.ts
Normal file
80
lib/mail-settings/sort-emails.ts
Normal 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
|
||||
}
|
||||
22
lib/mail-settings/types.ts
Normal file
22
lib/mail-settings/types.ts
Normal 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
170
lib/mail-thread/index.ts
Normal 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 l’aperç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]
|
||||
}
|
||||
9
lib/mail-xs-view-chrome.ts
Normal file
9
lib/mail-xs-view-chrome.ts
Normal 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
|
||||
}
|
||||
@ -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'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={
|
||||
|
||||
59
lib/stores/account-store.ts
Normal file
59
lib/stores/account-store.ts
Normal 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
88
lib/stores/mail-settings-store.ts
Normal file
88
lib/stores/mail-settings-store.ts
Normal 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"
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -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
2
next-env.d.ts
vendored
@ -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.
|
||||
|
||||
21
public/mail-backgrounds/gradient-aurora.svg
Normal file
21
public/mail-backgrounds/gradient-aurora.svg
Normal 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 |
20
public/mail-backgrounds/gradient-blossom.svg
Normal file
20
public/mail-backgrounds/gradient-blossom.svg
Normal 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 |
21
public/mail-backgrounds/gradient-ocean.svg
Normal file
21
public/mail-backgrounds/gradient-ocean.svg
Normal 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 |
17
public/mail-backgrounds/gradient-sunset.svg
Normal file
17
public/mail-backgrounds/gradient-sunset.svg
Normal 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
Loading…
Reference in New Issue
Block a user