before split 2

This commit is contained in:
R3D347HR4Y 2026-05-20 16:01:08 +02:00
parent aad897b617
commit 8551150ffe
33 changed files with 4422 additions and 3271 deletions

View File

@ -58,6 +58,11 @@
--mail-nav-hover: #f1f3f4; --mail-nav-hover: #f1f3f4;
--mail-nav-drop: #fef7cd; --mail-nav-drop: #fef7cd;
--mail-invitation: #e8f0fe; --mail-invitation: #e8f0fe;
--mail-list-divider: #eceff1;
--mail-list-chip-border: #dadce0;
--mail-list-chip-text: #3c4043;
--mail-list-chip-muted: #f1f3f4;
--mail-row-checkbox-border: #c2c2c2;
} }
.dark { .dark {
@ -81,6 +86,11 @@
--mail-nav-hover: #3c4043; --mail-nav-hover: #3c4043;
--mail-nav-drop: #4a4428; --mail-nav-drop: #4a4428;
--mail-invitation: #2d3a4d; --mail-invitation: #2d3a4d;
--mail-list-divider: #3c4043;
--mail-list-chip-border: #5f6368;
--mail-list-chip-text: #e8eaed;
--mail-list-chip-muted: #3c4043;
--mail-row-checkbox-border: #9aa0a6;
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0); --card: oklch(0.145 0 0);
@ -161,6 +171,11 @@
--color-mail-border: var(--mail-border); --color-mail-border: var(--mail-border);
--color-mail-border-subtle: var(--mail-border-subtle); --color-mail-border-subtle: var(--mail-border-subtle);
--color-mail-invitation: var(--mail-invitation); --color-mail-invitation: var(--mail-invitation);
--color-mail-list-divider: var(--mail-list-divider);
--color-mail-list-chip-border: var(--mail-list-chip-border);
--color-mail-list-chip-text: var(--mail-list-chip-text);
--color-mail-list-chip-muted: var(--mail-list-chip-muted);
--color-mail-row-checkbox-border: var(--mail-row-checkbox-border);
} }
@layer base { @layer base {
@ -352,11 +367,8 @@ html::before {
inset: 0; inset: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: var(--mail-bg-layer, none);
background-color: var(--mail-bg-fallback, transparent); 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; opacity: 0;
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
} }
@ -365,6 +377,10 @@ html[data-mail-background]:not([data-mail-background='none'])::before {
opacity: 1; opacity: 1;
} }
html[data-mail-background]:not([data-mail-background='none']) body {
background-color: transparent !important;
}
html[data-mail-background]:not([data-mail-background='none']) .ultimail-app { html[data-mail-background]:not([data-mail-background='none']) .ultimail-app {
background-color: transparent !important; background-color: transparent !important;
} }
@ -413,6 +429,28 @@ html[data-mail-background]:not([data-mail-background='none'])
background-color: var(--mail-invitation); background-color: var(--mail-invitation);
} }
/**
* Sidebar overlay (touch / xs) fond opaque.
* Nom hors préfixe bg-* pour éviter quun utility Tailwind écrase la règle.
*/
.ultimail-app .mail-sidebar-overlay-panel {
background-color: #ffffff;
}
html.dark .ultimail-app .mail-sidebar-overlay-panel {
background-color: var(--background);
}
html[data-mail-background]:not([data-mail-background='none']) .ultimail-app .mail-sidebar-overlay-panel {
background-color: #ffffff !important;
}
html.dark[data-mail-background]:not([data-mail-background='none'])
.ultimail-app
.mail-sidebar-overlay-panel {
background-color: var(--background) !important;
}
/* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */ /* ── Mail : mode sombre (surcharges ciblées dans le shell) ── */
html.dark .ultimail-app { html.dark .ultimail-app {
color-scheme: dark; color-scheme: dark;
@ -520,6 +558,33 @@ html.dark [data-slot='context-menu-separator'] {
background-color: var(--border) !important; background-color: var(--border) !important;
} }
/* Recherche avancée — champs (sheet xs + panneau dropdown desktop) */
html.dark :where([data-mail-mobile-search], [data-mail-search-advanced])
:where([data-slot='input'], [data-slot='select-trigger']) {
background-color: var(--mail-surface-muted) !important;
border: 1px solid var(--mail-border) !important;
color: var(--mail-text) !important;
}
/* Priorité sur .ultimail-app input { border-color: mail-border-subtle } */
html.dark .ultimail-app [data-mail-search-advanced]
:where([data-slot='input'], [data-slot='select-trigger']) {
background-color: var(--mail-surface-muted) !important;
border: 1px solid var(--mail-border) !important;
color: var(--mail-text) !important;
}
html.dark :where([data-mail-mobile-search], [data-mail-search-advanced])
:where([data-slot='checkbox']) {
background-color: var(--mail-surface-muted) !important;
border: 1.5px solid var(--mail-row-checkbox-border) !important;
}
html.dark .ultimail-app [data-mail-search-advanced] :where([data-slot='checkbox']) {
background-color: var(--mail-surface-muted) !important;
border: 1.5px solid var(--mail-row-checkbox-border) !important;
}
html.dark .ultimail-app :where(.hover\:bg-gray-50:hover, .hover\:bg-gray-100:hover) { html.dark .ultimail-app :where(.hover\:bg-gray-50:hover, .hover\:bg-gray-100:hover) {
background-color: var(--mail-nav-hover) !important; background-color: var(--mail-nav-hover) !important;
} }

View File

@ -5,17 +5,17 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useState, useState,
} from "react" } from "react"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
import { useMailSplitView } from "@/hooks/use-mail-split-view" import { useMailSplitView } from "@/hooks/use-mail-split-view"
import { useMailRoute } from "@/hooks/use-mail-route"
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar" import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay" import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome" import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
import { MailToaster } from "@/components/gmail/mail-toaster" import { MailToaster } from "@/components/gmail/mail-toaster"
import { useRouter, usePathname, useSearchParams } from "next/navigation" import { useRouter } from "next/navigation"
import { Sidebar } from "@/components/gmail/sidebar" import { Sidebar } from "@/components/gmail/sidebar"
import { Header } from "@/components/gmail/header" import { Header } from "@/components/gmail/header"
import { EmailList } from "@/components/gmail/email-list" import { EmailList } from "@/components/gmail/email-list"
@ -29,76 +29,45 @@ import { ComposeModalManager } from "@/components/gmail/compose-modal"
import { SidebarNavProvider } from "@/lib/sidebar-nav-context" import { SidebarNavProvider } from "@/lib/sidebar-nav-context"
import { mailNavVisitKey } from "@/lib/mail-folder-display" import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { useMailStore } from "@/lib/stores/mail-store" import { useMailStore } from "@/lib/stores/mail-store"
import { import { useMailUiStore } from "@/lib/stores/mail-ui-store"
parseMailSegments, import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
buildMailPath,
DEFAULT_INBOX_TAB,
type MailRouteState,
} from "@/lib/mail-url"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { ThemeProvider } from "@/components/theme-provider" import { ThemeProvider } from "@/components/theme-provider"
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier" import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root" import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
function segmentsFromPathname(pathname: string | null): string[] | undefined {
if (!pathname?.startsWith("/mail")) return undefined
const rest = pathname.slice("/mail".length).replace(/^\//, "")
if (!rest) return []
return rest.split("/").filter(Boolean)
}
function MailAppInner() { function MailAppInner() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const { route, navigateRoute, searchParams: currentSearchParams } =
const currentSearchParams = useSearchParams() useMailRoute()
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
const route = useMemo(() => parseMailSegments(segments), [segments])
const isXs = useIsXs() const isXs = useIsXs()
const touchNav = useTouchNav() const touchNav = useTouchNav()
const splitView = useMailSplitView() const splitView = useMailSplitView()
const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit) const pushRecentFolderVisit = useMailStore((s) => s.pushRecentFolderVisit)
/** Start closed so narrow viewports match SSR/CSS before JS runs; desktop opens in layout. */
const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const sidebarCollapsed = useMailUiStore((s) => s.sidebarCollapsed)
const setSidebarCollapsed = useMailUiStore((s) => s.setSidebarCollapsed)
const mobileSearchOpen = useMailUiStore((s) => s.mobileSearchOpen)
const setMobileSearchOpen = useMailUiStore((s) => s.setMobileSearchOpen)
const folderUnreadCounts = useMailUiStore((s) => s.folderUnreadCounts)
const setFolderUnreadCounts = useMailUiStore((s) => s.setFolderUnreadCounts)
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
if (!readTouchNavMatches()) setSidebarCollapsed(false) if (!readTouchNavMatches()) setSidebarCollapsed(false)
}, []) }, [setSidebarCollapsed])
useEffect(() => { useEffect(() => {
if (isXs) setSidebarCollapsed(true) if (isXs) setSidebarCollapsed(true)
}, [isXs]) }, [isXs, setSidebarCollapsed])
useEffect(() => { useEffect(() => {
if (route.folderId !== "search") { if (route.folderId !== "search") {
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab)) pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
} }
}, [route.folderId, route.inboxTab, pushRecentFolderVisit]) }, [route.folderId, route.inboxTab, pushRecentFolderVisit])
const [folderUnreadCounts, setFolderUnreadCounts] = useState<
Record<string, number>
>({})
const [xsViewChrome, setXsViewChrome] = useState<MailXsViewChrome | null>(null)
const [mobileSearchOpen, setMobileSearchOpen] = useState(false)
const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => {
const next: MailRouteState = {
folderId: patch.folderId ?? route.folderId,
inboxTab:
patch.inboxTab !== undefined && patch.inboxTab !== null
? patch.inboxTab
: route.inboxTab,
page: patch.page !== undefined ? patch.page : route.page,
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
}
let url = buildMailPath(next)
if (next.folderId === "search" && currentSearchParams.toString()) {
url += `?${currentSearchParams.toString()}`
}
router.push(url, { scroll: false })
},
[router, route, currentSearchParams]
)
const handleSelectFolder = useCallback( const handleSelectFolder = useCallback(
(id: string) => { (id: string) => {
@ -110,7 +79,7 @@ function MailAppInner() {
}) })
if (readTouchNavMatches()) setSidebarCollapsed(true) if (readTouchNavMatches()) setSidebarCollapsed(true)
}, },
[navigateRoute] [navigateRoute, setSidebarCollapsed]
) )
return ( return (
@ -181,7 +150,9 @@ function MailAppInner() {
listPage={route.page} listPage={route.page}
openMailId={route.mailId} openMailId={route.mailId}
splitView={splitView} splitView={splitView}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)} onToggleSidebar={() =>
useMailUiStore.getState().toggleSidebarCollapsed()
}
onMailRouteNavigate={navigateRoute} onMailRouteNavigate={navigateRoute}
onSelectFolder={handleSelectFolder} onSelectFolder={handleSelectFolder}
onFolderUnreadCountsChange={setFolderUnreadCounts} onFolderUnreadCountsChange={setFolderUnreadCounts}
@ -202,7 +173,9 @@ function MailAppInner() {
{!splitView ? ( {!splitView ? (
<MobileBottomBar <MobileBottomBar
sidebarOpen={!sidebarCollapsed} sidebarOpen={!sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed((c) => !c)} onToggleSidebar={() =>
useMailUiStore.getState().toggleSidebarCollapsed()
}
xsViewChrome={xsViewChrome} xsViewChrome={xsViewChrome}
onOpenSearch={() => setMobileSearchOpen(true)} onOpenSearch={() => setMobileSearchOpen(true)}
searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""} searchQuery={route.folderId === "search" ? (currentSearchParams.get("q") ?? "") : ""}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,356 @@
"use client"
import type { RefObject } from "react"
import {
ChevronDown,
Forward,
Maximize2,
Minimize2,
Paperclip,
Pencil,
Reply,
ReplyAll,
SquareArrowOutUpRight,
X,
Image as ImageIcon,
} from "lucide-react"
import type { ComposeState } from "@/lib/compose-context"
import type { Email } from "@/lib/email-data"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_DROP_ZONE_CLASS,
MAIL_COMPOSE_TITLEBAR_CLASS,
MAIL_ICON_BTN,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { COMPOSE_PORTAL_Z } from "./compose-shared"
import type { ComposeRecipientFieldsProps } from "./compose-recipients"
import { ComposeRecipientFields } from "./compose-recipients"
export function ComposeDropOverlay() {
return (
<div className={MAIL_COMPOSE_DROP_ZONE_CLASS}>
<div className="text-center">
<Paperclip className="mx-auto h-8 w-8 text-primary" />
<p className="mt-2 text-sm font-medium text-primary">Déposer les fichiers ici</p>
</div>
</div>
)
}
export interface ComposeInlineRecipientHeaderProps {
compose: ComposeState
threadSourceEmail: Email | null
recipientSummary: string
recipientsFocused: boolean
showReplyAllInMenu: boolean
ThreadKindIcon: typeof Reply
onOpenInlinePreset: (kind: "reply" | "replyAll" | "forward") => void
onOpenDockFromInline: (opts?: { focusSubject?: boolean }) => void
onActivateRecipients: () => void
updateCompose: (id: string, patch: Partial<ComposeState>) => void
recipientFieldsProps: ComposeRecipientFieldsProps
fieldsRef: RefObject<HTMLDivElement | null>
inlineRecipientShellRef: RefObject<HTMLDivElement | null>
}
export function ComposeInlineRecipientHeader({
compose,
threadSourceEmail,
recipientSummary,
recipientsFocused,
showReplyAllInMenu,
ThreadKindIcon,
onOpenInlinePreset,
onOpenDockFromInline,
onActivateRecipients,
updateCompose,
recipientFieldsProps,
fieldsRef,
inlineRecipientShellRef,
}: ComposeInlineRecipientHeaderProps) {
return (
<div ref={inlineRecipientShellRef} className="flex shrink-0 flex-col">
<div
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(" ")}`
: undefined
}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-0.5 rounded-full px-1.5 text-muted-foreground transition-colors hover:bg-accent"
title="Type de message"
>
<ThreadKindIcon className="h-[18px] w-[18px] shrink-0" strokeWidth={1.75} />
<ChevronDown className="h-4 w-4 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[260px]", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => onOpenInlinePreset("reply")}
>
<Reply className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Répondre
</DropdownMenuItem>
{showReplyAllInMenu ? (
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => onOpenInlinePreset("replyAll")}
>
<ReplyAll className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Répondre à tous
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={!threadSourceEmail}
onSelect={() => onOpenInlinePreset("forward")}
>
<Forward className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Transférer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onOpenDockFromInline({ focusSubject: true })}>
<Pencil className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Modifier l&apos;objet
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onOpenDockFromInline()}>
<SquareArrowOutUpRight className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.75} />
Ouvrir une fenêtre de réponse
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
className="min-w-0 flex-1 truncate rounded px-1 py-1 text-left text-sm text-foreground hover:bg-accent/70"
onClick={onActivateRecipients}
>
{recipientSummary}
</button>
{!recipientsFocused && (!compose.showCc || !compose.showBcc) ? (
<div className="flex shrink-0 items-center gap-2">
{!compose.showCc ? (
<button
type="button"
onClick={() => {
updateCompose(compose.id, { showCc: true })
onActivateRecipients()
}}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Cc
</button>
) : null}
{!compose.showBcc ? (
<button
type="button"
onClick={() => {
updateCompose(compose.id, { showBcc: true })
onActivateRecipients()
}}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Cci
</button>
) : null}
</div>
) : null}
<button
type="button"
className={cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-full", MAIL_ICON_BTN)}
title="Ouvrir dans une fenêtre"
onClick={() => onOpenDockFromInline()}
>
<SquareArrowOutUpRight className="h-[18px] w-[18px]" strokeWidth={1.75} />
</button>
</div>
<div
ref={fieldsRef}
className={cn("flex shrink-0 flex-col", !recipientsFocused && "hidden")}
>
<ComposeRecipientFields {...recipientFieldsProps} />
</div>
</div>
)
}
export function ComposeXsSheetHeader({
titleText,
onClose,
}: {
titleText: string
onClose: () => void
}) {
return (
<div
className={cn(
"flex h-11 shrink-0 items-center border-b border-border bg-muted px-3",
"pt-[max(_0.25rem,env(safe-area-inset-top))]"
)}
>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText}
</span>
<button
type="button"
onClick={onClose}
className={cn(
"flex h-8 w-8 items-center justify-center rounded-full",
MAIL_ICON_BTN
)}
title="Fermer"
>
<X className="h-4 w-4" />
</button>
</div>
)
}
export function ComposeDockTitleBar({
titleText,
maximized,
onMinimize,
onMaximize,
onClose,
}: {
titleText: string
maximized: boolean
onMinimize: () => void
onMaximize: () => void
onClose: () => void
}) {
return (
<div className={MAIL_COMPOSE_TITLEBAR_CLASS} onClick={onMinimize}>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{titleText}
</span>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onMinimize()
}}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full",
MAIL_ICON_BTN
)}
title="Réduire"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="shrink-0"
aria-hidden
>
<line x1="5" y1="17" x2="19" y2="17" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onMaximize()
}}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full",
MAIL_ICON_BTN
)}
title={maximized ? "Réduire la fenêtre" : "Plein écran"}
>
{maximized ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onClose()
}}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full",
MAIL_ICON_BTN
)}
title="Fermer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
export function ComposeAttachmentsList({
attachments,
onRemove,
}: {
attachments: ComposeState["attachments"]
onRemove: (attId: string) => void
}) {
if (attachments.length === 0) return null
return (
<div className="flex max-h-[120px] shrink-0 flex-col gap-1 overflow-y-auto border-t border-border px-3 py-2">
{attachments.map((att) => (
<div
key={att.id}
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-primary" />
) : (
<Paperclip className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{att.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{att.size < 1024
? `${att.size} o`
: att.size < 1048576
? `${(att.size / 1024).toFixed(1)} Ko`
: `${(att.size / 1048576).toFixed(1)} Mo`}
</span>
<button
type="button"
onClick={() => onRemove(att.id)}
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
MAIL_ICON_BTN
)}
title="Supprimer"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,405 @@
"use client"
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
type RefObject,
} from "react"
import { ChevronDown, X } from "lucide-react"
import {
type ComposeState,
type Contact,
DEFAULT_IDENTITIES,
MOCK_CONTACTS,
} from "@/lib/compose-context"
import { cn } from "@/lib/utils"
import {
MAIL_COMPOSE_CONTACT_PILL_CLASS,
MAIL_COMPOSE_RECIPIENT_DIVIDER,
MAIL_COMPOSE_SUGGESTION_HOVER,
MAIL_COMPOSE_SUGGESTION_SELECTED,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { COMPOSE_PORTAL_Z, EMAIL_REGEX } from "./compose-shared"
function RecipientField({
label,
contacts,
onChange,
placeholder,
onActivate,
autoFocus,
onAutoFocusDone,
}: {
label: string
contacts: Contact[]
onChange: (contacts: Contact[]) => void
placeholder?: string
onActivate?: () => void
autoFocus?: boolean
onAutoFocusDone?: () => void
}) {
const [inputValue, setInputValue] = useState("")
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedSuggestionIdx, setSelectedSuggestionIdx] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const suggestions = useMemo(() => {
if (!inputValue.trim()) return []
const q = inputValue.toLowerCase()
return MOCK_CONTACTS.filter(
(c) =>
!contacts.some((existing) => existing.email === c.email) &&
(c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q))
).slice(0, 6)
}, [inputValue, contacts])
useEffect(() => {
setSelectedSuggestionIdx(0)
}, [suggestions.length])
useEffect(() => {
if (!autoFocus) return
const id = window.requestAnimationFrame(() => {
inputRef.current?.focus()
onAutoFocusDone?.()
})
return () => window.cancelAnimationFrame(id)
}, [autoFocus, onAutoFocusDone])
const addContact = useCallback(
(contact: Contact) => {
if (!contacts.some((c) => c.email === contact.email)) {
onChange([...contacts, contact])
}
setInputValue("")
setShowSuggestions(false)
},
[contacts, onChange]
)
const tryAddRawEmail = useCallback(
(raw: string) => {
const trimmed = raw.trim().replace(/,$/, "")
if (!trimmed) return
const matchedContact = MOCK_CONTACTS.find(
(c) => c.email.toLowerCase() === trimmed.toLowerCase()
)
if (matchedContact) {
addContact(matchedContact)
} else if (EMAIL_REGEX.test(trimmed)) {
addContact({ name: trimmed, email: trimmed })
}
},
[addContact]
)
const removeContact = useCallback(
(email: string) => {
onChange(contacts.filter((c) => c.email !== email))
},
[contacts, onChange]
)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (
(e.key === "Enter" || e.key === "Tab" || e.key === "," || e.key === " ") &&
inputValue.trim()
) {
e.preventDefault()
if (showSuggestions && suggestions.length > 0) {
addContact(suggestions[selectedSuggestionIdx])
} else {
tryAddRawEmail(inputValue)
}
return
}
if (e.key === "Backspace" && !inputValue && contacts.length > 0) {
onChange(contacts.slice(0, -1))
return
}
if (showSuggestions && suggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault()
setSelectedSuggestionIdx((i) =>
i < suggestions.length - 1 ? i + 1 : 0
)
} else if (e.key === "ArrowUp") {
e.preventDefault()
setSelectedSuggestionIdx((i) =>
i > 0 ? i - 1 : suggestions.length - 1
)
}
}
if (e.key === "Escape") {
setShowSuggestions(false)
}
}
const getInitials = (name: string) => {
const parts = name.split(" ").filter(Boolean)
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: (parts[0]?.[0] ?? "").toUpperCase()
}
const pillColors = [
"bg-blue-600",
"bg-purple-600",
"bg-emerald-600",
"bg-amber-600",
"bg-rose-600",
"bg-teal-600",
"bg-indigo-600",
]
const getColor = (email: string) => {
let hash = 0
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash)
}
return pillColors[Math.abs(hash) % pillColors.length]
}
return (
<div className="relative" ref={containerRef}>
<div
className="flex min-h-[32px] cursor-text flex-wrap items-center gap-1 px-3 py-1"
onClick={() => {
inputRef.current?.focus()
onActivate?.()
}}
>
<span className="shrink-0 select-none text-sm text-muted-foreground">{label}</span>
{contacts.map((c) => (
<span key={c.email} className={MAIL_COMPOSE_CONTACT_PILL_CLASS}>
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold text-white",
getColor(c.email)
)}
>
{getInitials(c.name)}
</span>
<span className="max-w-[150px] truncate text-sm">
{c.name === c.email ? c.email : c.name}
</span>
<button
type="button"
onClick={() => removeContact(c.email)}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-black/10"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value)
setShowSuggestions(true)
}}
onKeyDown={handleKeyDown}
onFocus={() => {
setShowSuggestions(true)
onActivate?.()
}}
onBlur={() => {
setTimeout(() => {
setShowSuggestions(false)
if (inputValue.trim()) tryAddRawEmail(inputValue)
}, 200)
}}
placeholder={contacts.length === 0 ? placeholder : undefined}
className="min-w-[120px] flex-1 border-none bg-transparent py-1 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</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-border bg-popover py-1 text-popover-foreground shadow-lg">
{suggestions.map((s, idx) => (
<button
key={s.email}
type="button"
onMouseDown={(e) => {
e.preventDefault()
addContact(s)
}}
className={cn(
"flex w-full items-center gap-3 px-3 py-2 text-left text-sm transition-colors",
idx === selectedSuggestionIdx
? MAIL_COMPOSE_SUGGESTION_SELECTED
: MAIL_COMPOSE_SUGGESTION_HOVER
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white",
getColor(s.email)
)}
>
{getInitials(s.name)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">{s.name}</div>
<div className="truncate text-xs text-muted-foreground">{s.email}</div>
</div>
</button>
))}
</div>
)}
</div>
)
}
export interface ComposeRecipientFieldsProps {
compose: ComposeState
isInline: boolean
showFromField: boolean
updateCompose: (id: string, patch: Partial<ComposeState>) => void
handleIdentityChange: (identity: (typeof DEFAULT_IDENTITIES)[number]) => void
clearFocusToMount: () => void
subjectInputRef: RefObject<HTMLInputElement | null>
onRecipientsActivate: () => void
}
export function ComposeRecipientFields({
compose,
isInline,
showFromField,
updateCompose,
handleIdentityChange,
clearFocusToMount,
subjectInputRef,
onRecipientsActivate,
}: ComposeRecipientFieldsProps) {
const dockNewMessageTabOrder =
!isInline && !compose.threadEmailId && !compose.threadKind
const forwardDockSkipSubjectTab =
!isInline && compose.threadKind === "forward"
return (
<>
{showFromField && (
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-3 py-1.5">
<span className="shrink-0 text-sm text-muted-foreground">De</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex min-w-0 items-center gap-1 rounded px-1 py-0.5 text-sm text-foreground hover:bg-accent"
>
<span className="min-w-0 truncate font-medium">{compose.from.name}</span>
<span className="min-w-0 shrink truncate text-muted-foreground">
&lt;{compose.from.email}&gt;
</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[300px]", COMPOSE_PORTAL_Z)}>
{DEFAULT_IDENTITIES.map((id) => (
<DropdownMenuItem
key={id.email}
onSelect={() => handleIdentityChange(id)}
>
<div className="flex flex-col">
<span className="font-medium">{id.name}</span>
<span className="text-xs text-muted-foreground">{id.email}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{showFromField && !isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
<div className="relative flex items-start">
<div className="min-w-0 flex-1">
<RecipientField
label={showFromField || compose.to.length > 0 ? "À" : "Destinataires"}
contacts={compose.to}
onChange={(to) => updateCompose(compose.id, { to })}
onActivate={onRecipientsActivate}
autoFocus={Boolean(compose.focusToOnMount)}
onAutoFocusDone={clearFocusToMount}
/>
</div>
{showFromField && (!compose.showCc || !compose.showBcc) && (
<div className="flex shrink-0 items-center gap-1 px-2 py-1.5">
{!compose.showCc && (
<button
type="button"
tabIndex={dockNewMessageTabOrder ? -1 : undefined}
onClick={() => updateCompose(compose.id, { showCc: true })}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Cc
</button>
)}
{!compose.showBcc && (
<button
type="button"
tabIndex={dockNewMessageTabOrder ? -1 : undefined}
onClick={() => updateCompose(compose.id, { showBcc: true })}
className="text-sm text-muted-foreground hover:text-foreground hover:underline"
>
Cci
</button>
)}
</div>
)}
</div>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
{compose.showCc && (
<>
<RecipientField
label="Cc"
contacts={compose.cc}
onChange={(cc) => updateCompose(compose.id, { cc })}
/>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
</>
)}
{compose.showBcc && (
<>
<RecipientField
label="Cci"
contacts={compose.bcc}
onChange={(bcc) => updateCompose(compose.id, { bcc })}
/>
{!isInline && <div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />}
</>
)}
{!isInline && (
<>
<input
ref={subjectInputRef}
type="text"
value={compose.subject}
onChange={(e) =>
updateCompose(compose.id, { subject: e.target.value })
}
placeholder="Objet"
tabIndex={forwardDockSkipSubjectTab ? -1 : undefined}
className="h-8 w-full border-none bg-transparent px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
<div className={MAIL_COMPOSE_RECIPIENT_DIVIDER} />
</>
)}
</>
)
}

View File

@ -0,0 +1,36 @@
import { Node as TipTapNode, mergeAttributes } from "@tiptap/core"
import { SIGNATURES } from "@/lib/compose-context"
/** Menus/popovers Radix default z-50 ; compose sheet content uses z-61+. */
export const COMPOSE_PORTAL_Z = "z-[100]"
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const SignatureBlock = TipTapNode.create({
name: "signatureBlock",
group: "block",
content: "block+",
defining: true,
isolating: true,
parseHTML() {
return [{ tag: 'div[id="ultimail-signature"]' }]
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes, { id: "ultimail-signature" }), 0]
},
})
const SIG_REGEX = /<div id="ultimail-signature">[\s\S]*<\/div>/
export function stripSignature(html: string) {
return html.replace(SIG_REGEX, "")
}
export function insertSignatureHtml(html: string, sigId: string | null) {
const sig = sigId ? SIGNATURES.find((s) => s.id === sigId) : null
const clean = stripSignature(html)
if (!sig) return clean
return clean + `<div id="ultimail-signature"><p>--</p>${sig.html}</div>`
}

View File

@ -0,0 +1,994 @@
"use client"
import {
useState,
useCallback,
lazy,
Suspense,
} from "react"
import { useEditor, type Editor } from "@tiptap/react"
import {
ChevronDown,
Paperclip,
Link as LinkIcon,
Smile,
HardDrive,
Image as ImageIcon,
Lock,
PenTool,
MoreVertical,
Trash2,
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
List,
ListOrdered,
Undo,
Redo,
Type,
Clock,
Indent,
Outdent,
RemoveFormatting,
Palette,
ALargeSmall,
CaseSensitive,
Send,
} from "lucide-react"
import {
type ComposeState,
SIGNATURES,
useComposeActions,
} from "@/lib/compose-context"
import { cn, getNextLocalWallClockDate } from "@/lib/utils"
import {
MAIL_COMPOSE_BOTTOM_ICON_BTN,
MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE,
MAIL_COMPOSE_MENU_SELECTED_CLASS,
MAIL_COMPOSE_POPOVER_CLASS,
MAIL_COMPOSE_PRIMARY_SEND_BTN,
MAIL_COMPOSE_TOOLBAR_BTN,
MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE,
MAIL_COMPOSE_TOOLBAR_SEP,
MAIL_MENU_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import data from "@emoji-mart/data"
import { COMPOSE_PORTAL_Z, insertSignatureHtml } from "./compose-shared"
const LazyPicker = lazy(() => import("@emoji-mart/react"))
function ComposeEmojiPicker({ onSelect }: { onSelect: (emoji: { native: string }) => void }) {
const { resolvedTheme } = useTheme()
return (
<Suspense fallback={<div className="flex h-[435px] w-[352px] items-center justify-center text-sm text-muted-foreground">Chargement</div>}>
<LazyPicker
data={data}
onEmojiSelect={onSelect}
locale="fr"
theme={resolvedTheme === "dark" ? "dark" : "light"}
previewPosition="none"
skinTonePosition="search"
set="native"
/>
</Suspense>
)
}
function AlignmentDropdown({
editor,
btnClass,
activeClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
activeClass: string
}) {
const currentIcon = editor.isActive({ textAlign: "center" })
? AlignCenter
: editor.isActive({ textAlign: "right" })
? AlignRight
: editor.isActive({ textAlign: "justify" })
? AlignJustify
: AlignLeft
const CurrentIcon = currentIcon
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(btnClass, "w-auto gap-0.5 px-1")}
title="Alignement"
>
<CurrentIcon className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[160px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("left").run()}
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" }) && 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" }) && MAIL_COMPOSE_MENU_SELECTED_CLASS)}
>
<AlignRight className="h-4 w-4" /> Aligner à droite
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => editor.chain().focus().setTextAlign("justify").run()}
className={cn(editor.isActive({ textAlign: "justify" }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE)}
>
<AlignJustify className="h-4 w-4" /> Justifier
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
const FONT_FAMILIES = [
{ label: "Sans Serif", value: "sans-serif" },
{ label: "Serif", value: "serif" },
{ label: "Monospace", value: "monospace" },
{ label: "Cursive", value: "cursive" },
{ label: "Comic Sans MS", value: "Comic Sans MS, cursive" },
{ label: "Garamond", value: "Garamond, serif" },
{ label: "Georgia", value: "Georgia, serif" },
{ label: "Impact", value: "Impact, sans-serif" },
{ label: "Tahoma", value: "Tahoma, sans-serif" },
{ label: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
{ label: "Verdana", value: "Verdana, sans-serif" },
]
const FONT_SIZES = [
{ label: "Très petit", value: "10px" },
{ label: "Petit", value: "13px" },
{ label: "Normal", value: "" },
{ label: "Grand", value: "18px" },
{ label: "Très grand", value: "24px" },
{ label: "Énorme", value: "32px" },
]
const TEXT_COLORS = [
"#000000", "#434343", "#666666", "#999999", "#cccccc", "#efefef", "#f3f3f3", "#ffffff",
"#fb4934", "#fe8019", "#fabd2f", "#b8bb26", "#8ec07c", "#83a598", "#d3869b", "#ebdbb2",
"#cc241d", "#d65d0e", "#d79921", "#98971a", "#689d6a", "#458588", "#b16286", "#a89984",
"#9d0006", "#af3a03", "#b57614", "#79740e", "#427b58", "#076678", "#8f3f71", "#7c6f64",
]
function FontDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Police">
<CaseSensitive className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("max-h-[280px] min-w-[180px] overflow-y-auto", COMPOSE_PORTAL_Z)}
>
{FONT_FAMILIES.map((f) => (
<DropdownMenuItem
key={f.value}
onSelect={() => editor.chain().focus().setMark("textStyle", { fontFamily: f.value }).run()}
style={{ fontFamily: f.value }}
className={cn(
editor.isActive("textStyle", { fontFamily: f.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{f.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function FontSizeDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Taille du texte">
<ALargeSmall className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("min-w-[140px]", COMPOSE_PORTAL_Z)}
>
{FONT_SIZES.map((s) => (
<DropdownMenuItem
key={s.label}
onSelect={() => {
if (s.value) {
editor.chain().focus().setMark("textStyle", { fontSize: s.value }).run()
} else {
editor.chain().focus().setMark("textStyle", { fontSize: null }).removeEmptyTextStyle().run()
}
}}
style={s.value ? { fontSize: s.value } : undefined}
className={cn(
s.value && editor.isActive("textStyle", { fontSize: s.value }) && MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
)}
>
{s.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
function ColorDropdown({
editor,
btnClass,
}: {
editor: NonNullable<ReturnType<typeof useEditor>>
btnClass: string
}) {
const [tab, setTab] = useState<"text" | "bg">("text")
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={cn(btnClass, "w-auto gap-0.5 px-1")} title="Couleur du texte">
<Palette className="h-4 w-4" />
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={cn("w-[268px] p-2", COMPOSE_PORTAL_Z)}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="mb-2 flex gap-1 border-b border-border pb-2">
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "text" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("text")}
>
Couleur du texte
</button>
<button
type="button"
className={cn(
"flex-1 rounded px-2 py-1 text-xs font-medium transition-colors",
tab === "bg" ? MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE : "text-[#5f6368] hover:bg-[#f1f3f4]"
)}
onClick={() => setTab("bg")}
>
Couleur de fond
</button>
</div>
<div className="grid grid-cols-8 gap-1">
{TEXT_COLORS.map((color) => (
<button
key={`${tab}-${color}`}
type="button"
className="h-6 w-6 rounded border border-border hover:scale-110 transition-transform"
style={{ backgroundColor: color }}
title={color}
onClick={() => {
if (tab === "text") {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: color }).run()
}
}}
/>
))}
</div>
<button
type="button"
className="mt-2 w-full rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent transition-colors"
onClick={() => {
if (tab === "text") {
editor.chain().focus().unsetColor().run()
} else {
editor.chain().focus().setMark("textStyle", { backgroundColor: null }).removeEmptyTextStyle().run()
}
}}
>
Réinitialiser
</button>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function FormattingToolbar({
editor,
}: {
editor: Editor | null
}) {
if (!editor) return null
const btnClass = MAIL_COMPOSE_TOOLBAR_BTN
const activeClass = MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE
const sep = <span className={MAIL_COMPOSE_TOOLBAR_SEP} aria-hidden />
return (
<div className="compose-toolbar flex flex-wrap items-center border-t border-border bg-muted px-1 py-1">
{/* Undo / Redo */}
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annuler"
>
<Undo className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Rétablir"
>
<Redo className="h-4 w-4" />
</button>
{sep}
{/* Font */}
<FontDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Font size */}
<FontSizeDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Bold, Italic, Underline, Colors */}
<button
type="button"
className={cn(btnClass, editor.isActive("bold") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("bold").run()}
title="Gras"
>
<Bold className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("italic") && activeClass)}
onClick={() => editor.chain().focus().toggleMark("italic").run()}
title="Italique"
>
<Italic className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("underline") && activeClass)}
onClick={() => editor.chain().focus().toggleUnderline().run()}
title="Souligné"
>
<UnderlineIcon className="h-4 w-4" />
</button>
<ColorDropdown editor={editor} btnClass={btnClass} />
{sep}
{/* Alignment dropdown, lists, indent/outdent, remove formatting */}
<AlignmentDropdown editor={editor} btnClass={btnClass} activeClass={activeClass} />
<button
type="button"
className={cn(btnClass, editor.isActive("orderedList") && activeClass)}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Liste numérotée"
>
<ListOrdered className="h-4 w-4" />
</button>
<button
type="button"
className={cn(btnClass, editor.isActive("bulletList") && activeClass)}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Liste à puces"
>
<List className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().liftListItem("listItem").run() } catch { /* not in list */ }
}}
title="Désindenter"
>
<Outdent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => {
try { editor.chain().focus().sinkListItem("listItem").run() } catch { /* not in list */ }
}}
title="Indenter"
>
<Indent className="h-4 w-4" />
</button>
<button
type="button"
className={btnClass}
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Supprimer la mise en forme"
>
<RemoveFormatting className="h-4 w-4" />
</button>
</div>
)
}
function ComposeEmojiButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const handleSelect = useCallback(
(emoji: { native: string }) => {
editor?.chain().focus().insertContent(emoji.native).run()
setOpen(false)
},
[editor]
)
if (!editor) return null
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer un emoji"
>
<Smile className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className={cn("w-auto border-0 bg-popover p-0 shadow-xl", COMPOSE_PORTAL_Z)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<ComposeEmojiPicker onSelect={handleSelect} />
</PopoverContent>
</Popover>
)
}
function ComposeLinkButton({
editor,
}: {
editor: Editor | null
}) {
const [open, setOpen] = useState(false)
const [url, setUrl] = useState("")
const [text, setText] = useState("")
if (!editor) return null
const isLinkActive = editor.isActive("link")
const handleToggle = () => {
if (isLinkActive) {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
return
}
setOpen(true)
}
const handleOpen = (isOpen: boolean) => {
if (isOpen) {
const { from, to, empty } = editor.state.selection
if (isLinkActive) {
const attrs = editor.getAttributes("link")
setUrl(attrs.href || "")
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
} else if (!empty) {
const selectedText = editor.state.doc.textBetween(from, to, " ")
setText(selectedText)
setUrl("")
} else {
setText("")
setUrl("")
}
}
setOpen(isOpen)
}
const handleInsert = () => {
if (!url.trim()) return
const href = url.match(/^https?:\/\//) ? url : `https://${url}`
const { empty } = editor.state.selection
if (empty && !isLinkActive) {
const displayText = text.trim() || href
editor
.chain()
.focus()
.insertContent(`<a href="${href}">${displayText}</a>`)
.run()
} else {
if (text.trim() && text.trim() !== editor.state.doc.textBetween(
editor.state.selection.from,
editor.state.selection.to,
" "
)) {
editor
.chain()
.focus()
.deleteSelection()
.insertContent(`<a href="${href}">${text.trim()}</a>`)
.run()
} else {
editor
.chain()
.focus()
.extendMarkRange("link")
.setLink({ href })
.run()
}
}
setOpen(false)
setUrl("")
setText("")
}
const handleRemoveLink = () => {
editor.chain().focus().extendMarkRange("link").unsetLink().run()
setOpen(false)
setUrl("")
setText("")
}
return (
<Popover open={open} onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => {
if (isLinkActive) {
e.preventDefault()
handleToggle()
}
}}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
isLinkActive && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title={isLinkActive ? "Supprimer le lien" : "Insérer un lien"}
>
<LinkIcon className="h-[18px] w-[18px]" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
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-foreground">
{isLinkActive ? "Modifier le lien" : "Insérer un lien"}
</div>
<div className="flex flex-col gap-1.5">
<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-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-muted-foreground">URL</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleInsert()
}
}}
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>
<div className="flex items-center justify-between pt-1">
{isLinkActive ? (
<button
type="button"
onClick={handleRemoveLink}
className="text-sm text-destructive hover:text-destructive/90 transition-colors"
>
Supprimer le lien
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent transition-colors"
>
Annuler
</button>
<button
type="button"
onClick={handleInsert}
disabled={!url.trim()}
className={cn("rounded px-3 py-1.5 text-sm font-medium disabled:opacity-50", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
{isLinkActive ? "Modifier" : "Insérer"}
</button>
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function ComposeSignatureButton({
editor,
compose,
}: {
editor: Editor | null
compose: ComposeState
}) {
const { updateCompose } = useComposeActions()
const replaceSignature = useCallback(
(sigId: string | null) => {
if (!editor) return
const newHtml = insertSignatureHtml(editor.getHTML(), sigId)
editor.commands.setContent(newHtml)
updateCompose(compose.id, { bodyHtml: newHtml, signatureId: sigId })
},
[editor, compose.id, updateCompose]
)
const toggleAutoInsert = useCallback(() => {
const newVal = !compose.autoInsertSignature
updateCompose(compose.id, { autoInsertSignature: newVal })
if (!newVal) {
replaceSignature(null)
} else {
const sigId = compose.from.defaultSignatureId
if (sigId) replaceSignature(sigId)
}
}, [compose.autoInsertSignature, compose.from.defaultSignatureId, compose.id, updateCompose, replaceSignature])
if (!editor) return null
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une signature"
>
<PenTool className="h-[18px] w-[18px]" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
side="top"
className={cn(MAIL_MENU_SURFACE_CLASS, "min-w-[220px]", COMPOSE_PORTAL_Z)}
>
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
toggleAutoInsert()
}}
className="gap-2"
>
<span className="flex h-4 w-4 items-center justify-center">
{compose.autoInsertSignature && <span className="text-xs"></span>}
</span>
Insérer automatiquement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => replaceSignature(null)}
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>}
</span>
Aucune signature
</DropdownMenuItem>
{SIGNATURES.map((sig) => (
<DropdownMenuItem
key={sig.id}
onSelect={() => replaceSignature(sig.id)}
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>}
</span>
{sig.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
export interface ComposeBottomToolbarProps {
compose: ComposeState
editor: Editor | null
isEditingScheduled: boolean
showFormatting: boolean
sendMenuOpen: boolean
setShowFormatting: (v: boolean | ((prev: boolean) => boolean)) => void
setSendMenuOpen: (v: boolean) => void
handleSend: () => void
saveScheduledEdit: () => void | Promise<void>
sendScheduledFromEditNow: () => void | Promise<void>
applyScheduledPlanAt: (sendAt: Date) => void | Promise<void>
submitScheduledSendAt: (sendAt: Date) => void | Promise<void>
handleClose: () => void
fileInputRef: React.RefObject<HTMLInputElement | null>
imageInputRef: React.RefObject<HTMLInputElement | null>
}
export function ComposeBottomToolbar(props: ComposeBottomToolbarProps) {
const {
compose,
editor,
isEditingScheduled,
showFormatting,
sendMenuOpen,
setShowFormatting,
setSendMenuOpen,
handleSend,
saveScheduledEdit,
sendScheduledFromEditNow,
applyScheduledPlanAt,
submitScheduledSendAt,
handleClose,
fileInputRef,
imageInputRef,
} = props
return (
<div className="flex shrink-0 items-center gap-1 border-t border-border px-2 py-1.5">
{/* Send / save + dropdown */}
<div className="flex items-center">
{isEditingScheduled ? (
<>
<button
type="button"
onClick={() => void saveScheduledEdit()}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Enregistrer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void sendScheduledFromEditNow()
}}
>
<Send className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer maintenant
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="[&>svg:last-child]:text-muted-foreground">
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Planifier
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void applyScheduledPlanAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
Envoyer à 9h
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<button
type="button"
onClick={handleSend}
className={cn("rounded-l-full px-5 text-sm font-medium", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
Envoyer
</button>
<DropdownMenu modal={false} open={sendMenuOpen} onOpenChange={setSendMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn("rounded-r-full border-l border-primary-foreground/30 px-1.5", MAIL_COMPOSE_PRIMARY_SEND_BTN)}
>
<ChevronDown className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={cn("min-w-[220px]", COMPOSE_PORTAL_Z)}>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
new Date(Date.now() + 60 * 60 * 1000)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer dans une heure
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
void submitScheduledSendAt(
getNextLocalWallClockDate(9, 0)
)
}}
>
<Clock className="h-4 w-4 text-muted-foreground" />
Envoyer à 9h
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setSendMenuOpen(false)}>
<Clock className="h-4 w-4 text-muted-foreground" />
Programmer l&apos;envoi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
{/* Toolbar icons */}
<div className="flex items-center gap-0.5 ml-1">
<button
type="button"
onClick={() => setShowFormatting(!showFormatting)}
className={cn(
MAIL_COMPOSE_BOTTOM_ICON_BTN,
showFormatting && MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE
)}
title="Options de mise en forme"
>
<Type className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Joindre des fichiers"
onClick={() => fileInputRef.current?.click()}
>
<Paperclip className="h-[18px] w-[18px]" />
</button>
<ComposeLinkButton editor={editor} />
<ComposeEmojiButton editor={editor} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer des fichiers avec Google Drive"
>
<HardDrive className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Insérer une photo"
onClick={() => imageInputRef.current?.click()}
>
<ImageIcon className="h-[18px] w-[18px]" />
</button>
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Activer le mode confidentiel"
>
<Lock className="h-[18px] w-[18px]" />
</button>
<ComposeSignatureButton editor={editor} compose={compose} />
<button
type="button"
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Plus d'options"
>
<MoreVertical className="h-[18px] w-[18px]" />
</button>
</div>
<div className="flex-1" />
<button
type="button"
onClick={handleClose}
className={MAIL_COMPOSE_BOTTOM_ICON_BTN}
title="Supprimer le brouillon"
>
<Trash2 className="h-[18px] w-[18px]" />
</button>
</div>
)
}

View File

@ -213,7 +213,7 @@ export function ContactFormView({ mode, contactId }: ContactFormViewProps) {
birthday: { day: undefined, month: undefined, year: undefined }, birthday: { day: undefined, month: undefined, year: undefined },
notes: "", notes: "",
labels: [], labels: [],
}, { shouldDirty: true }) })
clearCreateDraft() clearCreateDraft()
}, [mode, createDraft, reset, clearCreateDraft]) }, [mode, createDraft, reset, clearCreateDraft])

View File

@ -206,423 +206,41 @@ import {
withTouchFullscreenComposePreset, withTouchFullscreenComposePreset,
} from "@/lib/thread-compose-preset" } from "@/lib/thread-compose-preset"
addCollection(mdiIcons) import {
LABEL_PICKER_EXCLUDE,
const LIST_PAGE_SIZE = 50 applyNavRenameToEdits,
const PULL_HOLD_HEIGHT = 48 applyNavRemoveLabelToEdits,
const PULL_REFRESH_THRESHOLD = 56 } from "@/lib/mail-list/label-actions"
const PULL_REFRESH_MAX = 112 import { EmailListAttachmentRow } from "@/components/gmail/email-list/attachments/email-list-attachment-row"
const PULL_SNAP_BACK_TRANSITION = import {
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)" MoveToDropdownItems,
const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]" MoveToContextMenuItems,
const PULL_ICON_FADE_MS = 120 } from "@/components/gmail/email-list/move-to-menu-items"
/** Tirage (px) avant que le spinner ne devienne visible. */ import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes"
const PULL_SPINNER_REVEAL_OFFSET = 26 import {
LIST_PAGE_SIZE,
function computePullOffset(delta: number): number { PULL_HOLD_HEIGHT,
if (delta <= 0) return 0 PULL_SNAP_BACK_TRANSITION,
const damped = delta * 0.48 REFRESH_SPIN_CLASS,
const capped = Math.min(PULL_REFRESH_MAX, damped) PULL_ICON_FADE_MS,
const ratio = capped / PULL_REFRESH_MAX PULL_REFRESH_THRESHOLD,
return capped * (1 - ratio * 0.12) computePullOffset,
} computeSpinnerRevealProgress,
type EmailListProps,
function computeSpinnerRevealProgress(y: number): number { collectTreeLabels,
if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0 contextMenuTargetIdsForRow,
const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET) escapeHtml,
return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35) importantSignalIcon,
} buildInboxTabBarItems,
inboxTabBadgeCountClass,
/** Libellés système quon ne propose pas dans « Ajouter le libellé ». */ inboxTabBadgeDotClass,
const LABEL_PICKER_EXCLUDE = new Set(["inbox", "sent", "drafts", "spam", "starred"]) CATEGORY_TAB_ICON_CLASS,
listRowCheckboxClass,
function collectTreeLabels(nodes: FolderTreeNode[]): string[] { listRowQuickHoverTrayToneClass,
const out: string[] = [] formatScheduledDateTimeDisplay,
for (const n of nodes) { scheduledIsoToDatetimeLocalValue,
out.push(n.label) parseDatetimeLocalToIso,
if (n.children?.length) out.push(...collectTreeLabels(n.children)) } from "@/components/gmail/email-list/email-list-helpers"
}
return out
}
function formatScheduledDateTimeDisplay(iso: string | undefined): string {
if (!iso) return "—"
return formatMailDetailDate(iso)
}
function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
}
function parseDatetimeLocalToIso(value: string): string | null {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return null
return d.toISOString()
}
/** Cibles du clic droit : sélection courante ou ligne seule ; en Planifié, seulement les ids réellement planifiés. */
function contextMenuTargetIdsForRow(
emailId: string,
selectedEmails: string[],
selectedFolder: string,
pool: Email[]
): string[] {
const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId]
if (selectedFolder !== "scheduled") return raw
const onlyScheduled = raw.filter((id) =>
pool.some((e) => e.id === id && e.labels?.includes("scheduled"))
)
return onlyScheduled.length > 0 ? onlyScheduled : [emailId]
}
function applyNavRenameToEdits(
pool: Email[],
prev: LabelEditState,
from: string,
to: string
): LabelEditState {
const lcFrom = from.toLowerCase()
const toTrim = to.trim()
if (!toTrim) return prev
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const e of pool) {
const id = e.id
const eff = effectiveLabels(e, prev.additions, prev.removals)
if (!eff.some((l) => l.toLowerCase() === lcFrom)) continue
const wanted = eff.map((l) => (l.toLowerCase() === lcFrom ? toTrim : l))
delete nextAdd[id]
delete nextRem[id]
const base = e.labels ?? []
const removals = base.filter(
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
)
const additions = wanted.filter(
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
)
if (removals.length) nextRem[id] = removals
if (additions.length) nextAdd[id] = additions
}
return { additions: nextAdd, removals: nextRem }
}
function applyNavRemoveLabelToEdits(
pool: Email[],
prev: LabelEditState,
label: string
): LabelEditState {
const lc = label.toLowerCase()
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const e of pool) {
const id = e.id
const eff = effectiveLabels(e, prev.additions, prev.removals)
if (!eff.some((l) => l.toLowerCase() === lc)) continue
const wanted = eff.filter((l) => l.toLowerCase() !== lc)
delete nextAdd[id]
delete nextRem[id]
const base = e.labels ?? []
const removals = base.filter(
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
)
const additions = wanted.filter(
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
)
if (removals.length) nextRem[id] = removals
if (additions.length) nextAdd[id] = additions
}
return { additions: nextAdd, removals: nextRem }
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function importantSignalIcon(isSpam: boolean, isImportant: boolean): string {
if (isSpam) return "mdi:flag-outline"
if (isImportant) return "mdi:label-variant"
return "mdi:label-variant-outline"
}
type InboxTabBarItem = {
id: string
label: string
icon: string
badgeColor: string
}
function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
return [
...buildInboxCategoryTabIcons(labelRows),
{
id: INBOX_ALL_TAB,
label: "Tous les messages",
icon: "mdi:inbox",
badgeColor: "bg-[#0b57d0]",
},
]
}
function inboxTabBadgeCountClass(badgeColor: string) {
return cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
badgeColor,
labelPillTextClassForTailwindBgUtility(badgeColor)
)
}
function inboxTabBadgeDotClass(badgeColor: string) {
return cn(
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface",
badgeColor
)
}
const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0"
function ListAttachmentChip({ att }: { att: EmailAttachment }) {
return (
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]">
{att.kind === "pdf" ? (
<File className="size-4 shrink-0 fill-[#d93025]" strokeWidth={0} aria-hidden />
) : att.kind === "image" ? (
<ImageIcon
className="size-4 shrink-0 text-[#5f6368] [&_circle]:fill-none [&_path]:fill-none [&_path]:stroke-current [&_rect]:fill-current [&_rect]:opacity-[0.32]"
strokeWidth={1.5}
aria-hidden
/>
) : (
<File className="size-4 shrink-0 fill-[#5f6368]" strokeWidth={0} aria-hidden />
)}
<span className="min-w-0 truncate">{att.name}</span>
</span>
)
}
function EmailListAttachmentRow({
emailId,
attachments,
}: {
emailId: string
attachments: EmailAttachment[]
}) {
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [collapsed, setCollapsed] = useState(false)
const attachSig = attachments.map((a) => `${a.name}\u0001${a.kind ?? ""}`).join("\u0002")
const updateCollapsed = useCallback(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure || attachments.length <= 1) {
setCollapsed(false)
return
}
const available = container.clientWidth
const needed = measure.scrollWidth
setCollapsed(needed > available + 1)
}, [attachSig, attachments.length])
useLayoutEffect(() => {
updateCollapsed()
}, [updateCollapsed])
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(() => updateCollapsed())
ro.observe(el)
return () => ro.disconnect()
}, [updateCollapsed])
const othersLabel =
attachments.length - 1 === 1 ? "1 autre" : `${attachments.length - 1} autres`
const othersTitle = attachments
.slice(1)
.map((a) => a.name)
.join(", ")
return (
<div ref={containerRef} className="relative min-w-0 w-full">
{attachments.length > 1 && (
<div
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 z-[-1] flex w-max flex-nowrap gap-1.5"
aria-hidden
>
{attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-m-${idx}`} att={att} />
))}
</div>
)}
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{collapsed && attachments.length > 1 ? (
<>
<ListAttachmentChip att={attachments[0]!} />
<span
className="shrink-0 rounded-full border border-[#dadce0] bg-[#f1f3f4] px-2.5 py-1 text-[13px] leading-snug text-[#5f6368]"
title={othersTitle}
>
{othersLabel}
</span>
</>
) : (
attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-v-${idx}`} att={att} />
))
)}
</div>
</div>
)
}
function MoveToDropdownItems({
targets,
onMoveTo,
}: {
targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
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>
))}
</>
)}
</>
)
}
function MoveToContextMenuItems({
targets,
onMoveTo,
}: {
targets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
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) => (
<ContextMenuItem 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}
</ContextMenuItem>
))}
<ContextMenuSeparator />
</>
)}
{targets.system.map((t) => (
<ContextMenuItem key={t.id} onSelect={() => onMoveTo(t.id)}>
{t.icon}
{t.label}
</ContextMenuItem>
))}
{targets.folders.length > 0 && (
<>
<ContextMenuSeparator />
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-[#5f6368]">
Dossiers
</div>
{targets.folders.map((t) => (
<ContextMenuItem
key={t.id}
onSelect={() => onMoveTo(t.id)}
style={{ paddingLeft: `${12 + t.depth * 16}px` }}
>
{t.icon}
{t.label}
</ContextMenuItem>
))}
</>
)}
</>
)
}
interface EmailListProps {
selectedFolder: string
/** Onglet catégories (boîte de réception), depuis lURL. */
inboxTab: string
/** Page de liste (1-based), depuis lURL. */
listPage: number
openMailId: string | null
/** md+ split pane: list left, reading pane right (tablet landscape or user setting). */
splitView?: boolean
onToggleSidebar?: () => void
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
/** Barre basse xs en lecture dun message. */
onXsViewChromeChange?: (chrome: MailXsViewChrome | null) => void
}
function listRowCheckboxClass(circular: boolean) {
return cn(
"size-4 min-h-4 min-w-4 shrink-0 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",
circular ? "rounded-full" : "rounded-[2.5px]"
)
}
function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
return isSelected
? "bg-mail-row-selected"
: isRead
? "bg-mail-row-read"
: "bg-mail-row-unread"
}
export function EmailList({ export function EmailList({
selectedFolder, selectedFolder,
@ -3629,7 +3247,7 @@ export function EmailList({
) : ( ) : (
<div <div
className={cn( className={cn(
"divide-y divide-[#eceff1]", MAIL_LIST_ROW_DIVIDER_CLASS,
listToolbarMode && "sm:pb-14" listToolbarMode && "sm:pb-14"
)} )}
> >

View File

@ -0,0 +1,88 @@
"use client"
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react"
import type { EmailAttachment } from "@/lib/email-data"
import { ListAttachmentChip } from "./list-attachment-chip"
export function EmailListAttachmentRow({
emailId,
attachments,
}: {
emailId: string
attachments: EmailAttachment[]
}) {
const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null)
const [collapsed, setCollapsed] = useState(false)
const attachSig = attachments.map((a) => `${a.name}\u0001${a.kind ?? ""}`).join("\u0002")
const updateCollapsed = useCallback(() => {
const container = containerRef.current
const measure = measureRef.current
if (!container || !measure || attachments.length <= 1) {
setCollapsed(false)
return
}
const available = container.clientWidth
const needed = measure.scrollWidth
setCollapsed(needed > available + 1)
}, [attachSig, attachments.length])
useLayoutEffect(() => {
updateCollapsed()
}, [updateCollapsed])
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver(() => updateCollapsed())
ro.observe(el)
return () => ro.disconnect()
}, [updateCollapsed])
const othersLabel =
attachments.length - 1 === 1 ? "1 autre" : `${attachments.length - 1} autres`
const othersTitle = attachments
.slice(1)
.map((a) => a.name)
.join(", ")
return (
<div ref={containerRef} className="relative min-w-0 w-full">
{attachments.length > 1 && (
<div
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 z-[-1] flex w-max flex-nowrap gap-1.5"
aria-hidden
>
{attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-m-${idx}`} att={att} />
))}
</div>
)}
<div className="flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
{collapsed && attachments.length > 1 ? (
<>
<ListAttachmentChip att={attachments[0]!} />
<span
className="shrink-0 rounded-full border border-mail-list-chip-border bg-mail-list-chip-muted px-2.5 py-1 text-[13px] leading-snug text-muted-foreground"
title={othersTitle}
>
{othersLabel}
</span>
</>
) : (
attachments.map((att, idx) => (
<ListAttachmentChip key={`${emailId}-v-${idx}`} att={att} />
))
)}
</div>
</div>
)
}

View File

@ -0,0 +1,23 @@
"use client"
import { File, Image as ImageIcon } from "lucide-react"
import type { EmailAttachment } from "@/lib/email-data"
export function ListAttachmentChip({ att }: { att: EmailAttachment }) {
return (
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-mail-list-chip-border bg-transparent px-2.5 py-1 text-[13px] leading-snug text-mail-list-chip-text">
{att.kind === "pdf" ? (
<File className="size-4 shrink-0 fill-destructive" strokeWidth={0} aria-hidden />
) : att.kind === "image" ? (
<ImageIcon
className="size-4 shrink-0 text-muted-foreground [&_circle]:fill-none [&_path]:fill-none [&_path]:stroke-current [&_rect]:fill-current [&_rect]:opacity-[0.32]"
strokeWidth={1.5}
aria-hidden
/>
) : (
<File className="size-4 shrink-0 fill-muted-foreground" strokeWidth={0} aria-hidden />
)}
<span className="min-w-0 truncate">{att.name}</span>
</span>
)
}

View File

@ -0,0 +1,145 @@
import { cn } from "@/lib/utils"
import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast"
import { buildInboxCategoryTabIcons } from "@/lib/inbox-category-tabs"
type InboxTabBarItem = {
id: string
label: string
icon: string
badgeColor: string
}
import { INBOX_ALL_TAB } from "@/lib/mail-url"
import type { Email } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import type { MailRouteState } from "@/lib/mail-url"
import { formatMailDetailDate } from "@/lib/mail-date"
import {
MAIL_LIST_ROW_CHECKBOX_CIRCULAR_CLASS,
MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS,
} from "@/lib/mail-chrome-classes"
export const LIST_PAGE_SIZE = 50
export {
PULL_HOLD_HEIGHT,
PULL_REFRESH_THRESHOLD,
PULL_REFRESH_MAX,
PULL_SNAP_BACK_TRANSITION,
REFRESH_SPIN_CLASS,
PULL_ICON_FADE_MS,
PULL_SPINNER_REVEAL_OFFSET,
computePullOffset,
computeSpinnerRevealProgress,
} from "@/hooks/use-mail-list-pull-refresh"
export interface EmailListProps {
selectedFolder: string
inboxTab: string
listPage: number
openMailId: string | null
splitView?: boolean
onToggleSidebar?: () => void
onMailRouteNavigate: (patch: Partial<MailRouteState>) => void
onSelectFolder?: (folder: string) => void
onFolderUnreadCountsChange?: (counts: Record<string, number>) => void
onXsViewChromeChange?: (chrome: import("@/lib/mail-xs-view-chrome").MailXsViewChrome | null) => void
}
export function collectTreeLabels(nodes: FolderTreeNode[]): string[] {
const out: string[] = []
for (const n of nodes) {
out.push(n.label)
if (n.children?.length) out.push(...collectTreeLabels(n.children))
}
return out
}
export function formatScheduledDateTimeDisplay(iso: string | undefined): string {
if (!iso) return "—"
return formatMailDetailDate(iso)
}
export function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
const p = (n: number) => String(n).padStart(2, "0")
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`
}
export function parseDatetimeLocalToIso(value: string): string | null {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return null
return d.toISOString()
}
export function contextMenuTargetIdsForRow(
emailId: string,
selectedEmails: string[],
selectedFolder: string,
pool: Email[]
): string[] {
const raw = selectedEmails.includes(emailId) ? selectedEmails : [emailId]
if (selectedFolder !== "scheduled") return raw
const onlyScheduled = raw.filter((id) =>
pool.some((e) => e.id === id && e.labels?.includes("scheduled"))
)
return onlyScheduled.length > 0 ? onlyScheduled : [emailId]
}
export function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
export function importantSignalIcon(isSpam: boolean, isImportant: boolean): string {
if (isSpam) return "mdi:flag-outline"
if (isImportant) return "mdi:label-variant"
return "mdi:label-variant-outline"
}
export function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
return [
...buildInboxCategoryTabIcons(labelRows),
{
id: INBOX_ALL_TAB,
label: "Tous les messages",
icon: "mdi:inbox",
badgeColor: "bg-[#0b57d0]",
},
]
}
export function inboxTabBadgeCountClass(badgeColor: string) {
return cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
badgeColor,
labelPillTextClassForTailwindBgUtility(badgeColor)
)
}
export function inboxTabBadgeDotClass(badgeColor: string) {
return cn(
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-mail-surface",
badgeColor
)
}
export const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0"
export function listRowCheckboxClass(circular: boolean) {
return circular
? MAIL_LIST_ROW_CHECKBOX_CIRCULAR_CLASS
: MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS
}
export function listRowQuickHoverTrayToneClass(isSelected: boolean, isRead: boolean) {
return isSelected
? "bg-mail-row-selected"
: isRead
? "bg-mail-row-read"
: "bg-mail-row-unread"
}

View File

@ -0,0 +1 @@
export { EmailList } from "@/components/gmail/email-list"

View File

@ -0,0 +1,127 @@
"use client"
import type { ReactNode } from "react"
import { Clock } from "lucide-react"
import {
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu"
import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
import type { MoveTarget } from "@/components/gmail/move-to-menu-items"
export type MoveToTargets = {
recents: MoveTarget[]
system: MoveTarget[]
folders: MoveTarget[]
}
function MoveToSectionHeader({ children }: { children: ReactNode }) {
return (
<div className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{children}
</div>
)
}
export function MoveToDropdownItems({
targets,
onMoveTo,
}: {
targets: MoveToTargets
onMoveTo: (targetId: string) => void
}) {
return (
<>
{targets.recents.length > 0 && (
<>
<MoveToSectionHeader>Récents</MoveToSectionHeader>
{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-muted-foreground/80" 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 />
<MoveToSectionHeader>Dossiers</MoveToSectionHeader>
{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 function MoveToContextMenuItems({
targets,
onMoveTo,
}: {
targets: MoveToTargets
onMoveTo: (targetId: string) => void
}) {
return (
<>
{targets.recents.length > 0 && (
<>
<MoveToSectionHeader>Récents</MoveToSectionHeader>
{targets.recents.map((t) => (
<ContextMenuItem key={`recent-${t.id}`} onSelect={() => onMoveTo(t.id)}>
<span className="flex items-center gap-2">
{t.icon}
<Clock className="size-3 shrink-0 text-muted-foreground/80" strokeWidth={1.5} />
</span>
{t.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
</>
)}
{targets.system.map((t) => (
<ContextMenuItem key={t.id} onSelect={() => onMoveTo(t.id)}>
{t.icon}
{t.label}
</ContextMenuItem>
))}
{targets.folders.length > 0 && (
<>
<ContextMenuSeparator />
<MoveToSectionHeader>Dossiers</MoveToSectionHeader>
{targets.folders.map((t) => (
<ContextMenuItem
key={t.id}
onSelect={() => onMoveTo(t.id)}
style={{ paddingLeft: `${12 + t.depth * 16}px` }}
>
{t.icon}
{t.label}
</ContextMenuItem>
))}
</>
)}
</>
)
}

View File

@ -13,36 +13,12 @@ import {
Reply, Reply,
ReplyAll, ReplyAll,
Forward, Forward,
MoreVertical,
Printer,
ExternalLink,
ChevronDown,
Info, Info,
TriangleAlert,
Trash2,
Mail,
Ban,
ShieldAlert,
Fish,
Flag,
SlidersHorizontal,
Languages,
Download,
Code2,
MessageCircleWarning,
HardDrive, HardDrive,
File, File,
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -86,11 +62,9 @@ import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-in
import { ComposeWindow } from "@/components/gmail/compose-modal" import { ComposeWindow } from "@/components/gmail/compose-modal"
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview" import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
import { ContactHoverCard } from "./contact-hover-card" import { ContactHoverCard } from "./contact-hover-card"
import { MailLabelPillStrip } from "./mail-label-pills" import { EmailViewSubjectHeader } from "./email-view/email-view-header"
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
import { import {
MAIL_ICON_BTN,
MAIL_INVITATION_CARD_CLASS,
MAIL_MENU_SURFACE_WIDE_CLASS,
MAIL_MESSAGE_HOVER_CLASS, MAIL_MESSAGE_HOVER_CLASS,
MAIL_PREVIEW_SCROLL_CLASS, MAIL_PREVIEW_SCROLL_CLASS,
MAIL_REPLY_BAR_CLASS, MAIL_REPLY_BAR_CLASS,
@ -102,7 +76,6 @@ import {
emailPreviewBaseCss, emailPreviewBaseCss,
emailPreviewDarkOverrideCss, emailPreviewDarkOverrideCss,
emailPreviewLightOverrideCss, emailPreviewLightOverrideCss,
emailPreviewSubjectCss,
preprocessEmailHtmlForTheme, preprocessEmailHtmlForTheme,
} from "@/lib/email-preview-dark-styles" } from "@/lib/email-preview-dark-styles"
@ -128,19 +101,6 @@ interface EmailViewProps {
isSingleMessageView?: boolean isSingleMessageView?: boolean
} }
const LABEL_DISPLAY_NAMES: Record<string, string> = {
inbox: "Boîte de réception",
starred: "Suivis",
snoozed: "En attente",
important: "Important",
sent: "Messages envoyés",
drafts: "Brouillons",
spam: "Spam",
trash: "Corbeille",
}
const MESSAGE_MORE_MENU_CLASS = MAIL_MENU_SURFACE_WIDE_CLASS
const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = { const EMAIL_PREVIEW_IFRAME_STYLE: React.CSSProperties = {
display: "block", display: "block",
background: "transparent", background: "transparent",
@ -232,45 +192,6 @@ function SandboxedContent({
) )
} }
/* ── Sandboxed iframe for subject ── */
function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
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>${emailPreviewSubjectCss(isDark)}</style>
</head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`)
doc.close()
}, [text, resolvedTheme])
return (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
title="Sujet du message"
className="pointer-events-none w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: "32px" }}
tabIndex={-1}
/>
)
}
function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) { function MessageAttachmentCard({ name, kind }: { name: string; kind: EmailAttachmentKind }) {
return ( return (
<> <>
@ -462,7 +383,7 @@ function CollapsedMessage({
<MailDateText <MailDateText
iso={message.date} iso={message.date}
variant="preview" variant="preview"
className="text-xs text-[#5f6368]" className="text-xs text-muted-foreground"
/> />
<Star <Star
strokeWidth={1.25} strokeWidth={1.25}
@ -470,7 +391,7 @@ function CollapsedMessage({
/> />
</div> </div>
</div> </div>
<p className="min-w-0 truncate text-sm leading-snug text-[#5f6368]">{message.preview}</p> <p className="min-w-0 truncate text-sm leading-snug text-muted-foreground">{message.preview}</p>
</div> </div>
</div> </div>
) )
@ -503,217 +424,19 @@ function ExpandedMessage({
onCollapse?: () => void onCollapse?: () => void
onPrintConversation?: () => void onPrintConversation?: () => void
}) { }) {
const [showDetails, setShowDetails] = useState(false)
const name = cleanSenderName(sender)
return ( return (
<div> <div>
{/* Sender row */} <EmailViewMessageToolbar
<div sender={sender}
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")} senderEmail={senderEmail}
onClick={!isLast ? onCollapse : undefined} dateIso={dateIso}
> isSpam={isSpam}
{isSpam ? ( isLast={isLast}
<div starred={starred}
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full bg-[#e8eaed] text-[#e8710a]" onToggleStar={onToggleStar}
aria-label="Expéditeur ou message suspect (spam)" onCollapse={onCollapse}
> onPrintConversation={onPrintConversation}
<TriangleAlert className="size-[22px]" strokeWidth={2} aria-hidden />
</div>
) : (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: avatarColor(name) }}
>
{senderInitial(name)}
</div>
)}
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="min-w-0 truncate text-sm leading-snug">
<ContactHoverCard
displayName={sender}
email={senderEmail}
onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined}
className="inline min-w-0 max-w-full align-baseline"
>
<span className="font-semibold text-foreground">{name}</span>
<span className="text-[#5f6368]"> &lt;{senderEmail}&gt;</span>
</ContactHoverCard>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="flex items-center gap-0.5 text-xs text-[#5f6368] hover:text-[#202124]"
onClick={(e) => {
e.stopPropagation()
setShowDetails(!showDetails)
}}
>
à moi
<ChevronDown className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")} />
</button>
</div>
{showDetails && (
<div className="mt-1 space-y-0.5 text-xs text-[#5f6368]">
<p>de : <span className="text-[#3c4043]">{name} &lt;{senderEmail}&gt;</span></p>
<p>à : <span className="text-[#3c4043]">moi</span></p>
<p>
date :{" "}
<MailDateText
iso={dateIso}
variant="detail"
className="text-[#3c4043]"
/> />
</p>
{isSpam && (
<p className="text-[#d93025]">sécurité : ce message est marqué comme spam les images et appels externes sont bloqués</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
<div className="flex items-center gap-1">
<MailDateText
iso={dateIso}
variant="preview"
className="hidden text-xs text-[#5f6368] sm:inline"
/>
{onToggleStar && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleStar()
}}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-[#c2c2c2] hover:bg-black/4 hover:text-[#5f6368]"
aria-label={starred ? "Retirer des favoris" : "Marquer comme favori"}
>
<Star
strokeWidth={starred ? 0 : 1.25}
className={cn(
"size-4",
starred
? "fill-[#f4cc70] stroke-none text-[#f4cc70]"
: "fill-transparent stroke-[#c2c2c2]"
)}
/>
</button>
)}
{!onToggleStar && (
<Star strokeWidth={1.25} className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]" />
)}
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Répondre</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Plus d'actions"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className={MESSAGE_MORE_MENU_CLASS}
>
<DropdownMenuItem>
<Reply className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Répondre
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Transférer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Supprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Mail className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Marquer comme non lus à partir d&apos;ici
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Ban className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Bloquer « {name} »
</DropdownMenuItem>
<DropdownMenuItem>
<ShieldAlert className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler comme spam
</DropdownMenuItem>
<DropdownMenuItem>
<Fish className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler comme hameçonnage
</DropdownMenuItem>
<DropdownMenuItem>
<Flag className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Signaler un contenu illégal
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SlidersHorizontal className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Filtrer les messages similaires
</DropdownMenuItem>
<DropdownMenuItem>
<Languages className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Traduire
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onPrintConversation?.()
}}
>
<Printer className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Imprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Download className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Télécharger le message
</DropdownMenuItem>
<DropdownMenuItem>
<Code2 className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Afficher l&apos;original
</DropdownMenuItem>
<DropdownMenuItem>
<MessageCircleWarning className="size-[18px] shrink-0 text-[#5f6368]" strokeWidth={1.5} />
Partager pour aider à améliorer Google
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-[#5f6368] sm:hidden"
/>
</div>
</div>
{/* Body */} {/* Body */}
<div <div
@ -739,7 +462,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
return ( return (
<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="mx-6 mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted px-4 py-3.5 max-sm:mx-4">
<div className="min-w-0 flex-1 space-y-3"> <div className="min-w-0 flex-1 space-y-3">
<p className="text-sm leading-snug text-[#3c4043]"> <p className="text-sm leading-snug text-foreground/80">
<span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "} <span className="font-medium text-foreground">Pourquoi ce message est-il dans le spam ?</span>{" "}
Ce message est semblable à des messages identifiés comme spam par le passé. Ce message est semblable à des messages identifiés comme spam par le passé.
</p> </p>
@ -757,7 +480,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-[#5f6368] hover:bg-black/6" className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-accent"
aria-label="En savoir plus sur le filtre anti-spam" aria-label="En savoir plus sur le filtre anti-spam"
> >
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} /> <Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
@ -903,65 +626,19 @@ export function EmailView({
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}> <div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
{/* Spacer for floating nav buttons on xs */} {/* Spacer for floating nav buttons on xs */}
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden /> <div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
{/* Subject header */} <EmailViewSubjectHeader
<div className="flex items-start gap-3 px-6 py-4 max-sm:px-4"> email={email}
<div className="min-w-0 flex-1"> isSpamMessage={isSpamMessage}
<div className="flex flex-wrap items-center gap-2"> onNotSpam={onNotSpam}
<SandboxedSubject text={email.subject} /> onNavigateToLabel={onNavigateToLabel}
{labelBgByText && onNavigateToLabel ? ( showLabelChip={showLabelChip}
<MailLabelPillStrip
variant="header"
labels={email.labels ?? ["inbox"]}
labelBgByText={labelBgByText} labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs} getNavItemPrefs={getNavItemPrefs}
labelRows={labelRows}
folderTree={folderTree} folderTree={folderTree}
labelRows={labelRows}
currentFolderId={currentFolderId} currentFolderId={currentFolderId}
onLabelNavigate={onNavigateToLabel}
showLabel={showLabelChip}
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
showRemoveOnPills
spamChip={
isSpamMessage && onNotSpam
? { onNotSpam }
: undefined
}
/> />
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Imprimer tout</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}>Dans une nouvelle fenêtre</TooltipContent>
</Tooltip>
</div>
</div>
{calendarInvitation ? ( {calendarInvitation ? (
<CalendarInvitationPreview invitation={calendarInvitation} /> <CalendarInvitationPreview invitation={calendarInvitation} />
@ -970,11 +647,11 @@ export function EmailView({
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />} {isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
{showRepliesCta ? ( {showRepliesCta ? (
<div className="border-b border-[#eceff1] px-6 py-3 max-sm:px-4"> <div className="border-b border-border px-6 py-3 max-sm:px-4">
<button <button
type="button" type="button"
onClick={() => setShowFullThread(true)} onClick={() => setShowFullThread(true)}
className="text-sm font-medium text-[#1a73e8] hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
{priorCount === 1 {priorCount === 1
? "Afficher la réponse" ? "Afficher la réponse"
@ -990,7 +667,7 @@ export function EmailView({
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={msg.id} className="border-b border-[#eceff1]"> <div key={msg.id} className="border-b border-border">
<ExpandedMessage <ExpandedMessage
sender={msg.sender} sender={msg.sender}
senderEmail={msg.senderEmail} senderEmail={msg.senderEmail}
@ -1022,7 +699,7 @@ export function EmailView({
sender={mainSenderName} sender={mainSenderName}
senderEmail={mainSenderAddr} senderEmail={mainSenderAddr}
dateIso={email.date} dateIso={email.date}
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`} body={email.body || `<p style="color:var(--muted-foreground);">${email.preview}</p>`}
isSpam={email.spam === true} isSpam={email.spam === true}
isLast={true} isLast={true}
starred={isStarred} starred={isStarred}

View File

@ -0,0 +1,183 @@
"use client"
import { useEffect, useRef, type CSSProperties } from "react"
import { Printer, ExternalLink } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import type { Email } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { openConversationPrint } from "@/lib/print-conversation"
import { MailLabelPillStrip } from "@/components/gmail/mail-label-pills"
import {
MAIL_ICON_BTN,
MAIL_PREVIEW_SUBJECT_HEADER_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
import { useTheme } from "next-themes"
import {
emailPreviewSubjectCss,
} from "@/lib/email-preview-dark-styles"
const EMAIL_PREVIEW_IFRAME_STYLE: CSSProperties = {
display: "block",
background: "transparent",
}
function documentIsDark(): boolean {
return document.documentElement.classList.contains("dark")
}
function SandboxedSubject({ text }: { text: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const { resolvedTheme } = useTheme()
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
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>${emailPreviewSubjectCss(isDark)}</style>
</head>
<body>${text.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</body>
</html>`)
doc.close()
}, [text, resolvedTheme])
return (
<iframe
ref={iframeRef}
sandbox="allow-same-origin"
title="Sujet du message"
className="pointer-events-none w-full border-0 bg-transparent"
style={{ ...EMAIL_PREVIEW_IFRAME_STYLE, height: "32px" }}
tabIndex={-1}
/>
)
}
const LABEL_DISPLAY_NAMES: Record<string, string> = {
inbox: "Boîte de réception",
starred: "Suivis",
snoozed: "En attente",
important: "Important",
sent: "Messages envoyés",
drafts: "Brouillons",
spam: "Spam",
trash: "Corbeille",
}
export interface EmailViewSubjectHeaderProps {
email: Email
isSpamMessage: boolean
onNotSpam?: () => void
onNavigateToLabel?: (label: string) => void
showLabelChip?: (label: string) => boolean
labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
currentFolderId?: string
}
export function EmailViewSubjectHeader({
email,
isSpamMessage,
onNotSpam,
onNavigateToLabel,
showLabelChip,
labelBgByText,
emailLabelToSidebarFolderId = {},
getNavItemPrefs = () => ({ messages: "show" }),
folderTree,
labelRows,
currentFolderId,
}: EmailViewSubjectHeaderProps) {
return (
<div
className={cn(
"flex items-start gap-3 px-6 py-4 max-sm:px-4",
MAIL_PREVIEW_SUBJECT_HEADER_CLASS
)}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SandboxedSubject text={email.subject} />
{labelBgByText && onNavigateToLabel ? (
<MailLabelPillStrip
variant="header"
labels={email.labels ?? ["inbox"]}
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
labelRows={labelRows}
folderTree={folderTree}
currentFolderId={currentFolderId}
onLabelNavigate={onNavigateToLabel}
showLabel={showLabelChip}
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
showRemoveOnPills
spamChip={
isSpamMessage && onNotSpam ? { onNotSpam } : undefined
}
/>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}
>
Imprimer tout
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}
>
Dans une nouvelle fenêtre
</TooltipContent>
</Tooltip>
</div>
</div>
)
}

View File

@ -0,0 +1,297 @@
"use client"
import { useState } from "react"
import {
Star,
Reply,
Forward,
MoreVertical,
Printer,
Trash2,
Mail,
Ban,
ShieldAlert,
Fish,
Flag,
SlidersHorizontal,
Languages,
Download,
Code2,
MessageCircleWarning,
ChevronDown,
TriangleAlert,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { avatarColor, cleanSenderName, senderInitial } from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import { ContactHoverCard } from "@/components/gmail/contact-hover-card"
import {
MAIL_ICON_BTN,
MAIL_MENU_SURFACE_WIDE_CLASS,
MAIL_TOOLTIP_CONTENT_CLASS,
} from "@/lib/mail-chrome-classes"
const MESSAGE_MORE_MENU_CLASS = MAIL_MENU_SURFACE_WIDE_CLASS
const MENU_ICON_CLASS = "size-[18px] shrink-0 text-muted-foreground"
export interface EmailViewMessageToolbarProps {
sender: string
senderEmail: string
dateIso: string
isSpam: boolean
isLast: boolean
starred: boolean
onToggleStar?: () => void
onCollapse?: () => void
onPrintConversation?: () => void
}
export function EmailViewMessageToolbar({
sender,
senderEmail,
dateIso,
isSpam,
isLast,
starred,
onToggleStar,
onCollapse,
onPrintConversation,
}: EmailViewMessageToolbarProps) {
const [showDetails, setShowDetails] = useState(false)
const name = cleanSenderName(sender)
return (
<>
<div
className={cn("flex items-start gap-3 px-4 py-3", !isLast && "cursor-pointer")}
onClick={!isLast ? onCollapse : undefined}
>
{isSpam ? (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full bg-muted text-amber-600"
aria-label="Expéditeur ou message suspect (spam)"
>
<TriangleAlert className="size-[22px]" strokeWidth={2} aria-hidden />
</div>
) : (
<div
className="flex h-10 w-10 shrink-0 self-start items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: avatarColor(name) }}
>
{senderInitial(name)}
</div>
)}
<div className="min-w-0 flex-1 flex flex-col gap-1" data-selectable-text>
<div className="min-w-0 truncate text-sm leading-snug">
<ContactHoverCard
displayName={sender}
email={senderEmail}
onTriggerClick={!isLast ? (e) => e.stopPropagation() : undefined}
className="inline min-w-0 max-w-full align-baseline"
>
<span className="font-semibold text-foreground">{name}</span>
<span className="text-muted-foreground"> &lt;{senderEmail}&gt;</span>
</ContactHoverCard>
</div>
<div className="flex items-center gap-1">
<button
type="button"
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation()
setShowDetails(!showDetails)
}}
>
à moi
<ChevronDown
className={cn("h-3 w-3 transition-transform", showDetails && "rotate-180")}
/>
</button>
</div>
{showDetails && (
<div className="mt-1 space-y-0.5 text-xs text-muted-foreground">
<p>
de : <span className="text-foreground">{name} &lt;{senderEmail}&gt;</span>
</p>
<p>
à : <span className="text-foreground">moi</span>
</p>
<p>
date :{" "}
<MailDateText iso={dateIso} variant="detail" className="text-foreground" />
</p>
{isSpam && (
<p className="text-destructive">
sécurité : ce message est marqué comme spam les images et appels externes
sont bloqués
</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1 self-start pt-0.5">
<div className="flex items-center gap-1">
<MailDateText
iso={dateIso}
variant="preview"
className="hidden text-xs text-muted-foreground sm:inline"
/>
{onToggleStar ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleStar()
}}
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground/60 hover:bg-black/4 hover:text-muted-foreground"
aria-label={starred ? "Retirer des favoris" : "Marquer comme favori"}
>
<Star
strokeWidth={starred ? 0 : 1.25}
className={cn(
"size-4",
starred
? "fill-amber-300 stroke-none text-amber-300"
: "fill-transparent stroke-muted-foreground/60"
)}
/>
</button>
) : (
<Star
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-muted-foreground/60"
/>
)}
<Tooltip delayDuration={400}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
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={cn(MAIL_TOOLTIP_CONTENT_CLASS, "text-xs")}
>
Répondre
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-8 w-8", MAIL_ICON_BTN)}
aria-label="Plus d'actions"
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-[18px] w-[18px]" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={4}
className={MESSAGE_MORE_MENU_CLASS}
>
<DropdownMenuItem>
<Reply className={MENU_ICON_CLASS} strokeWidth={1.5} />
Répondre
</DropdownMenuItem>
<DropdownMenuItem>
<Forward className={MENU_ICON_CLASS} strokeWidth={1.5} />
Transférer
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className={MENU_ICON_CLASS} strokeWidth={1.5} />
Supprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Mail className={MENU_ICON_CLASS} strokeWidth={1.5} />
Marquer comme non lus à partir d&apos;ici
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Ban className={MENU_ICON_CLASS} strokeWidth={1.5} />
Bloquer « {name} »
</DropdownMenuItem>
<DropdownMenuItem>
<ShieldAlert className={MENU_ICON_CLASS} strokeWidth={1.5} />
Signaler comme spam
</DropdownMenuItem>
<DropdownMenuItem>
<Fish className={MENU_ICON_CLASS} strokeWidth={1.5} />
Signaler comme hameçonnage
</DropdownMenuItem>
<DropdownMenuItem>
<Flag className={MENU_ICON_CLASS} strokeWidth={1.5} />
Signaler un contenu illégal
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<SlidersHorizontal className={MENU_ICON_CLASS} strokeWidth={1.5} />
Filtrer les messages similaires
</DropdownMenuItem>
<DropdownMenuItem>
<Languages className={MENU_ICON_CLASS} strokeWidth={1.5} />
Traduire
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
onPrintConversation?.()
}}
>
<Printer className={MENU_ICON_CLASS} strokeWidth={1.5} />
Imprimer
</DropdownMenuItem>
<DropdownMenuItem>
<Download className={MENU_ICON_CLASS} strokeWidth={1.5} />
Télécharger le message
</DropdownMenuItem>
<DropdownMenuItem>
<Code2 className={MENU_ICON_CLASS} strokeWidth={1.5} />
Afficher l&apos;original
</DropdownMenuItem>
<DropdownMenuItem>
<MessageCircleWarning className={MENU_ICON_CLASS} strokeWidth={1.5} />
Partager pour aider à améliorer Google
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<MailDateText
iso={dateIso}
variant="previewShort"
className="text-xs text-muted-foreground sm:hidden"
/>
</div>
</div>
</>
)
}

View File

@ -47,6 +47,17 @@ import {
SEARCH_IN_OPTIONS, SEARCH_IN_OPTIONS,
type SearchParams, type SearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import {
buildQuickSearchParams,
submitMailSearch,
} from "@/lib/mail-search/navigate"
import {
MAIL_SEARCH_ADVANCED_PANEL_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS,
} from "@/lib/mail-chrome-classes"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
interface MailSearchBarProps { interface MailSearchBarProps {
@ -107,74 +118,59 @@ function AdvancedSearchPanel({
} }
return ( return (
<div <div className={MAIL_SEARCH_ADVANCED_PANEL_CLASS}>
className={cn(
"absolute left-0 top-full z-50 mt-1 max-h-[80vh] overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900",
"sm:min-w-[34rem] sm:max-w-[min(42rem,calc(100vw-5rem))]",
"md:min-w-[38rem]",
"lg:right-0 lg:min-w-0 lg:max-w-none"
)}
>
<div className="space-y-3 p-4"> <div className="space-y-3 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">De</Label>
De
</Label>
<Input <Input
value={from} value={from}
onChange={(e) => setFrom(e.target.value)} onChange={(e) => setFrom(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
autoFocus autoFocus
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">À</Label>
À
</Label>
<Input <Input
value={to} value={to}
onChange={(e) => setTo(e.target.value)} onChange={(e) => setTo(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">Objet</Label>
Objet
</Label>
<Input <Input
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">
Contient les mots Contient les mots
</Label> </Label>
<Input <Input
value={hasWords} value={hasWords}
onChange={(e) => setHasWords(e.target.value)} onChange={(e) => setHasWords(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">
Ne contient pas Ne contient pas
</Label> </Label>
<Input <Input
value={doesNotHave} value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)} onChange={(e) => setDoesNotHave(e.target.value)}
className="h-8 flex-1 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">Taille</Label>
Taille
</Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}> <Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className="h-8 w-32 text-sm"> <SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -186,10 +182,10 @@ function AdvancedSearchPanel({
type="number" type="number"
value={sizeVal} value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)} onChange={(e) => setSizeVal(e.target.value)}
className="h-8 w-20 rounded border-0 border-b border-gray-300 bg-transparent px-1 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 w-20 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}> <Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className="h-8 w-20 text-sm"> <SelectTrigger className={cn("h-8 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -201,12 +197,12 @@ function AdvancedSearchPanel({
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">
Plage de dates Plage de dates
</Label> </Label>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Select value={within} onValueChange={setWithin}> <Select value={within} onValueChange={setWithin}>
<SelectTrigger className="h-8 w-32 text-sm"> <SelectTrigger className={cn("h-8 w-32 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue placeholder="Sélectionner" /> <SelectValue placeholder="Sélectionner" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -221,17 +217,17 @@ function AdvancedSearchPanel({
type="date" type="date"
value={dateAfter} value={dateAfter}
onChange={(e) => setDateAfter(e.target.value)} onChange={(e) => setDateAfter(e.target.value)}
className="h-8 min-w-0 flex-1 rounded border border-gray-300 bg-transparent px-2 text-sm shadow-none focus-visible:border-blue-500 focus-visible:ring-0 dark:border-gray-600" className={cn("h-8 min-w-0 flex-1 px-2 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400"> <Label className="w-36 shrink-0 text-sm text-muted-foreground">
Rechercher Rechercher
</Label> </Label>
<Select value={searchIn} onValueChange={setSearchIn}> <Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className="h-8 flex-1 text-sm"> <SelectTrigger className={cn("h-8 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -245,15 +241,17 @@ function AdvancedSearchPanel({
</div> </div>
<div className="flex items-center gap-6 pt-1"> <div className="flex items-center gap-6 pt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"> <label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox <Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={hasAttachment} checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)} onCheckedChange={(v) => setHasAttachment(v === true)}
/> />
Contenant une pièce jointe Contenant une pièce jointe
</label> </label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"> <label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox <Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={excludeChats} checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)} onCheckedChange={(v) => setExcludeChats(v === true)}
/> />
@ -261,7 +259,12 @@ function AdvancedSearchPanel({
</label> </label>
</div> </div>
<div className="flex items-center justify-end gap-3 border-t border-gray-100 pt-3 dark:border-gray-800"> <div
className={cn(
"flex items-center justify-end gap-3 border-t pt-3",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<Button variant="ghost" className="text-sm text-blue-600" disabled> <Button variant="ghost" className="text-sm text-blue-600" disabled>
Créer un filtre Créer un filtre
</Button> </Button>
@ -343,29 +346,39 @@ export function MailSearchBar({
const submitSearch = useCallback( const submitSearch = useCallback(
(overrideQuery?: string) => { (overrideQuery?: string) => {
const q = overrideQuery ?? inputValue const q = overrideQuery ?? inputValue
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return const params = buildQuickSearchParams(q, {
const params: Partial<SearchParams> = { q: q.trim() } chipAttachment,
if (chipAttachment) params.has = ["attachment"] chipLast7Days,
if (chipLast7Days) params.within = "1w" chipFromMe,
if (chipFromMe) params.from = account.email fromEmail: account.email,
router.push(buildSearchUrl(params)) })
if (!Object.keys(params).length) return
submitMailSearch(router, params, {
onAfter: () => {
setDropdownOpen(false) setDropdownOpen(false)
inputRef.current?.blur() inputRef.current?.blur()
}, },
})
},
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router] [inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
) )
const selectSuggestion = useCallback( const selectSuggestion = useCallback(
(s: SearchSuggestion) => { (s: SearchSuggestion) => {
const params: Partial<SearchParams> = { q: s.email } const params = buildQuickSearchParams(s.email, {
if (chipAttachment) params.has = ["attachment"] chipAttachment,
if (chipLast7Days) params.within = "1w" chipLast7Days,
if (chipFromMe) params.from = account.email chipFromMe,
router.push(buildSearchUrl(params)) fromEmail: account.email,
})
submitMailSearch(router, params, {
onAfter: () => {
setInputValue(s.email) setInputValue(s.email)
setDropdownOpen(false) setDropdownOpen(false)
inputRef.current?.blur() inputRef.current?.blur()
}, },
})
},
[chipAttachment, chipLast7Days, chipFromMe, account.email, router] [chipAttachment, chipLast7Days, chipFromMe, account.email, router]
) )
@ -535,7 +548,7 @@ export function MailSearchBar({
<div <div
id="search-suggestions" id="search-suggestions"
role="listbox" role="listbox"
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900" className={MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS}
> >
{/* Filter chips */} {/* Filter chips */}
<div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 whitespace-nowrap dark:border-gray-800"> <div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 whitespace-nowrap dark:border-gray-800">

View File

@ -47,7 +47,20 @@ import {
SEARCH_IN_OPTIONS, SEARCH_IN_OPTIONS,
type SearchParams, type SearchParams,
} from "@/lib/mail-search/search-params" } from "@/lib/mail-search/search-params"
import {
buildQuickSearchParams,
submitMailSearch,
} from "@/lib/mail-search/navigate"
import { useMailSearchStore } from "@/lib/stores/mail-search-store"
import { avatarColor, senderInitial } from "@/lib/sender-display" import { avatarColor, senderInitial } from "@/lib/sender-display"
import {
MAIL_MOBILE_SEARCH_SHEET_CLASS,
MAIL_SEARCH_CHECKBOX_CLASS,
MAIL_SEARCH_CHIP_INACTIVE_CLASS,
MAIL_SEARCH_FIELD_CLASS,
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS,
} from "@/lib/mail-chrome-classes"
interface MobileSearchOverlayProps { interface MobileSearchOverlayProps {
open: boolean open: boolean
@ -60,13 +73,22 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
const account = useActiveAccount() const account = useActiveAccount()
const contacts = useContactsStore((s) => s.contacts) const contacts = useContactsStore((s) => s.contacts)
const [inputValue, setInputValue] = useState(initialQuery) const inputValue = useMailSearchStore((s) => s.inputValue)
const [selectedIndex, setSelectedIndex] = useState(-1) const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
const [chipAttachment, setChipAttachment] = useState(false) const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
const [chipLast7Days, setChipLast7Days] = useState(false) const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
const [chipFromMe, setChipFromMe] = useState(false) const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
const [advancedMode, setAdvancedMode] = useState(false) const {
setInputValue,
setSelectedIndex,
toggleChipAttachment,
toggleChipLast7Days,
toggleChipFromMe,
resetChips,
reset,
} = useMailSearchStore.getState()
const [advancedMode, setAdvancedMode] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@ -75,13 +97,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
setAdvancedMode(false) setAdvancedMode(false)
setTimeout(() => inputRef.current?.focus(), 50) setTimeout(() => inputRef.current?.focus(), 50)
} else { } else {
setSelectedIndex(-1) reset()
setChipAttachment(false)
setChipLast7Days(false)
setChipFromMe(false)
setAdvancedMode(false) setAdvancedMode(false)
} }
}, [open, initialQuery]) }, [open, initialQuery, setInputValue, reset])
const suggestions = useMemo<SearchSuggestion[]>(() => { const suggestions = useMemo<SearchSuggestion[]>(() => {
if (!inputValue.trim()) return [] if (!inputValue.trim()) return []
@ -102,25 +121,27 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
const submitSearch = useCallback( const submitSearch = useCallback(
(overrideQuery?: string) => { (overrideQuery?: string) => {
const q = overrideQuery ?? inputValue const q = overrideQuery ?? inputValue
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return const params = buildQuickSearchParams(q, {
const params: Partial<SearchParams> = { q: q.trim() } chipAttachment,
if (chipAttachment) params.has = ["attachment"] chipLast7Days,
if (chipLast7Days) params.within = "1w" chipFromMe,
if (chipFromMe) params.from = account.email fromEmail: account.email,
router.push(buildSearchUrl(params)) })
onClose() if (!Object.keys(params).length) return
submitMailSearch(router, params, { onAfter: onClose })
}, },
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose] [inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
) )
const selectSuggestion = useCallback( const selectSuggestion = useCallback(
(s: SearchSuggestion) => { (s: SearchSuggestion) => {
const params: Partial<SearchParams> = { q: s.email } const params = buildQuickSearchParams(s.email, {
if (chipAttachment) params.has = ["attachment"] chipAttachment,
if (chipLast7Days) params.within = "1w" chipLast7Days,
if (chipFromMe) params.from = account.email chipFromMe,
router.push(buildSearchUrl(params)) fromEmail: account.email,
onClose() })
submitMailSearch(router, params, { onAfter: onClose })
}, },
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose] [chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
) )
@ -130,11 +151,11 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
switch (e.key) { switch (e.key) {
case "ArrowDown": case "ArrowDown":
e.preventDefault() e.preventDefault()
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0)) setSelectedIndex(selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0)
break break
case "ArrowUp": case "ArrowUp":
e.preventDefault() e.preventDefault()
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1)) setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1)
break break
case "Enter": case "Enter":
e.preventDefault() e.preventDefault()
@ -165,21 +186,20 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
side="bottom" side="bottom"
hideClose hideClose
overlayClassName="z-[100] bg-black/40" overlayClassName="z-[100] bg-black/40"
className={cn( className={MAIL_MOBILE_SEARCH_SHEET_CLASS}
"z-[101] flex h-[100dvh] max-h-[100dvh] w-full flex-col gap-0 rounded-none border-0 bg-background p-0 shadow-xl",
"duration-300 ease-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
"pb-[env(safe-area-inset-bottom)]"
)}
> >
<SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle> <SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle>
{/* Header */} {/* Header */}
<div className="flex shrink-0 items-center gap-2 border-b border-gray-200 px-2 py-2 dark:border-gray-800"> <div
className={cn(
"flex shrink-0 items-center gap-2 border-b bg-mail-surface-elevated px-2 py-2",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-10 shrink-0 text-gray-600" className="size-10 shrink-0 text-muted-foreground"
onClick={() => { if (advancedMode) setAdvancedMode(false); else onClose() }} onClick={() => { if (advancedMode) setAdvancedMode(false); else onClose() }}
aria-label="Retour" aria-label="Retour"
> >
@ -187,7 +207,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
</Button> </Button>
<div className="relative flex min-w-0 flex-1 items-center"> <div className="relative flex min-w-0 flex-1 items-center">
{ghostText && !advancedMode && ( {ghostText && !advancedMode && (
<div className="pointer-events-none absolute left-0 flex items-center text-sm text-gray-400" aria-hidden> <div className="pointer-events-none absolute left-0 flex items-center text-sm text-muted-foreground" aria-hidden>
<span className="invisible">{inputValue}</span> <span className="invisible">{inputValue}</span>
<span>{ghostText}</span> <span>{ghostText}</span>
</div> </div>
@ -203,7 +223,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Rechercher dans les messages" placeholder="Rechercher dans les messages"
className="h-10 w-full bg-transparent text-sm outline-none placeholder:text-gray-400" className="h-10 w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@ -211,7 +231,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-10 shrink-0 text-gray-600" className="size-10 shrink-0 text-muted-foreground"
onClick={() => { onClick={() => {
setInputValue("") setInputValue("")
inputRef.current?.focus() inputRef.current?.focus()
@ -224,7 +244,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-10 shrink-0 text-gray-600" className="size-10 shrink-0 text-muted-foreground"
onClick={() => setAdvancedMode(!advancedMode)} onClick={() => setAdvancedMode(!advancedMode)}
aria-label="Recherche avancée" aria-label="Recherche avancée"
> >
@ -238,17 +258,22 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
onSubmit={(url) => { router.push(url); onClose() }} onSubmit={(url) => { router.push(url); onClose() }}
/> />
) : ( ) : (
<> <div className={cn("flex min-h-0 flex-1 flex-col", MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS)}>
{/* Filter chips */} {/* Filter chips */}
<div className="flex items-center gap-2 overflow-x-auto border-b border-gray-100 px-4 py-2 dark:border-gray-800"> <div
className={cn(
"flex items-center gap-2 overflow-x-auto border-b px-4 py-2",
MAIL_SEARCH_SECTION_DIVIDER_CLASS
)}
>
<button <button
type="button" type="button"
onClick={() => setChipAttachment(!chipAttachment)} onClick={() => toggleChipAttachment()}
className={cn( className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors", "flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipAttachment chipAttachment
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300" ? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400" : MAIL_SEARCH_CHIP_INACTIVE_CLASS
)} )}
> >
<Paperclip className="size-3.5" /> <Paperclip className="size-3.5" />
@ -256,12 +281,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
</button> </button>
<button <button
type="button" type="button"
onClick={() => setChipLast7Days(!chipLast7Days)} onClick={() => toggleChipLast7Days()}
className={cn( className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors", "flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipLast7Days chipLast7Days
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300" ? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400" : MAIL_SEARCH_CHIP_INACTIVE_CLASS
)} )}
> >
<Clock className="size-3.5" /> <Clock className="size-3.5" />
@ -269,12 +294,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
</button> </button>
<button <button
type="button" type="button"
onClick={() => setChipFromMe(!chipFromMe)} onClick={() => toggleChipFromMe()}
className={cn( className={cn(
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors", "flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
chipFromMe chipFromMe
? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300" ? "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "border-gray-200 text-gray-600 dark:border-gray-700 dark:text-gray-400" : MAIL_SEARCH_CHIP_INACTIVE_CLASS
)} )}
> >
<User className="size-3.5" /> <User className="size-3.5" />
@ -296,8 +321,8 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
key={`c-${s.contact.id}-${s.email}`} key={`c-${s.contact.id}-${s.email}`}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800", "flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
isSelected && "bg-gray-100 dark:bg-gray-800" isSelected && "bg-mail-nav-hover"
)} )}
onClick={() => selectSuggestion(s)} onClick={() => selectSuggestion(s)}
> >
@ -308,10 +333,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
{initial} {initial}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium text-gray-900 dark:text-gray-100"> <div className="truncate font-medium text-foreground">
{s.displayName} {s.displayName}
</div> </div>
<div className="truncate text-xs text-gray-500"> <div className="truncate text-xs text-muted-foreground">
{s.email} {s.email}
</div> </div>
</div> </div>
@ -323,16 +348,16 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
key={`e-${s.email}`} key={`e-${s.email}`}
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800", "flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
isSelected && "bg-gray-100 dark:bg-gray-800" isSelected && "bg-mail-nav-hover"
)} )}
onClick={() => selectSuggestion(s)} onClick={() => selectSuggestion(s)}
> >
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-gray-200 text-gray-500 dark:bg-gray-700"> <div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-mail-surface-muted text-muted-foreground">
<User className="size-4" /> <User className="size-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-gray-700 dark:text-gray-300"> <div className="truncate text-foreground">
{s.email} {s.email}
</div> </div>
</div> </div>
@ -344,17 +369,17 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
<button <button
type="button" type="button"
className={cn( className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-gray-100 dark:active:bg-gray-800", "flex w-full items-center gap-3 px-4 py-3 text-left text-sm active:bg-mail-nav-hover",
selectedIndex === suggestions.length && "bg-gray-100 dark:bg-gray-800" selectedIndex === suggestions.length && "bg-mail-nav-hover"
)} )}
onClick={() => submitSearch()} onClick={() => submitSearch()}
> >
<div className="flex size-9 shrink-0 items-center justify-center"> <div className="flex size-9 shrink-0 items-center justify-center">
<Search className="size-5 text-gray-400" /> <Search className="size-5 text-muted-foreground" />
</div> </div>
<span className="text-gray-600 dark:text-gray-400"> <span className="text-muted-foreground">
Tous les résultats pour «&nbsp; Tous les résultats pour «&nbsp;
<span className="font-medium text-gray-900 dark:text-gray-100"> <span className="font-medium text-foreground">
{inputValue} {inputValue}
</span> </span>
&nbsp;» &nbsp;»
@ -363,7 +388,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
</> </>
)} )}
</div> </div>
</> </div>
)} )}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@ -416,30 +441,50 @@ function MobileAdvancedSearch({
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">De</Label> <Label className="text-xs text-muted-foreground">De</Label>
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 text-sm" /> <Input
value={from}
onChange={(e) => setFrom(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">À</Label> <Label className="text-xs text-muted-foreground">À</Label>
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-9 text-sm" /> <Input
value={to}
onChange={(e) => setTo(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Objet</Label> <Label className="text-xs text-muted-foreground">Objet</Label>
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-sm" /> <Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Contient les mots</Label> <Label className="text-xs text-muted-foreground">Contient les mots</Label>
<Input value={hasWords} onChange={(e) => setHasWords(e.target.value)} className="h-9 text-sm" /> <Input
value={hasWords}
onChange={(e) => setHasWords(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Ne contient pas</Label> <Label className="text-xs text-muted-foreground">Ne contient pas</Label>
<Input value={doesNotHave} onChange={(e) => setDoesNotHave(e.target.value)} className="h-9 text-sm" /> <Input
value={doesNotHave}
onChange={(e) => setDoesNotHave(e.target.value)}
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Taille</Label> <Label className="text-xs text-muted-foreground">Taille</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}> <Select value={sizeOp} onValueChange={(v) => setSizeOp(v as "gt" | "lt")}>
<SelectTrigger className="h-9 flex-1 text-sm"> <SelectTrigger className={cn("h-9 flex-1 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -451,10 +496,10 @@ function MobileAdvancedSearch({
type="number" type="number"
value={sizeVal} value={sizeVal}
onChange={(e) => setSizeVal(e.target.value)} onChange={(e) => setSizeVal(e.target.value)}
className="h-9 w-20 text-sm" className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}
/> />
<Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}> <Select value={sizeUnit} onValueChange={(v) => setSizeUnit(v as "Mo" | "Ko")}>
<SelectTrigger className="h-9 w-20 text-sm"> <SelectTrigger className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -465,9 +510,9 @@ function MobileAdvancedSearch({
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Plage de dates</Label> <Label className="text-xs text-muted-foreground">Plage de dates</Label>
<Select value={within} onValueChange={setWithin}> <Select value={within} onValueChange={setWithin}>
<SelectTrigger className="h-9 text-sm"> <SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue placeholder="Sélectionner" /> <SelectValue placeholder="Sélectionner" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -480,9 +525,9 @@ function MobileAdvancedSearch({
</Select> </Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-gray-500">Rechercher dans</Label> <Label className="text-xs text-muted-foreground">Rechercher dans</Label>
<Select value={searchIn} onValueChange={setSearchIn}> <Select value={searchIn} onValueChange={setSearchIn}>
<SelectTrigger className="h-9 text-sm"> <SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -495,15 +540,17 @@ function MobileAdvancedSearch({
</Select> </Select>
</div> </div>
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-1">
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"> <label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox <Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={hasAttachment} checked={hasAttachment}
onCheckedChange={(v) => setHasAttachment(v === true)} onCheckedChange={(v) => setHasAttachment(v === true)}
/> />
Contenant une pièce jointe Contenant une pièce jointe
</label> </label>
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300"> <label className="flex items-center gap-2 text-sm text-foreground">
<Checkbox <Checkbox
className={MAIL_SEARCH_CHECKBOX_CLASS}
checked={excludeChats} checked={excludeChats}
onCheckedChange={(v) => setExcludeChats(v === true)} onCheckedChange={(v) => setExcludeChats(v === true)}
/> />

View File

@ -116,642 +116,46 @@ import {
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu" } from "@/components/ui/context-menu"
/** Retourne les ids des parents à ouvrir pour afficher `targetId`, ou null. */ import {
function ancestorFolderIdsForTarget( ancestorFolderIdsForTarget,
nodes: FolderTreeNode[], folderSubtreeContainsId,
targetId: string, } from "@/lib/sidebar-folder-tree-utils"
chain: string[] = [] import {
): string[] | null { mainItems,
for (const n of nodes) { CATEGORY_IDS_IN_PLUS_ONLY,
if (n.id === targetId) return chain sortSystemLabelRows,
if (n.children?.length) { sidebarSecondaryActions,
const found = ancestorFolderIdsForTarget(n.children, targetId, [...chain, n.id]) hasPlusOnlyExtras,
if (found) return found LABEL_MENU_COLOR_SWATCHES,
} type CategoryNavSourceItem,
} } from "@/components/gmail/sidebar/sidebar-nav-constants"
return null import {
} LabelMenuOptionWithCheck,
ContextLabelMenuOptionWithCheck,
function folderSubtreeContainsId(node: FolderTreeNode, targetId: string): boolean { folderParentSelectOptions,
if (node.id === targetId) return true navRowRoundedWhenActive,
return node.children?.some((c) => folderSubtreeContainsId(c, targetId)) ?? false SidebarNavIconSlot,
} navRowActivate,
FolderTreeNavIcon,
SidebarNavDragHandle,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
import { CategoryNavRow } from "@/components/gmail/sidebar/category-nav-row"
import { useSidebarNavDrag } from "@/hooks/use-sidebar-nav-drag"
import {
MAIL_SIDEBAR_PANEL_SURFACE_CLASS,
MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS,
} from "@/lib/mail-chrome-classes"
interface SidebarProps { interface SidebarProps {
selectedFolder: string selectedFolder: string
onSelectFolder: (folder: string) => void onSelectFolder: (folder: string) => void
collapsed: boolean collapsed: boolean
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
folderUnreadCounts?: Record<string, number> folderUnreadCounts?: Record<string, number>
/** md+ split pane: mobile-style branding, no header compose. */
splitView?: boolean splitView?: boolean
} }
const mainItems = [
{ id: "inbox", label: "Boîte de réception", icon: Inbox },
{ id: "starred", label: "Messages suivis", icon: Star },
{ id: "snoozed", label: "En attente", icon: Clock },
{ id: "important", label: "Important", icon: "mdi:label-variant-outline" },
{ id: "sent", label: "Messages envoyés", icon: Send },
{ id: "drafts", label: "Brouillons", icon: FileText },
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
{ id: "trash", label: "Corbeille", icon: Trash2 },
]
/** Catégories système affichées sous « Plus » uniquement. */
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["mises-a-jour", "finance"])
const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
function sortSystemLabelRows(rows: { id: string }[]): { id: string; label: string; icon?: string }[] {
const copy = [...rows]
copy.sort(
(a, b) =>
SYSTEM_NAV_LABEL_ORDER.indexOf(a.id) - SYSTEM_NAV_LABEL_ORDER.indexOf(b.id)
)
return copy as { id: string; label: string; icon?: string }[]
}
/** Liens secondaires sous la liste (jusquà Gérer les abonnements). */
const sidebarSecondaryActions = [
{ id: "customize-inbox", label: "Personnaliser la zone de réception", icon: LayoutGrid },
{ id: "manage-sections", label: "Gérer les sections", icon: Newspaper },
{ id: "manage-news", label: "Gérer les actualités", icon: Rss },
{ id: "manage-subscriptions", label: "Gérer les abonnements", icon: Mail },
] as const
const hasPlusOnlyExtras =
SYSTEM_NAV_LABEL_DEFAULTS.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
sidebarSecondaryActions.length > 0
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */
const LABEL_MENU_COLOR_SWATCHES = [
"bg-gray-500",
"bg-red-400",
"bg-orange-400",
"bg-amber-500",
"bg-yellow-400",
"bg-lime-500",
"bg-emerald-500",
"bg-teal-500",
"bg-blue-500",
"bg-indigo-500",
"bg-purple-500",
"bg-pink-500",
] as const
function LabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onPick()
}}
className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</DropdownMenuItem>
)
}
function ContextLabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<ContextMenuItem
onClick={() => onPick()}
className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</ContextMenuItem>
)
}
function folderParentSelectOptions(tree: FolderTreeNode[]): {
value: string
label: string
}[] {
const out: { value: string; label: string }[] = [
{ value: "__root__", label: "Racine" },
]
const walk = (nodes: FolderTreeNode[], depth: number) => {
for (const n of nodes) {
out.push({
value: n.id,
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
})
if (n.children?.length) walk(n.children, depth + 1)
}
}
walk(tree, 0)
return out
}
type CategoryNavSourceItem = {
id: string
label: string
icon?: string
}
/** Pill à droite seulement quand le fond daccent est visible (évite frange sur fond neutre). */
function navRowRoundedWhenActive(active: boolean) {
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
}
/** Pastille non-lus : point jaune + ping en haut à droite du picto. */
function SidebarNavIconUnreadDot({ show }: { show: boolean }) {
if (!show) return null
return (
<>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400 opacity-75 motion-reduce:animate-none animate-ping"
aria-hidden
/>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400"
aria-hidden
/>
</>
)
}
function SidebarNavIconSlot({
showUnreadDot,
children,
}: {
showUnreadDot?: boolean
children: ReactNode
}) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
{children}
<SidebarNavIconUnreadDot show={!!showUnreadDot} />
</span>
)
}
/** Mark an element as the nav drag source (opacity via CSS). */
function markNavDragSource(el: HTMLElement | null) {
el?.setAttribute("data-nav-drag-source", "true")
}
function unmarkNavDragSource(el: HTMLElement | null) {
el?.removeAttribute("data-nav-drag-source")
}
/** Mark / unmark a drop indicator via data attribute (CSS driven). */
function setNavDropIndicator(
el: HTMLElement | null,
placement: SidebarNavDropPlacement | null,
) {
if (!el) return
if (placement) {
el.setAttribute("data-nav-drop", placement)
} else {
el.removeAttribute("data-nav-drop")
}
}
function navRowActivate(
e: React.KeyboardEvent,
action: () => void
) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
action()
}
}
function FolderTreeNavIcon({
hasChildren,
open,
colorBgClass,
className,
style,
}: {
hasChildren: boolean
open: boolean
colorBgClass: string
className?: string
style?: CSSProperties
}) {
return (
<Icon
icon={folderTreeNavIconName(hasChildren, open)}
className={cn("h-5 w-5 shrink-0", className)}
style={{ color: navFolderIconColorFromBgClass(colorBgClass), ...style }}
aria-hidden
/>
)
}
function SidebarNavDragHandle({
label,
onDragStart,
onDragEnd,
}: {
label: string
onDragStart: (e: React.DragEvent<HTMLSpanElement>) => void
onDragEnd: () => void
}) {
return (
<span
draggable
title={`Réorganiser : ${label}`}
aria-label={`Réorganiser : ${label}`}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="pointer-events-none absolute left-0 top-1/2 z-10 flex h-8 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-gray-400 opacity-0 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:pointer-events-auto group-hover/folderrow:opacity-100 group-hover/labelrow:pointer-events-auto group-hover/labelrow:opacity-100"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</span>
)
}
const OVERFLOW_COUNT_HOVER_HIDE = {
folderrow: "group-hover/folderrow:opacity-0",
labelrow: "group-hover/labelrow:opacity-0",
catnav: "group-hover/catnav:opacity-0",
} as const
const OVERFLOW_MENU_HOVER_SHOW = {
folderrow:
"group-hover/folderrow:opacity-100 group-has-[button:focus-visible]/folderrow:opacity-100",
labelrow:
"group-hover/labelrow:opacity-100 group-has-[button:focus-visible]/labelrow:opacity-100",
catnav:
"group-hover/catnav:opacity-100 group-has-[button:focus-visible]/catnav:opacity-100",
} as const
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
function SidebarOverflowColumn({
unread,
menuOpen,
hoverGroup,
isSelected,
hasUnread,
className,
showMenuButton = true,
children,
}: {
unread: number
menuOpen: boolean
hoverGroup: "folderrow" | "labelrow" | "catnav"
isSelected?: boolean
hasUnread?: boolean
className?: string
showMenuButton?: boolean
children?: ReactNode
}) {
if (!showMenuButton) {
if (unread <= 0) return null
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
<span
className={cn(
"flex h-full items-center justify-center text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unread)}
</span>
</div>
)
}
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
{unread > 0 && (
<span
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center text-xs tabular-nums leading-none transition-opacity duration-150",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold",
menuOpen ? "opacity-0" : OVERFLOW_COUNT_HOVER_HIDE[hoverGroup]
)}
>
{formatCount(unread)}
</span>
)}
<div
className={cn(
"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-150",
menuOpen ? "opacity-100" : OVERFLOW_MENU_HOVER_SHOW[hoverGroup]
)}
>
{children}
</div>
</div>
)
}
/** 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"
function CategoryNavRow({
item,
isSelected,
isExpanded,
unreadCount,
onSelectFolder,
onDisableNavLabel,
onEnableNavLabel,
touchNav,
variant = "listed",
}: {
item: CategoryNavSourceItem
isSelected: boolean
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
touchNav: boolean
variant?: "listed" | "hidden"
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const [menuOpen, setMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const isHiddenRow = variant === "hidden"
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchMenuEnabled)
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isHiddenRow && !isSelected && !isOver && (menuOpen || sheetOpen)
const rowIcon = item.icon ? (
<Icon
icon={item.icon}
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
) : (
<Folder
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
)
if (isHiddenRow) {
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
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-mail-nav-drop text-foreground",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
>
{rowIcon}
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none text-gray-700",
hasUnread && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
</button>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={sidebarOverflowMenuButtonClass}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem
onClick={() => {
onEnableNavLabel(item.id)
setMenuOpen(false)
}}
>
Réactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{touchNav && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<SidebarNavSheetAction
onClick={() => {
onEnableNavLabel(item.id)
closeSheet()
}}
>
Réactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
className={cn(
"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-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
title={!isExpanded ? item.label : undefined}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none",
showCategoryMenu ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
{rowIcon}
</SidebarNavIconSlot>
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{!showCategoryMenu && unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
)}
</button>
{showCategoryMenu && (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen || sheetOpen}
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`}
onClick={(e) => {
e.stopPropagation()
}}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem disabled className="text-gray-400">
Afficher
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onDisableNavLabel(item.id)
setMenuOpen(false)
}}
>
Désactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)}
</div>
{touchNav && showCategoryMenu && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<div className="px-4 py-3 text-sm text-[#9aa0a6]">Afficher</div>
<SidebarNavSheetAction
onClick={() => {
onDisableNavLabel(item.id)
closeSheet()
}}
>
Désactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
export function Sidebar({ export function Sidebar({
selectedFolder, selectedFolder,
onSelectFolder, onSelectFolder,
@ -791,74 +195,19 @@ export function Sidebar({
setLabelRowEnabled, setLabelRowEnabled,
} = useSidebarNav() } = useSidebarNav()
const navDragRef = useRef<SidebarNavDragPayload | null>(null) const {
const navDragSourceElRef = useRef<HTMLElement | null>(null) navDragRef,
const navDropTargetElRef = useRef<HTMLElement | null>(null) navDropPlacementRef,
const navDropPlacementRef = useRef<SidebarNavDropPlacement | null>(null) beginNavDrag,
clearNavDrag,
const beginNavDrag = useCallback( updateNavDropTarget,
(payload: SidebarNavDragPayload, sourceEl: HTMLElement | null) => { clearNavDropTarget,
navDragRef.current = payload commitNavDrop,
navDragSourceElRef.current = sourceEl } = useSidebarNavDrag({
markNavDragSource(sourceEl) reorderLabelRows,
}, moveFolderRelative,
[] setExpandedFolderIds,
)
const clearNavDrag = useCallback(() => {
unmarkNavDragSource(navDragSourceElRef.current)
setNavDropIndicator(navDropTargetElRef.current, null)
navDragRef.current = null
navDragSourceElRef.current = null
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}, [])
const updateNavDropTarget = useCallback(
(el: HTMLElement, placement: SidebarNavDropPlacement) => {
if (navDropTargetElRef.current !== el) {
setNavDropIndicator(navDropTargetElRef.current, null)
}
navDropTargetElRef.current = el
navDropPlacementRef.current = placement
setNavDropIndicator(el, placement)
},
[]
)
const clearNavDropTarget = useCallback((el: HTMLElement) => {
if (navDropTargetElRef.current === el) {
setNavDropIndicator(el, null)
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}
}, [])
const commitNavDrop = useCallback(
(
payload: SidebarNavDragPayload,
targetId: string,
placement: SidebarNavDropPlacement,
targetKind: "label" | "folder"
) => {
clearNavDrag()
if (payload.id === targetId && placement !== "inside") return
if (targetKind === "label" && payload.kind === "label") {
if (placement === "inside") return
reorderLabelRows(payload.id, targetId, placement)
} else if (targetKind === "folder" && payload.kind === "folder") {
moveFolderRelative(payload.id, targetId, placement)
if (placement === "inside") {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
next.add(targetId)
return next
}) })
}
}
},
[clearNavDrag, moveFolderRelative, reorderLabelRows]
)
const visibleNavLabelRows = useMemo(() => { const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => { return labelRows.filter((row) => {
@ -1198,7 +547,10 @@ export function Sidebar({
"group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors", "group/folderrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none", isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70", isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas", isStickyBranch &&
!isSelected &&
!rowHoverHeld &&
(isOverlayOpen ? "mail-sidebar-overlay-panel" : "bg-app-canvas"),
isSelected && "bg-mail-nav-selected font-medium text-mail-nav-selected", isSelected && "bg-mail-nav-selected font-medium text-mail-nav-selected",
!isSelected && hasUnread && "text-gray-900", !isSelected && hasUnread && "text-gray-900",
isOver && "bg-mail-nav-drop text-foreground", isOver && "bg-mail-nav-drop text-foreground",
@ -2464,15 +1816,20 @@ export function Sidebar({
) )
} }
const panelSurfaceClass = isOverlayOpen
? MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS
: MAIL_SIDEBAR_PANEL_SURFACE_CLASS
return ( return (
<aside <aside
ref={sidebarRef} ref={sidebarRef}
data-sidebar data-sidebar
data-sidebar-overlay={isOverlayOpen ? "" : undefined}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
className={cn( className={cn(
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden 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, panelSurfaceClass,
isExpanded ? "w-60" : "w-[68px]", isExpanded ? "w-60" : "w-[68px]",
splitView && "border-r border-gray-200", splitView && "border-r border-gray-200",
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200", !touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
@ -2483,7 +1840,7 @@ export function Sidebar({
<div <div
className={cn( className={cn(
"flex shrink-0 items-center", "flex shrink-0 items-center",
SIDEBAR_PANEL_SURFACE_CLASS, panelSurfaceClass,
splitView splitView
? cn( ? cn(
splitViewLogoHeaderClass, splitViewLogoHeaderClass,
@ -2524,7 +1881,7 @@ export function Sidebar({
<div <div
className={cn( className={cn(
"hidden shrink-0 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, panelSurfaceClass,
isExpanded ? "pr-3.5" : "pr-2", isExpanded ? "pr-3.5" : "pr-2",
splitView && "!hidden" splitView && "!hidden"
)} )}
@ -2680,7 +2037,7 @@ export function Sidebar({
<div <div
className={cn( 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", "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 panelSurfaceClass
)} )}
title={!isExpanded ? "Dossiers" : undefined} title={!isExpanded ? "Dossiers" : undefined}
> >
@ -2721,7 +2078,7 @@ export function Sidebar({
<div <div
className={cn( 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", "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 panelSurfaceClass
)} )}
title={!isExpanded ? "Libellés" : undefined} title={!isExpanded ? "Libellés" : undefined}
> >
@ -2765,7 +2122,7 @@ export function Sidebar({
<div <div
className={cn( className={cn(
"relative z-32 mt-auto pt-2", "relative z-32 mt-auto pt-2",
SIDEBAR_PANEL_SURFACE_CLASS, panelSurfaceClass,
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3" "max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
)} )}
> >

View File

@ -0,0 +1,297 @@
"use client"
import { useRef, useState } from "react"
import { Folder, MoreVertical } from "lucide-react"
import { Icon } from "@iconify/react"
import { cn, formatCount } from "@/lib/utils"
import { useEmailDropTarget } from "@/lib/drag-context"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarNavOptionsSheet,
SidebarNavSheetAction,
} from "@/components/gmail/sidebar-nav-options-sheet"
import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options"
import type { CategoryNavSourceItem } from "@/components/gmail/sidebar/sidebar-nav-constants"
import {
navRowRoundedWhenActive,
SidebarNavIconSlot,
SidebarOverflowColumn,
sidebarOverflowMenuButtonClass,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
export function CategoryNavRow({
item,
isSelected,
isExpanded,
unreadCount,
onSelectFolder,
onDisableNavLabel,
onEnableNavLabel,
touchNav,
variant = "listed",
}: {
item: CategoryNavSourceItem
isSelected: boolean
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
touchNav: boolean
variant?: "listed" | "hidden"
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const [menuOpen, setMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const isHiddenRow = variant === "hidden"
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0
const touchMenuEnabled = touchNav && (isHiddenRow || showCategoryMenu)
const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } =
useSidebarTouchOptionsMenu(touchMenuEnabled)
const handleMenuOpenChange = (open: boolean) => {
setMenuOpen(open)
if (!open) {
queueMicrotask(() => menuTriggerRef.current?.blur())
}
}
const rowHoverHeld =
!isHiddenRow && !isSelected && !isOver && (menuOpen || sheetOpen)
const rowIcon = item.icon ? (
<Icon
icon={item.icon}
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
) : (
<Folder
className={cn(
"h-5 w-5 shrink-0",
isHiddenRow && "opacity-70",
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
)}
aria-hidden
/>
)
if (isHiddenRow) {
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
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-mail-nav-drop text-foreground",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
>
{rowIcon}
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none text-gray-700",
hasUnread && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
</button>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={sidebarOverflowMenuButtonClass}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem
onClick={() => {
onEnableNavLabel(item.id)
setMenuOpen(false)
}}
>
Réactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{touchNav && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<SidebarNavSheetAction
onClick={() => {
onEnableNavLabel(item.id)
closeSheet()
}}
>
Réactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}
return (
<>
<div
{...dropHandlers}
{...touchRowProps}
className={cn(
"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-mail-nav-selected text-mail-nav-selected font-medium"
: isOver
? "bg-mail-nav-drop text-foreground"
: rowHoverHeld
? "bg-mail-nav-hover text-foreground"
: hasUnread
? "text-gray-900 hover:bg-mail-nav-hover"
: "text-gray-700 hover:bg-mail-nav-hover",
touchRowClassName
)}
>
<button
type="button"
onClick={() => onSelectFolder(item.id)}
title={!isExpanded ? item.label : undefined}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none",
showCategoryMenu ? "pr-1" : "pr-3"
)}
>
<SidebarNavIconSlot showUnreadDot={hasUnread}>
{rowIcon}
</SidebarNavIconSlot>
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
"min-w-0 flex-1 truncate text-sm leading-5",
hasUnread && !isSelected && "font-semibold text-gray-900"
)}
>
{item.label}
</span>
{!showCategoryMenu && unreadCount > 0 && (
<span
className={cn(
"shrink-0 text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unreadCount)}
</span>
)}
</div>
)}
</button>
{showCategoryMenu && (
<SidebarOverflowColumn
unread={unreadCount}
menuOpen={menuOpen || sheetOpen}
hoverGroup="catnav"
isSelected={isSelected}
hasUnread={hasUnread}
className="mr-[-7px]"
showMenuButton={!touchNav}
>
{!touchNav && (
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
ref={menuTriggerRef}
type="button"
className={cn(
sidebarOverflowMenuButtonClass,
isSelected && "text-gray-900"
)}
aria-label={`Options pour ${item.label}`}
onClick={(e) => {
e.stopPropagation()
}}
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem disabled className="text-gray-400">
Afficher
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onDisableNavLabel(item.id)
setMenuOpen(false)
}}
>
Désactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarOverflowColumn>
)}
</div>
{touchNav && showCategoryMenu && (
<SidebarNavOptionsSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={item.label}
>
<div className="px-4 py-3 text-sm text-muted-foreground">Afficher</div>
<SidebarNavSheetAction
onClick={() => {
onDisableNavLabel(item.id)
closeSheet()
}}
>
Désactiver le libellé
</SidebarNavSheetAction>
</SidebarNavOptionsSheet>
)}
</>
)
}

View File

@ -0,0 +1,78 @@
import {
Inbox,
Star,
Clock,
ClockArrowUp,
Send,
FileText,
ShieldAlert,
Trash2,
LayoutGrid,
Newspaper,
Rss,
Mail,
} from "lucide-react"
import { SYSTEM_NAV_LABEL_DEFAULTS } from "@/lib/sidebar-nav-data"
export const mainItems = [
{ id: "inbox", label: "Boîte de réception", icon: Inbox },
{ id: "starred", label: "Messages suivis", icon: Star },
{ id: "snoozed", label: "En attente", icon: Clock },
{ id: "important", label: "Important", icon: "mdi:label-variant-outline" },
{ id: "sent", label: "Messages envoyés", icon: Send },
{ id: "drafts", label: "Brouillons", icon: FileText },
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
{ id: "trash", label: "Corbeille", icon: Trash2 },
] as const
export const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>([
"mises-a-jour",
"finance",
])
export const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
export function sortSystemLabelRows(
rows: { id: string }[]
): { id: string; label: string; icon?: string }[] {
const copy = [...rows]
copy.sort(
(a, b) =>
SYSTEM_NAV_LABEL_ORDER.indexOf(a.id) -
SYSTEM_NAV_LABEL_ORDER.indexOf(b.id)
)
return copy as { id: string; label: string; icon?: string }[]
}
export const sidebarSecondaryActions = [
{ id: "customize-inbox", label: "Personnaliser la zone de réception", icon: LayoutGrid },
{ id: "manage-sections", label: "Gérer les sections", icon: Newspaper },
{ id: "manage-news", label: "Gérer les actualités", icon: Rss },
{ id: "manage-subscriptions", label: "Gérer les abonnements", icon: Mail },
] as const
export const hasPlusOnlyExtras =
SYSTEM_NAV_LABEL_DEFAULTS.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
sidebarSecondaryActions.length > 0
export const LABEL_MENU_COLOR_SWATCHES = [
"bg-gray-500",
"bg-red-400",
"bg-orange-400",
"bg-amber-500",
"bg-yellow-400",
"bg-lime-500",
"bg-emerald-500",
"bg-teal-500",
"bg-blue-500",
"bg-indigo-500",
"bg-purple-500",
"bg-pink-500",
] as const
export type CategoryNavSourceItem = {
id: string
label: string
icon?: string
}

View File

@ -0,0 +1,294 @@
"use client"
import {
useState,
type ReactNode,
type CSSProperties,
type KeyboardEvent,
type DragEvent,
} from "react"
import { Check, GripVertical } from "lucide-react"
import { Icon } from "@iconify/react"
import { cn, formatCount } from "@/lib/utils"
import {
MAIL_SIDEBAR_MENU_ITEM_CLASS,
MAIL_SIDEBAR_OVERFLOW_BTN_CLASS,
} from "@/lib/mail-chrome-classes"
import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu"
import {
ContextMenuItem,
} from "@/components/ui/context-menu"
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
import {
folderTreeNavIconName,
navFolderIconColorFromBgClass,
} from "@/lib/folder-nav-icons"
import type { SidebarNavDropPlacement } from "@/lib/sidebar-nav-dnd"
export function LabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onPick()
}}
className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</DropdownMenuItem>
)
}
export function ContextLabelMenuOptionWithCheck({
checked,
onPick,
children,
}: {
checked: boolean
onPick: () => void
children: ReactNode
}) {
return (
<ContextMenuItem
onClick={() => onPick()}
className={MAIL_SIDEBAR_MENU_ITEM_CLASS}
>
<span className="min-w-0 flex-1 text-left">{children}</span>
<span
className="flex size-4 shrink-0 items-center justify-center"
aria-hidden={!checked}
>
{checked ? (
<Check className="size-4 text-foreground" strokeWidth={2} aria-hidden />
) : null}
</span>
</ContextMenuItem>
)
}
export function folderParentSelectOptions(tree: FolderTreeNode[]): {
value: string
label: string
}[] {
const out: { value: string; label: string }[] = [
{ value: "__root__", label: "Racine" },
]
const walk = (nodes: FolderTreeNode[], depth: number) => {
for (const n of nodes) {
out.push({
value: n.id,
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
})
if (n.children?.length) walk(n.children, depth + 1)
}
}
walk(tree, 0)
return out
}
export function navRowRoundedWhenActive(active: boolean) {
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
}
export function SidebarNavIconUnreadDot({ show }: { show: boolean }) {
if (!show) return null
return (
<>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400 opacity-75 motion-reduce:animate-none animate-ping"
aria-hidden
/>
<span
className="pointer-events-none absolute -right-0.5 -top-0.5 size-2 rounded-full bg-yellow-400"
aria-hidden
/>
</>
)
}
export function SidebarNavIconSlot({
showUnreadDot,
children,
}: {
showUnreadDot?: boolean
children: ReactNode
}) {
return (
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
{children}
<SidebarNavIconUnreadDot show={!!showUnreadDot} />
</span>
)
}
export function markNavDragSource(el: HTMLElement | null) {
el?.setAttribute("data-nav-drag-source", "true")
}
export function unmarkNavDragSource(el: HTMLElement | null) {
el?.removeAttribute("data-nav-drag-source")
}
export function setNavDropIndicator(
el: HTMLElement | null,
placement: SidebarNavDropPlacement | null
) {
if (!el) return
if (placement) {
el.setAttribute("data-nav-drop", placement)
} else {
el.removeAttribute("data-nav-drop")
}
}
export function navRowActivate(e: KeyboardEvent, action: () => void) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
action()
}
}
export function FolderTreeNavIcon({
hasChildren,
open,
colorBgClass,
className,
style,
}: {
hasChildren: boolean
open: boolean
colorBgClass: string
className?: string
style?: CSSProperties
}) {
return (
<Icon
icon={folderTreeNavIconName(hasChildren, open)}
className={cn("h-5 w-5 shrink-0", className)}
style={{ color: navFolderIconColorFromBgClass(colorBgClass), ...style }}
aria-hidden
/>
)
}
export function SidebarNavDragHandle({
label,
onDragStart,
onDragEnd,
}: {
label: string
onDragStart: (e: DragEvent<HTMLSpanElement>) => void
onDragEnd: () => void
}) {
return (
<span
draggable
title={`Réorganiser : ${label}`}
aria-label={`Réorganiser : ${label}`}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="pointer-events-none absolute left-0 top-1/2 z-10 flex h-8 w-4 -translate-y-1/2 cursor-grab items-center justify-center text-gray-400 opacity-0 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:pointer-events-auto group-hover/folderrow:opacity-100 group-hover/labelrow:pointer-events-auto group-hover/labelrow:opacity-100"
>
<GripVertical className="h-3.5 w-3.5" aria-hidden />
</span>
)
}
const OVERFLOW_COUNT_HOVER_HIDE = {
folderrow: "group-hover/folderrow:opacity-0",
labelrow: "group-hover/labelrow:opacity-0",
catnav: "group-hover/catnav:opacity-0",
} as const
const OVERFLOW_MENU_HOVER_SHOW = {
folderrow:
"group-hover/folderrow:opacity-100 group-has-[button:focus-visible]/folderrow:opacity-100",
labelrow:
"group-hover/labelrow:opacity-100 group-has-[button:focus-visible]/labelrow:opacity-100",
catnav:
"group-hover/catnav:opacity-100 group-has-[button:focus-visible]/catnav:opacity-100",
} as const
export function SidebarOverflowColumn({
unread,
menuOpen,
hoverGroup,
isSelected,
hasUnread,
className,
showMenuButton = true,
children,
}: {
unread: number
menuOpen: boolean
hoverGroup: "folderrow" | "labelrow" | "catnav"
isSelected?: boolean
hasUnread?: boolean
className?: string
showMenuButton?: boolean
children?: ReactNode
}) {
if (!showMenuButton) {
if (unread <= 0) return null
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
<span
className={cn(
"flex h-full items-center justify-center text-xs tabular-nums leading-none",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold"
)}
>
{formatCount(unread)}
</span>
</div>
)
}
return (
<div className={cn("relative h-8 w-8 shrink-0", className)}>
{unread > 0 && (
<span
className={cn(
"pointer-events-none absolute inset-0 flex items-center justify-center text-xs tabular-nums leading-none transition-opacity duration-150",
isSelected && "font-medium",
hasUnread && !isSelected && "font-semibold",
menuOpen ? "opacity-0" : OVERFLOW_COUNT_HOVER_HIDE[hoverGroup]
)}
>
{formatCount(unread)}
</span>
)}
<div
className={cn(
"absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-150",
menuOpen ? "opacity-100" : OVERFLOW_MENU_HOVER_SHOW[hoverGroup]
)}
>
{children}
</div>
</div>
)
}
export const sidebarOverflowMenuButtonClass = MAIL_SIDEBAR_OVERFLOW_BTN_CLASS

View File

@ -0,0 +1,133 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
export const PULL_HOLD_HEIGHT = 48
export const PULL_REFRESH_THRESHOLD = 56
export const PULL_REFRESH_MAX = 112
export const PULL_SNAP_BACK_TRANSITION =
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)"
export const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]"
export const PULL_ICON_FADE_MS = 120
export const PULL_SPINNER_REVEAL_OFFSET = 26
export function computePullOffset(delta: number): number {
if (delta <= 0) return 0
const damped = delta * 0.48
const capped = Math.min(PULL_REFRESH_MAX, damped)
const ratio = capped / PULL_REFRESH_MAX
return capped * (1 - ratio * 0.12)
}
export function computeSpinnerRevealProgress(y: number): number {
if (y <= PULL_SPINNER_REVEAL_OFFSET) return 0
const range = Math.max(1, PULL_REFRESH_THRESHOLD - PULL_SPINNER_REVEAL_OFFSET)
return Math.min(1, ((y - PULL_SPINNER_REVEAL_OFFSET) / range) * 1.35)
}
export function useMailListPullRefresh(opts: {
enabled: boolean
isViewMode: boolean
onRefresh: () => void | Promise<void>
}) {
const { enabled, isViewMode, onRefresh } = opts
const [isRefreshing, setIsRefreshing] = useState(false)
const pullYRef = useRef(0)
const pullActiveRef = useRef(false)
const pullContentRef = useRef<HTMLDivElement>(null)
const pullIconRef = useRef<SVGSVGElement | null>(null)
const listViewportRef = useRef<HTMLDivElement>(null)
const applyPullVisual = useCallback((y: number, animate: boolean) => {
const el = pullContentRef.current
const icon = pullIconRef.current
if (!el) return
const transition = animate ? PULL_SNAP_BACK_TRANSITION : "none"
el.style.transition = transition
el.style.transform = y > 0 ? `translateY(${y}px)` : ""
if (icon) {
const progress = computeSpinnerRevealProgress(y)
icon.style.opacity = String(progress)
icon.style.transform = `rotate(${progress * 360}deg)`
icon.style.transition = animate
? `opacity ${PULL_ICON_FADE_MS}ms ease-out, transform ${PULL_ICON_FADE_MS}ms ease-out`
: "none"
}
}, [])
const resetPullVisual = useCallback(() => {
pullYRef.current = 0
applyPullVisual(0, true)
}, [applyPullVisual])
const releasePull = useCallback(async () => {
const offset = pullYRef.current
pullActiveRef.current = false
if (offset >= PULL_REFRESH_THRESHOLD) {
pullYRef.current = PULL_HOLD_HEIGHT
applyPullVisual(PULL_HOLD_HEIGHT, false)
setIsRefreshing(true)
try {
await onRefresh()
} finally {
setIsRefreshing(false)
pullYRef.current = 0
applyPullVisual(0, true)
}
} else {
resetPullVisual()
}
}, [applyPullVisual, onRefresh, resetPullVisual])
useEffect(() => {
if (isViewMode || !enabled || isRefreshing) return
const root = listViewportRef.current
if (!root) return
let startY = 0
const onTouchStart = (e: TouchEvent) => {
if (root.scrollTop > 0 || isRefreshing) return
startY = e.touches[0]?.clientY ?? 0
pullActiveRef.current = true
}
const onTouchMove = (e: TouchEvent) => {
if (!pullActiveRef.current || isRefreshing) return
const y = e.touches[0]?.clientY ?? 0
const delta = y - startY
if (delta <= 0) {
pullYRef.current = 0
applyPullVisual(0, false)
return
}
if (root.scrollTop > 0) return
e.preventDefault()
const offset = computePullOffset(delta)
pullYRef.current = offset
applyPullVisual(offset, false)
}
const onTouchEnd = () => {
if (!pullActiveRef.current) return
void releasePull()
}
root.addEventListener("touchstart", onTouchStart, { passive: true })
root.addEventListener("touchmove", onTouchMove, { passive: false })
root.addEventListener("touchend", onTouchEnd)
root.addEventListener("touchcancel", onTouchEnd)
return () => {
root.removeEventListener("touchstart", onTouchStart)
root.removeEventListener("touchmove", onTouchMove)
root.removeEventListener("touchend", onTouchEnd)
root.removeEventListener("touchcancel", onTouchEnd)
}
}, [enabled, isRefreshing, isViewMode, applyPullVisual, releasePull])
return {
isRefreshing,
setIsRefreshing,
listViewportRef,
pullContentRef,
pullIconRef,
resetPullVisual,
}
}

51
hooks/use-mail-route.ts Normal file
View File

@ -0,0 +1,51 @@
"use client"
import { useCallback, useMemo } from "react"
import { useRouter, usePathname, useSearchParams } from "next/navigation"
import {
parseMailSegments,
buildMailPath,
type MailRouteState,
} from "@/lib/mail-url"
function segmentsFromPathname(pathname: string | null): string[] | undefined {
if (!pathname?.startsWith("/mail")) return undefined
const rest = pathname.slice("/mail".length).replace(/^\//, "")
if (!rest) return []
return rest.split("/").filter(Boolean)
}
export function useMailRoute() {
const router = useRouter()
const pathname = usePathname()
const currentSearchParams = useSearchParams()
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
const route = useMemo(() => parseMailSegments(segments), [segments])
const navigateRoute = useCallback(
(patch: Partial<MailRouteState>) => {
const next: MailRouteState = {
folderId: patch.folderId ?? route.folderId,
inboxTab:
patch.inboxTab !== undefined && patch.inboxTab !== null
? patch.inboxTab
: route.inboxTab,
page: patch.page !== undefined ? patch.page : route.page,
mailId: patch.mailId !== undefined ? patch.mailId : route.mailId,
}
let url = buildMailPath(next)
if (next.folderId === "search" && currentSearchParams.toString()) {
url += `?${currentSearchParams.toString()}`
}
router.push(url, { scroll: false })
},
[router, route, currentSearchParams]
)
return {
route,
navigateRoute,
pathname,
searchParams: currentSearchParams,
}
}

View File

@ -0,0 +1,103 @@
"use client"
import { useCallback, useRef, type Dispatch, type SetStateAction } from "react"
import type { SidebarNavDragPayload, SidebarNavDropPlacement } from "@/lib/sidebar-nav-dnd"
import {
markNavDragSource,
setNavDropIndicator,
unmarkNavDragSource,
} from "@/components/gmail/sidebar/sidebar-nav-primitives"
export function useSidebarNavDrag(opts: {
reorderLabelRows: (
sourceId: string,
targetId: string,
placement: "before" | "after"
) => void
moveFolderRelative: (
sourceId: string,
targetId: string,
placement: SidebarNavDropPlacement
) => void
setExpandedFolderIds: Dispatch<SetStateAction<Set<string>>>
}) {
const { reorderLabelRows, moveFolderRelative, setExpandedFolderIds } = opts
const navDragRef = useRef<SidebarNavDragPayload | null>(null)
const navDragSourceElRef = useRef<HTMLElement | null>(null)
const navDropTargetElRef = useRef<HTMLElement | null>(null)
const navDropPlacementRef = useRef<SidebarNavDropPlacement | null>(null)
const beginNavDrag = useCallback(
(payload: SidebarNavDragPayload, sourceEl: HTMLElement | null) => {
navDragRef.current = payload
navDragSourceElRef.current = sourceEl
markNavDragSource(sourceEl)
},
[]
)
const clearNavDrag = useCallback(() => {
unmarkNavDragSource(navDragSourceElRef.current)
setNavDropIndicator(navDropTargetElRef.current, null)
navDragRef.current = null
navDragSourceElRef.current = null
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}, [])
const updateNavDropTarget = useCallback(
(el: HTMLElement, placement: SidebarNavDropPlacement) => {
if (navDropTargetElRef.current !== el) {
setNavDropIndicator(navDropTargetElRef.current, null)
}
navDropTargetElRef.current = el
navDropPlacementRef.current = placement
setNavDropIndicator(el, placement)
},
[]
)
const clearNavDropTarget = useCallback((el: HTMLElement) => {
if (navDropTargetElRef.current === el) {
setNavDropIndicator(el, null)
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}
}, [])
const commitNavDrop = useCallback(
(
payload: SidebarNavDragPayload,
targetId: string,
placement: SidebarNavDropPlacement,
targetKind: "label" | "folder"
) => {
clearNavDrag()
if (payload.id === targetId && placement !== "inside") return
if (targetKind === "label" && payload.kind === "label") {
if (placement === "inside") return
reorderLabelRows(payload.id, targetId, placement)
} else if (targetKind === "folder" && payload.kind === "folder") {
moveFolderRelative(payload.id, targetId, placement)
if (placement === "inside") {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
next.add(targetId)
return next
})
}
}
},
[clearNavDrag, moveFolderRelative, reorderLabelRows, setExpandedFolderIds]
)
return {
navDragRef,
navDropPlacementRef,
beginNavDrag,
clearNavDrag,
updateNavDropTarget,
clearNavDropTarget,
commitNavDrop,
}
}

View File

@ -114,6 +114,43 @@ export const MAIL_COMPOSE_POPOVER_CLASS = cn(
export const MAIL_COMPOSE_MENU_SELECTED_CLASS = "bg-accent text-accent-foreground" export const MAIL_COMPOSE_MENU_SELECTED_CLASS = "bg-accent text-accent-foreground"
export const MAIL_COMPOSE_TOOLBAR_BTN = cn(
"flex h-7 w-7 items-center justify-center rounded text-muted-foreground transition-colors",
"hover:bg-accent hover:text-accent-foreground disabled:opacity-40"
)
export const MAIL_COMPOSE_TOOLBAR_BTN_ACTIVE = "bg-accent text-foreground"
export const MAIL_COMPOSE_TOOLBAR_SEP = "mx-0.5 h-5 w-px bg-border"
export const MAIL_COMPOSE_BOTTOM_ICON_BTN = cn(
"flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition-colors",
"hover:bg-accent hover:text-accent-foreground"
)
export const MAIL_COMPOSE_BOTTOM_ICON_BTN_ACTIVE = "bg-accent text-foreground"
export const MAIL_COMPOSE_PRIMARY_SEND_BTN = cn(
"inline-flex h-9 items-center bg-primary text-primary-foreground",
"hover:bg-primary/90 hover:shadow-md transition-all"
)
export const MAIL_COMPOSE_RECIPIENT_DIVIDER = "ml-3 border-b border-border"
export const MAIL_COMPOSE_SUGGESTION_SELECTED = "bg-primary/10"
export const MAIL_COMPOSE_SUGGESTION_HOVER = "hover:bg-accent"
export const MAIL_COMPOSE_CONTACT_PILL_CLASS = cn(
"inline-flex items-center gap-1 rounded-full bg-muted py-0.5 pl-0.5 pr-2 text-sm text-foreground",
"hover:bg-accent transition-colors"
)
export const MAIL_COMPOSE_DROP_ZONE_CLASS = cn(
"absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-primary",
"bg-primary/5"
)
/** Bouton pilule xs (barres flottantes liste / lecture). */ /** Bouton pilule xs (barres flottantes liste / lecture). */
export const XS_FLOATING_CONTROL_BTN = cn( export const XS_FLOATING_CONTROL_BTN = cn(
"pointer-events-auto size-9 shrink-0 rounded-full border border-border", "pointer-events-auto size-9 shrink-0 rounded-full border border-border",
@ -136,6 +173,106 @@ export const MAIL_TOAST_SURFACE_CLASS = cn(
"bg-mail-surface text-foreground shadow-md ring-1 ring-primary/15" "bg-mail-surface text-foreground shadow-md ring-1 ring-primary/15"
) )
/** Liste — barre doutils (sélection, pagination, refresh). */
export const MAIL_LIST_TOOLBAR_CLASS = "flex shrink-0 items-center gap-0.5 border-b border-border px-2"
export const MAIL_LIST_TOOLBAR_BTN = MAIL_TOOLBAR_ICON_BTN
/** Ligne liste — cases à cocher. */
export const MAIL_LIST_ROW_CHECKBOX_CLASS = cn(
"size-4 min-h-4 min-w-4 shrink-0 border-[1.5px] border-mail-row-checkbox-border bg-transparent shadow-none",
"dark:bg-transparent focus-visible:ring-mail-row-checkbox-border/30",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
)
export const MAIL_LIST_ROW_CHECKBOX_CIRCULAR_CLASS = cn(
MAIL_LIST_ROW_CHECKBOX_CLASS,
"rounded-full"
)
export const MAIL_LIST_ROW_CHECKBOX_SQUARE_CLASS = cn(
MAIL_LIST_ROW_CHECKBOX_CLASS,
"rounded-[2.5px]"
)
export const MAIL_LIST_ROW_DIVIDER_CLASS = "divide-y divide-mail-list-divider"
/** Recherche — champ et panneau avancé. */
export const MAIL_SEARCH_INPUT_WRAP_CLASS = cn(
"relative flex min-w-0 flex-1 items-center rounded-full border border-border",
"bg-mail-surface-elevated shadow-sm transition-shadow focus-within:shadow-md"
)
/** Séparateurs recherche (header, bandeau chips) — bordure lisible en dark. */
export const MAIL_SEARCH_SECTION_DIVIDER_CLASS = "border-mail-border"
/** Chip filtre recherche — inactif. */
export const MAIL_SEARCH_CHIP_INACTIVE_CLASS = cn(
"border-mail-list-chip-border bg-mail-list-chip-muted text-mail-list-chip-text"
)
/** Champs recherche avancée — même fond/bordure que les pickers (label, select). */
export const MAIL_SEARCH_FIELD_CLASS = cn(
"rounded-md border border-solid !border-mail-border bg-mail-surface-muted text-foreground shadow-none",
"focus-visible:!border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"dark:!border-mail-border dark:!bg-mail-surface-muted"
)
/** Cases à cocher recherche avancée — bordure lisible en dark. */
export const MAIL_SEARCH_CHECKBOX_CLASS = cn(
"size-4 border-[1.5px] border-mail-row-checkbox-border bg-mail-surface-muted shadow-none",
"dark:bg-mail-surface-muted",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
"data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"
)
/** Fond vitré suggestions recherche (desktop + mobile). */
export const MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS = cn(
"bg-mail-surface-elevated/80 backdrop-blur-xl backdrop-saturate-150",
"supports-[backdrop-filter]:bg-mail-surface-elevated/65"
)
/** Suggestions recherche (desktop) — panneau déroulant vitré. */
export const MAIL_SEARCH_SUGGESTIONS_DROPDOWN_CLASS = cn(
"absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border text-foreground shadow-lg",
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS
)
export const MAIL_SEARCH_ADVANCED_PANEL_CLASS = cn(
"absolute left-0 top-full z-50 mt-1 max-h-[80vh] overflow-y-auto rounded-lg border",
MAIL_SEARCH_SECTION_DIVIDER_CLASS,
"bg-mail-surface-elevated text-foreground shadow-lg",
"data-mail-search-advanced",
"sm:min-w-[34rem] sm:max-w-[min(42rem,calc(100vw-5rem))]",
"md:min-w-[38rem]",
"lg:right-0 lg:min-w-0 lg:max-w-none"
)
/** Recherche mobile (xs) — plein écran, gris mail en dark (pas `bg-background`). */
export const MAIL_MOBILE_SEARCH_SHEET_CLASS = cn(
"z-[101] flex h-[100dvh] max-h-[100dvh] w-full flex-col gap-0 rounded-none border-0",
"bg-mail-surface text-foreground p-0 shadow-xl",
"duration-300 ease-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
"pb-[env(safe-area-inset-bottom)]",
"data-mail-mobile-search"
)
/** Sidebar — panneau desktop (flou sur le canvas). */
export const MAIL_SIDEBAR_PANEL_SURFACE_CLASS = cn(
"bg-app-canvas/80 backdrop-blur-xl backdrop-saturate-150 supports-[backdrop-filter]:bg-app-canvas/65"
)
/** Sidebar — overlay mobile/touch : classe CSS dédiée (pas bg-* Tailwind). */
export const MAIL_SIDEBAR_PANEL_SURFACE_MOBILE_CLASS = "mail-sidebar-overlay-panel"
export const MAIL_SIDEBAR_OVERFLOW_BTN_CLASS = cn(
"flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground",
"outline-none hover:bg-accent/80 focus-visible:ring-2 focus-visible:ring-ring/50"
)
export function mailNavRowClass(opts: { export function mailNavRowClass(opts: {
isSelected: boolean isSelected: boolean
isOver?: boolean isOver?: boolean

View File

@ -0,0 +1,71 @@
import type { Email } from "@/lib/email-data"
import { effectiveLabels } from "@/lib/label-edits"
import type { LabelEditState } from "@/lib/stores/mail-store"
/** Libellés système exclus du picker « Ajouter le libellé ». */
export const LABEL_PICKER_EXCLUDE = new Set([
"inbox",
"sent",
"drafts",
"spam",
"starred",
])
export function applyNavRenameToEdits(
pool: Email[],
prev: LabelEditState,
from: string,
to: string
): LabelEditState {
const lcFrom = from.toLowerCase()
const toTrim = to.trim()
if (!toTrim) return prev
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const e of pool) {
const id = e.id
const eff = effectiveLabels(e, prev.additions, prev.removals)
if (!eff.some((l) => l.toLowerCase() === lcFrom)) continue
const wanted = eff.map((l) => (l.toLowerCase() === lcFrom ? toTrim : l))
delete nextAdd[id]
delete nextRem[id]
const base = e.labels ?? []
const removals = base.filter(
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
)
const additions = wanted.filter(
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
)
if (removals.length) nextRem[id] = removals
if (additions.length) nextAdd[id] = additions
}
return { additions: nextAdd, removals: nextRem }
}
export function applyNavRemoveLabelToEdits(
pool: Email[],
prev: LabelEditState,
label: string
): LabelEditState {
const lc = label.toLowerCase()
const nextAdd = { ...prev.additions }
const nextRem = { ...prev.removals }
for (const e of pool) {
const id = e.id
const eff = effectiveLabels(e, prev.additions, prev.removals)
if (!eff.some((l) => l.toLowerCase() === lc)) continue
const wanted = eff.filter((l) => l.toLowerCase() !== lc)
delete nextAdd[id]
delete nextRem[id]
const base = e.labels ?? []
const removals = base.filter(
(b) => !wanted.some((w) => w.toLowerCase() === b.toLowerCase())
)
const additions = wanted.filter(
(w) => !base.some((b) => b.toLowerCase() === w.toLowerCase())
)
if (removals.length) nextRem[id] = removals
if (additions.length) nextAdd[id] = additions
}
return { additions: nextAdd, removals: nextRem }
}

View File

@ -0,0 +1,41 @@
import {
buildSearchUrl,
type SearchParams,
} from "@/lib/mail-search/search-params"
export type MailSearchRouter = {
push: (url: string, options?: { scroll?: boolean }) => void
}
export type QuickSearchChipState = {
chipAttachment?: boolean
chipLast7Days?: boolean
chipFromMe?: boolean
fromEmail?: string
}
/** Build params from bar/overlay quick search + filter chips. */
export function buildQuickSearchParams(
query: string,
chips: QuickSearchChipState = {}
): Partial<SearchParams> {
const q = query.trim()
if (!q && !chips.chipAttachment && !chips.chipLast7Days && !chips.chipFromMe) {
return {}
}
const params: Partial<SearchParams> = { q }
if (chips.chipAttachment) params.has = ["attachment"]
if (chips.chipLast7Days) params.within = "1w"
if (chips.chipFromMe && chips.fromEmail) params.from = chips.fromEmail
return params
}
/** Navigate to `/mail/search` with encoded query params. */
export function submitMailSearch(
router: MailSearchRouter,
params: Partial<SearchParams>,
options?: { scroll?: boolean; onAfter?: () => void }
): void {
router.push(buildSearchUrl(params), { scroll: options?.scroll ?? false })
options?.onAfter?.()
}

View File

@ -0,0 +1,28 @@
import type { FolderTreeNode } from "@/lib/sidebar-nav-data"
/** Retourne les ids des parents à ouvrir pour afficher `targetId`, ou null. */
export function ancestorFolderIdsForTarget(
nodes: FolderTreeNode[],
targetId: string,
chain: string[] = []
): string[] | null {
for (const n of nodes) {
if (n.id === targetId) return chain
if (n.children?.length) {
const found = ancestorFolderIdsForTarget(n.children, targetId, [
...chain,
n.id,
])
if (found) return found
}
}
return null
}
export function folderSubtreeContainsId(
node: FolderTreeNode,
targetId: string
): boolean {
if (node.id === targetId) return true
return node.children?.some((c) => folderSubtreeContainsId(c, targetId)) ?? false
}

20
lib/stores/README.md Normal file
View File

@ -0,0 +1,20 @@
# Mail stores (Zustand)
## Persisted (`debounced-json-storage` / `persist`)
- `mail-store` — read/star/important overrides, label edits, recent folder visits
- `mail-settings-store` — density, conversation mode, sort, display prefs
- `nav-store` — sidebar folder/label tree, disabled nav labels
- `account-store` — active account
- `scheduled-store` — scheduled send queue
## Ephemeral UI (session only)
- `mail-search-store` — search input, dropdown, filter chips before URL submit
- `mail-ui-store` — shell chrome: sidebar collapsed, mobile search overlay, folder unread badges
## Rules
- **URL is source of truth** for folder, inbox tab, page, open message (`useMailRoute` + `mail-url.ts`)
- **Search results** use query params on `/mail/search` (`SearchParams`); bar/overlay state syncs via `submitMailSearch` in `lib/mail-search/navigate.ts`
- Do not persist ephemeral UI unless product explicitly requires it

View File

@ -0,0 +1,28 @@
"use client"
import { create } from "zustand"
interface MailUiState {
sidebarCollapsed: boolean
mobileSearchOpen: boolean
folderUnreadCounts: Record<string, number>
}
interface MailUiActions {
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebarCollapsed: () => void
setMobileSearchOpen: (open: boolean) => void
setFolderUnreadCounts: (counts: Record<string, number>) => void
}
export const useMailUiStore = create<MailUiState & MailUiActions>()((set) => ({
sidebarCollapsed: true,
mobileSearchOpen: false,
folderUnreadCounts: {},
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
toggleSidebarCollapsed: () =>
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setMobileSearchOpen: (open) => set({ mobileSearchOpen: open }),
setFolderUnreadCounts: (counts) => set({ folderUnreadCounts: counts }),
}))

File diff suppressed because one or more lines are too long