"use client" import { Inbox, Star, Clock, ClockArrowUp, Send, FileText, Tag, ChevronDown, ChevronRight, GripVertical, Pencil, Plus, Bot, Folder, MoreVertical, Newspaper, LayoutGrid, Rss, Mail, ShieldAlert, Check, Trash2, } from "lucide-react" import { cn, formatCount } from "@/lib/utils" import { useIsXs } from "@/hooks/use-xs" import { readTouchNavMatches, useTouchNav } from "@/hooks/use-touch-nav" import { useState, useRef, useEffect, useMemo, useCallback, type ReactNode, type CSSProperties, } from "react" import { useEmailDropTarget } from "@/lib/drag-context" import { readSidebarNavDragData, resolveNavDropPlacement, setSidebarNavDragData, type SidebarNavDragPayload, type SidebarNavDropPlacement, } from "@/lib/sidebar-nav-dnd" import { useComposeActions } from "@/lib/compose-context" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { type FolderTreeNode, isSystemNavLabelId, SYSTEM_NAV_LABEL_DEFAULTS } from "@/lib/sidebar-nav-data" import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Icon, addCollection } from "@iconify/react" import { icons as mdiIcons } from "@iconify-json/mdi" import { UltiMailLogo } from "@/components/ultimail-logo" import { SidebarNavOptionsSheet, SidebarNavSheetAction, SidebarNavSheetCheckOption, SidebarNavSheetColorPicker, SidebarNavSheetDivider, SidebarNavSheetSectionLabel, } from "@/components/gmail/sidebar-nav-options-sheet" import { useSidebarTouchOptionsMenu } from "@/components/gmail/use-sidebar-touch-options" addCollection(mdiIcons) import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, 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 } 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 /** 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: Tag }, { 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(["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 ( { e.stopPropagation() onPick() }} className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm text-gray-800 focus:bg-gray-100" > {children} {checked ? ( ) : null} ) } function ContextLabelMenuOptionWithCheck({ checked, onPick, children, }: { checked: boolean onPick: () => void children: ReactNode }) { return ( onPick()} className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm" > {children} {checked ? ( ) : null} ) } 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" } /** 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 SidebarNavDragHandle({ label, onDragStart, onDragEnd, }: { label: string onDragStart: (e: React.DragEvent) => void onDragEnd: () => void }) { return ( 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" > ) } /** 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 (
{formatCount(unread)}
) } const countHoverHide = `group-hover/${hoverGroup}:opacity-0` const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100` return (
{unread > 0 && ( {formatCount(unread)} )}
{children}
) } 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(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 rowIcon = item.icon ? ( ) : ( ) if (isHiddenRow) { return ( <>
{!touchNav && ( { onEnableNavLabel(item.id) setMenuOpen(false) }} > Réactiver le libellé )}
{touchNav && ( { onEnableNavLabel(item.id) closeSheet() }} > Réactiver le libellé )} ) } return ( <>
{showCategoryMenu && ( {!touchNav && ( Afficher { onDisableNavLabel(item.id) setMenuOpen(false) }} > Désactiver le libellé )} )}
{touchNav && showCategoryMenu && (
Afficher
{ onDisableNavLabel(item.id) closeSheet() }} > Désactiver le libellé
)} ) } export function Sidebar({ selectedFolder, onSelectFolder, collapsed, folderUnreadCounts = {}, splitView = false, }: SidebarProps) { const { openCompose } = useComposeActions() const [hoverExpanded, setHoverExpanded] = useState(false) const [navMoreOpen, setNavMoreOpen] = useState(false) const [expandedFolderIds, setExpandedFolderIds] = useState>(() => new Set()) const hoverTimeoutRef = useRef(null) const sidebarRef = useRef(null) const touchNav = useTouchNav() const isXs = useIsXs() const isExpanded = !collapsed || (!touchNav && hoverExpanded) const isOverlayOpen = touchNav && !collapsed const { folderTree, labelRows, folderIdToLabel, addFolder, addLabelRowFromSidebar, getNavItemPrefs, setNavItemSidebarVisibility, setNavItemMessageVisibility, updateFolderOrLabelColor, renameFolderOrLabel, removeFolderOrLabelRow, moveFolder, reorderLabelRows, moveFolderRelative, addSubfolder, addChildLabelRow, setLabelRowEnabled, } = useSidebarNav() const navDragRef = useRef(null) const navDragSourceElRef = useRef(null) const navDropTargetElRef = useRef(null) const navDropPlacementRef = useRef(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] ) const visibleNavLabelRows = useMemo(() => { return labelRows.filter((row) => { if (row.enabled === false) return false if (isSystemNavLabelId(row.id)) return false const p = getNavItemPrefs(row.id) if (p.sidebar === "hide") return false if ( p.sidebar === "showUnread" && (folderUnreadCounts[row.id] ?? 0) === 0 ) { return false } return true }) }, [labelRows, getNavItemPrefs, folderUnreadCounts]) const validNavFolderIds = useMemo(() => { const s = new Set() for (const i of mainItems) s.add(i.id) for (const k of Object.keys(folderIdToLabel)) s.add(k) return s }, [folderIdToLabel]) useEffect(() => { if (!validNavFolderIds.has(selectedFolder)) { onSelectFolder("inbox") } }, [validNavFolderIds, selectedFolder, onSelectFolder]) const [folderDialogOpen, setFolderDialogOpen] = useState(false) const [labelDialogOpen, setLabelDialogOpen] = useState(false) const [newFolderName, setNewFolderName] = useState("") const [newFolderParent, setNewFolderParent] = useState("__root__") const [newLabelName, setNewLabelName] = useState("") const newFolderNameInputRef = useRef(null) const newLabelNameInputRef = useRef(null) const folderParentOptions = useMemo( () => folderParentSelectOptions(folderTree), [folderTree] ) const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => { const systemEnabled = sortSystemLabelRows( labelRows.filter((r) => r.enabled !== false && isSystemNavLabelId(r.id)) ).map((r) => ({ id: r.id, label: r.label, icon: r.icon })) return { primaryVisibleCategories: systemEnabled.filter( (c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id) ), plusOnlyVisibleCategories: systemEnabled.filter((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id) ), } }, [labelRows]) const disabledSystemNavItems = useMemo(() => { return sortSystemLabelRows( labelRows.filter((r) => r.enabled === false && isSystemNavLabelId(r.id)) ).map((r) => ({ id: r.id, label: r.label, icon: r.icon })) }, [labelRows]) const visibleMainItems = useMemo(() => { const scheduledTotal = folderUnreadCounts.scheduled ?? 0 if (scheduledTotal > 0) return mainItems return mainItems.filter((item) => item.id !== "scheduled") }, [folderUnreadCounts.scheduled]) const toggleFolderExpanded = (id: string) => { setExpandedFolderIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const handleSubmitNewFolder = () => { const name = newFolderName.trim() if (!name) return const parentId = newFolderParent === "__root__" ? null : newFolderParent addFolder(parentId, name) setNewFolderName("") setFolderDialogOpen(false) } const handleSubmitNewLabel = () => { const name = newLabelName.trim() if (!name) return addLabelRowFromSidebar(name) setNewLabelName("") setLabelDialogOpen(false) } useEffect(() => { const row = labelRows.find((r) => r.id === selectedFolder) if (row && row.enabled === false) { onSelectFolder("inbox") } }, [labelRows, selectedFolder, onSelectFolder]) useEffect(() => { if (selectedFolder !== "scheduled") return if ((folderUnreadCounts.scheduled ?? 0) > 0) return onSelectFolder("inbox") }, [folderUnreadCounts.scheduled, selectedFolder, onSelectFolder]) useEffect(() => { if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder) && !navMoreOpen) { setNavMoreOpen(true) } }, [selectedFolder, navMoreOpen]) useEffect(() => { const ancestors = ancestorFolderIdsForTarget(folderTree, selectedFolder) if (ancestors?.length) { setExpandedFolderIds((prev) => { const next = new Set(prev) ancestors.forEach((id) => next.add(id)) return next }) } }, [selectedFolder]) const handleMouseEnter = () => { if (readTouchNavMatches()) return if (collapsed) { hoverTimeoutRef.current = setTimeout(() => { setHoverExpanded(true) }, 300) } } const handleMouseLeave = () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current) hoverTimeoutRef.current = null } if (readTouchNavMatches()) return setHoverExpanded(false) } useEffect(() => { if (touchNav) setHoverExpanded(false) }, [touchNav, collapsed]) useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current) } } }, []) /** Inset rows from sidebar right edge (padding works with w-full; margin-right often clips under overflow-x-hidden). */ const navRailInset = "pr-3.5" /** pl-6 + demi-largeur icône nav (h-5) → axe à 34px ; picto split (size-9) centré sur cet axe. */ const splitViewLogoIconClass = "size-9 shrink-0" const splitViewLogoHeaderClass = "min-h-10 pl-4 pr-3.5 pb-2" /** Same row geometry collapsed / expanded / hover so icons never jump (h-8, pl-6 icon column). */ const NavItem = ({ item, isSelected, unreadCount, }: { item: { id: string; label: string; icon: React.ElementType } isSelected: boolean unreadCount: number }) => { const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label) const hasUnread = unreadCount > 0 return ( ) } const FolderRowExpanded = ({ node, depth, }: { node: FolderTreeNode depth: number }) => { const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label) const hasChildren = !!(node.children?.length) const isBranchOpen = expandedFolderIds.has(node.id) const dotClass = node.color ?? "bg-gray-400" const isSelected = selectedFolder === node.id const unread = folderUnreadCounts[node.id] ?? 0 const hasUnread = unread > 0 const isStickyBranch = hasChildren && isBranchOpen const stickyTopPx = 32 + depth * 32 const [menuOpen, setMenuOpen] = useState(false) const [contextMenuOpen, setContextMenuOpen] = useState(false) const menuTriggerRef = useRef(null) const [renameOpen, setRenameOpen] = useState(false) const [renameDraft, setRenameDraft] = useState(node.label) const [moveOpen, setMoveOpen] = useState(false) const [moveParent, setMoveParent] = useState("__root__") const [subfolderOpen, setSubfolderOpen] = useState(false) const [subfolderName, setSubfolderName] = useState("") const folderRenameInputRef = useRef(null) const subfolderNameInputRef = useRef(null) const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } = useSidebarTouchOptionsMenu(touchNav && isExpanded) useEffect(() => { setRenameDraft(node.label) }, [node.label]) const handleMenuOpenChange = (open: boolean) => { setMenuOpen(open) if (!open) { queueMicrotask(() => menuTriggerRef.current?.blur()) } } const rowHoverHeld = !isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen) const prefs = getNavItemPrefs(node.id) const moveTargets = useMemo( () => folderMoveParentOptions(folderTree, node.id), [folderTree, node.id] ) const folderMenuSurface = "min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md" const colorSub = ( subKind: "dropdown" | "context" ) => { const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub const SubTr = subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger const SubCo = subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent return ( Couleur du dossier
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
) } const rowClass = cn( "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", isSelected && "bg-[#d3e3fd] font-medium text-gray-900", !isSelected && hasUnread && "text-gray-900", isOver && "bg-yellow-100 text-gray-900", rowHoverHeld && "bg-gray-100 text-gray-900", touchRowClassName ) const rowStyle: CSSProperties = { paddingLeft: 24 + depth * 16, ...(isStickyBranch ? { top: stickyTopPx, zIndex: 30 - depth } : {}), } const overflowMenu = ( {!touchNav && ( {colorSub("dropdown")} Dans la liste des dossiers setNavItemSidebarVisibility(node.id, "show")} > Afficher setNavItemSidebarVisibility(node.id, "showUnread")} > Afficher si messages non lus setNavItemSidebarVisibility(node.id, "hide")} > Masquer Dans la liste des messages setNavItemMessageVisibility(node.id, "show")} > Afficher setNavItemMessageVisibility(node.id, "hide")} > Masquer { setRenameDraft(node.label) setRenameOpen(true) setMenuOpen(false) }} > Renommer… { setMoveParent("__root__") setMoveOpen(true) setMenuOpen(false) }} > Déplacer… { setSubfolderName("") setSubfolderOpen(true) setMenuOpen(false) }} > Nouveau sous-dossier… { removeFolderOrLabelRow(node.id) setMenuOpen(false) }} > Supprimer le dossier )} ) const folderOptionsSheet = touchNav && isExpanded && ( { updateFolderOrLabelColor(node.id, sw) closeSheet() }} /> Dans la liste des dossiers { setNavItemSidebarVisibility(node.id, "show") closeSheet() }} > Afficher { setNavItemSidebarVisibility(node.id, "showUnread") closeSheet() }} > Afficher si messages non lus { setNavItemSidebarVisibility(node.id, "hide") closeSheet() }} > Masquer Dans la liste des messages { setNavItemMessageVisibility(node.id, "show") closeSheet() }} > Afficher { setNavItemMessageVisibility(node.id, "hide") closeSheet() }} > Masquer { setRenameDraft(node.label) setRenameOpen(true) closeSheet() }} > Renommer… { setMoveParent("__root__") setMoveOpen(true) closeSheet() }} > Déplacer… { setSubfolderName("") setSubfolderOpen(true) closeSheet() }} > Nouveau sous-dossier… { removeFolderOrLabelRow(node.id) closeSheet() }} > Supprimer le dossier ) const onFolderRowDragEnter = (e: React.DragEvent) => { const active = navDragRef.current if (active?.kind === "folder" && active.id !== node.id) { e.preventDefault() return } dropHandlers.onDragEnter(e) } const onFolderRowDragOver = (e: React.DragEvent) => { const active = navDragRef.current if (active?.kind === "folder") { e.preventDefault() e.stopPropagation() if (active.id === node.id) return const ancestors = ancestorFolderIdsForTarget(folderTree, node.id) if (ancestors?.includes(active.id)) return e.dataTransfer.dropEffect = "move" updateNavDropTarget( e.currentTarget as HTMLElement, resolveNavDropPlacement(e, true) ) return } dropHandlers.onDragOver(e) } const onFolderRowDragLeave = (e: React.DragEvent) => { if (navDragRef.current?.kind === "folder") { const rt = e.relatedTarget as Node | null if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return clearNavDropTarget(e.currentTarget as HTMLElement) return } dropHandlers.onDragLeave(e) } const onFolderRowDrop = (e: React.DragEvent) => { const payload = readSidebarNavDragData(e, navDragRef.current) if (payload?.kind === "folder") { e.preventDefault() e.stopPropagation() const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, true) commitNavDrop(payload, node.id, placement, "folder") return } dropHandlers.onDrop(e) } const onFolderDragHandleStart = (e: React.DragEvent) => { const payload = { kind: "folder" as const, id: node.id } setSidebarNavDragData(e, payload) const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null beginNavDrag(payload, rowEl) } const folderRowEl = (
{isExpanded ? ( ) : null} {hasChildren ? ( ) : ( )}
onSelectFolder(node.id)} onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))} className={cn( "flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50", !isSelected && !isOver && !rowHoverHeld && "rounded-r-none hover:rounded-r-full hover:bg-gray-100", rowHoverHeld && !isSelected && !isOver && "rounded-r-full", isSelected ? "text-gray-900" : isOver ? "text-gray-900" : "text-gray-700" )} >
{node.label}
{overflowMenu}
) return ( <> {touchNav ? ( folderRowEl ) : ( {folderRowEl} {colorSub("context")} Dans la liste des dossiers setNavItemSidebarVisibility(node.id, "show")} > Afficher setNavItemSidebarVisibility(node.id, "showUnread")} > Afficher si non lus setNavItemSidebarVisibility(node.id, "hide")} > Masquer Dans la liste des messages setNavItemMessageVisibility(node.id, "show")} > Afficher setNavItemMessageVisibility(node.id, "hide")} > Masquer { setRenameDraft(node.label) setRenameOpen(true) }} > Renommer… { setMoveParent("__root__") setMoveOpen(true) }} > Déplacer… { setSubfolderName("") setSubfolderOpen(true) }} > Nouveau sous-dossier… removeFolderOrLabelRow(node.id)} > Supprimer le dossier )} {folderOptionsSheet} { e.preventDefault() window.requestAnimationFrame(() => folderRenameInputRef.current?.focus() ) }} > Renommer le dossier Nouveau nom pour « {node.label} ». setRenameDraft(e.target.value)} autoComplete="off" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() renameFolderOrLabel(node.id, renameDraft) setRenameOpen(false) } }} /> Déplacer le dossier Choisissez le dossier parent. { e.preventDefault() window.requestAnimationFrame(() => subfolderNameInputRef.current?.focus() ) }} > Nouveau sous-dossier Sous « {node.label} ». setSubfolderName(e.target.value)} placeholder="Nom du dossier" autoComplete="off" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() addSubfolder(node.id, subfolderName) setSubfolderOpen(false) } }} /> ) } const renderExpandedFolderSubtree = ( nodes: FolderTreeNode[], depth: number ): ReactNode => nodes .filter((node) => { const p = getNavItemPrefs(node.id) if (p.sidebar === "hide") return false if ( p.sidebar === "showUnread" && (folderUnreadCounts[node.id] ?? 0) === 0 ) { return false } return true }) .map((node) => { const isBranchOpen = expandedFolderIds.has(node.id) const kids = node.children return ( /* Limite le sticky au sous-arbre (évite l’empilement hors contexte). */
{kids?.length && isBranchOpen ? (
{renderExpandedFolderSubtree(kids, depth + 1)}
) : null}
) }) const FolderButtonCollapsed = ({ node }: { node: FolderTreeNode }) => { const { isOver, dropHandlers } = useEmailDropTarget(node.id, node.label) const dotClass = node.color ?? "bg-gray-400" const isHighlighted = folderSubtreeContainsId(node, selectedFolder) const unread = folderUnreadCounts[node.id] ?? 0 const hasUnread = unread > 0 return ( ) } const LabelItemRow = ({ item, unreadCount, isExpanded: labelRowExpanded, }: { item: { id: string; label: string; color: string; count?: number } unreadCount: number isExpanded: boolean }) => { const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label) const isSelected = selectedFolder === item.id const hasUnread = unreadCount > 0 const [menuOpen, setMenuOpen] = useState(false) const [contextMenuOpen, setContextMenuOpen] = useState(false) const menuTriggerRef = useRef(null) const [renameOpen, setRenameOpen] = useState(false) const [renameDraft, setRenameDraft] = useState(item.label) const [sublabelOpen, setSublabelOpen] = useState(false) const [sublabelName, setSublabelName] = useState("") const labelRenameInputRef = useRef(null) const sublabelNameInputRef = useRef(null) const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id) const { sheetOpen, setSheetOpen, touchRowProps, touchRowClassName, closeSheet } = useSidebarTouchOptionsMenu(touchNav && labelRowExpanded) useEffect(() => { setRenameDraft(item.label) }, [item.label]) const handleMenuOpenChange = (open: boolean) => { setMenuOpen(open) if (!open) { queueMicrotask(() => menuTriggerRef.current?.blur()) } } const rowHoverHeld = !isSelected && !isOver && (contextMenuOpen || menuOpen || sheetOpen) const prefs = getNavItemPrefs(item.id) const labelDotClass = item.color ?? "bg-gray-400" const labelMenuSurface = "min-w-[240px] border-gray-200 bg-white p-0 py-1.5 shadow-md" const colorSub = (subKind: "dropdown" | "context") => { const Sub = subKind === "dropdown" ? DropdownMenuSub : ContextMenuSub const SubTr = subKind === "dropdown" ? DropdownMenuSubTrigger : ContextMenuSubTrigger const SubCo = subKind === "dropdown" ? DropdownMenuSubContent : ContextMenuSubContent return ( Couleur du libellé
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
) } const rowClass = cn( "group/labelrow relative flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors", navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld), isSelected ? "bg-[#d3e3fd] text-gray-900 font-medium" : isOver ? "bg-yellow-100 text-gray-900" : rowHoverHeld ? "bg-gray-100 text-gray-900" : hasUnread ? "text-gray-900 hover:bg-gray-100" : "text-gray-700 hover:bg-gray-100", touchRowClassName ) const onLabelRowDragEnter = (e: React.DragEvent) => { const active = navDragRef.current if (active?.kind === "label" && active.id !== item.id) { e.preventDefault() return } dropHandlers.onDragEnter(e) } const onLabelRowDragOver = (e: React.DragEvent) => { const active = navDragRef.current if (active?.kind === "label") { e.preventDefault() e.stopPropagation() if (active.id === item.id) return e.dataTransfer.dropEffect = "move" updateNavDropTarget( e.currentTarget as HTMLElement, resolveNavDropPlacement(e, false) ) return } dropHandlers.onDragOver(e) } const onLabelRowDragLeave = (e: React.DragEvent) => { if (navDragRef.current?.kind === "label") { const rt = e.relatedTarget as Node | null if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return clearNavDropTarget(e.currentTarget as HTMLElement) return } dropHandlers.onDragLeave(e) } const onLabelRowDrop = (e: React.DragEvent) => { const payload = readSidebarNavDragData(e, navDragRef.current) if (payload?.kind === "label") { e.preventDefault() e.stopPropagation() const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, false) if (placement !== "inside") { commitNavDrop(payload, item.id, placement, "label") } else { clearNavDrag() } return } dropHandlers.onDrop(e) } const onLabelDragHandleStart = (e: React.DragEvent) => { const payload = { kind: "label" as const, id: item.id } setSidebarNavDragData(e, payload) const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null beginNavDrag(payload, rowEl) } const overflowMenu = labelRowExpanded ? ( {!touchNav && ( {colorSub("dropdown")} Dans la liste des libellés setNavItemSidebarVisibility(item.id, "show")} > Afficher setNavItemSidebarVisibility(item.id, "showUnread")} > Afficher si messages non lus setNavItemSidebarVisibility(item.id, "hide")} > Masquer Dans la liste des messages setNavItemMessageVisibility(item.id, "show")} > Afficher setNavItemMessageVisibility(item.id, "hide")} > Masquer { setRenameDraft(item.label) setRenameOpen(true) setMenuOpen(false) }} > Renommer… { removeFolderOrLabelRow(item.id) setMenuOpen(false) }} > Supprimer le libellé { setSublabelName("") setSublabelOpen(true) setMenuOpen(false) }} > Ajouter un sous-libellé )} ) : null const labelOptionsSheet = touchNav && labelRowExpanded && ( { updateFolderOrLabelColor(item.id, sw) closeSheet() }} /> Dans la liste des libellés { setNavItemSidebarVisibility(item.id, "show") closeSheet() }} > Afficher { setNavItemSidebarVisibility(item.id, "showUnread") closeSheet() }} > Afficher si messages non lus { setNavItemSidebarVisibility(item.id, "hide") closeSheet() }} > Masquer Dans la liste des messages { setNavItemMessageVisibility(item.id, "show") closeSheet() }} > Afficher { setNavItemMessageVisibility(item.id, "hide") closeSheet() }} > Masquer { setRenameDraft(item.label) setRenameOpen(true) closeSheet() }} > Renommer… { removeFolderOrLabelRow(item.id) closeSheet() }} > Supprimer le libellé { setSublabelName("") setSublabelOpen(true) closeSheet() }} > Ajouter un sous-libellé ) const labelRowEl = (
{canDragLabel ? ( ) : null}
onSelectFolder(item.id)} onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))} className={cn( "flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50", labelRowExpanded ? "pr-1" : "pr-3" )} > {labelRowExpanded && ( {item.label} )}
{overflowMenu}
) return ( <> {touchNav ? ( labelRowEl ) : ( {labelRowEl} {colorSub("context")} Dans la liste des libellés setNavItemSidebarVisibility(item.id, "show")} > Afficher setNavItemSidebarVisibility(item.id, "showUnread")} > Afficher si non lus setNavItemSidebarVisibility(item.id, "hide")} > Masquer Dans la liste des messages setNavItemMessageVisibility(item.id, "show")} > Afficher setNavItemMessageVisibility(item.id, "hide")} > Masquer { setRenameDraft(item.label) setRenameOpen(true) }} > Renommer… removeFolderOrLabelRow(item.id)} > Supprimer le libellé { setSublabelName("") setSublabelOpen(true) }} > Ajouter un sous-libellé )} {labelOptionsSheet} { e.preventDefault() window.requestAnimationFrame(() => labelRenameInputRef.current?.focus() ) }} > Renommer le libellé Nouveau nom pour « {item.label} ». setRenameDraft(e.target.value)} autoComplete="off" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() renameFolderOrLabel(item.id, renameDraft) setRenameOpen(false) } }} /> { e.preventDefault() window.requestAnimationFrame(() => sublabelNameInputRef.current?.focus() ) }} > Sous-libellé Sera créé sous « {item.label} » (chemin type Parent/Enfant). setSublabelName(e.target.value)} placeholder="Nom du sous-libellé" autoComplete="off" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() addChildLabelRow(item.id, sublabelName) setSublabelOpen(false) } }} /> ) } return ( ) }