before split 2
This commit is contained in:
parent
aad897b617
commit
8551150ffe
@ -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 qu’un 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
356
components/gmail/compose/compose-editor-chrome.tsx
Normal file
356
components/gmail/compose/compose-editor-chrome.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
405
components/gmail/compose/compose-recipients.tsx
Normal file
405
components/gmail/compose/compose-recipients.tsx
Normal 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">
|
||||||
|
<{compose.from.email}>
|
||||||
|
</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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
components/gmail/compose/compose-shared.ts
Normal file
36
components/gmail/compose/compose-shared.ts
Normal 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>`
|
||||||
|
}
|
||||||
994
components/gmail/compose/compose-toolbar.tsx
Normal file
994
components/gmail/compose/compose-toolbar.tsx
Normal 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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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 qu’on 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, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 l’URL. */
|
|
||||||
inboxTab: string
|
|
||||||
/** Page de liste (1-based), depuis l’URL. */
|
|
||||||
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 d’un 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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
components/gmail/email-list/email-list-helpers.ts
Normal file
145
components/gmail/email-list/email-list-helpers.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
1
components/gmail/email-list/index.tsx
Normal file
1
components/gmail/email-list/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { EmailList } from "@/components/gmail/email-list"
|
||||||
127
components/gmail/email-list/move-to-menu-items.tsx
Normal file
127
components/gmail/email-list/move-to-menu-items.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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, "<").replace(/>/g, ">")}</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]"> <{senderEmail}></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} <{senderEmail}></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'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'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}
|
||||||
|
|||||||
183
components/gmail/email-view/email-view-header.tsx
Normal file
183
components/gmail/email-view/email-view-header.tsx
Normal 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, "<").replace(/>/g, ">")}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
297
components/gmail/email-view/email-view-toolbar.tsx
Normal file
297
components/gmail/email-view/email-view-toolbar.tsx
Normal 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"> <{senderEmail}></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} <{senderEmail}></span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
à : <span className="text-foreground">moi</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
date :{" "}
|
||||||
|
<MailDateText iso={dateIso} variant="detail" className="text-foreground" />
|
||||||
|
</p>
|
||||||
|
{isSpam && (
|
||||||
|
<p className="text-destructive">
|
||||||
|
sécurité : ce message est marqué comme spam — les images et appels externes
|
||||||
|
sont bloqués
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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'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'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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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 «
|
Tous les résultats pour «
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
<span className="font-medium text-foreground">
|
||||||
{inputValue}
|
{inputValue}
|
||||||
</span>
|
</span>
|
||||||
»
|
»
|
||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 d’accent 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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
297
components/gmail/sidebar/category-nav-row.tsx
Normal file
297
components/gmail/sidebar/category-nav-row.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
components/gmail/sidebar/sidebar-nav-constants.ts
Normal file
78
components/gmail/sidebar/sidebar-nav-constants.ts
Normal 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
|
||||||
|
}
|
||||||
294
components/gmail/sidebar/sidebar-nav-primitives.tsx
Normal file
294
components/gmail/sidebar/sidebar-nav-primitives.tsx
Normal 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
|
||||||
133
hooks/use-mail-list-pull-refresh.ts
Normal file
133
hooks/use-mail-list-pull-refresh.ts
Normal 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
51
hooks/use-mail-route.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
103
hooks/use-sidebar-nav-drag.ts
Normal file
103
hooks/use-sidebar-nav-drag.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 d’outils (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
|
||||||
|
|||||||
71
lib/mail-list/label-actions.ts
Normal file
71
lib/mail-list/label-actions.ts
Normal 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 }
|
||||||
|
}
|
||||||
41
lib/mail-search/navigate.ts
Normal file
41
lib/mail-search/navigate.ts
Normal 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?.()
|
||||||
|
}
|
||||||
28
lib/sidebar-folder-tree-utils.ts
Normal file
28
lib/sidebar-folder-tree-utils.ts
Normal 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
20
lib/stores/README.md
Normal 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
|
||||||
28
lib/stores/mail-ui-store.ts
Normal file
28
lib/stores/mail-ui-store.ts
Normal 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
Loading…
Reference in New Issue
Block a user