before split 2
This commit is contained in:
parent
aad897b617
commit
8551150ffe
@ -58,6 +58,11 @@
|
||||
--mail-nav-hover: #f1f3f4;
|
||||
--mail-nav-drop: #fef7cd;
|
||||
--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 {
|
||||
@ -81,6 +86,11 @@
|
||||
--mail-nav-hover: #3c4043;
|
||||
--mail-nav-drop: #4a4428;
|
||||
--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);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.145 0 0);
|
||||
@ -161,6 +171,11 @@
|
||||
--color-mail-border: var(--mail-border);
|
||||
--color-mail-border-subtle: var(--mail-border-subtle);
|
||||
--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 {
|
||||
@ -352,11 +367,8 @@ html::before {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: var(--mail-bg-layer, none);
|
||||
background-color: var(--mail-bg-fallback, transparent);
|
||||
background-image: var(--mail-bg-layer, none);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
@ -365,6 +377,10 @@ html[data-mail-background]:not([data-mail-background='none'])::before {
|
||||
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 {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
@ -413,6 +429,28 @@ html[data-mail-background]:not([data-mail-background='none'])
|
||||
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) ── */
|
||||
html.dark .ultimail-app {
|
||||
color-scheme: dark;
|
||||
@ -520,6 +558,33 @@ html.dark [data-slot='context-menu-separator'] {
|
||||
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) {
|
||||
background-color: var(--mail-nav-hover) !important;
|
||||
}
|
||||
|
||||
@ -5,17 +5,17 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useIsXs } from "@/hooks/use-xs"
|
||||
import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav"
|
||||
import { useMailSplitView } from "@/hooks/use-mail-split-view"
|
||||
import { useMailRoute } from "@/hooks/use-mail-route"
|
||||
import { MobileBottomBar } from "@/components/gmail/mobile-bottom-bar"
|
||||
import { MobileSearchOverlay } from "@/components/gmail/mobile-search-overlay"
|
||||
import type { MailXsViewChrome } from "@/lib/mail-xs-view-chrome"
|
||||
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 { Header } from "@/components/gmail/header"
|
||||
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 { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||
import { useMailStore } from "@/lib/stores/mail-store"
|
||||
import {
|
||||
parseMailSegments,
|
||||
buildMailPath,
|
||||
DEFAULT_INBOX_TAB,
|
||||
type MailRouteState,
|
||||
} from "@/lib/mail-url"
|
||||
import { useMailUiStore } from "@/lib/stores/mail-ui-store"
|
||||
import { DEFAULT_INBOX_TAB } from "@/lib/mail-url"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import { MailThemeApplier } from "@/components/gmail/mail-theme-applier"
|
||||
import { QuickSettingsRoot } from "@/components/gmail/quick-settings/quick-settings-root"
|
||||
|
||||
function segmentsFromPathname(pathname: string | null): string[] | undefined {
|
||||
if (!pathname?.startsWith("/mail")) return undefined
|
||||
const rest = pathname.slice("/mail".length).replace(/^\//, "")
|
||||
if (!rest) return []
|
||||
return rest.split("/").filter(Boolean)
|
||||
}
|
||||
|
||||
function MailAppInner() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const currentSearchParams = useSearchParams()
|
||||
const segments = useMemo(() => segmentsFromPathname(pathname), [pathname])
|
||||
const route = useMemo(() => parseMailSegments(segments), [segments])
|
||||
const { route, navigateRoute, searchParams: currentSearchParams } =
|
||||
useMailRoute()
|
||||
|
||||
const isXs = useIsXs()
|
||||
const touchNav = useTouchNav()
|
||||
const splitView = useMailSplitView()
|
||||
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(() => {
|
||||
if (!readTouchNavMatches()) setSidebarCollapsed(false)
|
||||
}, [])
|
||||
}, [setSidebarCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (isXs) setSidebarCollapsed(true)
|
||||
}, [isXs])
|
||||
}, [isXs, setSidebarCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
if (route.folderId !== "search") {
|
||||
pushRecentFolderVisit(mailNavVisitKey(route.folderId, route.inboxTab))
|
||||
}
|
||||
}, [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(
|
||||
(id: string) => {
|
||||
@ -110,7 +79,7 @@ function MailAppInner() {
|
||||
})
|
||||
if (readTouchNavMatches()) setSidebarCollapsed(true)
|
||||
},
|
||||
[navigateRoute]
|
||||
[navigateRoute, setSidebarCollapsed]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -181,7 +150,9 @@ function MailAppInner() {
|
||||
listPage={route.page}
|
||||
openMailId={route.mailId}
|
||||
splitView={splitView}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
onToggleSidebar={() =>
|
||||
useMailUiStore.getState().toggleSidebarCollapsed()
|
||||
}
|
||||
onMailRouteNavigate={navigateRoute}
|
||||
onSelectFolder={handleSelectFolder}
|
||||
onFolderUnreadCountsChange={setFolderUnreadCounts}
|
||||
@ -202,7 +173,9 @@ function MailAppInner() {
|
||||
{!splitView ? (
|
||||
<MobileBottomBar
|
||||
sidebarOpen={!sidebarCollapsed}
|
||||
onToggleSidebar={() => setSidebarCollapsed((c) => !c)}
|
||||
onToggleSidebar={() =>
|
||||
useMailUiStore.getState().toggleSidebarCollapsed()
|
||||
}
|
||||
xsViewChrome={xsViewChrome}
|
||||
onOpenSearch={() => setMobileSearchOpen(true)}
|
||||
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 },
|
||||
notes: "",
|
||||
labels: [],
|
||||
}, { shouldDirty: true })
|
||||
})
|
||||
clearCreateDraft()
|
||||
}, [mode, createDraft, reset, clearCreateDraft])
|
||||
|
||||
|
||||
@ -206,423 +206,41 @@ import {
|
||||
withTouchFullscreenComposePreset,
|
||||
} from "@/lib/thread-compose-preset"
|
||||
|
||||
addCollection(mdiIcons)
|
||||
|
||||
const LIST_PAGE_SIZE = 50
|
||||
const PULL_HOLD_HEIGHT = 48
|
||||
const PULL_REFRESH_THRESHOLD = 56
|
||||
const PULL_REFRESH_MAX = 112
|
||||
const PULL_SNAP_BACK_TRANSITION =
|
||||
"transform 0.24s cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
const REFRESH_SPIN_CLASS = "animate-[spin_0.55s_linear_infinite]"
|
||||
const PULL_ICON_FADE_MS = 120
|
||||
/** Tirage (px) avant que le spinner ne devienne visible. */
|
||||
const PULL_SPINNER_REVEAL_OFFSET = 26
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** Libellés système qu’on ne propose pas dans « Ajouter le libellé ». */
|
||||
const LABEL_PICKER_EXCLUDE = new Set(["inbox", "sent", "drafts", "spam", "starred"])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
import {
|
||||
LABEL_PICKER_EXCLUDE,
|
||||
applyNavRenameToEdits,
|
||||
applyNavRemoveLabelToEdits,
|
||||
} from "@/lib/mail-list/label-actions"
|
||||
import { EmailListAttachmentRow } from "@/components/gmail/email-list/attachments/email-list-attachment-row"
|
||||
import {
|
||||
MoveToDropdownItems,
|
||||
MoveToContextMenuItems,
|
||||
} from "@/components/gmail/email-list/move-to-menu-items"
|
||||
import { MAIL_LIST_ROW_DIVIDER_CLASS } from "@/lib/mail-chrome-classes"
|
||||
import {
|
||||
LIST_PAGE_SIZE,
|
||||
PULL_HOLD_HEIGHT,
|
||||
PULL_SNAP_BACK_TRANSITION,
|
||||
REFRESH_SPIN_CLASS,
|
||||
PULL_ICON_FADE_MS,
|
||||
PULL_REFRESH_THRESHOLD,
|
||||
computePullOffset,
|
||||
computeSpinnerRevealProgress,
|
||||
type EmailListProps,
|
||||
collectTreeLabels,
|
||||
contextMenuTargetIdsForRow,
|
||||
escapeHtml,
|
||||
importantSignalIcon,
|
||||
buildInboxTabBarItems,
|
||||
inboxTabBadgeCountClass,
|
||||
inboxTabBadgeDotClass,
|
||||
CATEGORY_TAB_ICON_CLASS,
|
||||
listRowCheckboxClass,
|
||||
listRowQuickHoverTrayToneClass,
|
||||
formatScheduledDateTimeDisplay,
|
||||
scheduledIsoToDatetimeLocalValue,
|
||||
parseDatetimeLocalToIso,
|
||||
} from "@/components/gmail/email-list/email-list-helpers"
|
||||
|
||||
export function EmailList({
|
||||
selectedFolder,
|
||||
@ -3629,7 +3247,7 @@ export function EmailList({
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"divide-y divide-[#eceff1]",
|
||||
MAIL_LIST_ROW_DIVIDER_CLASS,
|
||||
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,
|
||||
ReplyAll,
|
||||
Forward,
|
||||
MoreVertical,
|
||||
Printer,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
Info,
|
||||
TriangleAlert,
|
||||
Trash2,
|
||||
Mail,
|
||||
Ban,
|
||||
ShieldAlert,
|
||||
Fish,
|
||||
Flag,
|
||||
SlidersHorizontal,
|
||||
Languages,
|
||||
Download,
|
||||
Code2,
|
||||
MessageCircleWarning,
|
||||
HardDrive,
|
||||
File,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -86,11 +62,9 @@ import { resolveParsedCalendarInvitation } from "@/lib/resolve-email-calendar-in
|
||||
import { ComposeWindow } from "@/components/gmail/compose-modal"
|
||||
import { CalendarInvitationPreview } from "@/components/gmail/calendar-invitation-preview"
|
||||
import { ContactHoverCard } from "./contact-hover-card"
|
||||
import { MailLabelPillStrip } from "./mail-label-pills"
|
||||
import { EmailViewSubjectHeader } from "./email-view/email-view-header"
|
||||
import { EmailViewMessageToolbar } from "./email-view/email-view-toolbar"
|
||||
import {
|
||||
MAIL_ICON_BTN,
|
||||
MAIL_INVITATION_CARD_CLASS,
|
||||
MAIL_MENU_SURFACE_WIDE_CLASS,
|
||||
MAIL_MESSAGE_HOVER_CLASS,
|
||||
MAIL_PREVIEW_SCROLL_CLASS,
|
||||
MAIL_REPLY_BAR_CLASS,
|
||||
@ -102,7 +76,6 @@ import {
|
||||
emailPreviewBaseCss,
|
||||
emailPreviewDarkOverrideCss,
|
||||
emailPreviewLightOverrideCss,
|
||||
emailPreviewSubjectCss,
|
||||
preprocessEmailHtmlForTheme,
|
||||
} from "@/lib/email-preview-dark-styles"
|
||||
|
||||
@ -128,19 +101,6 @@ interface EmailViewProps {
|
||||
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 = {
|
||||
display: "block",
|
||||
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 }) {
|
||||
return (
|
||||
<>
|
||||
@ -462,7 +383,7 @@ function CollapsedMessage({
|
||||
<MailDateText
|
||||
iso={message.date}
|
||||
variant="preview"
|
||||
className="text-xs text-[#5f6368]"
|
||||
className="text-xs text-muted-foreground"
|
||||
/>
|
||||
<Star
|
||||
strokeWidth={1.25}
|
||||
@ -470,7 +391,7 @@ function CollapsedMessage({
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
@ -503,217 +424,19 @@ function ExpandedMessage({
|
||||
onCollapse?: () => void
|
||||
onPrintConversation?: () => void
|
||||
}) {
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const name = cleanSenderName(sender)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Sender row */}
|
||||
<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-[#e8eaed] text-[#e8710a]"
|
||||
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-[#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]"
|
||||
<EmailViewMessageToolbar
|
||||
sender={sender}
|
||||
senderEmail={senderEmail}
|
||||
dateIso={dateIso}
|
||||
isSpam={isSpam}
|
||||
isLast={isLast}
|
||||
starred={starred}
|
||||
onToggleStar={onToggleStar}
|
||||
onCollapse={onCollapse}
|
||||
onPrintConversation={onPrintConversation}
|
||||
/>
|
||||
</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 */}
|
||||
<div
|
||||
@ -739,7 +462,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
|
||||
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="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>{" "}
|
||||
Ce message est semblable à des messages identifiés comme spam par le passé.
|
||||
</p>
|
||||
@ -757,7 +480,7 @@ function SpamWhyBanner({ onNotSpam }: { onNotSpam?: () => void }) {
|
||||
<TooltipTrigger asChild>
|
||||
<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"
|
||||
>
|
||||
<Info className="h-[18px] w-[18px]" strokeWidth={1.75} />
|
||||
@ -903,65 +626,19 @@ export function EmailView({
|
||||
<div ref={previewScrollRef} className={MAIL_PREVIEW_SCROLL_CLASS}>
|
||||
{/* Spacer for floating nav buttons on xs */}
|
||||
<div className="h-[52px] shrink-0 bg-mail-surface sm:hidden" aria-hidden />
|
||||
{/* Subject header */}
|
||||
<div className="flex items-start gap-3 px-6 py-4 max-sm:px-4">
|
||||
<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"]}
|
||||
<EmailViewSubjectHeader
|
||||
email={email}
|
||||
isSpamMessage={isSpamMessage}
|
||||
onNotSpam={onNotSpam}
|
||||
onNavigateToLabel={onNavigateToLabel}
|
||||
showLabelChip={showLabelChip}
|
||||
labelBgByText={labelBgByText}
|
||||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||||
getNavItemPrefs={getNavItemPrefs}
|
||||
labelRows={labelRows}
|
||||
folderTree={folderTree}
|
||||
labelRows={labelRows}
|
||||
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 ? (
|
||||
<CalendarInvitationPreview invitation={calendarInvitation} />
|
||||
@ -970,11 +647,11 @@ export function EmailView({
|
||||
{isSpamMessage && <SpamWhyBanner onNotSpam={onNotSpam} />}
|
||||
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => setShowFullThread(true)}
|
||||
className="text-sm font-medium text-[#1a73e8] hover:underline"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{priorCount === 1
|
||||
? "Afficher la réponse"
|
||||
@ -990,7 +667,7 @@ export function EmailView({
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={msg.id} className="border-b border-[#eceff1]">
|
||||
<div key={msg.id} className="border-b border-border">
|
||||
<ExpandedMessage
|
||||
sender={msg.sender}
|
||||
senderEmail={msg.senderEmail}
|
||||
@ -1022,7 +699,7 @@ export function EmailView({
|
||||
sender={mainSenderName}
|
||||
senderEmail={mainSenderAddr}
|
||||
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}
|
||||
isLast={true}
|
||||
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,
|
||||
type SearchParams,
|
||||
} 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"
|
||||
|
||||
interface MailSearchBarProps {
|
||||
@ -107,74 +118,59 @@ function AdvancedSearchPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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={MAIL_SEARCH_ADVANCED_PANEL_CLASS}>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
De
|
||||
</Label>
|
||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">De</Label>
|
||||
<Input
|
||||
value={from}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
À
|
||||
</Label>
|
||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">À</Label>
|
||||
<Input
|
||||
value={to}
|
||||
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 className="flex items-center gap-3">
|
||||
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
Objet
|
||||
</Label>
|
||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Objet</Label>
|
||||
<Input
|
||||
value={subject}
|
||||
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 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
|
||||
</Label>
|
||||
<Input
|
||||
value={hasWords}
|
||||
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 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
|
||||
</Label>
|
||||
<Input
|
||||
value={doesNotHave}
|
||||
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 className="flex items-center gap-3">
|
||||
<Label className="w-36 shrink-0 text-sm text-gray-600 dark:text-gray-400">
|
||||
Taille
|
||||
</Label>
|
||||
<Label className="w-36 shrink-0 text-sm text-muted-foreground">Taille</Label>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -186,10 +182,10 @@ function AdvancedSearchPanel({
|
||||
type="number"
|
||||
value={sizeVal}
|
||||
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")}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectTrigger className={cn("h-8 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -201,12 +197,12 @@ function AdvancedSearchPanel({
|
||||
</div>
|
||||
|
||||
<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
|
||||
</Label>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -221,17 +217,17 @@ function AdvancedSearchPanel({
|
||||
type="date"
|
||||
value={dateAfter}
|
||||
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 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
|
||||
</Label>
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -245,15 +241,17 @@ function AdvancedSearchPanel({
|
||||
</div>
|
||||
|
||||
<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
|
||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
||||
checked={hasAttachment}
|
||||
onCheckedChange={(v) => setHasAttachment(v === true)}
|
||||
/>
|
||||
Contenant une pièce jointe
|
||||
</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
|
||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
||||
checked={excludeChats}
|
||||
onCheckedChange={(v) => setExcludeChats(v === true)}
|
||||
/>
|
||||
@ -261,7 +259,12 @@ function AdvancedSearchPanel({
|
||||
</label>
|
||||
</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>
|
||||
Créer un filtre
|
||||
</Button>
|
||||
@ -343,29 +346,39 @@ export function MailSearchBar({
|
||||
const submitSearch = useCallback(
|
||||
(overrideQuery?: string) => {
|
||||
const q = overrideQuery ?? inputValue
|
||||
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return
|
||||
const params: Partial<SearchParams> = { q: q.trim() }
|
||||
if (chipAttachment) params.has = ["attachment"]
|
||||
if (chipLast7Days) params.within = "1w"
|
||||
if (chipFromMe) params.from = account.email
|
||||
router.push(buildSearchUrl(params))
|
||||
const params = buildQuickSearchParams(q, {
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
})
|
||||
if (!Object.keys(params).length) return
|
||||
submitMailSearch(router, params, {
|
||||
onAfter: () => {
|
||||
setDropdownOpen(false)
|
||||
inputRef.current?.blur()
|
||||
},
|
||||
})
|
||||
},
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
||||
)
|
||||
|
||||
const selectSuggestion = useCallback(
|
||||
(s: SearchSuggestion) => {
|
||||
const params: Partial<SearchParams> = { q: s.email }
|
||||
if (chipAttachment) params.has = ["attachment"]
|
||||
if (chipLast7Days) params.within = "1w"
|
||||
if (chipFromMe) params.from = account.email
|
||||
router.push(buildSearchUrl(params))
|
||||
const params = buildQuickSearchParams(s.email, {
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
})
|
||||
submitMailSearch(router, params, {
|
||||
onAfter: () => {
|
||||
setInputValue(s.email)
|
||||
setDropdownOpen(false)
|
||||
inputRef.current?.blur()
|
||||
},
|
||||
})
|
||||
},
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account.email, router]
|
||||
)
|
||||
|
||||
@ -535,7 +548,7 @@ export function MailSearchBar({
|
||||
<div
|
||||
id="search-suggestions"
|
||||
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 */}
|
||||
<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,
|
||||
type SearchParams,
|
||||
} 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 {
|
||||
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 {
|
||||
open: boolean
|
||||
@ -60,13 +73,22 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
const account = useActiveAccount()
|
||||
const contacts = useContactsStore((s) => s.contacts)
|
||||
|
||||
const [inputValue, setInputValue] = useState(initialQuery)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [chipAttachment, setChipAttachment] = useState(false)
|
||||
const [chipLast7Days, setChipLast7Days] = useState(false)
|
||||
const [chipFromMe, setChipFromMe] = useState(false)
|
||||
const [advancedMode, setAdvancedMode] = useState(false)
|
||||
const inputValue = useMailSearchStore((s) => s.inputValue)
|
||||
const selectedIndex = useMailSearchStore((s) => s.selectedIndex)
|
||||
const chipAttachment = useMailSearchStore((s) => s.chipAttachment)
|
||||
const chipLast7Days = useMailSearchStore((s) => s.chipLast7Days)
|
||||
const chipFromMe = useMailSearchStore((s) => s.chipFromMe)
|
||||
const {
|
||||
setInputValue,
|
||||
setSelectedIndex,
|
||||
toggleChipAttachment,
|
||||
toggleChipLast7Days,
|
||||
toggleChipFromMe,
|
||||
resetChips,
|
||||
reset,
|
||||
} = useMailSearchStore.getState()
|
||||
|
||||
const [advancedMode, setAdvancedMode] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -75,13 +97,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
setAdvancedMode(false)
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
} else {
|
||||
setSelectedIndex(-1)
|
||||
setChipAttachment(false)
|
||||
setChipLast7Days(false)
|
||||
setChipFromMe(false)
|
||||
reset()
|
||||
setAdvancedMode(false)
|
||||
}
|
||||
}, [open, initialQuery])
|
||||
}, [open, initialQuery, setInputValue, reset])
|
||||
|
||||
const suggestions = useMemo<SearchSuggestion[]>(() => {
|
||||
if (!inputValue.trim()) return []
|
||||
@ -102,25 +121,27 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
const submitSearch = useCallback(
|
||||
(overrideQuery?: string) => {
|
||||
const q = overrideQuery ?? inputValue
|
||||
if (!q.trim() && !chipAttachment && !chipLast7Days && !chipFromMe) return
|
||||
const params: Partial<SearchParams> = { q: q.trim() }
|
||||
if (chipAttachment) params.has = ["attachment"]
|
||||
if (chipLast7Days) params.within = "1w"
|
||||
if (chipFromMe) params.from = account.email
|
||||
router.push(buildSearchUrl(params))
|
||||
onClose()
|
||||
const params = buildQuickSearchParams(q, {
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
})
|
||||
if (!Object.keys(params).length) return
|
||||
submitMailSearch(router, params, { onAfter: onClose })
|
||||
},
|
||||
[inputValue, chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
|
||||
)
|
||||
|
||||
const selectSuggestion = useCallback(
|
||||
(s: SearchSuggestion) => {
|
||||
const params: Partial<SearchParams> = { q: s.email }
|
||||
if (chipAttachment) params.has = ["attachment"]
|
||||
if (chipLast7Days) params.within = "1w"
|
||||
if (chipFromMe) params.from = account.email
|
||||
router.push(buildSearchUrl(params))
|
||||
onClose()
|
||||
const params = buildQuickSearchParams(s.email, {
|
||||
chipAttachment,
|
||||
chipLast7Days,
|
||||
chipFromMe,
|
||||
fromEmail: account.email,
|
||||
})
|
||||
submitMailSearch(router, params, { onAfter: onClose })
|
||||
},
|
||||
[chipAttachment, chipLast7Days, chipFromMe, account.email, router, onClose]
|
||||
)
|
||||
@ -130,11 +151,11 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0))
|
||||
setSelectedIndex(selectedIndex < totalItems - 1 ? selectedIndex + 1 : 0)
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1))
|
||||
setSelectedIndex(selectedIndex > 0 ? selectedIndex - 1 : totalItems - 1)
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
@ -165,21 +186,20 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
side="bottom"
|
||||
hideClose
|
||||
overlayClassName="z-[100] bg-black/40"
|
||||
className={cn(
|
||||
"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)]"
|
||||
)}
|
||||
className={MAIL_MOBILE_SEARCH_SHEET_CLASS}
|
||||
>
|
||||
<SheetTitle className="sr-only">Rechercher dans les messages</SheetTitle>
|
||||
{/* 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
|
||||
variant="ghost"
|
||||
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() }}
|
||||
aria-label="Retour"
|
||||
>
|
||||
@ -187,7 +207,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
</Button>
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
{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>{ghostText}</span>
|
||||
</div>
|
||||
@ -203,7 +223,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -211,7 +231,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 shrink-0 text-gray-600"
|
||||
className="size-10 shrink-0 text-muted-foreground"
|
||||
onClick={() => {
|
||||
setInputValue("")
|
||||
inputRef.current?.focus()
|
||||
@ -224,7 +244,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10 shrink-0 text-gray-600"
|
||||
className="size-10 shrink-0 text-muted-foreground"
|
||||
onClick={() => setAdvancedMode(!advancedMode)}
|
||||
aria-label="Recherche avancée"
|
||||
>
|
||||
@ -238,17 +258,22 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
onSubmit={(url) => { router.push(url); onClose() }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col", MAIL_SEARCH_SUGGESTIONS_SURFACE_CLASS)}>
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={() => setChipAttachment(!chipAttachment)}
|
||||
onClick={() => toggleChipAttachment()}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
||||
chipAttachment
|
||||
? "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" />
|
||||
@ -256,12 +281,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChipLast7Days(!chipLast7Days)}
|
||||
onClick={() => toggleChipLast7Days()}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
||||
chipLast7Days
|
||||
? "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" />
|
||||
@ -269,12 +294,12 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChipFromMe(!chipFromMe)}
|
||||
onClick={() => toggleChipFromMe()}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs transition-colors",
|
||||
chipFromMe
|
||||
? "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" />
|
||||
@ -296,8 +321,8 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
key={`c-${s.contact.id}-${s.email}`}
|
||||
type="button"
|
||||
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",
|
||||
isSelected && "bg-gray-100 dark: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-mail-nav-hover"
|
||||
)}
|
||||
onClick={() => selectSuggestion(s)}
|
||||
>
|
||||
@ -308,10 +333,10 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
{initial}
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-500">
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{s.email}
|
||||
</div>
|
||||
</div>
|
||||
@ -323,16 +348,16 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
key={`e-${s.email}`}
|
||||
type="button"
|
||||
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",
|
||||
isSelected && "bg-gray-100 dark: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-mail-nav-hover"
|
||||
)}
|
||||
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" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-gray-700 dark:text-gray-300">
|
||||
<div className="truncate text-foreground">
|
||||
{s.email}
|
||||
</div>
|
||||
</div>
|
||||
@ -344,17 +369,17 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
selectedIndex === suggestions.length && "bg-gray-100 dark: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-mail-nav-hover"
|
||||
)}
|
||||
onClick={() => submitSearch()}
|
||||
>
|
||||
<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>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground">
|
||||
Tous les résultats pour «
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
<span className="font-medium text-foreground">
|
||||
{inputValue}
|
||||
</span>
|
||||
»
|
||||
@ -363,7 +388,7 @@ export function MobileSearchOverlay({ open, onClose, initialQuery = "" }: Mobile
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@ -416,30 +441,50 @@ function MobileAdvancedSearch({
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">De</Label>
|
||||
<Input value={from} onChange={(e) => setFrom(e.target.value)} className="h-9 text-sm" />
|
||||
<Label className="text-xs text-muted-foreground">De</Label>
|
||||
<Input
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">À</Label>
|
||||
<Input value={to} onChange={(e) => setTo(e.target.value)} className="h-9 text-sm" />
|
||||
<Label className="text-xs text-muted-foreground">À</Label>
|
||||
<Input
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">Objet</Label>
|
||||
<Input value={subject} onChange={(e) => setSubject(e.target.value)} className="h-9 text-sm" />
|
||||
<Label className="text-xs text-muted-foreground">Objet</Label>
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">Contient les mots</Label>
|
||||
<Input value={hasWords} onChange={(e) => setHasWords(e.target.value)} className="h-9 text-sm" />
|
||||
<Label className="text-xs text-muted-foreground">Contient les mots</Label>
|
||||
<Input
|
||||
value={hasWords}
|
||||
onChange={(e) => setHasWords(e.target.value)}
|
||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-gray-500">Ne contient pas</Label>
|
||||
<Input value={doesNotHave} onChange={(e) => setDoesNotHave(e.target.value)} className="h-9 text-sm" />
|
||||
<Label className="text-xs text-muted-foreground">Ne contient pas</Label>
|
||||
<Input
|
||||
value={doesNotHave}
|
||||
onChange={(e) => setDoesNotHave(e.target.value)}
|
||||
className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -451,10 +496,10 @@ function MobileAdvancedSearch({
|
||||
type="number"
|
||||
value={sizeVal}
|
||||
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")}>
|
||||
<SelectTrigger className="h-9 w-20 text-sm">
|
||||
<SelectTrigger className={cn("h-9 w-20 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -465,9 +510,9 @@ function MobileAdvancedSearch({
|
||||
</div>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
||||
<SelectValue placeholder="Sélectionner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -480,9 +525,9 @@ function MobileAdvancedSearch({
|
||||
</Select>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectTrigger className={cn("h-9 text-sm", MAIL_SEARCH_FIELD_CLASS)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -495,15 +540,17 @@ function MobileAdvancedSearch({
|
||||
</Select>
|
||||
</div>
|
||||
<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
|
||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
||||
checked={hasAttachment}
|
||||
onCheckedChange={(v) => setHasAttachment(v === true)}
|
||||
/>
|
||||
Contenant une pièce jointe
|
||||
</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
|
||||
className={MAIL_SEARCH_CHECKBOX_CLASS}
|
||||
checked={excludeChats}
|
||||
onCheckedChange={(v) => setExcludeChats(v === true)}
|
||||
/>
|
||||
|
||||
@ -116,642 +116,46 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
|
||||
/** Retourne les ids des parents à ouvrir pour afficher `targetId`, ou null. */
|
||||
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
|
||||
}
|
||||
|
||||
function folderSubtreeContainsId(node: FolderTreeNode, targetId: string): boolean {
|
||||
if (node.id === targetId) return true
|
||||
return node.children?.some((c) => folderSubtreeContainsId(c, targetId)) ?? false
|
||||
}
|
||||
import {
|
||||
ancestorFolderIdsForTarget,
|
||||
folderSubtreeContainsId,
|
||||
} from "@/lib/sidebar-folder-tree-utils"
|
||||
import {
|
||||
mainItems,
|
||||
CATEGORY_IDS_IN_PLUS_ONLY,
|
||||
sortSystemLabelRows,
|
||||
sidebarSecondaryActions,
|
||||
hasPlusOnlyExtras,
|
||||
LABEL_MENU_COLOR_SWATCHES,
|
||||
type CategoryNavSourceItem,
|
||||
} from "@/components/gmail/sidebar/sidebar-nav-constants"
|
||||
import {
|
||||
LabelMenuOptionWithCheck,
|
||||
ContextLabelMenuOptionWithCheck,
|
||||
folderParentSelectOptions,
|
||||
navRowRoundedWhenActive,
|
||||
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 {
|
||||
selectedFolder: string
|
||||
onSelectFolder: (folder: string) => void
|
||||
collapsed: boolean
|
||||
/** Nombre de messages non lus par id de ligne (boîte, catégorie, dossier, libellé). */
|
||||
folderUnreadCounts?: Record<string, number>
|
||||
/** md+ split pane: mobile-style branding, no header compose. */
|
||||
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({
|
||||
selectedFolder,
|
||||
onSelectFolder,
|
||||
@ -791,74 +195,19 @@ export function Sidebar({
|
||||
setLabelRowEnabled,
|
||||
} = useSidebarNav()
|
||||
|
||||
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
|
||||
const {
|
||||
navDragRef,
|
||||
navDropPlacementRef,
|
||||
beginNavDrag,
|
||||
clearNavDrag,
|
||||
updateNavDropTarget,
|
||||
clearNavDropTarget,
|
||||
commitNavDrop,
|
||||
} = useSidebarNavDrag({
|
||||
reorderLabelRows,
|
||||
moveFolderRelative,
|
||||
setExpandedFolderIds,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[clearNavDrag, moveFolderRelative, reorderLabelRows]
|
||||
)
|
||||
|
||||
const visibleNavLabelRows = useMemo(() => {
|
||||
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",
|
||||
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
|
||||
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 && hasUnread && "text-gray-900",
|
||||
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 (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
data-sidebar
|
||||
data-sidebar-overlay={isOverlayOpen ? "" : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={cn(
|
||||
"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]",
|
||||
splitView && "border-r border-gray-200",
|
||||
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
|
||||
@ -2483,7 +1840,7 @@ export function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center",
|
||||
SIDEBAR_PANEL_SURFACE_CLASS,
|
||||
panelSurfaceClass,
|
||||
splitView
|
||||
? cn(
|
||||
splitViewLogoHeaderClass,
|
||||
@ -2524,7 +1881,7 @@ export function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"hidden shrink-0 z-10 pt-1 pb-3 pl-2 sm:flex",
|
||||
SIDEBAR_PANEL_SURFACE_CLASS,
|
||||
panelSurfaceClass,
|
||||
isExpanded ? "pr-3.5" : "pr-2",
|
||||
splitView && "!hidden"
|
||||
)}
|
||||
@ -2680,7 +2037,7 @@ export function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
|
||||
SIDEBAR_PANEL_SURFACE_CLASS
|
||||
panelSurfaceClass
|
||||
)}
|
||||
title={!isExpanded ? "Dossiers" : undefined}
|
||||
>
|
||||
@ -2721,7 +2078,7 @@ export function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 pl-6 pr-3",
|
||||
SIDEBAR_PANEL_SURFACE_CLASS
|
||||
panelSurfaceClass
|
||||
)}
|
||||
title={!isExpanded ? "Libellés" : undefined}
|
||||
>
|
||||
@ -2765,7 +2122,7 @@ export function Sidebar({
|
||||
<div
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
|
||||
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_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). */
|
||||
export const XS_FLOATING_CONTROL_BTN = cn(
|
||||
"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"
|
||||
)
|
||||
|
||||
/** 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: {
|
||||
isSelected: 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