2769 lines
93 KiB
TypeScript
2769 lines
93 KiB
TypeScript
"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<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: 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<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="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
>
|
||
<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-gray-900" strokeWidth={2} aria-hidden />
|
||
) : null}
|
||
</span>
|
||
</DropdownMenuItem>
|
||
)
|
||
}
|
||
|
||
function ContextLabelMenuOptionWithCheck({
|
||
checked,
|
||
onPick,
|
||
children,
|
||
}: {
|
||
checked: boolean
|
||
onPick: () => void
|
||
children: ReactNode
|
||
}) {
|
||
return (
|
||
<ContextMenuItem
|
||
onClick={() => onPick()}
|
||
className="mx-1 flex cursor-pointer items-center justify-between gap-3 px-3 py-2 text-sm"
|
||
>
|
||
<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-gray-900" 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"
|
||
}
|
||
|
||
/** 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<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>
|
||
)
|
||
}
|
||
|
||
/** 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>
|
||
)
|
||
}
|
||
|
||
const countHoverHide = `group-hover/${hoverGroup}:opacity-0`
|
||
const menuHoverShow = `group-hover/${hoverGroup}:opacity-100 [&:has(button:focus-visible)]:opacity-100`
|
||
|
||
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" : cn("opacity-100", countHoverHide)
|
||
)}
|
||
>
|
||
{formatCount(unread)}
|
||
</span>
|
||
)}
|
||
<div
|
||
className={cn(
|
||
"absolute inset-0 flex items-center justify-center transition-opacity duration-150",
|
||
menuOpen ? "opacity-100" : cn("opacity-0", menuHoverShow)
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 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-yellow-100 text-gray-900",
|
||
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),
|
||
isSelected
|
||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||
: isOver
|
||
? "bg-yellow-100 text-gray-900"
|
||
: hasUnread
|
||
? "text-gray-900 hover:bg-gray-100"
|
||
: "text-gray-700 hover:bg-gray-100",
|
||
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"
|
||
)}
|
||
>
|
||
{rowIcon}
|
||
{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}
|
||
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,
|
||
collapsed,
|
||
folderUnreadCounts = {},
|
||
splitView = false,
|
||
}: SidebarProps) {
|
||
const { openCompose } = useComposeActions()
|
||
const [hoverExpanded, setHoverExpanded] = useState(false)
|
||
const [navMoreOpen, setNavMoreOpen] = useState(false)
|
||
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
|
||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||
const sidebarRef = useRef<HTMLElement>(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<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]
|
||
)
|
||
|
||
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<string>()
|
||
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<HTMLInputElement>(null)
|
||
const newLabelNameInputRef = useRef<HTMLInputElement>(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 (
|
||
<button
|
||
onClick={() => onSelectFolder(item.id)}
|
||
title={!isExpanded ? item.label : undefined}
|
||
{...dropHandlers}
|
||
className={cn(
|
||
"flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 transition-colors",
|
||
navRowRoundedWhenActive(isSelected || isOver),
|
||
isSelected
|
||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||
: isOver
|
||
? "bg-yellow-100 text-gray-900"
|
||
: hasUnread
|
||
? "text-gray-900 hover:bg-gray-100"
|
||
: "text-gray-700 hover:bg-gray-100"
|
||
)}
|
||
>
|
||
<item.icon
|
||
className={cn(
|
||
"h-5 w-5 shrink-0",
|
||
hasUnread && !isSelected && "text-gray-900"
|
||
)}
|
||
/>
|
||
{isExpanded && (
|
||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||
<span
|
||
className={cn(
|
||
"min-w-0 flex-1 truncate text-left text-sm leading-5",
|
||
hasUnread && !isSelected && "font-semibold text-gray-900"
|
||
)}
|
||
>
|
||
{item.label}
|
||
</span>
|
||
{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>
|
||
)
|
||
}
|
||
|
||
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<HTMLButtonElement>(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<HTMLInputElement>(null)
|
||
const subfolderNameInputRef = useRef<HTMLInputElement>(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 (
|
||
<Sub>
|
||
<SubTr
|
||
className={cn(
|
||
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100",
|
||
subKind === "context" && "flex items-center gap-2"
|
||
)}
|
||
>
|
||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
|
||
<span
|
||
className={cn(
|
||
"block size-3 rounded-sm border border-black/10",
|
||
dotClass
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
</span>
|
||
<span className="flex-1 text-left text-sm">Couleur du dossier</span>
|
||
</SubTr>
|
||
<SubCo className="min-w-[180px] border-gray-200 bg-white p-2 shadow-md">
|
||
<div className="grid grid-cols-6 gap-1.5">
|
||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
||
<button
|
||
key={sw}
|
||
type="button"
|
||
title={sw}
|
||
onClick={() => {
|
||
updateFolderOrLabelColor(node.id, sw)
|
||
setMenuOpen(false)
|
||
}}
|
||
className={cn(
|
||
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
|
||
sw
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SubCo>
|
||
</Sub>
|
||
)
|
||
}
|
||
|
||
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 = (
|
||
<SidebarOverflowColumn
|
||
unread={unread}
|
||
menuOpen={menuOpen || sheetOpen}
|
||
hoverGroup="folderrow"
|
||
isSelected={isSelected}
|
||
hasUnread={hasUnread}
|
||
className={cn(!isExpanded && "hidden", "mr-[-11px]")}
|
||
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 ${node.label}`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<MoreVertical className="h-4 w-4" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className={folderMenuSurface}>
|
||
{colorSub("dropdown")}
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des dossiers
|
||
</DropdownMenuLabel>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
|
||
>
|
||
Afficher
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
|
||
>
|
||
Afficher si messages non lus
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
|
||
>
|
||
Masquer
|
||
</LabelMenuOptionWithCheck>
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des messages
|
||
</DropdownMenuLabel>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => setNavItemMessageVisibility(node.id, "show")}
|
||
>
|
||
Afficher
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
|
||
>
|
||
Masquer
|
||
</LabelMenuOptionWithCheck>
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
onClick={() => {
|
||
setRenameDraft(node.label)
|
||
setRenameOpen(true)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Renommer…
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
onClick={() => {
|
||
setMoveParent("__root__")
|
||
setMoveOpen(true)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Déplacer…
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
onClick={() => {
|
||
setSubfolderName("")
|
||
setSubfolderOpen(true)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Nouveau sous-dossier…
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50"
|
||
onClick={() => {
|
||
removeFolderOrLabelRow(node.id)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Supprimer le dossier
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
</SidebarOverflowColumn>
|
||
)
|
||
|
||
const folderOptionsSheet = touchNav && isExpanded && (
|
||
<SidebarNavOptionsSheet
|
||
open={sheetOpen}
|
||
onOpenChange={setSheetOpen}
|
||
title={node.label}
|
||
colorDotClass={dotClass}
|
||
>
|
||
<SidebarNavSheetColorPicker
|
||
title="Couleur du dossier"
|
||
dotClass={dotClass}
|
||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||
onPick={(sw) => {
|
||
updateFolderOrLabelColor(node.id, sw)
|
||
closeSheet()
|
||
}}
|
||
/>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetSectionLabel>Dans la liste des dossiers</SidebarNavSheetSectionLabel>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(node.id, "show")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(node.id, "showUnread")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher si messages non lus
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(node.id, "hide")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Masquer
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => {
|
||
setNavItemMessageVisibility(node.id, "show")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => {
|
||
setNavItemMessageVisibility(node.id, "hide")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Masquer
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetAction
|
||
onClick={() => {
|
||
setRenameDraft(node.label)
|
||
setRenameOpen(true)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Renommer…
|
||
</SidebarNavSheetAction>
|
||
<SidebarNavSheetAction
|
||
onClick={() => {
|
||
setMoveParent("__root__")
|
||
setMoveOpen(true)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Déplacer…
|
||
</SidebarNavSheetAction>
|
||
<SidebarNavSheetAction
|
||
onClick={() => {
|
||
setSubfolderName("")
|
||
setSubfolderOpen(true)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Nouveau sous-dossier…
|
||
</SidebarNavSheetAction>
|
||
<SidebarNavSheetAction
|
||
destructive
|
||
onClick={() => {
|
||
removeFolderOrLabelRow(node.id)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Supprimer le dossier
|
||
</SidebarNavSheetAction>
|
||
</SidebarNavOptionsSheet>
|
||
)
|
||
|
||
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<HTMLSpanElement>) => {
|
||
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 = (
|
||
<div
|
||
data-nav-row
|
||
{...touchRowProps}
|
||
onDragEnter={onFolderRowDragEnter}
|
||
onDragOver={onFolderRowDragOver}
|
||
onDragLeave={onFolderRowDragLeave}
|
||
onDrop={onFolderRowDrop}
|
||
className={rowClass}
|
||
style={rowStyle}
|
||
>
|
||
{isExpanded ? (
|
||
<SidebarNavDragHandle
|
||
label={node.label}
|
||
onDragStart={onFolderDragHandleStart}
|
||
onDragEnd={clearNavDrag}
|
||
/>
|
||
) : null}
|
||
{hasChildren ? (
|
||
<button
|
||
type="button"
|
||
draggable={false}
|
||
className={cn(
|
||
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
|
||
isSelected && "text-gray-900"
|
||
)}
|
||
aria-expanded={isBranchOpen}
|
||
aria-label={
|
||
isBranchOpen
|
||
? `Replier le dossier ${node.label}`
|
||
: `Déplier le dossier ${node.label}`
|
||
}
|
||
onClick={(e) => {
|
||
e.preventDefault()
|
||
toggleFolderExpanded(node.id)
|
||
}}
|
||
>
|
||
<ChevronRight
|
||
className={cn(
|
||
"h-4 w-4 shrink-0 transition-transform duration-200",
|
||
isBranchOpen && "rotate-90"
|
||
)}
|
||
/>
|
||
</button>
|
||
) : (
|
||
<span
|
||
className="inline-flex h-8 w-5 shrink-0 items-center justify-center"
|
||
aria-hidden
|
||
/>
|
||
)}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => 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"
|
||
)}
|
||
>
|
||
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||
<span className={cn("block h-3 w-3 rounded-sm", dotClass)} />
|
||
</span>
|
||
<div className="flex min-w-0 flex-1 items-baseline gap-3">
|
||
<span className="min-w-0 flex-1 truncate leading-5">
|
||
<span
|
||
className={cn(
|
||
hasUnread && !isSelected && "font-semibold text-gray-900"
|
||
)}
|
||
>
|
||
{node.label}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{overflowMenu}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{touchNav ? (
|
||
folderRowEl
|
||
) : (
|
||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||
<ContextMenuTrigger asChild>{folderRowEl}</ContextMenuTrigger>
|
||
<ContextMenuContent className={folderMenuSurface}>
|
||
{colorSub("context")}
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des dossiers
|
||
</ContextMenuLabel>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "show")}
|
||
>
|
||
Afficher
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "showUnread")}
|
||
>
|
||
Afficher si non lus
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => setNavItemSidebarVisibility(node.id, "hide")}
|
||
>
|
||
Masquer
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des messages
|
||
</ContextMenuLabel>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => setNavItemMessageVisibility(node.id, "show")}
|
||
>
|
||
Afficher
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => setNavItemMessageVisibility(node.id, "hide")}
|
||
>
|
||
Masquer
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => {
|
||
setRenameDraft(node.label)
|
||
setRenameOpen(true)
|
||
}}
|
||
>
|
||
Renommer…
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => {
|
||
setMoveParent("__root__")
|
||
setMoveOpen(true)
|
||
}}
|
||
>
|
||
Déplacer…
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => {
|
||
setSubfolderName("")
|
||
setSubfolderOpen(true)
|
||
}}
|
||
>
|
||
Nouveau sous-dossier…
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
variant="destructive"
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => removeFolderOrLabelRow(node.id)}
|
||
>
|
||
Supprimer le dossier
|
||
</ContextMenuItem>
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
)}
|
||
{folderOptionsSheet}
|
||
|
||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
folderRenameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Renommer le dossier</DialogTitle>
|
||
<DialogDescription>Nouveau nom pour « {node.label} ».</DialogDescription>
|
||
</DialogHeader>
|
||
<Input
|
||
ref={folderRenameInputRef}
|
||
value={renameDraft}
|
||
onChange={(e) => setRenameDraft(e.target.value)}
|
||
autoComplete="off"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
renameFolderOrLabel(node.id, renameDraft)
|
||
setRenameOpen(false)
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
renameFolderOrLabel(node.id, renameDraft)
|
||
setRenameOpen(false)
|
||
}}
|
||
>
|
||
Enregistrer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={moveOpen} onOpenChange={setMoveOpen}>
|
||
<DialogContent className="sm:max-w-md" showCloseButton>
|
||
<DialogHeader>
|
||
<DialogTitle>Déplacer le dossier</DialogTitle>
|
||
<DialogDescription>Choisissez le dossier parent.</DialogDescription>
|
||
</DialogHeader>
|
||
<Select value={moveParent} onValueChange={setMoveParent}>
|
||
<SelectTrigger className="w-full min-w-0" size="sm">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper" className="max-h-72">
|
||
{moveTargets.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>
|
||
{o.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<DialogFooter>
|
||
<Button variant="outline" type="button" onClick={() => setMoveOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
moveFolder(
|
||
node.id,
|
||
moveParent === "__root__" ? null : moveParent
|
||
)
|
||
setMoveOpen(false)
|
||
}}
|
||
>
|
||
Déplacer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={subfolderOpen} onOpenChange={setSubfolderOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
subfolderNameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Nouveau sous-dossier</DialogTitle>
|
||
<DialogDescription>Sous « {node.label} ».</DialogDescription>
|
||
</DialogHeader>
|
||
<Input
|
||
ref={subfolderNameInputRef}
|
||
value={subfolderName}
|
||
onChange={(e) => setSubfolderName(e.target.value)}
|
||
placeholder="Nom du dossier"
|
||
autoComplete="off"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
addSubfolder(node.id, subfolderName)
|
||
setSubfolderOpen(false)
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" type="button" onClick={() => setSubfolderOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
addSubfolder(node.id, subfolderName)
|
||
setSubfolderOpen(false)
|
||
}}
|
||
>
|
||
Créer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
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). */
|
||
<div key={node.id} className="min-w-0">
|
||
<FolderRowExpanded node={node} depth={depth} />
|
||
{kids?.length && isBranchOpen ? (
|
||
<div className="min-w-0">
|
||
{renderExpandedFolderSubtree(kids, depth + 1)}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
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 (
|
||
<button
|
||
type="button"
|
||
title={
|
||
!isExpanded
|
||
? unread > 0
|
||
? `${node.label} — ${unread} non lus`
|
||
: node.label
|
||
: undefined
|
||
}
|
||
onClick={() => onSelectFolder(node.id)}
|
||
{...dropHandlers}
|
||
className={cn(
|
||
"relative flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm transition-colors",
|
||
navRowRoundedWhenActive(isHighlighted || isOver),
|
||
isHighlighted
|
||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||
: isOver
|
||
? "bg-yellow-100 text-gray-900"
|
||
: hasUnread
|
||
? "text-gray-900 hover:bg-gray-100"
|
||
: "text-gray-700 hover:bg-gray-100"
|
||
)}
|
||
>
|
||
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
|
||
{hasUnread && (
|
||
<span
|
||
className={cn(
|
||
"absolute block h-3 w-3 rounded-sm opacity-75 animate-ping",
|
||
dotClass
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
)}
|
||
<span className={cn("relative block h-3 w-3 rounded-sm", dotClass)} />
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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<HTMLButtonElement>(null)
|
||
const [renameOpen, setRenameOpen] = useState(false)
|
||
const [renameDraft, setRenameDraft] = useState(item.label)
|
||
const [sublabelOpen, setSublabelOpen] = useState(false)
|
||
const [sublabelName, setSublabelName] = useState("")
|
||
const labelRenameInputRef = useRef<HTMLInputElement>(null)
|
||
const sublabelNameInputRef = useRef<HTMLInputElement>(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 (
|
||
<Sub>
|
||
<SubTr
|
||
className={cn(
|
||
"mx-1 cursor-pointer rounded-sm px-2 py-2 text-gray-800 focus:bg-gray-100 data-[state=open]:bg-gray-100",
|
||
subKind === "context" && "flex items-center gap-2"
|
||
)}
|
||
>
|
||
<span className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-300 bg-white">
|
||
<span
|
||
className={cn(
|
||
"block size-3 rounded-sm border border-black/10",
|
||
labelDotClass
|
||
)}
|
||
aria-hidden
|
||
/>
|
||
</span>
|
||
<span className="flex-1 text-left text-sm">Couleur du libellé</span>
|
||
</SubTr>
|
||
<SubCo className="min-w-[180px] border-gray-200 bg-white p-2 shadow-md">
|
||
<div className="grid grid-cols-6 gap-1.5">
|
||
{LABEL_MENU_COLOR_SWATCHES.map((sw) => (
|
||
<button
|
||
key={sw}
|
||
type="button"
|
||
title={sw}
|
||
onClick={() => {
|
||
updateFolderOrLabelColor(item.id, sw)
|
||
setMenuOpen(false)
|
||
}}
|
||
className={cn(
|
||
"size-6 rounded-full border border-black/10 outline-none ring-offset-1 hover:ring-2 hover:ring-gray-400 focus-visible:ring-2 focus-visible:ring-gray-500",
|
||
sw
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SubCo>
|
||
</Sub>
|
||
)
|
||
}
|
||
|
||
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<HTMLSpanElement>) => {
|
||
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 ? (
|
||
<SidebarOverflowColumn
|
||
unread={unreadCount}
|
||
menuOpen={menuOpen || sheetOpen}
|
||
hoverGroup="labelrow"
|
||
isSelected={isSelected}
|
||
hasUnread={hasUnread}
|
||
className="mr-[-7px]"
|
||
showMenuButton={!touchNav}
|
||
>
|
||
{!touchNav && (
|
||
<DropdownMenu open={menuOpen} onOpenChange={handleMenuOpenChange}>
|
||
<DropdownMenuTrigger asChild>
|
||
<button
|
||
ref={menuTriggerRef}
|
||
type="button"
|
||
draggable={false}
|
||
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={labelMenuSurface}>
|
||
{colorSub("dropdown")}
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des libellés
|
||
</DropdownMenuLabel>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
|
||
>
|
||
Afficher
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
|
||
>
|
||
Afficher si messages non lus
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
|
||
>
|
||
Masquer
|
||
</LabelMenuOptionWithCheck>
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des messages
|
||
</DropdownMenuLabel>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => setNavItemMessageVisibility(item.id, "show")}
|
||
>
|
||
Afficher
|
||
</LabelMenuOptionWithCheck>
|
||
<LabelMenuOptionWithCheck
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
|
||
>
|
||
Masquer
|
||
</LabelMenuOptionWithCheck>
|
||
<DropdownMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<DropdownMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
onClick={() => {
|
||
setRenameDraft(item.label)
|
||
setRenameOpen(true)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Renommer…
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm focus:bg-red-50"
|
||
onClick={() => {
|
||
removeFolderOrLabelRow(item.id)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Supprimer le libellé
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm text-gray-800 focus:bg-gray-100"
|
||
onClick={() => {
|
||
setSublabelName("")
|
||
setSublabelOpen(true)
|
||
setMenuOpen(false)
|
||
}}
|
||
>
|
||
Ajouter un sous-libellé
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
</SidebarOverflowColumn>
|
||
) : null
|
||
|
||
const labelOptionsSheet = touchNav && labelRowExpanded && (
|
||
<SidebarNavOptionsSheet
|
||
open={sheetOpen}
|
||
onOpenChange={setSheetOpen}
|
||
title={item.label}
|
||
colorDotClass={labelDotClass}
|
||
>
|
||
<SidebarNavSheetColorPicker
|
||
title="Couleur du libellé"
|
||
dotClass={labelDotClass}
|
||
swatches={LABEL_MENU_COLOR_SWATCHES}
|
||
onPick={(sw) => {
|
||
updateFolderOrLabelColor(item.id, sw)
|
||
closeSheet()
|
||
}}
|
||
/>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetSectionLabel>Dans la liste des libellés</SidebarNavSheetSectionLabel>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(item.id, "show")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(item.id, "showUnread")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher si messages non lus
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => {
|
||
setNavItemSidebarVisibility(item.id, "hide")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Masquer
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetSectionLabel>Dans la liste des messages</SidebarNavSheetSectionLabel>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => {
|
||
setNavItemMessageVisibility(item.id, "show")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Afficher
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetCheckOption
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => {
|
||
setNavItemMessageVisibility(item.id, "hide")
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Masquer
|
||
</SidebarNavSheetCheckOption>
|
||
<SidebarNavSheetDivider />
|
||
<SidebarNavSheetAction
|
||
onClick={() => {
|
||
setRenameDraft(item.label)
|
||
setRenameOpen(true)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Renommer…
|
||
</SidebarNavSheetAction>
|
||
<SidebarNavSheetAction
|
||
destructive
|
||
onClick={() => {
|
||
removeFolderOrLabelRow(item.id)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Supprimer le libellé
|
||
</SidebarNavSheetAction>
|
||
<SidebarNavSheetAction
|
||
onClick={() => {
|
||
setSublabelName("")
|
||
setSublabelOpen(true)
|
||
closeSheet()
|
||
}}
|
||
>
|
||
Ajouter un sous-libellé
|
||
</SidebarNavSheetAction>
|
||
</SidebarNavOptionsSheet>
|
||
)
|
||
|
||
const labelRowEl = (
|
||
<div
|
||
data-nav-row
|
||
{...touchRowProps}
|
||
onDragEnter={onLabelRowDragEnter}
|
||
onDragOver={onLabelRowDragOver}
|
||
onDragLeave={onLabelRowDragLeave}
|
||
onDrop={onLabelRowDrop}
|
||
className={rowClass}
|
||
>
|
||
{canDragLabel ? (
|
||
<SidebarNavDragHandle
|
||
label={item.label}
|
||
onDragStart={onLabelDragHandleStart}
|
||
onDragEnd={clearNavDrag}
|
||
/>
|
||
) : null}
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
title={!labelRowExpanded ? item.label : undefined}
|
||
onClick={() => 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"
|
||
)}
|
||
>
|
||
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||
<span
|
||
className={cn("block h-3 w-3 rounded-sm", item.color ?? "bg-gray-400")}
|
||
/>
|
||
</span>
|
||
{labelRowExpanded && (
|
||
<span
|
||
className={cn(
|
||
"min-w-0 flex-1 truncate text-sm leading-5",
|
||
hasUnread && !isSelected && "font-semibold text-gray-900"
|
||
)}
|
||
>
|
||
{item.label}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{overflowMenu}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<>
|
||
{touchNav ? (
|
||
labelRowEl
|
||
) : (
|
||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||
<ContextMenuTrigger asChild>{labelRowEl}</ContextMenuTrigger>
|
||
<ContextMenuContent className={labelMenuSurface}>
|
||
{colorSub("context")}
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des libellés
|
||
</ContextMenuLabel>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "show"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "show")}
|
||
>
|
||
Afficher
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "showUnread"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "showUnread")}
|
||
>
|
||
Afficher si non lus
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.sidebar === "hide"}
|
||
onPick={() => setNavItemSidebarVisibility(item.id, "hide")}
|
||
>
|
||
Masquer
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuLabel className="px-3 py-1 text-[11px] font-normal normal-case tracking-normal text-gray-500">
|
||
Dans la liste des messages
|
||
</ContextMenuLabel>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.messages === "show"}
|
||
onPick={() => setNavItemMessageVisibility(item.id, "show")}
|
||
>
|
||
Afficher
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextLabelMenuOptionWithCheck
|
||
checked={prefs.messages === "hide"}
|
||
onPick={() => setNavItemMessageVisibility(item.id, "hide")}
|
||
>
|
||
Masquer
|
||
</ContextLabelMenuOptionWithCheck>
|
||
<ContextMenuSeparator className="my-1.5 bg-gray-200" />
|
||
<ContextMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => {
|
||
setRenameDraft(item.label)
|
||
setRenameOpen(true)
|
||
}}
|
||
>
|
||
Renommer…
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
variant="destructive"
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => removeFolderOrLabelRow(item.id)}
|
||
>
|
||
Supprimer le libellé
|
||
</ContextMenuItem>
|
||
<ContextMenuItem
|
||
className="mx-1 cursor-pointer px-3 py-2 text-sm"
|
||
onClick={() => {
|
||
setSublabelName("")
|
||
setSublabelOpen(true)
|
||
}}
|
||
>
|
||
Ajouter un sous-libellé
|
||
</ContextMenuItem>
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
)}
|
||
{labelOptionsSheet}
|
||
|
||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
labelRenameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Renommer le libellé</DialogTitle>
|
||
<DialogDescription>Nouveau nom pour « {item.label} ».</DialogDescription>
|
||
</DialogHeader>
|
||
<Input
|
||
ref={labelRenameInputRef}
|
||
value={renameDraft}
|
||
onChange={(e) => setRenameDraft(e.target.value)}
|
||
autoComplete="off"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
renameFolderOrLabel(item.id, renameDraft)
|
||
setRenameOpen(false)
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" type="button" onClick={() => setRenameOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
renameFolderOrLabel(item.id, renameDraft)
|
||
setRenameOpen(false)
|
||
}}
|
||
>
|
||
Enregistrer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={sublabelOpen} onOpenChange={setSublabelOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
sublabelNameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Sous-libellé</DialogTitle>
|
||
<DialogDescription>
|
||
Sera créé sous « {item.label} » (chemin type Parent/Enfant).
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Input
|
||
ref={sublabelNameInputRef}
|
||
value={sublabelName}
|
||
onChange={(e) => 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)
|
||
}
|
||
}}
|
||
/>
|
||
<DialogFooter>
|
||
<Button variant="outline" type="button" onClick={() => setSublabelOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
onClick={() => {
|
||
addChildLabelRow(item.id, sublabelName)
|
||
setSublabelOpen(false)
|
||
}}
|
||
>
|
||
Créer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<aside
|
||
ref={sidebarRef}
|
||
data-sidebar
|
||
onMouseEnter={handleMouseEnter}
|
||
onMouseLeave={handleMouseLeave}
|
||
className={cn(
|
||
"absolute left-0 top-0 bottom-0 flex flex-col overflow-hidden bg-app-canvas transition-[width,transform] duration-200 z-40 select-none",
|
||
isExpanded ? "w-60" : "w-[68px]",
|
||
!touchNav && hoverExpanded && "shadow-xl border-r border-gray-200",
|
||
isOverlayOpen && "z-50 shadow-xl border-r border-gray-200",
|
||
collapsed && isXs && "-translate-x-full pointer-events-none"
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"flex shrink-0 items-center bg-app-canvas",
|
||
splitView
|
||
? cn(
|
||
splitViewLogoHeaderClass,
|
||
isExpanded ? "justify-between" : "justify-start"
|
||
)
|
||
: "justify-between px-4 pt-4 pb-4 sm:hidden"
|
||
)}
|
||
>
|
||
{splitView && !isExpanded ? (
|
||
<UltiMailLogo variant="mark" className={splitViewLogoIconClass} />
|
||
) : (
|
||
<>
|
||
<UltiMailLogo
|
||
className={cn(
|
||
"shrink-0",
|
||
splitView
|
||
? "max-w-[140px] gap-4 [&_img]:size-9"
|
||
: "min-h-8"
|
||
)}
|
||
/>
|
||
{(splitView || touchNav) && isExpanded && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="size-9 shrink-0 text-gray-600"
|
||
aria-label="Réglages"
|
||
>
|
||
<Icon icon="mdi:cog" className="size-5 shrink-0" aria-hidden />
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"hidden shrink-0 bg-app-canvas z-10 pt-1 pb-3 pl-2 sm:flex",
|
||
isExpanded ? "pr-3.5" : "pr-2",
|
||
splitView && "!hidden"
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
title={!isExpanded ? "Nouveau message" : undefined}
|
||
aria-label={!isExpanded ? "Nouveau message" : undefined}
|
||
onClick={openCompose}
|
||
className={cn(
|
||
"inline-flex h-[52px] min-w-0 shrink-0 cursor-pointer items-center rounded-2xl border border-gray-200 bg-white text-sm font-medium text-gray-700 shadow-sm outline-none transition-[box-shadow,background-color,border-color,color] duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
|
||
isExpanded
|
||
? "w-auto max-w-full justify-start gap-3 self-start pl-4 pr-8"
|
||
: "w-[52px] justify-center px-0 py-0"
|
||
)}
|
||
>
|
||
<Pencil className="size-5 shrink-0" />
|
||
{isExpanded && (
|
||
<span className="min-w-0 truncate text-sm font-medium">
|
||
Nouveau message
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
|
||
"[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||
)}
|
||
>
|
||
{/* Boîte principale + catégories (hors « Plus »), puis Plus / Moins, puis extras. */}
|
||
<nav className={cn("flex min-h-full flex-col", navRailInset)}>
|
||
{visibleMainItems.map((item) => (
|
||
<NavItem
|
||
key={item.id}
|
||
item={item}
|
||
isSelected={selectedFolder === item.id}
|
||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||
/>
|
||
))}
|
||
{primaryVisibleCategories.map((item) => (
|
||
<CategoryNavRow
|
||
key={item.id}
|
||
item={item}
|
||
isSelected={selectedFolder === item.id}
|
||
isExpanded={isExpanded}
|
||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||
onSelectFolder={onSelectFolder}
|
||
touchNav={touchNav}
|
||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||
/>
|
||
))}
|
||
|
||
{hasPlusOnlyExtras && (
|
||
<button
|
||
type="button"
|
||
title={!isExpanded ? (navMoreOpen ? "Moins" : "Plus") : undefined}
|
||
aria-expanded={navMoreOpen}
|
||
aria-label={
|
||
!isExpanded
|
||
? navMoreOpen
|
||
? "Moins d’entrées"
|
||
: "Plus d’entrées"
|
||
: undefined
|
||
}
|
||
onClick={() =>
|
||
setNavMoreOpen((wasOpen) => {
|
||
if (!wasOpen) return true
|
||
if (CATEGORY_IDS_IN_PLUS_ONLY.has(selectedFolder)) {
|
||
onSelectFolder("inbox")
|
||
return false
|
||
}
|
||
return false
|
||
})
|
||
}
|
||
className={cn(
|
||
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-gray-700 transition-colors hover:bg-gray-100",
|
||
navRowRoundedWhenActive(false)
|
||
)}
|
||
>
|
||
<ChevronDown
|
||
className={cn(
|
||
"h-5 w-5 shrink-0 transition-transform duration-200",
|
||
navMoreOpen && "rotate-180"
|
||
)}
|
||
/>
|
||
{isExpanded && (
|
||
<span className="text-sm">{navMoreOpen ? "Moins" : "Plus"}</span>
|
||
)}
|
||
</button>
|
||
)}
|
||
|
||
{navMoreOpen && (
|
||
<>
|
||
{plusOnlyVisibleCategories.map((item) => (
|
||
<CategoryNavRow
|
||
key={item.id}
|
||
item={item}
|
||
isSelected={selectedFolder === item.id}
|
||
isExpanded={isExpanded}
|
||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||
onSelectFolder={onSelectFolder}
|
||
touchNav={touchNav}
|
||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||
/>
|
||
))}
|
||
{isExpanded && (
|
||
<div className="mt-1 flex flex-col gap-px">
|
||
{sidebarSecondaryActions.map((a) => {
|
||
const ActionIcon = a.icon
|
||
return (
|
||
<button
|
||
key={a.id}
|
||
type="button"
|
||
className="flex min-h-8 w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pl-6 pr-3 text-left text-xs text-gray-600 transition-colors hover:bg-gray-50 hover:text-gray-800"
|
||
>
|
||
<ActionIcon className="h-3.5 w-3.5 shrink-0 opacity-70" aria-hidden />
|
||
<span className="min-w-0 leading-snug">{a.label}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{isExpanded && disabledSystemNavItems.length > 0 && (
|
||
<div className="mt-2 pt-2">
|
||
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
|
||
Désactivées
|
||
</div>
|
||
{disabledSystemNavItems.map((item) => (
|
||
<CategoryNavRow
|
||
key={item.id}
|
||
item={item}
|
||
isSelected={false}
|
||
isExpanded={isExpanded}
|
||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||
onSelectFolder={onSelectFolder}
|
||
touchNav={touchNav}
|
||
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||
variant="hidden"
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Dossiers (hiérarchie : chevron = replier / déplier uniquement) */}
|
||
<div className="mt-3 pt-1">
|
||
<div
|
||
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||
title={!isExpanded ? "Dossiers" : undefined}
|
||
>
|
||
<Folder className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
|
||
{isExpanded && (
|
||
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
|
||
Dossiers
|
||
</span>
|
||
)}
|
||
{isExpanded && (
|
||
<button
|
||
type="button"
|
||
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||
aria-label="Ajouter un dossier"
|
||
title="Ajouter un dossier"
|
||
onClick={() => {
|
||
setNewFolderParent("__root__")
|
||
setNewFolderName("")
|
||
setFolderDialogOpen(true)
|
||
}}
|
||
>
|
||
<Plus className="h-5 w-5 shrink-0" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{isExpanded
|
||
? renderExpandedFolderSubtree(folderTree, 0)
|
||
: folderTree.map((node) => (
|
||
<FolderButtonCollapsed key={node.id} node={node} />
|
||
))}
|
||
</div>
|
||
|
||
{/* Labels */}
|
||
<div className="mt-3 pt-1">
|
||
<div
|
||
className="sticky top-0 z-30 flex h-8 w-full min-w-0 shrink-0 items-center gap-2 bg-app-canvas pl-6 pr-3"
|
||
title={!isExpanded ? "Libellés" : undefined}
|
||
>
|
||
<Tag className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
|
||
{isExpanded && (
|
||
<span className="min-w-0 flex-1 truncate text-left text-sm font-medium text-gray-700">
|
||
Libellés
|
||
</span>
|
||
)}
|
||
{isExpanded && (
|
||
<button
|
||
type="button"
|
||
className="flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-full text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||
aria-label="Ajouter un libellé"
|
||
title="Ajouter un libellé"
|
||
onClick={() => {
|
||
setNewLabelName("")
|
||
setLabelDialogOpen(true)
|
||
}}
|
||
>
|
||
<Plus className="h-5 w-5 shrink-0" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{visibleNavLabelRows.map((item) => (
|
||
<LabelItemRow
|
||
key={item.id}
|
||
item={item}
|
||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||
isExpanded={isExpanded}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Sortbot */}
|
||
<div
|
||
className={cn(
|
||
"relative z-32 mt-auto bg-app-canvas pt-2",
|
||
"max-sm:pb-16 sm:sticky sm:bottom-0 sm:border-t sm:border-gray-200 sm:pb-3"
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
title={!isExpanded ? "Sortbot" : undefined}
|
||
className={cn(
|
||
"flex h-8 w-full shrink-0 cursor-pointer items-center gap-4 pl-6 pr-3 text-sm text-gray-700 transition-colors hover:bg-gray-100",
|
||
navRowRoundedWhenActive(false)
|
||
)}
|
||
>
|
||
<Bot className="h-5 w-5 shrink-0 text-gray-600" aria-hidden />
|
||
{isExpanded && <span>Sortbot</span>}
|
||
</button>
|
||
</div>
|
||
</nav>
|
||
</div>
|
||
|
||
<Dialog open={folderDialogOpen} onOpenChange={setFolderDialogOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
newFolderNameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Nouveau dossier</DialogTitle>
|
||
<DialogDescription>
|
||
Choisissez l’emplacement (racine ou dossier parent) puis le nom.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-3 py-1">
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="new-folder-parent">Emplacement</Label>
|
||
<Select value={newFolderParent} onValueChange={setNewFolderParent}>
|
||
<SelectTrigger id="new-folder-parent" className="w-full min-w-0" size="sm">
|
||
<SelectValue placeholder="Parent" />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper" className="max-h-72">
|
||
{folderParentOptions.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>
|
||
{o.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<Label htmlFor="new-folder-name">Nom</Label>
|
||
<Input
|
||
id="new-folder-name"
|
||
ref={newFolderNameInputRef}
|
||
value={newFolderName}
|
||
onChange={(e) => setNewFolderName(e.target.value)}
|
||
placeholder="Mon dossier"
|
||
autoComplete="off"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
handleSubmitNewFolder()
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
type="button"
|
||
onClick={() => setFolderDialogOpen(false)}
|
||
>
|
||
Annuler
|
||
</Button>
|
||
<Button type="button" onClick={handleSubmitNewFolder}>
|
||
Créer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
||
<DialogContent
|
||
className="sm:max-w-md"
|
||
showCloseButton
|
||
onOpenAutoFocus={(e) => {
|
||
e.preventDefault()
|
||
window.requestAnimationFrame(() =>
|
||
newLabelNameInputRef.current?.focus()
|
||
)
|
||
}}
|
||
>
|
||
<DialogHeader>
|
||
<DialogTitle>Nouveau libellé</DialogTitle>
|
||
<DialogDescription>
|
||
Nom affiché dans la barre latérale et utilisé sur les messages.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="grid gap-2 py-1">
|
||
<Label htmlFor="new-label-name">Nom</Label>
|
||
<Input
|
||
id="new-label-name"
|
||
ref={newLabelNameInputRef}
|
||
value={newLabelName}
|
||
onChange={(e) => setNewLabelName(e.target.value)}
|
||
placeholder="Libellé"
|
||
autoComplete="off"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
handleSubmitNewLabel()
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
type="button"
|
||
onClick={() => setLabelDialogOpen(false)}
|
||
>
|
||
Annuler
|
||
</Button>
|
||
<Button type="button" onClick={handleSubmitNewLabel}>
|
||
Créer
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</aside>
|
||
)
|
||
}
|