Massive upgrades

This commit is contained in:
R3D347HR4Y 2026-05-16 20:30:50 +02:00
parent 6af6e62774
commit 489c0d0c5c
33 changed files with 1817 additions and 459 deletions

View File

@ -219,3 +219,19 @@
background: none; background: none;
padding: 0; padding: 0;
} }
/* Sidebar nav DnD visual feedback (data-attribute driven, no React re-render) */
[data-nav-drag-source="true"] {
opacity: 0.45;
}
[data-nav-drop="before"] {
box-shadow: inset 0 2px 0 0 #1a73e8;
}
[data-nav-drop="after"] {
box-shadow: inset 0 -2px 0 0 #1a73e8;
}
[data-nav-drop="inside"] {
background-color: #e8f0fe;
outline: 1px solid rgba(26, 115, 232, 0.4);
outline-offset: -1px;
}

View File

@ -1,9 +1,11 @@
"use client" "use client"
import type { ComponentType, ReactNode } from "react" import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
import { Check, Minus, Plus } from "lucide-react" import { Check, Minus, Plus } from "lucide-react"
import { Icon } from "@iconify/react"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
export type CatalogLabelPresence = "none" | "some" | "all" export type CatalogLabelPresence = "none" | "some" | "all"
@ -13,6 +15,32 @@ export type LabelPickerItemComponent = ComponentType<{
className?: string className?: string
}> }>
export function LabelPickerLeadingVisual({
visual,
}: {
visual: LabelPickerVisual
}) {
if (visual.kind === "iconify") {
return (
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<Icon
icon={visual.icon}
className="size-[18px] shrink-0 text-[#5f6368]"
aria-hidden
/>
</span>
)
}
return (
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
<span
className={cn("block h-3 w-3 rounded-sm", visual.colorClass)}
aria-hidden
/>
</span>
)
}
function LabelPickerCheckboxVisual({ function LabelPickerCheckboxVisual({
checked, checked,
}: { }: {
@ -40,21 +68,41 @@ export function EmailLabelPickerBlock({
query, query,
onQueryChange, onQueryChange,
catalogLabels, catalogLabels,
resolveLabelVisual,
Item, Item,
getLabelPresence, getLabelPresence,
onToggleCatalogLabel, onToggleCatalogLabel,
onCreateLabel, onCreateLabel,
listClassName, listClassName,
searchAutoFocus = true,
}: { }: {
query: string query: string
onQueryChange: (v: string) => void onQueryChange: (v: string) => void
catalogLabels: string[] catalogLabels: string[]
resolveLabelVisual: (label: string) => LabelPickerVisual
Item: LabelPickerItemComponent Item: LabelPickerItemComponent
getLabelPresence: (label: string) => CatalogLabelPresence getLabelPresence: (label: string) => CatalogLabelPresence
onToggleCatalogLabel: (label: string) => void onToggleCatalogLabel: (label: string) => void
onCreateLabel: (label: string) => void onCreateLabel: (label: string) => void
listClassName?: string listClassName?: string
/** Focus search field when the picker mounts (submenu / sheet open). */
searchAutoFocus?: boolean
}) { }) {
const searchInputRef = useRef<HTMLInputElement>(null)
useLayoutEffect(() => {
if (!searchAutoFocus) return
let inner = 0
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => {
searchInputRef.current?.focus({ preventScroll: true })
})
})
return () => {
cancelAnimationFrame(outer)
if (inner) cancelAnimationFrame(inner)
}
}, [searchAutoFocus])
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
const filtered = catalogLabels.filter( const filtered = catalogLabels.filter(
(l) => q.length === 0 || l.toLowerCase().includes(q) (l) => q.length === 0 || l.toLowerCase().includes(q)
@ -72,9 +120,11 @@ export function EmailLabelPickerBlock({
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
> >
<Input <Input
ref={searchInputRef}
value={query} value={query}
onChange={(e) => onQueryChange(e.target.value)} onChange={(e) => onQueryChange(e.target.value)}
placeholder="Rechercher ou créer un libellé…" placeholder="Rechercher ou créer un libellé…"
aria-label="Rechercher ou créer un libellé"
className="h-8 border-[#dadce0] text-sm shadow-none" className="h-8 border-[#dadce0] text-sm shadow-none"
autoComplete="off" autoComplete="off"
onPointerDown={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}
@ -91,7 +141,7 @@ export function EmailLabelPickerBlock({
}} }}
> >
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} /> <Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
<span className="min-w-0 text-[#0b57d0]"> <span className="min-w-0 flex-1 text-[#0b57d0]">
Créer le libellé « {trimmed} » Créer le libellé « {trimmed} »
</span> </span>
</Item> </Item>
@ -109,7 +159,8 @@ export function EmailLabelPickerBlock({
}} }}
> >
<LabelPickerCheckboxVisual checked={boxChecked} /> <LabelPickerCheckboxVisual checked={boxChecked} />
<span className="min-w-0 truncate">{label}</span> <LabelPickerLeadingVisual visual={resolveLabelVisual(label)} />
<span className="min-w-0 flex-1 truncate">{label}</span>
</Item> </Item>
) )
})} })}

View File

@ -9,10 +9,8 @@ import {
useState, useState,
type ComponentType, type ComponentType,
type DragEvent, type DragEvent,
type ElementType,
type MouseEvent, type MouseEvent,
type ReactNode, type ReactNode,
type SVGProps,
} from "react" } from "react"
import { Icon, addCollection } from "@iconify/react" import { Icon, addCollection } from "@iconify/react"
import { icons as mdiIcons } from "@iconify-json/mdi" import { icons as mdiIcons } from "@iconify-json/mdi"
@ -29,9 +27,6 @@ import {
RefreshCw, RefreshCw,
ChevronDown, ChevronDown,
Tag, Tag,
Users,
Info,
MessageSquare,
Reply, Reply,
ReplyAll, ReplyAll,
Forward, Forward,
@ -96,31 +91,46 @@ import {
EmptyTitle, EmptyTitle,
} from "@/components/ui/empty" } from "@/components/ui/empty"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast"
import { import {
buildLabelTextToNavColorClass, buildLabelTextToNavColorClass,
MailLabelPillStrip, MailLabelPillStrip,
mailLabelShouldShowInListStrip,
} from "@/components/gmail/mail-label-pills" } from "@/components/gmail/mail-label-pills"
import { emails, type Email, type EmailAttachment } from "@/lib/email-data" import { emails, type Email, type EmailAttachment } from "@/lib/email-data"
import { useScheduledMail } from "@/lib/scheduled-mail-context" import { useScheduledMail } from "@/lib/scheduled-mail-context"
import { useMailStore } from "@/lib/stores/mail-store" import { useMailStore } from "@/lib/stores/mail-store"
import { useScheduledStore } from "@/lib/stores/scheduled-store"
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
import { import {
emailMatchesFolder, emailMatchesFolder,
emailMatchesInboxPrimaryTab,
type MailNavFolderMaps, type MailNavFolderMaps,
} from "@/lib/mail-folder-filter" } from "@/lib/mail-folder-filter"
import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display" import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display"
import { getMailNavFolderLabel, type FolderTreeNode } from "@/lib/sidebar-nav-data" import {
getMailNavFolderLabel,
inboxTabDisplayLabel,
tabbedInboxLabelRows,
type FolderTreeNode,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
import { mailNavVisitKey } from "@/lib/mail-folder-display" import { mailNavVisitKey } from "@/lib/mail-folder-display"
import { normalizeInboxTabSegment } from "@/lib/mail-url"
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator" import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context" import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
import { ContactHoverCard } from "./contact-hover-card" import { ContactHoverCard } from "./contact-hover-card"
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block" import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets" import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets"
import { import {
useMoveTargets, useMoveTargets,
type MoveTarget, type MoveTarget,
} from "@/components/gmail/move-to-menu-items" } from "@/components/gmail/move-to-menu-items"
import { EmailView } from "./email-view" import { EmailView } from "./email-view"
import { MailDateText } from "@/components/gmail/mail-date-text"
import { formatMailDetailDate } from "@/lib/mail-date"
import { buildListMailIndex } from "./email-list-row" import { buildListMailIndex } from "./email-list-row"
import { import {
useComposeActions, useComposeActions,
@ -178,9 +188,7 @@ function collectTreeLabels(nodes: FolderTreeNode[]): string[] {
function formatScheduledDateTimeDisplay(iso: string | undefined): string { function formatScheduledDateTimeDisplay(iso: string | undefined): string {
if (!iso) return "—" if (!iso) return "—"
const d = new Date(iso) return formatMailDetailDate(iso)
if (Number.isNaN(d.getTime())) return "—"
return d.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" })
} }
function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string { function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
@ -286,78 +294,46 @@ function importantSignalIcon(isSpam: boolean, isImportant: boolean): string {
return "mdi:label-variant-outline" return "mdi:label-variant-outline"
} }
type TabBadgeTone = "green" | "blue" | "orange" | "purple" type InboxTabBarItem = {
interface CategoryTab {
id: string id: string
label: string label: string
icon: ElementType icon: string
badgeTone?: TabBadgeTone badgeColor: string
} }
const categoryTabs: CategoryTab[] = [ function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
{ id: "primary", label: "Principale", icon: Inbox, badgeTone: "blue" }, return [
{ {
id: "promotions", id: "primary",
label: "Promotions", label: "Principale",
icon: Tag, icon: "mdi:inbox",
badgeTone: "green", badgeColor: "bg-[#0b57d0]",
}, },
{ ...tabbedInboxLabelRows(labelRows).map((r) => ({
id: "social", id: r.id,
label: "Réseaux sociaux", label: r.label,
icon: Users, icon: r.icon ?? "mdi:label-outline",
badgeTone: "blue", badgeColor: r.color,
}, })),
{
id: "updates",
label: "Notifications",
icon: Info,
badgeTone: "orange",
},
{ id: "forums", label: "Forums", icon: MessageSquare, badgeTone: "purple" },
] ]
}
const CATEGORY_TAB_ICON_STROKE = 2.5 function inboxTabBadgeCountClass(badgeColor: string) {
function categoryBadgeClass(tone: TabBadgeTone) {
return cn( return cn(
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none text-white", "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
tone === "green" && "bg-[#1e8e3e]", badgeColor,
tone === "blue" && "bg-[#0b57d0]", labelPillTextClassForTailwindBgUtility(badgeColor)
tone === "orange" && "bg-[#e8710a]",
tone === "purple" && "bg-[#9334e6]"
) )
} }
function categoryBadgeDotClass(tone: TabBadgeTone) { function inboxTabBadgeDotClass(badgeColor: string) {
return cn( return cn(
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white", "absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white",
tone === "green" && "bg-[#1e8e3e]", badgeColor
tone === "blue" && "bg-[#0b57d0]",
tone === "orange" && "bg-[#e8710a]",
tone === "purple" && "bg-[#9334e6]"
)
}
function Inbox({ className, strokeWidth = CATEGORY_TAB_ICON_STROKE, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
) )
} }
const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0"
function ListAttachmentChip({ att }: { att: EmailAttachment }) { function ListAttachmentChip({ att }: { att: EmailAttachment }) {
return ( return (
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]"> <span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]">
@ -622,9 +598,14 @@ export function EmailList({
requestRestoreSnoozedToInbox, requestRestoreSnoozedToInbox,
} = useScheduledMail() } = useScheduledMail()
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
const allEmails = useMemo( const allEmails = useMemo(
() => [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails], () =>
[scheduledEmails, snoozedEmails, sentPlaceholderEmails] scheduledPersistHydrated
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
: emails,
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
) )
const sidebarNav = useSidebarNav() const sidebarNav = useSidebarNav()
@ -632,8 +613,14 @@ export function EmailList({
() => ({ () => ({
folderIdToLabel: sidebarNav.folderIdToLabel, folderIdToLabel: sidebarNav.folderIdToLabel,
folderTree: sidebarNav.folderTree, folderTree: sidebarNav.folderTree,
labelRows: sidebarNav.labelRows,
}), }),
[sidebarNav.folderIdToLabel, sidebarNav.folderTree] [sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
)
const inboxTabBarItems = useMemo(
() => buildInboxTabBarItems(sidebarNav.labelRows),
[sidebarNav.labelRows]
) )
const listRowLabelBgByTextLower = useMemo( const listRowLabelBgByTextLower = useMemo(
@ -911,7 +898,35 @@ export function EmailList({
) )
) )
if (selectedFolder === "inbox") { if (selectedFolder === "inbox") {
rows = rows.filter((email) => email.category === inboxTab) const tab = normalizeInboxTabSegment(inboxTab)
if (tab === "primary") {
rows = rows.filter((email) =>
emailMatchesInboxPrimaryTab(
email,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
} else {
rows = rows.filter(
(email) =>
emailMatchesFolder(
email,
"inbox",
folderFilterCtx,
navMaps,
subtreeIdsCache
) &&
emailMatchesFolder(
email,
tab,
folderFilterCtx,
navMaps,
subtreeIdsCache
)
)
}
} }
return rows return rows
}, [ }, [
@ -926,8 +941,13 @@ export function EmailList({
]) ])
const inboxCategoryTabLabel = useMemo( const inboxCategoryTabLabel = useMemo(
() => categoryTabs.find((t) => t.id === inboxTab)?.label ?? inboxTab, () =>
[inboxTab] inboxTabDisplayLabel(
inboxTab,
sidebarNav.labelRows,
sidebarNav.folderIdToLabel
),
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
) )
const mobileUnreadCount = useMemo( const mobileUnreadCount = useMemo(
@ -935,13 +955,17 @@ export function EmailList({
[filteredEmails, readOverrides] [filteredEmails, readOverrides]
) )
const mobileFolderLabel = useMemo( const mobileFolderLabel = useMemo(() => {
() => const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
selectedFolder === "inbox" && inboxTab !== "primary" return selectedFolder === "inbox" && inboxTabNorm !== "primary"
? inboxCategoryTabLabel ? inboxCategoryTabLabel
: getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel), : getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)
[selectedFolder, inboxTab, inboxCategoryTabLabel, sidebarNav.folderIdToLabel] }, [
) selectedFolder,
inboxTab,
inboxCategoryTabLabel,
sidebarNav.folderIdToLabel,
])
useEffect(() => { useEffect(() => {
setMobileSelectionMode(false) setMobileSelectionMode(false)
@ -1176,6 +1200,20 @@ export function EmailList({
return [...s].sort((a, b) => a.localeCompare(b, "fr")) return [...s].sort((a, b) => a.localeCompare(b, "fr"))
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds]) }, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
const resolveLabelVisual = useCallback(
(label: string) =>
resolveLabelPickerVisual(label, {
folderTree: sidebarNav.folderTree,
labelRows: sidebarNav.labelRows,
emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId,
}),
[
sidebarNav.folderTree,
sidebarNav.labelRows,
sidebarNav.emailLabelToSidebarFolderId,
]
)
const resolveLabelCasing = useCallback( const resolveLabelCasing = useCallback(
(raw: string) => { (raw: string) => {
const t = raw.trim() const t = raw.trim()
@ -1407,10 +1445,21 @@ export function EmailList({
) )
const counts: Record<string, number> = {} const counts: Record<string, number> = {}
const preview: Record<string, string> = {} const preview: Record<string, string> = {}
for (const tab of categoryTabs) { const tabCache = new Map<string, string[] | null>()
const rows = inboxPool.filter( for (const tab of inboxTabBarItems) {
(e) => e.category === tab.id && !seen.has(e.id) const rows = inboxPool.filter((e) => {
if (tab.id === "primary") {
return (
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
) )
}
return (
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
!seen.has(e.id)
)
})
counts[tab.id] = rows.length counts[tab.id] = rows.length
const chain: string[] = [] const chain: string[] = []
const used = new Set<string>() const used = new Set<string>()
@ -1424,7 +1473,7 @@ export function EmailList({
preview[tab.id] = chain.join(", ") preview[tab.id] = chain.join(", ")
} }
return { unseenInTabById: counts, tabUnseenSenderLineById: preview } return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds]) }, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
const effectiveStarred = (email: Email) => const effectiveStarred = (email: Email) =>
starredEmails.includes(email.id) || email.starred starredEmails.includes(email.id) || email.starred
@ -1964,6 +2013,7 @@ export function EmailList({
labelPickerQuery={labelPickerQuery} labelPickerQuery={labelPickerQuery}
onLabelPickerQueryChange={setLabelPickerQuery} onLabelPickerQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels} catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
moveTargets={moveTargets} moveTargets={moveTargets}
onMoveTo={bulkMoveTo} onMoveTo={bulkMoveTo}
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)} getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
@ -2423,6 +2473,7 @@ export function EmailList({
query={labelPickerQuery} query={labelPickerQuery}
onQueryChange={setLabelPickerQuery} onQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels} catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
Item={DropdownMenuItem} Item={DropdownMenuItem}
getLabelPresence={(lab) => getLabelPresence={(lab) =>
getCatalogLabelPresence(bulkTargetIds, lab) getCatalogLabelPresence(bulkTargetIds, lab)
@ -2597,11 +2648,12 @@ export function EmailList({
<div <div
className="grid w-full max-w-[1260px] min-w-0" className="grid w-full max-w-[1260px] min-w-0"
style={{ style={{
gridTemplateColumns: `repeat(${categoryTabs.length}, minmax(0, 1fr))`, gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
}} }}
> >
{categoryTabs.map((tab) => { {inboxTabBarItems.map((tab) => {
const isActive = inboxTab === tab.id const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
const isActive = inboxTabNorm === tab.id
const isPrimaryTab = tab.id === "primary" const isPrimaryTab = tab.id === "primary"
const unseen = unseenInTabById[tab.id] ?? 0 const unseen = unseenInTabById[tab.id] ?? 0
const senderLine = tabUnseenSenderLineById[tab.id] ?? "" const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
@ -2625,16 +2677,17 @@ export function EmailList({
> >
<div className="flex h-10 w-full items-center justify-center sm:hidden"> <div className="flex h-10 w-full items-center justify-center sm:hidden">
<div className="relative inline-flex shrink-0"> <div className="relative inline-flex shrink-0">
<tab.icon <Icon
strokeWidth={CATEGORY_TAB_ICON_STROKE} icon={tab.icon}
className={cn( className={cn(
"h-4 w-4 shrink-0", CATEGORY_TAB_ICON_CLASS,
isActive ? "text-[#0b57d0]" : "text-[#5f6368]" isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
)} )}
aria-hidden
/> />
{showMeta && unseen > 0 && tab.badgeTone ? ( {showMeta && unseen > 0 ? (
<span <span
className={categoryBadgeDotClass(tab.badgeTone)} className={inboxTabBadgeDotClass(tab.badgeColor)}
aria-hidden aria-hidden
/> />
) : null} ) : null}
@ -2642,12 +2695,14 @@ export function EmailList({
</div> </div>
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex"> <div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
<tab.icon <Icon
strokeWidth={CATEGORY_TAB_ICON_STROKE} icon={tab.icon}
className={cn( className={cn(
"h-4 w-4 shrink-0 self-center", CATEGORY_TAB_ICON_CLASS,
"self-center",
isActive ? "text-[#0b57d0]" : "text-[#5f6368]" isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
)} )}
aria-hidden
/> />
<div className="flex min-w-0 w-0 flex-1 flex-col gap-px"> <div className="flex min-w-0 w-0 flex-1 flex-col gap-px">
<div <div
@ -2664,8 +2719,8 @@ export function EmailList({
> >
{tab.label} {tab.label}
</span> </span>
{showMeta && unseen > 0 && tab.badgeTone ? ( {showMeta && unseen > 0 ? (
<span className={categoryBadgeClass(tab.badgeTone)}> <span className={inboxTabBadgeCountClass(tab.badgeColor)}>
{unseen} {unseen}
<span className="hidden md:inline"> <span className="hidden md:inline">
{" "} {" "}
@ -2726,11 +2781,17 @@ export function EmailList({
labelBgByText={listRowLabelBgByTextLower} labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs} getNavItemPrefs={sidebarNav.getNavItemPrefs}
folderTree={sidebarNav.folderTree}
labelRows={sidebarNav.labelRows}
currentFolderId={selectedFolder}
showLabelChip={(lab) => { showLabelChip={(lab) => {
if (LABEL_PICKER_EXCLUDE.has(lab)) return true if (LABEL_PICKER_EXCLUDE.has(lab)) return true
const fid = sidebarNav.emailLabelToSidebarFolderId[lab] return mailLabelShouldShowInListStrip(
if (!fid) return true lab,
return sidebarNav.getNavItemPrefs(fid).messages !== "hide" sidebarNav.emailLabelToSidebarFolderId,
sidebarNav.getNavItemPrefs,
sidebarNav.labelRows
)
}} }}
/> />
) : ( ) : (
@ -3023,9 +3084,11 @@ export function EmailList({
!isRead ? "text-gray-900" : "text-gray-700" !isRead ? "text-gray-900" : "text-gray-700"
)} )}
> >
{isScheduled {isScheduled ? (
? formatScheduledDateTimeDisplay(email.scheduledSendAt) formatScheduledDateTimeDisplay(email.scheduledSendAt)
: email.date} ) : (
<MailDateText iso={email.date} variant="list" />
)}
</span> </span>
</div> </div>
</div> </div>
@ -3043,6 +3106,7 @@ export function EmailList({
labelBgByText={listRowLabelBgByTextLower} labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs} getNavItemPrefs={sidebarNav.getNavItemPrefs}
labelRows={sidebarNav.labelRows}
onLabelNavigate={handleNavigateToLabel} onLabelNavigate={handleNavigateToLabel}
currentFolderId={selectedFolder} currentFolderId={selectedFolder}
folderTree={sidebarNav.folderTree} folderTree={sidebarNav.folderTree}
@ -3216,6 +3280,7 @@ export function EmailList({
labelBgByText={listRowLabelBgByTextLower} labelBgByText={listRowLabelBgByTextLower}
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
getNavItemPrefs={sidebarNav.getNavItemPrefs} getNavItemPrefs={sidebarNav.getNavItemPrefs}
labelRows={sidebarNav.labelRows}
onLabelNavigate={handleNavigateToLabel} onLabelNavigate={handleNavigateToLabel}
currentFolderId={selectedFolder} currentFolderId={selectedFolder}
folderTree={sidebarNav.folderTree} folderTree={sidebarNav.folderTree}
@ -3559,7 +3624,7 @@ export function EmailList({
!isRead ? "font-semibold text-gray-900" : "text-gray-600" !isRead ? "font-semibold text-gray-900" : "text-gray-600"
)} )}
> >
{email.date} <MailDateText iso={email.date} variant="list" />
</span> </span>
</div> </div>
<div <div
@ -4026,6 +4091,7 @@ export function EmailList({
query={labelPickerQuery} query={labelPickerQuery}
onQueryChange={setLabelPickerQuery} onQueryChange={setLabelPickerQuery}
catalogLabels={catalogLabels} catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
Item={ContextMenuItem} Item={ContextMenuItem}
getLabelPresence={(lab) => getLabelPresence={(lab) =>
getCatalogLabelPresence(contextTargetIds, lab) getCatalogLabelPresence(contextTargetIds, lab)
@ -4078,6 +4144,7 @@ export function EmailList({
currentKey={mailNavVisitKey(selectedFolder, inboxTab)} currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
folderTree={sidebarNav.folderTree} folderTree={sidebarNav.folderTree}
folderIdToLabel={sidebarNav.folderIdToLabel} folderIdToLabel={sidebarNav.folderIdToLabel}
labelRows={sidebarNav.labelRows}
/> />
</div> </div>
) : null} ) : null}

View File

@ -49,12 +49,14 @@ import {
cleanSenderName, cleanSenderName,
senderInitial, senderInitial,
} from "@/lib/sender-display" } from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type { import type {
Email, Email,
ConversationMessage, ConversationMessage,
EmailAttachment, EmailAttachment,
EmailAttachmentKind, EmailAttachmentKind,
} from "@/lib/email-data" } from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { import {
attachmentPreviewTooltip, attachmentPreviewTooltip,
resolveAttachmentKind, resolveAttachmentKind,
@ -88,6 +90,10 @@ interface EmailViewProps {
labelBgByText?: Map<string, string> labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, string> emailLabelToSidebarFolderId?: Record<string, string>
getNavItemPrefs?: (id: string) => { messages: string } getNavItemPrefs?: (id: string) => { messages: string }
folderTree?: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
currentFolderId?: string
} }
const LABEL_DISPLAY_NAMES: Record<string, string> = { const LABEL_DISPLAY_NAMES: Record<string, string> = {
@ -437,7 +443,11 @@ function CollapsedMessage({
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span> <span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
</ContactHoverCard> </ContactHoverCard>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
<span className="text-xs text-[#5f6368]">{message.date}</span> <MailDateText
iso={message.date}
variant="preview"
className="text-xs text-[#5f6368]"
/>
<Star <Star
strokeWidth={1.25} strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]" className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
@ -455,7 +465,7 @@ function CollapsedMessage({
function ExpandedMessage({ function ExpandedMessage({
sender, sender,
senderEmail, senderEmail,
date, dateIso,
body, body,
isSpam, isSpam,
isLast, isLast,
@ -467,7 +477,7 @@ function ExpandedMessage({
}: { }: {
sender: string sender: string
senderEmail: string senderEmail: string
date: string dateIso: string
body: string body: string
isSpam: boolean isSpam: boolean
isLast: boolean isLast: boolean
@ -534,7 +544,14 @@ function ExpandedMessage({
<div className="mt-1 space-y-0.5 text-xs text-[#5f6368]"> <div className="mt-1 space-y-0.5 text-xs text-[#5f6368]">
<p>de : <span className="text-[#3c4043]">{name} &lt;{senderEmail}&gt;</span></p> <p>de : <span className="text-[#3c4043]">{name} &lt;{senderEmail}&gt;</span></p>
<p>à : <span className="text-[#3c4043]">moi</span></p> <p>à : <span className="text-[#3c4043]">moi</span></p>
<p>date : <span className="text-[#3c4043]">{date}</span></p> <p>
date :{" "}
<MailDateText
iso={dateIso}
variant="detail"
className="text-[#3c4043]"
/>
</p>
{isSpam && ( {isSpam && (
<p className="text-[#d93025]">sécurité : ce message est marqué comme spam les images et appels externes sont bloqués</p> <p className="text-[#d93025]">sécurité : ce message est marqué comme spam les images et appels externes sont bloqués</p>
)} )}
@ -543,7 +560,11 @@ function ExpandedMessage({
</div> </div>
<div className="flex shrink-0 self-start items-center gap-1 pt-0.5"> <div className="flex shrink-0 self-start items-center gap-1 pt-0.5">
<span className="text-xs text-[#5f6368]">{date}</span> <MailDateText
iso={dateIso}
variant="preview"
className="text-xs text-[#5f6368]"
/>
{onToggleStar && ( {onToggleStar && (
<button <button
@ -739,6 +760,9 @@ export function EmailView({
labelBgByText, labelBgByText,
emailLabelToSidebarFolderId = {}, emailLabelToSidebarFolderId = {},
getNavItemPrefs = () => ({ messages: "show" }), getNavItemPrefs = () => ({ messages: "show" }),
folderTree,
labelRows,
currentFolderId,
}: EmailViewProps) { }: EmailViewProps) {
const conversation = email.conversation ?? [] const conversation = email.conversation ?? []
const hasConversation = conversation.length > 0 const hasConversation = conversation.length > 0
@ -842,6 +866,9 @@ export function EmailView({
labelBgByText={labelBgByText} labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId} emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs} getNavItemPrefs={getNavItemPrefs}
labelRows={labelRows}
folderTree={folderTree}
currentFolderId={currentFolderId}
onLabelNavigate={onNavigateToLabel} onLabelNavigate={onNavigateToLabel}
showLabel={showLabelChip} showLabel={showLabelChip}
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab} resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
@ -904,7 +931,7 @@ export function EmailView({
<ExpandedMessage <ExpandedMessage
sender={msg.sender} sender={msg.sender}
senderEmail={msg.senderEmail} senderEmail={msg.senderEmail}
date={msg.date} dateIso={msg.date}
body={msg.body} body={msg.body}
isSpam={false} isSpam={false}
isLast={false} isLast={false}
@ -931,7 +958,7 @@ export function EmailView({
<ExpandedMessage <ExpandedMessage
sender={mainSenderName} sender={mainSenderName}
senderEmail={mainSenderAddr} senderEmail={mainSenderAddr}
date={email.date} dateIso={email.date}
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`} body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`}
isSpam={email.spam === true} isSpam={email.spam === true}
isLast={true} isLast={true}

View File

@ -0,0 +1,33 @@
"use client"
import { useEffect, useState } from "react"
import {
formatMailDate,
type MailDateDisplayVariant,
} from "@/lib/mail-date"
import { cn } from "@/lib/utils"
type MailDateTextProps = {
iso: string | undefined
variant: MailDateDisplayVariant
className?: string
}
/** Date mail formatée côté client (fuseau navigateur, évite mismatch SSR). */
export function MailDateText({ iso, variant, className }: MailDateTextProps) {
const [text, setText] = useState("\u00a0")
useEffect(() => {
if (!iso?.trim()) {
setText("—")
return
}
setText(formatMailDate(iso, variant))
}, [iso, variant])
return (
<span className={cn(className)} suppressHydrationWarning>
{text}
</span>
)
}

View File

@ -1,8 +1,10 @@
"use client" "use client"
import { Fragment, useMemo } from "react" import { Fragment, useMemo } from "react"
import { Icon } from "@iconify/react"
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps" import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import { breadcrumbSegmentsForVisitKey } from "@/lib/mail-folder-display" import type { LabelRowItem } from "@/lib/sidebar-nav-data"
import { breadcrumbItemsForVisitKey } from "@/lib/mail-folder-display"
import { resolveMailNavIcon } from "@/lib/mail-nav-icons" import { resolveMailNavIcon } from "@/lib/mail-nav-icons"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -10,26 +12,29 @@ type MailFolderStackIndicatorProps = {
currentKey: string currentKey: string
folderTree: FolderTreeNode[] folderTree: FolderTreeNode[]
folderIdToLabel: Record<string, string> folderIdToLabel: Record<string, string>
labelRows?: readonly LabelRowItem[]
className?: string className?: string
} }
function MailNavIconGlyph({ function MailNavIconGlyph({
visitKey, visitKey,
folderTree, folderTree,
labelRows,
}: { }: {
visitKey: string visitKey: string
folderTree: FolderTreeNode[] folderTree: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
}) { }) {
const resolved = useMemo( const resolved = useMemo(
() => resolveMailNavIcon(visitKey, folderTree), () => resolveMailNavIcon(visitKey, folderTree, labelRows),
[visitKey, folderTree] [visitKey, folderTree, labelRows]
) )
if (resolved.kind === "folder-dot") { if (resolved.kind === "folder-dot") {
return ( return (
<span <span
className={cn( className={cn(
"inline-block size-4 shrink-0 rounded-sm", "inline-block size-4 shrink-0 rounded-full",
resolved.colorClass resolved.colorClass
)} )}
aria-hidden aria-hidden
@ -37,9 +42,19 @@ function MailNavIconGlyph({
) )
} }
const { Icon } = resolved if (resolved.kind === "iconify") {
return ( return (
<Icon <Icon
icon={resolved.icon}
className="size-4 shrink-0 text-[#5f6368]"
aria-hidden
/>
)
}
const { Icon: LucideIcon } = resolved
return (
<LucideIcon
className="size-4 shrink-0 text-[#5f6368]" className="size-4 shrink-0 text-[#5f6368]"
strokeWidth={1.5} strokeWidth={1.5}
aria-hidden aria-hidden
@ -51,14 +66,15 @@ export function MailFolderStackIndicator({
currentKey, currentKey,
folderTree, folderTree,
folderIdToLabel, folderIdToLabel,
labelRows,
className, className,
}: MailFolderStackIndicatorProps) { }: MailFolderStackIndicatorProps) {
const segments = useMemo( const items = useMemo(
() => breadcrumbSegmentsForVisitKey(currentKey, folderTree, folderIdToLabel), () => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
[currentKey, folderTree, folderIdToLabel] [currentKey, folderTree, folderIdToLabel]
) )
const ariaLabel = segments.join(" · ") const ariaLabel = items.map((i) => i.label).join(" · ")
return ( return (
<div <div
@ -66,17 +82,16 @@ export function MailFolderStackIndicator({
aria-live="polite" aria-live="polite"
aria-label={`Boîte actuelle : ${ariaLabel}`} aria-label={`Boîte actuelle : ${ariaLabel}`}
className={cn( className={cn(
"flex max-w-[min(360px,calc(100vw-1rem))] items-center gap-2.5", "flex max-w-[min(360px,calc(100vw-1rem))] items-center",
"border-t border-r border-[#dadce0]/90", "border-t border-r border-[#dadce0]/90",
"bg-white/78 px-3.5 py-2.5 text-sm font-medium leading-snug text-[#3c4043]", "bg-white/78 px-3.5 py-2.5 text-sm font-medium leading-snug text-[#3c4043]",
"rounded-tr-2xl shadow-sm backdrop-blur-md", "rounded-tr-2xl shadow-sm backdrop-blur-md",
className className
)} )}
> >
<MailNavIconGlyph visitKey={currentKey} folderTree={folderTree} />
<span className="flex min-w-0 items-center gap-1.5"> <span className="flex min-w-0 items-center gap-1.5">
{segments.map((seg, i) => ( {items.map((item, i) => (
<Fragment key={`${seg}-${i}`}> <Fragment key={`${item.visitKey}-${i}`}>
{i > 0 ? ( {i > 0 ? (
<span <span
className="shrink-0 text-xs leading-none text-[#9aa0a6]" className="shrink-0 text-xs leading-none text-[#9aa0a6]"
@ -85,7 +100,14 @@ export function MailFolderStackIndicator({
· ·
</span> </span>
) : null} ) : null}
<span className="truncate">{seg}</span> <span className="flex min-w-0 items-center gap-1.5">
<MailNavIconGlyph
visitKey={item.visitKey}
folderTree={folderTree}
labelRows={labelRows}
/>
<span className="truncate">{item.label}</span>
</span>
</Fragment> </Fragment>
))} ))}
</span> </span>

View File

@ -44,10 +44,20 @@ export function buildLabelTextToNavColorClass(
export function mailLabelShouldShowInListStrip( export function mailLabelShouldShowInListStrip(
lab: string, lab: string,
emailLabelToSidebarFolderId: Record<string, string>, emailLabelToSidebarFolderId: Record<string, string>,
getNavItemPrefs: (id: string) => { messages: string } getNavItemPrefs: (id: string) => { messages: string },
labelRows?: readonly LabelRowItem[]
): boolean { ): boolean {
if (MAIL_LABEL_STRIP_EXCLUDE.has(lab.toLowerCase())) return false if (MAIL_LABEL_STRIP_EXCLUDE.has(lab.toLowerCase())) return false
const fid = emailLabelToSidebarFolderId[lab] const fid = emailLabelToSidebarFolderId[lab]
let row: LabelRowItem | undefined
if (fid) row = labelRows?.find((r) => r.id === fid)
if (!row && labelRows?.length) {
row = labelRows.find((r) => r.label.toLowerCase() === lab.toLowerCase())
}
if (row) {
if (row.enabled === false) return false
if (row.showInMessageList === false) return false
}
if (fid) return getNavItemPrefs(fid).messages !== "hide" if (fid) return getNavItemPrefs(fid).messages !== "hide"
return true return true
} }
@ -153,7 +163,9 @@ type MailLabelPillStripProps = {
showRemoveOnPills?: boolean showRemoveOnPills?: boolean
/** Dossier actuel (id) — son label est masqué en list, mais pas ses sous-dossiers. */ /** Dossier actuel (id) — son label est masqué en list, mais pas ses sous-dossiers. */
currentFolderId?: string currentFolderId?: string
/** Arbre dossiers — nécessaire pour déterminer le label du dossier courant et l'ordre. */ /** Libellés navigation (couleurs / enabled / showInMessageList). */
labelRows?: readonly LabelRowItem[]
/** Arbre dossiers (tri pastilles liste, masquage libellé du dossier courant). */
folderTree?: FolderTreeNode[] folderTree?: FolderTreeNode[]
} }
@ -170,6 +182,7 @@ export function MailLabelPillStrip({
showRemoveOnPills, showRemoveOnPills,
currentFolderId, currentFolderId,
folderTree, folderTree,
labelRows,
}: MailLabelPillStripProps) { }: MailLabelPillStripProps) {
const currentFolderLabel = useMemo(() => { const currentFolderLabel = useMemo(() => {
if (!currentFolderId || !folderTree) return null if (!currentFolderId || !folderTree) return null
@ -184,7 +197,14 @@ export function MailLabelPillStrip({
const shown = useMemo(() => { const shown = useMemo(() => {
const filtered = (labels ?? []).filter((lab) => { const filtered = (labels ?? []).filter((lab) => {
if (variant === "list") { if (variant === "list") {
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs)) { if (
!mailLabelShouldShowInListStrip(
lab,
emailLabelToSidebarFolderId,
getNavItemPrefs,
labelRows
)
) {
return false return false
} }
if (currentFolderLabel && lab.toLowerCase() === currentFolderLabel.toLowerCase()) { if (currentFolderLabel && lab.toLowerCase() === currentFolderLabel.toLowerCase()) {
@ -192,6 +212,9 @@ export function MailLabelPillStrip({
} }
return true return true
} }
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs, labelRows)) {
return false
}
if (lab === "spam" && spamChip) return false if (lab === "spam" && spamChip) return false
if (showLabel && !showLabel(lab)) return false if (showLabel && !showLabel(lab)) return false
return true return true
@ -208,7 +231,7 @@ export function MailLabelPillStrip({
} }
return filtered return filtered
}, [labels, variant, emailLabelToSidebarFolderId, getNavItemPrefs, currentFolderLabel, folderTree, folderLabelsLower, spamChip, showLabel]) }, [labels, variant, emailLabelToSidebarFolderId, getNavItemPrefs, labelRows, currentFolderLabel, folderTree, folderLabelsLower, spamChip, showLabel])
const hasSpam = variant === "header" && spamChip const hasSpam = variant === "header" && spamChip
if (shown.length === 0 && !hasSpam) return null if (shown.length === 0 && !hasSpam) return null

View File

@ -11,6 +11,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block" import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
import type { MoveTarget } from "@/components/gmail/move-to-menu-items" import type { MoveTarget } from "@/components/gmail/move-to-menu-items"
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
function SheetActionRow({ function SheetActionRow({
children, children,
@ -72,6 +73,7 @@ export type MobileXsBulkSheetsProps = {
labelPickerQuery: string labelPickerQuery: string
onLabelPickerQueryChange: (query: string) => void onLabelPickerQueryChange: (query: string) => void
catalogLabels: string[] catalogLabels: string[]
resolveLabelVisual: (label: string) => LabelPickerVisual
moveTargets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] } moveTargets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
onMoveTo: (targetId: string) => void onMoveTo: (targetId: string) => void
getLabelPresence: (label: string) => CatalogLabelPresence getLabelPresence: (label: string) => CatalogLabelPresence
@ -87,6 +89,7 @@ export function MobileXsBulkSheets({
labelPickerQuery, labelPickerQuery,
onLabelPickerQueryChange, onLabelPickerQueryChange,
catalogLabels, catalogLabels,
resolveLabelVisual,
moveTargets, moveTargets,
onMoveTo, onMoveTo,
getLabelPresence, getLabelPresence,
@ -176,6 +179,7 @@ export function MobileXsBulkSheets({
<SheetContent <SheetContent
side="bottom" side="bottom"
className="max-h-[min(85vh,520px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2 [&>button]:top-3.5 [&>button]:right-3.5" className="max-h-[min(85vh,520px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2 [&>button]:top-3.5 [&>button]:right-3.5"
onOpenAutoFocus={(e) => e.preventDefault()}
> >
<SheetHeader className="shrink-0 border-b border-[#eceff1] px-4 pb-3 text-left"> <SheetHeader className="shrink-0 border-b border-[#eceff1] px-4 pb-3 text-left">
<SheetTitle className="flex items-center gap-2 text-base font-medium text-[#3c4043]"> <SheetTitle className="flex items-center gap-2 text-base font-medium text-[#3c4043]">
@ -188,6 +192,7 @@ export function MobileXsBulkSheets({
query={labelPickerQuery} query={labelPickerQuery}
onQueryChange={onLabelPickerQueryChange} onQueryChange={onLabelPickerQueryChange}
catalogLabels={catalogLabels} catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
Item={SheetLabelItem} Item={SheetLabelItem}
getLabelPresence={getLabelPresence} getLabelPresence={getLabelPresence}
onToggleCatalogLabel={onToggleCatalogLabel} onToggleCatalogLabel={onToggleCatalogLabel}

View File

@ -10,6 +10,8 @@ import {
} from "@/lib/drag-pointer-store" } from "@/lib/drag-pointer-store"
import { useIsXs } from "@/hooks/use-xs" import { useIsXs } from "@/hooks/use-xs"
const DRAG_POINTER_SERVER_SNAPSHOT = { x: 0, y: 0 } as const
export function MoveDragIndicator() { export function MoveDragIndicator() {
const isXs = useIsXs() const isXs = useIsXs()
const { state } = useEmailDrag() const { state } = useEmailDrag()
@ -19,7 +21,7 @@ export function MoveDragIndicator() {
const livePointer = useSyncExternalStore( const livePointer = useSyncExternalStore(
subscribeDragPointer, subscribeDragPointer,
getDragPointerSnapshot, getDragPointerSnapshot,
() => ({ x: 0, y: 0 }) () => DRAG_POINTER_SERVER_SNAPSHOT
) )
useEffect(() => { useEffect(() => {

View File

@ -10,30 +10,39 @@ import {
Tag, Tag,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
GripVertical,
Pencil, Pencil,
ShoppingCart,
MapPin,
Share2,
Bell,
MessageSquare,
BadgePercent,
Plus, Plus,
Bot, Bot,
Folder, Folder,
MoreVertical, MoreVertical,
Sparkles,
Newspaper, Newspaper,
LayoutGrid, LayoutGrid,
Rss, Rss,
CreditCard,
Mail, Mail,
ShieldAlert, ShieldAlert,
Check, Check,
Trash2,
} from "lucide-react" } from "lucide-react"
import { cn, formatCount } from "@/lib/utils" import { cn, formatCount } from "@/lib/utils"
import { readXsMatches } from "@/hooks/use-xs" import { readXsMatches } from "@/hooks/use-xs"
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react" import {
useState,
useRef,
useEffect,
useMemo,
useCallback,
type ReactNode,
type CSSProperties,
} from "react"
import { useEmailDropTarget } from "@/lib/drag-context" 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 { useComposeActions } from "@/lib/compose-context"
import { import {
DropdownMenu, DropdownMenu,
@ -46,7 +55,7 @@ import {
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { type FolderTreeNode } from "@/lib/sidebar-nav-data" import { type FolderTreeNode, isSystemNavLabelId, SYSTEM_NAV_LABEL_DEFAULTS } from "@/lib/sidebar-nav-data"
import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context" import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context"
import { import {
Dialog, Dialog,
@ -121,24 +130,22 @@ const mainItems = [
{ id: "drafts", label: "Brouillons", icon: FileText }, { id: "drafts", label: "Brouillons", icon: FileText },
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp }, { id: "scheduled", label: "Planifié", icon: ClockArrowUp },
{ id: "spam", label: "Indésirables", icon: ShieldAlert }, { id: "spam", label: "Indésirables", icon: ShieldAlert },
{ id: "trash", label: "Corbeille", icon: Trash2 },
] ]
const categoryItemsSource = [ /** Catégories système affichées sous « Plus » uniquement. */
{ id: "purchases", label: "Achats", icon: ShoppingCart }, const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["mises-a-jour", "finance"])
{ id: "travel", label: "Déplacements", icon: MapPin },
{ id: "social", label: "Réseaux sociaux", icon: Share2 },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "updates", label: "Mises à jour", icon: Sparkles },
{ id: "forums", label: "Forums", icon: MessageSquare },
{ id: "finance", label: "Finance", icon: CreditCard },
{ id: "promotions", label: "Promotions", icon: BadgePercent },
]
/** Ids catégories : menu ⋮ (Afficher / Masquer) du survol. */ const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
const CATEGORY_MENU_IDS = new Set(categoryItemsSource.map((c) => c.id))
/** Catégories affichées sous « Plus » uniquement (Mises à jour, Finance, …). */ function sortSystemLabelRows(rows: { id: string }[]): { id: string; label: string; icon?: string }[] {
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["updates", "finance"]) 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). */ /** Liens secondaires sous la liste (jusquà Gérer les abonnements). */
const sidebarSecondaryActions = [ const sidebarSecondaryActions = [
@ -149,7 +156,7 @@ const sidebarSecondaryActions = [
] as const ] as const
const hasPlusOnlyExtras = const hasPlusOnlyExtras =
categoryItemsSource.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) || SYSTEM_NAV_LABEL_DEFAULTS.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
sidebarSecondaryActions.length > 0 sidebarSecondaryActions.length > 0
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */ /** Pastilles sous-menu « Couleur du libellé » (démo UI). */
@ -245,13 +252,72 @@ function folderParentSelectOptions(tree: FolderTreeNode[]): {
return out return out
} }
type CategoryNavSourceItem = (typeof categoryItemsSource)[number] type CategoryNavSourceItem = {
id: string
label: string
icon?: string
}
/** Pill à droite seulement quand le fond daccent est visible (évite frange sur fond neutre). */ /** Pill à droite seulement quand le fond daccent est visible (évite frange sur fond neutre). */
function navRowRoundedWhenActive(active: boolean) { function navRowRoundedWhenActive(active: boolean) {
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full" 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="flex h-8 w-4 shrink-0 cursor-grab items-center justify-center text-gray-400 opacity-50 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:opacity-100 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). */ /** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
function SidebarOverflowColumn({ function SidebarOverflowColumn({
unread, unread,
@ -308,8 +374,8 @@ function CategoryNavRow({
isExpanded, isExpanded,
unreadCount, unreadCount,
onSelectFolder, onSelectFolder,
onHideCategory, onDisableNavLabel,
onShowCategory, onEnableNavLabel,
variant = "listed", variant = "listed",
}: { }: {
item: CategoryNavSourceItem item: CategoryNavSourceItem
@ -317,15 +383,15 @@ function CategoryNavRow({
isExpanded: boolean isExpanded: boolean
unreadCount: number unreadCount: number
onSelectFolder: (id: string) => void onSelectFolder: (id: string) => void
onHideCategory: (id: string) => void onDisableNavLabel: (id: string) => void
onShowCategory: (id: string) => void onEnableNavLabel: (id: string) => void
variant?: "listed" | "hidden" variant?: "listed" | "hidden"
}) { }) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label) const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null) const menuTriggerRef = useRef<HTMLButtonElement>(null)
const isHiddenRow = variant === "hidden" const isHiddenRow = variant === "hidden"
const showCategoryMenu = CATEGORY_MENU_IDS.has(item.id) && isExpanded const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0 const hasUnread = unreadCount > 0
const handleMenuOpenChange = (open: boolean) => { const handleMenuOpenChange = (open: boolean) => {
@ -335,6 +401,27 @@ function CategoryNavRow({
} }
} }
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) { if (isHiddenRow) {
return ( return (
<div <div
@ -350,7 +437,7 @@ function CategoryNavRow({
onClick={() => onSelectFolder(item.id)} 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" 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"
> >
<item.icon className="h-5 w-5 shrink-0 opacity-70" /> {rowIcon}
<div className="flex min-w-0 flex-1 items-baseline gap-4"> <div className="flex min-w-0 flex-1 items-baseline gap-4">
<span <span
className={cn( className={cn(
@ -387,14 +474,11 @@ function CategoryNavRow({
<DropdownMenuContent align="end" className="min-w-40"> <DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
onShowCategory(item.id) onEnableNavLabel(item.id)
setMenuOpen(false) setMenuOpen(false)
}} }}
> >
Afficher Réactiver le libellé
</DropdownMenuItem>
<DropdownMenuItem disabled className="text-gray-400">
Masquer
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -426,12 +510,7 @@ function CategoryNavRow({
showCategoryMenu ? "pr-1" : "pr-3" showCategoryMenu ? "pr-1" : "pr-3"
)} )}
> >
<item.icon {rowIcon}
className={cn(
"h-5 w-5 shrink-0",
hasUnread && !isSelected && "text-gray-900"
)}
/>
{isExpanded && ( {isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4"> <div className="flex min-w-0 flex-1 items-baseline gap-4">
<span <span
@ -484,11 +563,11 @@ function CategoryNavRow({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
onHideCategory(item.id) onDisableNavLabel(item.id)
setMenuOpen(false) setMenuOpen(false)
}} }}
> >
Masquer Désactiver le libellé
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -508,7 +587,6 @@ export function Sidebar({
const [hoverExpanded, setHoverExpanded] = useState(false) const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false) const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set()) const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const [hiddenCategoryIds, setHiddenCategoryIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null) const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
@ -527,12 +605,86 @@ export function Sidebar({
renameFolderOrLabel, renameFolderOrLabel,
removeFolderOrLabelRow, removeFolderOrLabelRow,
moveFolder, moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder, addSubfolder,
addChildLabelRow, addChildLabelRow,
setLabelRowEnabled,
} = useSidebarNav() } = 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(() => { const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => { return labelRows.filter((row) => {
if (row.enabled === false) return false
if (isSystemNavLabelId(row.id)) return false
const p = getNavItemPrefs(row.id) const p = getNavItemPrefs(row.id)
if (p.sidebar === "hide") return false if (p.sidebar === "hide") return false
if ( if (
@ -548,7 +700,6 @@ export function Sidebar({
const validNavFolderIds = useMemo(() => { const validNavFolderIds = useMemo(() => {
const s = new Set<string>() const s = new Set<string>()
for (const i of mainItems) s.add(i.id) for (const i of mainItems) s.add(i.id)
for (const c of categoryItemsSource) s.add(c.id)
for (const k of Object.keys(folderIdToLabel)) s.add(k) for (const k of Object.keys(folderIdToLabel)) s.add(k)
return s return s
}, [folderIdToLabel]) }, [folderIdToLabel])
@ -573,17 +724,24 @@ export function Sidebar({
) )
const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => { const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => {
const vis = categoryItemsSource.filter((c) => !hiddenCategoryIds.has(c.id)) const systemEnabled = sortSystemLabelRows(
labelRows.filter((r) => r.enabled !== false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
return { return {
primaryVisibleCategories: vis.filter((c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)), primaryVisibleCategories: systemEnabled.filter(
plusOnlyVisibleCategories: vis.filter((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)), (c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
plusOnlyVisibleCategories: systemEnabled.filter((c) =>
CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
),
} }
}, [hiddenCategoryIds]) }, [labelRows])
const hiddenCategoryItems = useMemo( const disabledSystemNavItems = useMemo(() => {
() => categoryItemsSource.filter((c) => hiddenCategoryIds.has(c.id)), return sortSystemLabelRows(
[hiddenCategoryIds] 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 visibleMainItems = useMemo(() => {
const scheduledTotal = folderUnreadCounts.scheduled ?? 0 const scheduledTotal = folderUnreadCounts.scheduled ?? 0
@ -591,22 +749,6 @@ export function Sidebar({
return mainItems.filter((item) => item.id !== "scheduled") return mainItems.filter((item) => item.id !== "scheduled")
}, [folderUnreadCounts.scheduled]) }, [folderUnreadCounts.scheduled])
const hideCategory = (id: string) => {
setHiddenCategoryIds((prev) => {
const next = new Set(prev)
next.add(id)
return next
})
}
const showCategory = (id: string) => {
setHiddenCategoryIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
const toggleFolderExpanded = (id: string) => { const toggleFolderExpanded = (id: string) => {
setExpandedFolderIds((prev) => { setExpandedFolderIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -634,10 +776,11 @@ export function Sidebar({
} }
useEffect(() => { useEffect(() => {
if (hiddenCategoryIds.has(selectedFolder)) { const row = labelRows.find((r) => r.id === selectedFolder)
if (row && row.enabled === false) {
onSelectFolder("inbox") onSelectFolder("inbox")
} }
}, [hiddenCategoryIds, selectedFolder, onSelectFolder]) }, [labelRows, selectedFolder, onSelectFolder])
useEffect(() => { useEffect(() => {
if (selectedFolder !== "scheduled") return if (selectedFolder !== "scheduled") return
@ -770,6 +913,7 @@ export function Sidebar({
const isStickyBranch = hasChildren && isBranchOpen const isStickyBranch = hasChildren && isBranchOpen
const stickyTopPx = 32 + depth * 32 const stickyTopPx = 32 + depth * 32
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null) const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(node.label) const [renameDraft, setRenameDraft] = useState(node.label)
@ -791,6 +935,9 @@ export function Sidebar({
} }
} }
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
const prefs = getNavItemPrefs(node.id) const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo( const moveTargets = useMemo(
() => folderMoveParentOptions(folderTree, node.id), () => folderMoveParentOptions(folderTree, node.id),
@ -851,13 +998,14 @@ export function Sidebar({
} }
const rowClass = cn( const rowClass = cn(
"group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm", "group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
isSelected || isOver ? "rounded-r-full" : "rounded-r-none", isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
isStickyBranch && "sticky border-b border-gray-200/70", isStickyBranch && "sticky border-b border-gray-200/70",
isStickyBranch && !isSelected && "bg-app-canvas", isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
isSelected && "bg-[#d3e3fd] font-medium text-gray-900", isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
!isSelected && hasUnread && "text-gray-900", !isSelected && hasUnread && "text-gray-900",
isOver && "bg-yellow-100 text-gray-900" isOver && "bg-yellow-100 text-gray-900",
rowHoverHeld && "bg-gray-100 text-gray-900"
) )
const rowStyle: CSSProperties = { const rowStyle: CSSProperties = {
paddingLeft: 24 + depth * 16, paddingLeft: 24 + depth * 16,
@ -971,14 +1119,86 @@ export function Sidebar({
</SidebarOverflowColumn> </SidebarOverflowColumn>
) )
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)
}
return ( return (
<> <>
<ContextMenu> <ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div {...dropHandlers} className={rowClass} style={rowStyle}> <div
data-nav-row
onDragEnter={onFolderRowDragEnter}
onDragOver={onFolderRowDragOver}
onDragLeave={onFolderRowDragLeave}
onDrop={onFolderRowDrop}
className={rowClass}
style={rowStyle}
>
{isExpanded ? (
<SidebarNavDragHandle
label={node.label}
onDragStart={onFolderDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
{hasChildren ? ( {hasChildren ? (
<button <button
type="button" type="button"
draggable={false}
className={cn( 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", "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" isSelected && "text-gray-900"
@ -1007,14 +1227,18 @@ export function Sidebar({
aria-hidden aria-hidden
/> />
)} )}
<button <div
type="button" role="button"
tabIndex={0}
onClick={() => onSelectFolder(node.id)} onClick={() => onSelectFolder(node.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))}
className={cn( className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors", "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 && !isSelected &&
!isOver && !isOver &&
!rowHoverHeld &&
"rounded-r-none hover:rounded-r-full hover:bg-gray-100", "rounded-r-none hover:rounded-r-full hover:bg-gray-100",
rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
isSelected isSelected
? "text-gray-900" ? "text-gray-900"
: isOver : isOver
@ -1036,7 +1260,7 @@ export function Sidebar({
</span> </span>
</span> </span>
</div> </div>
</button> </div>
{overflowMenu} {overflowMenu}
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -1341,6 +1565,7 @@ export function Sidebar({
const isSelected = selectedFolder === item.id const isSelected = selectedFolder === item.id
const hasUnread = unreadCount > 0 const hasUnread = unreadCount > 0
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null) const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false) const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(item.label) const [renameDraft, setRenameDraft] = useState(item.label)
@ -1348,6 +1573,7 @@ export function Sidebar({
const [sublabelName, setSublabelName] = useState("") const [sublabelName, setSublabelName] = useState("")
const labelRenameInputRef = useRef<HTMLInputElement>(null) const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null) const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
useEffect(() => { useEffect(() => {
setRenameDraft(item.label) setRenameDraft(item.label)
@ -1360,6 +1586,9 @@ export function Sidebar({
} }
} }
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
const prefs = getNavItemPrefs(item.id) const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400" const labelDotClass = item.color ?? "bg-gray-400"
const labelMenuSurface = const labelMenuSurface =
@ -1415,16 +1644,76 @@ export function Sidebar({
const rowClass = cn( const rowClass = cn(
"group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors", "group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
navRowRoundedWhenActive(isSelected || isOver), navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium" ? "bg-[#d3e3fd] text-gray-900 font-medium"
: isOver : isOver
? "bg-yellow-100 text-gray-900" ? "bg-yellow-100 text-gray-900"
: rowHoverHeld
? "bg-gray-100 text-gray-900"
: hasUnread : hasUnread
? "text-gray-900 hover:bg-gray-100" ? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100" : "text-gray-700 hover:bg-gray-100"
) )
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 ? ( const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn <SidebarOverflowColumn
unread={unreadCount} unread={unreadCount}
@ -1439,6 +1728,7 @@ export function Sidebar({
<button <button
ref={menuTriggerRef} ref={menuTriggerRef}
type="button" type="button"
draggable={false}
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")} className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`} aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@ -1524,15 +1814,31 @@ export function Sidebar({
return ( return (
<> <>
<ContextMenu> <ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div {...dropHandlers} className={rowClass}> <div
<button data-nav-row
type="button" 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} title={!labelRowExpanded ? item.label : undefined}
onClick={() => onSelectFolder(item.id)} onClick={() => onSelectFolder(item.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))}
className={cn( className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none", "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 ? "pr-1" : "pr-3"
)} )}
> >
@ -1551,7 +1857,7 @@ export function Sidebar({
{item.label} {item.label}
</span> </span>
)} )}
</button> </div>
{overflowMenu} {overflowMenu}
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@ -1791,8 +2097,8 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
onHideCategory={hideCategory} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onShowCategory={showCategory} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/> />
))} ))}
@ -1845,8 +2151,8 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
onHideCategory={hideCategory} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onShowCategory={showCategory} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/> />
))} ))}
{isExpanded && ( {isExpanded && (
@ -1866,12 +2172,12 @@ export function Sidebar({
})} })}
</div> </div>
)} )}
{isExpanded && hiddenCategoryItems.length > 0 && ( {isExpanded && disabledSystemNavItems.length > 0 && (
<div className="mt-2 pt-2"> <div className="mt-2 pt-2">
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500"> <div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
Masquées Désactivées
</div> </div>
{hiddenCategoryItems.map((item) => ( {disabledSystemNavItems.map((item) => (
<CategoryNavRow <CategoryNavRow
key={item.id} key={item.id}
item={item} item={item}
@ -1879,8 +2185,8 @@ export function Sidebar({
isExpanded={isExpanded} isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0} unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder} onSelectFolder={onSelectFolder}
onHideCategory={hideCategory} onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onShowCategory={showCategory} onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
variant="hidden" variant="hidden"
/> />
))} ))}

View File

@ -0,0 +1,23 @@
"use client"
import { useEffect, useState } from "react"
type PersistApi = {
hasHydrated: () => boolean
onFinishHydration: (fn: () => void) => () => void
}
/** True after a zustand `persist` store has rehydrated from storage (client-only). */
export function usePersistHydrated(store: { persist: PersistApi }): boolean {
const [hydrated, setHydrated] = useState(() => store.persist.hasHydrated())
useEffect(() => {
if (store.persist.hasHydrated()) {
setHydrated(true)
return
}
return store.persist.onFinishHydration(() => setHydrated(true))
}, [store])
return hydrated
}

View File

@ -56,11 +56,10 @@ export async function createScheduledSend(
subject: payload.subject.trim() || "(Sans objet)", subject: payload.subject.trim() || "(Sans objet)",
preview: payload.previewText.slice(0, 200), preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml, body: payload.bodyHtml,
date: "", date: payload.sendAtIso,
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["scheduled"], labels: ["scheduled"],
scheduledSendAt: payload.sendAtIso, scheduledSendAt: payload.sendAtIso,
scheduledToName: toName, scheduledToName: toName,
@ -98,7 +97,6 @@ export async function snoozeScheduledSend(id: string): Promise<void> {
scheduledToName: undefined, scheduledToName: undefined,
snoozeWakeAt: wakeIso, snoozeWakeAt: wakeIso,
sender: row.scheduledToName ?? row.sender, sender: row.scheduledToName ?? row.sender,
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
read: true, read: true,
}) })
} }
@ -192,11 +190,10 @@ export async function sendScheduledNow(id: string): Promise<void> {
subject: row.subject, subject: row.subject,
preview: row.preview, preview: row.preview,
body: row.body, body: row.body,
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }), date: now.toISOString(),
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["sent"], labels: ["sent"],
}) })
} }

View File

@ -12,10 +12,16 @@ type DemoVcBrand = {
/** Heure de début (Europe/Paris) — 15 mai 2026 */ /** Heure de début (Europe/Paris) — 15 mai 2026 */
startHour: number startHour: number
startMinute?: number startMinute?: number
dateLabel: string /** Jour du mois (mai 2026, Europe/Paris) */
mailDay: number
read?: boolean read?: boolean
} }
function fixtureParisIso(day: number, hour: number, minute = 0): string {
const p = (n: number) => String(n).padStart(2, "0")
return `2026-05-${p(day)}T${p(hour)}:${p(minute)}:00+02:00`
}
const DEMO_ORGANIZER = "demo.organisateur@ultimail.test" const DEMO_ORGANIZER = "demo.organisateur@ultimail.test"
const DEMO_ATTENDEE = "moi@example.com" const DEMO_ATTENDEE = "moi@example.com"
@ -85,13 +91,12 @@ function buildDemoVcEmail(brand: DemoVcBrand): Email {
ven. 15 mai 2026 ${timeLabel} (Europe/Paris)</p> ven. 15 mai 2026 ${timeLabel} (Europe/Paris)</p>
<p><a href="${brand.location}">Rejoindre</a></p> <p><a href="${brand.location}">Rejoindre</a></p>
</div>`, </div>`,
date: brand.dateLabel, date: fixtureParisIso(brand.mailDay, brand.startHour, startMinute),
read: brand.read ?? false, read: brand.read ?? false,
starred: false, starred: false,
important: false, important: false,
hasInvitation: true, hasInvitation: true,
hasAttachment: true, hasAttachment: true,
category: "primary",
labels: ["inbox", "Démos visio"], labels: ["inbox", "Démos visio"],
calendarInvitation: { ics }, calendarInvitation: { ics },
attachments: [ attachments: [
@ -115,7 +120,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Point produit — Google Meet", summary: "Point produit — Google Meet",
location: "https://meet.google.com/ultimail-demo-meet", location: "https://meet.google.com/ultimail-demo-meet",
startHour: 9, startHour: 9,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "teams", provider: "teams",
@ -127,7 +132,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: location:
"https://teams.microsoft.com/l/meetup-join/19%3ameeting_ultimail_demo", "https://teams.microsoft.com/l/meetup-join/19%3ameeting_ultimail_demo",
startHour: 10, startHour: 10,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "zoom", provider: "zoom",
@ -138,7 +143,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Stand-up équipe — Zoom", summary: "Stand-up équipe — Zoom",
location: "https://zoom.us/j/98765432101?pwd=demo", location: "https://zoom.us/j/98765432101?pwd=demo",
startHour: 11, startHour: 11,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "skype", provider: "skype",
@ -149,7 +154,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Entretien client — Skype", summary: "Entretien client — Skype",
location: "https://join.skype.com/invite/ultimailDemoSkype", location: "https://join.skype.com/invite/ultimailDemoSkype",
startHour: 12, startHour: 12,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "jitsi", provider: "jitsi",
@ -160,7 +165,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Atelier open source — Jitsi", summary: "Atelier open source — Jitsi",
location: "https://meet.jit.si/ultimail-demo-jitsi", location: "https://meet.jit.si/ultimail-demo-jitsi",
startHour: 13, startHour: 13,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "whatsapp", provider: "whatsapp",
@ -171,7 +176,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Groupe projet — WhatsApp", summary: "Groupe projet — WhatsApp",
location: "https://chat.whatsapp.com/InviteUltimailDemoWA", location: "https://chat.whatsapp.com/InviteUltimailDemoWA",
startHour: 14, startHour: 14,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "signal", provider: "signal",
@ -182,7 +187,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Comité confidentialité — Signal", summary: "Comité confidentialité — Signal",
location: "https://signal.group/#UltimailDemoSignalGroup", location: "https://signal.group/#UltimailDemoSignalGroup",
startHour: 15, startHour: 15,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "instagram", provider: "instagram",
@ -193,7 +198,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Live créateurs — Instagram", summary: "Live créateurs — Instagram",
location: "https://www.instagram.com/direct/t/ultimail-demo-live", location: "https://www.instagram.com/direct/t/ultimail-demo-live",
startHour: 16, startHour: 16,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "discord", provider: "discord",
@ -204,7 +209,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
summary: "Voice channel équipe — Discord", summary: "Voice channel équipe — Discord",
location: "https://discord.gg/ultimail-demo-voice", location: "https://discord.gg/ultimail-demo-voice",
startHour: 17, startHour: 17,
dateLabel: "14 mai", mailDay: 14,
}, },
{ {
provider: "slack", provider: "slack",
@ -216,7 +221,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "https://app.slack.com/huddle/TULTIMail/CdemoHuddle", location: "https://app.slack.com/huddle/TULTIMail/CdemoHuddle",
startHour: 9, startHour: 9,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
{ {
@ -229,7 +234,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "https://t.me/joinchat/UltimailDemoTelegram", location: "https://t.me/joinchat/UltimailDemoTelegram",
startHour: 10, startHour: 10,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
{ {
@ -242,7 +247,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "https://m.me/c/ultimail-demo-room", location: "https://m.me/c/ultimail-demo-room",
startHour: 11, startHour: 11,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
{ {
@ -255,7 +260,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "facetime://demo.organisateur@ultimail.test", location: "facetime://demo.organisateur@ultimail.test",
startHour: 14, startHour: 14,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
{ {
@ -268,7 +273,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "tel:+33183927510", location: "tel:+33183927510",
startHour: 15, startHour: 15,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
{ {
@ -281,7 +286,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
location: "https://company.webex.com/meet/ultimail-demo-webex", location: "https://company.webex.com/meet/ultimail-demo-webex",
startHour: 16, startHour: 16,
startMinute: 30, startMinute: 30,
dateLabel: "13 mai", mailDay: 13,
read: true, read: true,
}, },
] ]

View File

@ -21,6 +21,7 @@ export interface ConversationMessage {
id: string id: string
sender: string sender: string
senderEmail: string senderEmail: string
/** ISO 8601 avec fuseau (ex. `2026-05-16T01:38:00+02:00`) */
date: string date: string
body: string body: string
preview: string preview: string
@ -36,6 +37,7 @@ export interface Email {
preview: string preview: string
/** HTML body — rendu dans une iframe sandbox pour raisons de sécurité */ /** HTML body — rendu dans une iframe sandbox pour raisons de sécurité */
body?: string body?: string
/** ISO 8601 avec fuseau — affichage via `formatMail*` / `MailDateText` */
date: string date: string
read: boolean read: boolean
starred: boolean starred: boolean
@ -47,7 +49,8 @@ export interface Email {
calendarInvitation?: CalendarInvitationMeta calendarInvitation?: CalendarInvitationMeta
attachments?: EmailAttachment[] attachments?: EmailAttachment[]
tag?: string tag?: string
category: "primary" | "promotions" | "social" | "updates" | "forums" /** Corbeille : pseudo-dossier `trash` quand true */
deleted?: boolean
/** Libellés / dossiers associés à cette conversation */ /** Libellés / dossiers associés à cette conversation */
labels?: string[] labels?: string[]
/** Messages précédents dans la conversation (le dernier message est le body principal) */ /** Messages précédents dans la conversation (le dernier message est le body principal) */
@ -73,7 +76,7 @@ export const emails: Email[] = [
<p>Closed <a href="#">#16007</a> as completed via <a href="#">#16053</a>.</p> <p>Closed <a href="#">#16007</a> as completed via <a href="#">#16053</a>.</p>
<p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p> <p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
</div>`, </div>`,
date: "01:38", date: "2026-05-16T01:38:00+02:00",
read: false, read: false,
starred: false, starred: false,
important: false, important: false,
@ -84,14 +87,13 @@ export const emails: Email[] = [
{ name: "notes.txt", kind: "other", sizeBytes: 2_048 }, { name: "notes.txt", kind: "other", sizeBytes: 2_048 },
{ name: "build_log.pdf", kind: "pdf", sizeBytes: 1_024_512 }, { name: "build_log.pdf", kind: "pdf", sizeBytes: 1_024_512 },
], ],
category: "primary",
labels: ["inbox"], labels: ["inbox"],
conversation: [ conversation: [
{ {
id: "1-a", id: "1-a",
sender: "ronenrozn", sender: "ronenrozn",
senderEmail: "ronenrozn@users.noreply.github.com", senderEmail: "ronenrozn@users.noreply.github.com",
date: "lun. 12 mai 23:15", date: "2026-05-12T23:15:00+02:00",
preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...", preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p> <p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p>
@ -109,7 +111,7 @@ export const emails: Email[] = [
id: "1-b", id: "1-b",
sender: "Daniel", sender: "Daniel",
senderEmail: "daniel@ollama.ai", senderEmail: "daniel@ollama.ai",
date: "mar. 13 mai 00:42", date: "2026-05-13T00:42:00+02:00",
preview: "Thanks for reporting. This is a known regression in the MLX backend. We have a fix in #16053 that should resolve...", preview: "Thanks for reporting. This is a known regression in the MLX backend. We have a fix in #16053 that should resolve...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>Thanks for reporting. This is a known regression in the MLX backend.</p> <p>Thanks for reporting. This is a known regression in the MLX backend.</p>
@ -134,13 +136,12 @@ export const emails: Email[] = [
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">12,34 </td></tr> <tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">12,34 </td></tr>
</table> </table>
</div>`, </div>`,
date: "7 mai", date: "2026-05-07T01:11:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
attachments: [{ name: "recu_course.pdf", kind: "pdf", sizeBytes: 88_320 }], attachments: [{ name: "recu_course.pdf", kind: "pdf", sizeBytes: 88_320 }],
category: "promotions", labels: ["inbox", "Factures", "Achats", "Promotions"],
labels: ["inbox", "Factures", "Achats"],
}, },
{ {
id: "3", id: "3",
@ -158,18 +159,17 @@ export const emails: Email[] = [
<hr style="border:none; border-top:1px solid #d0d7de; margin:16px 0;"/> <hr style="border:none; border-top:1px solid #d0d7de; margin:16px 0;"/>
<p style="color:#57606a; font-size:12px;">Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p> <p style="color:#57606a; font-size:12px;">Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
</div>`, </div>`,
date: "5 avr.", date: "2026-04-05T12:00:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "social", labels: ["inbox", "Réseaux sociaux"],
labels: ["inbox"],
conversation: [ conversation: [
{ {
id: "3-a", id: "3-a",
sender: "SannyGrooves", sender: "SannyGrooves",
senderEmail: "sannygrooves@users.noreply.github.com", senderEmail: "sannygrooves@users.noreply.github.com",
date: "sam. 14 mars 11:55", date: "2026-03-14T11:55:00+01:00",
preview: "SannyGrooves left a comment (IceWhaleTech/ZimaOS#5) Just wanted to say: Big shoutout to Rogger for this automated installation script. Just run in Proxmox SSH (", preview: "SannyGrooves left a comment (IceWhaleTech/ZimaOS#5) Just wanted to say: Big shoutout to Rogger for this automated installation script. Just run in Proxmox SSH (",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>SannyGrooves left a comment (<a href="#">IceWhaleTech/ZimaOS#5</a>)</p> <p>SannyGrooves left a comment (<a href="#">IceWhaleTech/ZimaOS#5</a>)</p>
@ -192,12 +192,11 @@ export const emails: Email[] = [
<blockquote style="border-left:3px solid #d0d7de; padding-left:12px; margin:8px 0; color:#57606a;">This is a great idea. I've been thinking about how we could leverage MCP servers to make Parse Server more accessible to AI agents.</blockquote> <blockquote style="border-left:3px solid #d0d7de; padding-left:12px; margin:8px 0; color:#57606a;">This is a great idea. I've been thinking about how we could leverage MCP servers to make Parse Server more accessible to AI agents.</blockquote>
<p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.</p> <p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.</p>
</div>`, </div>`,
date: "19 mars", date: "2026-03-19T10:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "updates", labels: ["inbox", "Mises à jour"],
labels: ["inbox"],
}, },
{ {
id: "5", id: "5",
@ -210,18 +209,17 @@ export const emails: Email[] = [
<p>Closed <a href="#">#88</a> as completed via <a href="#">80ce59f</a>.</p> <p>Closed <a href="#">#88</a> as completed via <a href="#">80ce59f</a>.</p>
<p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p> <p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
</div>`, </div>`,
date: "28 févr.", date: "2026-02-28T12:00:00+01:00",
read: true, read: true,
starred: true, starred: true,
important: false, important: false,
category: "forums", labels: ["inbox", "starred", "Travail", "Forums"],
labels: ["inbox", "starred", "Travail"],
conversation: [ conversation: [
{ {
id: "5-a", id: "5-a",
sender: "Pyxage", sender: "Pyxage",
senderEmail: "pyxage@users.noreply.github.com", senderEmail: "pyxage@users.noreply.github.com",
date: "lun. 10 févr. 14:22", date: "2026-02-10T14:22:00+01:00",
preview: "This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw. Blockers: 1. Plugin system...", preview: "This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw. Blockers: 1. Plugin system...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw.</p> <p>This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw.</p>
@ -238,7 +236,7 @@ export const emails: Email[] = [
id: "5-b", id: "5-b",
sender: "Argenis", sender: "Argenis",
senderEmail: "argenis@zeroclaw-labs.com", senderEmail: "argenis@zeroclaw-labs.com",
date: "mer. 19 févr. 09:10", date: "2026-02-19T09:10:00+01:00",
preview: "Update: items 1-3 are resolved. Rate limiting will ship in v2.4. Moving to close once CI passes...", preview: "Update: items 1-3 are resolved. Rate limiting will ship in v2.4. Moving to close once CI passes...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>Update: items 1-3 are resolved. Rate limiting will ship in v2.4.</p> <p>Update: items 1-3 are resolved. Rate limiting will ship in v2.4.</p>
@ -263,12 +261,11 @@ export const emails: Email[] = [
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">18,50 </td></tr> <tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">18,50 </td></tr>
</table> </table>
</div>`, </div>`,
date: "28 févr.", date: "2026-02-28T02:10:00+01:00",
read: false, read: false,
starred: false, starred: false,
important: true, important: true,
hasInvitation: true, hasInvitation: true,
category: "primary",
labels: ["inbox", "Factures"], labels: ["inbox", "Factures"],
}, },
{ {
@ -282,11 +279,10 @@ export const emails: Email[] = [
<p>Merged <a href="#">#138</a> into dev.</p> <p>Merged <a href="#">#138</a> into dev.</p>
<p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p> <p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
</div>`, </div>`,
date: "27 févr.", date: "2026-02-27T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["sent", "[Imap]/Sent"], labels: ["sent", "[Imap]/Sent"],
}, },
{ {
@ -306,11 +302,10 @@ export const emails: Email[] = [
<li>chore: update dependencies</li> <li>chore: update dependencies</li>
</ul> </ul>
</div>`, </div>`,
date: "23 févr.", date: "2026-02-23T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["drafts"], labels: ["drafts"],
}, },
{ {
@ -324,11 +319,10 @@ export const emails: Email[] = [
<p>Closed <a href="#">#130</a>.</p> <p>Closed <a href="#">#130</a>.</p>
<p><br/>You are receiving this because you were mentioned.<br/>Message ID: &lt;wiarabeauty/frontend/pull/130/issue_event/22471S3&gt;</p> <p><br/>You are receiving this because you were mentioned.<br/>Message ID: &lt;wiarabeauty/frontend/pull/130/issue_event/22471S3&gt;</p>
</div>`, </div>`,
date: "23 févr.", date: "2026-02-23T15:30:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "CCTV"], labels: ["inbox", "CCTV"],
}, },
{ {
@ -346,11 +340,10 @@ export const emails: Email[] = [
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">22,10 </td></tr> <tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">22,10 </td></tr>
</table> </table>
</div>`, </div>`,
date: "2 févr.", date: "2026-02-02T03:55:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Déplacements"], labels: ["inbox", "Déplacements"],
}, },
{ {
@ -364,11 +357,10 @@ export const emails: Email[] = [
<p><strong>leonace924</strong> left a comment on <a href="#">parse-community/parse-dashboard#482</a></p> <p><strong>leonace924</strong> left a comment on <a href="#">parse-community/parse-dashboard#482</a></p>
<p>@mtrezza What is your working branch for this feature? I'd like to contribute some timezone utilities.</p> <p>@mtrezza What is your working branch for this feature? I'd like to contribute some timezone utilities.</p>
</div>`, </div>`,
date: "30 janv.", date: "2026-01-30T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Clients"], labels: ["inbox", "Clients"],
}, },
{ {
@ -389,12 +381,11 @@ export const emails: Email[] = [
</ul> </ul>
<p><a href="#" style="display:inline-block; background:#1a73e8; color:white; padding:10px 24px; border-radius:4px; text-decoration:none; font-weight:500;">Se connecter</a></p> <p><a href="#" style="display:inline-block; background:#1a73e8; color:white; padding:10px 24px; border-radius:4px; text-decoration:none; font-weight:500;">Se connecter</a></p>
</div>`, </div>`,
date: "9 janv.", date: "2026-01-09T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
hasInvitation: true, hasInvitation: true,
category: "primary",
labels: ["inbox", "BrowserAlerts", "Twitch"], labels: ["inbox", "BrowserAlerts", "Twitch"],
}, },
{ {
@ -413,11 +404,10 @@ export const emails: Email[] = [
<li>Steps to reproduce</li> <li>Steps to reproduce</li>
</ul> </ul>
</div>`, </div>`,
date: "29/12/2025", date: "2025-12-29T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Q1 2026", "CMSecurity Alerts"], labels: ["inbox", "Q1 2026", "CMSecurity Alerts"],
}, },
{ {
@ -436,18 +426,17 @@ export const emails: Email[] = [
<li>Mention supported Parse Server versions</li> <li>Mention supported Parse Server versions</li>
</ol> </ol>
</div>`, </div>`,
date: "01/12/2025", date: "2025-12-01T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Travail", "RH"], labels: ["inbox", "Travail", "RH"],
conversation: [ conversation: [
{ {
id: "14-a", id: "14-a",
sender: "parse-github-assistant", sender: "parse-github-assistant",
senderEmail: "parse-github-assistant[bot]@users.noreply.github.com", senderEmail: "parse-github-assistant[bot]@users.noreply.github.com",
date: "ven. 28 nov. 2025 10:00", date: "2025-11-28T10:00:00+01:00",
preview: "Thanks for opening this pull request! A maintainer will review it shortly...", preview: "Thanks for opening this pull request! A maintainer will review it shortly...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>Thanks for opening this pull request! 🎯</p> <p>Thanks for opening this pull request! 🎯</p>
@ -459,7 +448,7 @@ export const emails: Email[] = [
id: "14-b", id: "14-b",
sender: "R3D347HR4Y", sender: "R3D347HR4Y",
senderEmail: "r3d347hr4y@users.noreply.github.com", senderEmail: "r3d347hr4y@users.noreply.github.com",
date: "ven. 28 nov. 2025 14:32", date: "2025-11-28T14:32:00+01:00",
preview: "Here is the initial PR adding the AI Integration docs section. It covers MCP server setup, authentication...", preview: "Here is the initial PR adding the AI Integration docs section. It covers MCP server setup, authentication...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>Here is the initial PR adding the AI Integration docs section. It covers:</p> <p>Here is the initial PR adding the AI Integration docs section. It covers:</p>
@ -475,7 +464,7 @@ export const emails: Email[] = [
id: "14-c", id: "14-c",
sender: "dblythy", sender: "dblythy",
senderEmail: "dblythy@users.noreply.github.com", senderEmail: "dblythy@users.noreply.github.com",
date: "sam. 29 nov. 2025 08:15", date: "2025-11-29T08:15:00+01:00",
preview: "Looks good overall! One concern: the auth section should mention that Parse Server keys are required...", preview: "Looks good overall! One concern: the auth section should mention that Parse Server keys are required...",
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;"> body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
<p>Looks good overall! One concern: the auth section should mention that Parse Server master key is <strong>never</strong> to be exposed to AI agents in production.</p> <p>Looks good overall! One concern: the auth section should mention that Parse Server master key is <strong>never</strong> to be exposed to AI agents in production.</p>
@ -495,11 +484,10 @@ export const emails: Email[] = [
<p><strong>parse-github-assistant[bot]</strong> left a comment on <a href="#">parse-community/parse-server#9889</a></p> <p><strong>parse-github-assistant[bot]</strong> left a comment on <a href="#">parse-community/parse-server#9889</a></p>
<p>This issue has been automatically marked as stale because it has not had recent activity.</p> <p>This issue has been automatically marked as stale because it has not had recent activity.</p>
</div>`, </div>`,
date: "05/11/2025", date: "2025-11-05T12:00:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox"], labels: ["inbox"],
}, },
{ {
@ -513,11 +501,10 @@ export const emails: Email[] = [
<p style="font-size:16px; font-weight:600;">Eliott, merci d'avoir utilisé Uber</p> <p style="font-size:16px; font-weight:600;">Eliott, merci d'avoir utilisé Uber</p>
<p>Nous espérons que vous avez apprécié votre course ce samedi matin.</p> <p>Nous espérons que vous avez apprécié votre course ce samedi matin.</p>
</div>`, </div>`,
date: "01/11/2025", date: "2025-11-01T02:55:00+01:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Voyages"], labels: ["inbox", "Voyages"],
}, },
{ {
@ -532,7 +519,7 @@ export const emails: Email[] = [
<p>To avoid any disruptions, please regenerate your token before it expires.</p> <p>To avoid any disruptions, please regenerate your token before it expires.</p>
<p><a href="#" style="display:inline-block; background:#2da44e; color:white; padding:8px 16px; border-radius:6px; text-decoration:none; font-weight:500;">Regenerate token</a></p> <p><a href="#" style="display:inline-block; background:#2da44e; color:white; padding:8px 16px; border-radius:6px; text-decoration:none; font-weight:500;">Regenerate token</a></p>
</div>`, </div>`,
date: "30/09/2025", date: "2025-09-30T12:00:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
@ -541,7 +528,6 @@ export const emails: Email[] = [
{ name: "screenshot.png", kind: "image", sizeBytes: 412_000 }, { name: "screenshot.png", kind: "image", sizeBytes: 412_000 },
{ name: "notes.txt", kind: "other", sizeBytes: 1_280 }, { name: "notes.txt", kind: "other", sizeBytes: 1_280 },
], ],
category: "primary",
labels: ["inbox", "Finance"], labels: ["inbox", "Finance"],
}, },
{ {
@ -559,12 +545,11 @@ export const emails: Email[] = [
jeu. 14 mai 2026 14:30 15:30 (heure dEurope centrale)</p> jeu. 14 mai 2026 14:30 15:30 (heure dEurope centrale)</p>
<p><a href="#">Rejoindre la réunion</a></p> <p><a href="#">Rejoindre la réunion</a></p>
</div>`, </div>`,
date: "30/09/2025", date: "2025-09-30T10:13:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
hasInvitation: true, hasInvitation: true,
category: "primary",
labels: ["inbox", "Travail"], labels: ["inbox", "Travail"],
hasAttachment: true, hasAttachment: true,
calendarInvitation: { calendarInvitation: {
@ -626,13 +611,12 @@ END:VCALENDAR`,
<p style="font-size:11px; color:#999;">Ne pas répondre à ce message.</p> <p style="font-size:11px; color:#999;">Ne pas répondre à ce message.</p>
<img src="http://tracking.scam-domain.xyz/pixel.gif" width="1" height="1" /> <img src="http://tracking.scam-domain.xyz/pixel.gif" width="1" height="1" />
</div>`, </div>`,
date: "26/09/2025", date: "2025-09-26T12:00:00+02:00",
read: false, read: false,
starred: false, starred: false,
important: false, important: false,
spam: true, spam: true,
labels: ["spam"], labels: ["spam"],
category: "primary"
}, },
{ {
id: "20", id: "20",
@ -649,11 +633,10 @@ END:VCALENDAR`,
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">66,61 </td></tr> <tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">66,61 </td></tr>
</table> </table>
</div>`, </div>`,
date: "21/09/2025", date: "2025-09-21T12:00:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["inbox", "Famille"], labels: ["inbox", "Famille"],
}, },
{ {
@ -671,12 +654,44 @@ END:VCALENDAR`,
<p style="font-size:10px; color:#999;">Offre limitée. Désabonnement impossible.</p> <p style="font-size:10px; color:#999;">Offre limitée. Désabonnement impossible.</p>
<img src="http://tracking.lottovip-scam.net/pixel.gif" width="1" height="1" /> <img src="http://tracking.lottovip-scam.net/pixel.gif" width="1" height="1" />
</div>`, </div>`,
date: "20/09/2025", date: "2025-09-20T12:00:00+02:00",
read: true, read: true,
starred: false, starred: false,
important: true, important: true,
spam: true, spam: true,
labels: ["spam"], labels: ["spam"],
category: "primary" },
{
id: "22",
sender: "Les Amis du Louvre",
senderEmail: "newsletter@louvre.fr",
subject: "Lettre des Amis du Louvre — mai 2026",
preview: "- Expositions, conférences et coulisses du musée : découvrez le programme du mois.",
body: `<div style="font-family: Georgia, serif; font-size: 15px; line-height: 1.6; color: #222;">
<p>Bonjour,</p>
<p>Voici la <strong>lettre de mai</strong> des Amis du Louvre : parcours Napoléon, nocturnes et ateliers jeune public.</p>
<p><a href="#">Lire la lettre en ligne</a></p>
</div>`,
date: "2026-05-12T08:00:00+02:00",
read: false,
starred: false,
important: false,
labels: ["inbox", "Newsletters"],
},
{
id: "23",
sender: "The Washington Post",
senderEmail: "newsletters@washpost.com",
subject: "The Daily 202 : ce quil faut retenir ce matin",
preview: "- Analyses politiques et agenda de Washington — édition du 11 mai.",
body: `<div style="font-family: -apple-system, sans-serif; font-size: 14px; color: #111;">
<p>Good morning heres what were watching today on Capitol Hill and beyond.</p>
<p><a href="#">Read todays edition</a></p>
</div>`,
date: "2026-05-11T07:00:00+02:00",
read: true,
starred: false,
important: false,
labels: ["inbox", "Newsletters"],
}, },
] ]

View File

@ -0,0 +1,63 @@
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
import { labelRowById } from "@/lib/sidebar-nav-data"
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
export type LabelPickerVisual =
| { kind: "iconify"; icon: string }
| { kind: "dot"; colorClass: string }
function findFolderNodeByLabel(
nodes: FolderTreeNode[],
labelText: string
): FolderTreeNode | undefined {
const lower = labelText.toLowerCase()
for (const n of nodes) {
if (n.label === labelText || n.label.toLowerCase() === lower) return n
if (n.children?.length) {
const hit = findFolderNodeByLabel(n.children, labelText)
if (hit) return hit
}
}
return undefined
}
function labelRowByLabelText(
rows: readonly LabelRowItem[],
labelText: string
): LabelRowItem | undefined {
const lower = labelText.toLowerCase()
return rows.find((r) => r.label === labelText || r.label.toLowerCase() === lower)
}
/** Icône nav ou pastille couleur pour une entrée du sélecteur de libellés. */
export function resolveLabelPickerVisual(
labelText: string,
opts: {
folderTree: FolderTreeNode[]
labelRows: readonly LabelRowItem[]
emailLabelToSidebarFolderId: Record<string, string>
}
): LabelPickerVisual {
const { folderTree, labelRows, emailLabelToSidebarFolderId } = opts
const fid = emailLabelToSidebarFolderId[labelText]
if (fid) {
const row = labelRowById(labelRows, fid)
if (row?.icon) return { kind: "iconify", icon: row.icon }
if (row?.color) return { kind: "dot", colorClass: row.color }
const path = findFolderPath(folderTree, fid)
if (path?.length) {
const leaf = path[path.length - 1]!
return { kind: "dot", colorClass: leaf.color ?? "bg-gray-400" }
}
}
const row = labelRowByLabelText(labelRows, labelText)
if (row?.icon) return { kind: "iconify", icon: row.icon }
if (row?.color) return { kind: "dot", colorClass: row.color }
const folder = findFolderNodeByLabel(folderTree, labelText)
if (folder?.color) return { kind: "dot", colorClass: folder.color }
return { kind: "dot", colorClass: "bg-gray-400" }
}

106
lib/mail-date.ts Normal file
View File

@ -0,0 +1,106 @@
import dayjs, { type Dayjs } from "dayjs"
import localizedFormat from "dayjs/plugin/localizedFormat"
import relativeTime from "dayjs/plugin/relativeTime"
import "dayjs/locale/fr"
import "dayjs/locale/en"
dayjs.extend(localizedFormat)
dayjs.extend(relativeTime)
const SUPPORTED_LOCALES = new Set(["fr", "en", "de", "es", "it", "pt", "nl", "pl", "ja", "zh"])
let activeLocale: string | null = null
/** Browser locale for dayjs (client); stable fallback on server. */
export function resolveMailDateLocale(): string {
if (typeof navigator === "undefined") return "fr"
const base = navigator.language.split("-")[0]?.toLowerCase() ?? "fr"
return SUPPORTED_LOCALES.has(base) ? base : "en"
}
export function ensureMailDateLocale(): void {
const next = resolveMailDateLocale()
if (activeLocale === next) return
dayjs.locale(next)
activeLocale = next
}
export function parseMailDate(iso: string): Dayjs | null {
if (!iso?.trim()) return null
const d = dayjs(iso)
return d.isValid() ? d : null
}
export type MailDateDisplayVariant = "list" | "preview" | "detail"
const TWO_WEEKS_MS = 14 * 24 * 60 * 60 * 1000
function relativeSuffix(d: Dayjs, now: Dayjs): string {
if (d.isAfter(now)) return ""
return ` (${d.fromNow()})`
}
/** Colonne date de la liste (fuseau navigateur). */
export function formatMailListDate(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return "—"
if (d.isSame(now, "day")) {
return d.format("LT")
}
if (d.isSame(now, "year")) {
return d.format("D MMM")
}
return d.format("L")
}
/** En-tête / aperçu dun message (fuseau navigateur). */
export function formatMailPreviewDate(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return "—"
const time = d.format("LT")
if (d.isSame(now, "day")) {
return `${time}${relativeSuffix(d, now)}`
}
const msAgo = now.valueOf() - d.valueOf()
const withinTwoWeeks = msAgo >= 0 && msAgo < TWO_WEEKS_MS
const datePart = d.format("ddd D MMM")
if (withinTwoWeeks) {
return `${datePart} ${time}${relativeSuffix(d, now)}`
}
if (d.isSame(now, "year")) {
return `${datePart} ${time}`
}
return `${d.format("ddd D MMM YYYY")} ${time}`
}
/** Citations, impression, panneau « détails » (sans relatif). */
export function formatMailDetailDate(iso: string, now: Dayjs = dayjs()): string {
ensureMailDateLocale()
const d = parseMailDate(iso)
if (!d) return "—"
const time = d.format("LT")
const datePart = d.format("ddd D MMM")
if (d.isSame(now, "year")) {
return `${datePart} ${time}`
}
return `${d.format("ddd D MMM YYYY")} ${time}`
}
export function formatMailDate(iso: string, variant: MailDateDisplayVariant): string {
switch (variant) {
case "list":
return formatMailListDate(iso)
case "preview":
return formatMailPreviewDate(iso)
case "detail":
return formatMailDetailDate(iso)
}
}

View File

@ -2,12 +2,9 @@ import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids" import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data" import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
/** @deprecated Utiliser `getMailNavFolderLabel(inboxTab, folderIdToLabel)` ou `inboxTabDisplayLabel`. */
export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = { export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = {
primary: "Principale", primary: "Principale",
promotions: "Promotions",
social: "Réseaux sociaux",
updates: "Notifications",
forums: "Forums",
} }
/** Clé stable pour historique navigation (dossier + onglet boîte de réception). */ /** Clé stable pour historique navigation (dossier + onglet boîte de réception). */
@ -28,29 +25,85 @@ export function parseMailNavVisitKey(key: string): {
return { folderId: key } return { folderId: key }
} }
export function getMailNavFolderBreadcrumbSegments( export type MailNavBreadcrumbItem = {
label: string
visitKey: string
}
export function getMailNavFolderBreadcrumbItems(
folderId: string, folderId: string,
folderTree: FolderTreeNode[], folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>, folderIdToLabel?: Record<string, string>,
inboxCategoryLabel?: string | null inboxCategory?: { tabId: string; label: string } | null
): string[] { ): MailNavBreadcrumbItem[] {
if (folderId === "inbox") { if (folderId === "inbox") {
const base = getMailNavFolderLabel(folderId, folderIdToLabel) const base = getMailNavFolderLabel(folderId, folderIdToLabel)
if ( if (inboxCategory && inboxCategory.label !== "Principale") {
inboxCategoryLabel && return [
inboxCategoryLabel !== INBOX_CATEGORY_TAB_LABELS.primary { label: base, visitKey: mailNavVisitKey("inbox") },
) { {
return [base, inboxCategoryLabel] label: inboxCategory.label,
visitKey: mailNavVisitKey("inbox", inboxCategory.tabId),
},
]
} }
return [base] return [{ label: base, visitKey: mailNavVisitKey(folderId) }]
} }
const path = findFolderPath(folderTree, folderId) const path = findFolderPath(folderTree, folderId)
if (path?.length) { if (path?.length) {
return path.map((n) => n.label) return path.map((n) => ({ label: n.label, visitKey: n.id }))
} }
return [getMailNavFolderLabel(folderId, folderIdToLabel)] return [
{
label: getMailNavFolderLabel(folderId, folderIdToLabel),
visitKey: folderId,
},
]
}
export function getMailNavFolderBreadcrumbSegments(
folderId: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>,
inboxCategoryLabel?: string | null,
inboxTabId?: string | null
): string[] {
const inboxCategory =
folderId === "inbox" &&
inboxTabId &&
inboxCategoryLabel &&
inboxCategoryLabel !== "Principale"
? { tabId: inboxTabId, label: inboxCategoryLabel }
: null
return getMailNavFolderBreadcrumbItems(
folderId,
folderTree,
folderIdToLabel,
inboxCategory
).map((i) => i.label)
}
export function breadcrumbItemsForVisitKey(
key: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>
): MailNavBreadcrumbItem[] {
const { folderId, inboxTab } = parseMailNavVisitKey(key)
const inboxCategory =
folderId === "inbox" && inboxTab && inboxTab !== "primary"
? {
tabId: inboxTab,
label: getMailNavFolderLabel(inboxTab, folderIdToLabel),
}
: null
return getMailNavFolderBreadcrumbItems(
folderId,
folderTree,
folderIdToLabel,
inboxCategory
)
} }
export function breadcrumbSegmentsForVisitKey( export function breadcrumbSegmentsForVisitKey(
@ -58,16 +111,8 @@ export function breadcrumbSegmentsForVisitKey(
folderTree: FolderTreeNode[], folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string> folderIdToLabel?: Record<string, string>
): string[] { ): string[] {
const { folderId, inboxTab } = parseMailNavVisitKey(key) return breadcrumbItemsForVisitKey(key, folderTree, folderIdToLabel).map(
const cat = (i) => i.label
folderId === "inbox" && inboxTab
? INBOX_CATEGORY_TAB_LABELS[inboxTab] ?? inboxTab
: null
return getMailNavFolderBreadcrumbSegments(
folderId,
folderTree,
folderIdToLabel,
cat
) )
} }

View File

@ -2,9 +2,11 @@ import type { Email } from "@/lib/email-data"
import { import {
folderTree as defaultFolderTree, folderTree as defaultFolderTree,
sidebarNavFolderIdToLabel as defaultSidebarNavFolderIdToLabel, sidebarNavFolderIdToLabel as defaultSidebarNavFolderIdToLabel,
type FolderTreeNode, defaultNavLabelRowsSnapshot,
type LabelRowItem,
} from "@/lib/sidebar-nav-data" } from "@/lib/sidebar-nav-data"
import { collectSubtreeFolderIds } from "@/lib/sidebar-nav-maps" import { collectSubtreeFolderIds } from "@/lib/sidebar-nav-maps"
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
export type MailFolderFilterCtx = { export type MailFolderFilterCtx = {
starredEmailIds: string[] starredEmailIds: string[]
@ -15,20 +17,14 @@ export type MailFolderFilterCtx = {
export type MailNavFolderMaps = { export type MailNavFolderMaps = {
folderIdToLabel: Record<string, string> folderIdToLabel: Record<string, string>
folderTree: FolderTreeNode[] folderTree: FolderTreeNode[]
labelRows: LabelRowItem[]
} }
const CATEGORY_EMAIL_TAB_IDS = new Set([ function hasFutureScheduledSend(email: Email): boolean {
"social", if (!email.scheduledSendAt) return false
"promotions", const t = new Date(email.scheduledSendAt).getTime()
"updates", if (!Number.isFinite(t)) return false
"forums", return t > Date.now()
])
/** Catégories latérales (sidebar) → libellé optionnel sur `email.labels`. */
const SIDEBAR_CATEGORY_EXTRA_LABEL: Partial<Record<string, string>> = {
purchases: "Achats",
travel: "Déplacements",
finance: "Finance",
} }
function effectiveStarred(email: Email, ctx: MailFolderFilterCtx): boolean { function effectiveStarred(email: Email, ctx: MailFolderFilterCtx): boolean {
@ -40,17 +36,27 @@ function effectiveImportant(email: Email, ctx: MailFolderFilterCtx): boolean {
} }
function isInInbox(email: Email): boolean { function isInInbox(email: Email): boolean {
if (email.deleted) return false
if (email.spam) return false if (email.spam) return false
if (hasFutureScheduledSend(email)) return false
const ls = email.labels const ls = email.labels
if (!ls?.length) return true if (!ls?.length) return true
return ls.includes("inbox") return ls.includes("inbox")
} }
function labelRowForNavId(
folderId: string,
rows: readonly LabelRowItem[]
): LabelRowItem | undefined {
return rows.find((r) => r.id === folderId)
}
function resolveNavMaps(maps?: MailNavFolderMaps | null): MailNavFolderMaps { function resolveNavMaps(maps?: MailNavFolderMaps | null): MailNavFolderMaps {
if (maps) return maps if (maps) return maps
return { return {
folderIdToLabel: defaultSidebarNavFolderIdToLabel as Record<string, string>, folderIdToLabel: defaultSidebarNavFolderIdToLabel as Record<string, string>,
folderTree: defaultFolderTree, folderTree: defaultFolderTree,
labelRows: defaultNavLabelRowsSnapshot,
} }
} }
@ -66,6 +72,8 @@ function matchesFolderLabelRow(
folderId: string, folderId: string,
maps: MailNavFolderMaps maps: MailNavFolderMaps
): boolean { ): boolean {
const row = labelRowForNavId(folderId, maps.labelRows)
if (row && row.enabled === false) return false
const label = maps.folderIdToLabel[folderId] const label = maps.folderIdToLabel[folderId]
if (!label) return false if (!label) return false
return emailHasAnyLabel(email, [label]) return emailHasAnyLabel(email, [label])
@ -96,6 +104,24 @@ function matchesLabelNav(
return matchesFolderLabelRow(email, folderId, maps) return matchesFolderLabelRow(email, folderId, maps)
} }
/** Onglet Principale : boîte sans libellés système « exclude » actifs. */
export function emailMatchesInboxPrimaryTab(
email: Email,
ctx: MailFolderFilterCtx,
maps: MailNavFolderMaps,
subtreeIdsCache?: Map<string, string[] | null>
): boolean {
if (!emailMatchesFolder(email, "inbox", ctx, maps, subtreeIdsCache)) return false
if (email.deleted) return false
if (hasFutureScheduledSend(email)) return false
for (const row of maps.labelRows) {
if (row.enabled === false) continue
if (!row.excludeFromPrincipal) continue
if (emailHasAnyLabel(email, [row.label])) return false
}
return true
}
export function emailMatchesFolder( export function emailMatchesFolder(
email: Email, email: Email,
folderId: string, folderId: string,
@ -106,39 +132,38 @@ export function emailMatchesFolder(
): boolean { ): boolean {
const nav = resolveNavMaps(maps) const nav = resolveNavMaps(maps)
if (email.deleted && folderId !== "trash") {
return false
}
switch (folderId) { switch (folderId) {
case "inbox": case "inbox":
return isInInbox(email) return isInInbox(email)
case "starred": case "starred":
return effectiveStarred(email, ctx) return effectiveStarred(email, ctx)
case "snoozed": case "snoozed":
return email.labels?.includes("snoozed") ?? false return (email.labels?.includes("snoozed") ?? false) && !email.deleted
case "important": case "important":
return effectiveImportant(email, ctx) return effectiveImportant(email, ctx)
case "sent": case "sent":
return email.labels?.includes("sent") ?? false return (email.labels?.includes("sent") ?? false) && !email.deleted
case "drafts": case "drafts":
return email.labels?.includes("drafts") ?? false return (email.labels?.includes("drafts") ?? false) && !email.deleted
case "scheduled": case "scheduled":
return email.labels?.includes("scheduled") ?? false return !email.deleted && hasFutureScheduledSend(email)
case "spam": case "spam":
return email.spam === true || (email.labels?.includes("spam") ?? false) return (
case "notifications": !email.deleted &&
return email.category === "updates" (email.spam === true || (email.labels?.includes("spam") ?? false))
case "purchases": )
case "travel": case "trash":
case "finance": { return email.deleted === true
const extra = SIDEBAR_CATEGORY_EXTRA_LABEL[folderId]
if (!extra) return false
return email.labels?.includes(extra) ?? false
}
default: default:
break break
} }
if (CATEGORY_EMAIL_TAB_IDS.has(folderId)) { const row = labelRowForNavId(folderId, nav.labelRows)
return email.category === folderId if (row && row.enabled === false) return false
}
if (nav.folderIdToLabel[folderId]) { if (nav.folderIdToLabel[folderId]) {
return matchesLabelNav(email, folderId, nav, subtreeIdsCache) return matchesLabelNav(email, folderId, nav, subtreeIdsCache)

View File

@ -10,13 +10,13 @@ import {
ShieldAlert, ShieldAlert,
Trash2, Trash2,
Folder, Folder,
Users,
Info,
MessageSquare,
} from "lucide-react" } from "lucide-react"
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps" import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
import { defaultNavLabelRowsSnapshot, labelRowById } from "@/lib/sidebar-nav-data"
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids" import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
import { parseMailNavVisitKey } from "@/lib/mail-folder-display" import { parseMailNavVisitKey } from "@/lib/mail-folder-display"
import { normalizeInboxTabSegment } from "@/lib/mail-url"
const SYSTEM_ICONS: Record<string, LucideIcon> = { const SYSTEM_ICONS: Record<string, LucideIcon> = {
inbox: Inbox, inbox: Inbox,
@ -30,32 +30,32 @@ const SYSTEM_ICONS: Record<string, LucideIcon> = {
trash: Trash2, trash: Trash2,
} }
const INBOX_TAB_ICONS: Record<string, LucideIcon> = {
primary: Inbox,
promotions: Tag,
social: Users,
updates: Info,
forums: MessageSquare,
}
export type MailNavIcon = export type MailNavIcon =
| { kind: "lucide"; Icon: LucideIcon } | { kind: "lucide"; Icon: LucideIcon }
| { kind: "folder-dot"; colorClass: string } | { kind: "folder-dot"; colorClass: string }
| { kind: "iconify"; icon: string }
export function resolveMailNavIcon( export function resolveMailNavIcon(
visitKey: string, visitKey: string,
folderTree: FolderTreeNode[] folderTree: FolderTreeNode[],
labelRows: readonly LabelRowItem[] = defaultNavLabelRowsSnapshot
): MailNavIcon { ): MailNavIcon {
const { folderId, inboxTab } = parseMailNavVisitKey(visitKey) const { folderId, inboxTab } = parseMailNavVisitKey(visitKey)
if (folderId === "inbox") { if (folderId === "inbox") {
const tab = inboxTab ?? "primary" const tab = normalizeInboxTabSegment(inboxTab ?? "primary")
return { kind: "lucide", Icon: INBOX_TAB_ICONS[tab] ?? Inbox } if (tab === "primary") return { kind: "lucide", Icon: Inbox }
const row = labelRowById(labelRows, tab)
if (row?.icon) return { kind: "iconify", icon: row.icon }
return { kind: "lucide", Icon: Inbox }
} }
const system = SYSTEM_ICONS[folderId] const system = SYSTEM_ICONS[folderId]
if (system) return { kind: "lucide", Icon: system } if (system) return { kind: "lucide", Icon: system }
const navRow = labelRowById(labelRows, folderId)
if (navRow?.icon) return { kind: "iconify", icon: navRow.icon }
const path = findFolderPath(folderTree, folderId) const path = findFolderPath(folderTree, folderId)
if (path?.length) { if (path?.length) {
const leaf = path[path.length - 1]! const leaf = path[path.length - 1]!

View File

@ -9,6 +9,7 @@ import type { LabelEditState } from "@/lib/stores/mail-store"
import { import {
folderTree as defaultFolderTree, folderTree as defaultFolderTree,
sidebarNavFolderIdToLabel, sidebarNavFolderIdToLabel,
defaultNavLabelRowsSnapshot,
type FolderTreeNode, type FolderTreeNode,
} from "@/lib/sidebar-nav-data" } from "@/lib/sidebar-nav-data"
@ -21,17 +22,7 @@ export const MAIN_NAV_FOLDER_IDS = [
"drafts", "drafts",
"scheduled", "scheduled",
"spam", "spam",
] as const "trash",
export const CATEGORY_NAV_IDS = [
"purchases",
"travel",
"social",
"notifications",
"updates",
"forums",
"finance",
"promotions",
] as const ] as const
function collectTreeIds(nodes: FolderTreeNode[]): string[] { function collectTreeIds(nodes: FolderTreeNode[]): string[] {
@ -44,21 +35,23 @@ function collectTreeIds(nodes: FolderTreeNode[]): string[] {
} }
const MAIN_SET = new Set<string>(MAIN_NAV_FOLDER_IDS) const MAIN_SET = new Set<string>(MAIN_NAV_FOLDER_IDS)
const CATEGORY_SET = new Set<string>(CATEGORY_NAV_IDS)
/** Tous les ids de lignes sidebar pour lesquelles on calcule un décompte « non lus ». */ /** Tous les ids de lignes sidebar pour lesquelles on calcule un décompte « non lus ». */
export function allSidebarNavFolderIds(maps?: MailNavFolderMaps | null): string[] { export function allSidebarNavFolderIds(maps?: MailNavFolderMaps | null): string[] {
const tree = maps?.folderTree ?? defaultFolderTree const tree = maps?.folderTree ?? defaultFolderTree
const rows = maps?.labelRows ?? defaultNavLabelRowsSnapshot
const idToLabel = const idToLabel =
maps?.folderIdToLabel ?? (sidebarNavFolderIdToLabel as Record<string, string>) maps?.folderIdToLabel ?? (sidebarNavFolderIdToLabel as Record<string, string>)
const treeIds = collectTreeIds(tree) const treeIds = collectTreeIds(tree)
const labelRowIds = Object.keys(idToLabel).filter((id) => { const labelRowIds = Object.keys(idToLabel).filter((id) => {
if (MAIN_SET.has(id) || CATEGORY_SET.has(id)) return false if (MAIN_SET.has(id)) return false
if (id === "scheduled") return false if (id === "scheduled") return false
if (treeIds.includes(id)) return false if (treeIds.includes(id)) return false
const row = rows.find((r) => r.id === id)
if (row && row.enabled === false) return false
return true return true
}) })
return [...MAIN_NAV_FOLDER_IDS, ...CATEGORY_NAV_IDS, ...treeIds, ...labelRowIds] return [...MAIN_NAV_FOLDER_IDS, ...treeIds, ...labelRowIds]
} }
function effectiveRead( function effectiveRead(

View File

@ -1,16 +1,42 @@
/** Routage URL sous `/mail` : dossier, onglet boîte de réception, page, message ouvert. */ /** Routage URL sous `/mail` : dossier, onglet boîte de réception, page, message ouvert. */
import {
cloneDefaultLabelRows,
tabbedInboxLabelRows,
} from "@/lib/sidebar-nav-data"
export const DEFAULT_MAIL_FOLDER = "inbox" export const DEFAULT_MAIL_FOLDER = "inbox"
export const DEFAULT_INBOX_TAB = "primary" export const DEFAULT_INBOX_TAB = "primary"
/** Onglets catégories boîte de réception (alignés sur `categoryTabs` dans email-list). */ /** Segments dURL historiques → ids libellés actuels. */
export const INBOX_CATEGORY_TAB_IDS = new Set([ const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = {
"primary", updates: "mises-a-jour",
"promotions", notifications: "newsletters",
"social", social: "reseaux-sociaux",
"updates", purchases: "achats",
"forums", travel: "deplacements",
]) }
export function normalizeInboxTabSegment(tab: string): string {
return LEGACY_INBOX_TAB_SEGMENT[tab] ?? tab
}
function defaultInboxTabIdSet(): Set<string> {
const s = new Set<string>([DEFAULT_INBOX_TAB])
for (const r of tabbedInboxLabelRows(cloneDefaultLabelRows())) {
s.add(r.id)
}
return s
}
/** Onglets boîte de réception reconnus dans lURL (ids courants + segments legacy). */
export const INBOX_CATEGORY_TAB_IDS = (() => {
const s = defaultInboxTabIdSet()
for (const legacy of Object.keys(LEGACY_INBOX_TAB_SEGMENT)) {
s.add(legacy)
}
return s
})()
export type MailRouteState = { export type MailRouteState = {
folderId: string folderId: string
@ -69,7 +95,14 @@ export function parseMailSegments(
if (parts.length >= 2) { if (parts.length >= 2) {
const tab = parts[1]! const tab = parts[1]!
if (INBOX_CATEGORY_TAB_IDS.has(tab)) { if (INBOX_CATEGORY_TAB_IDS.has(tab)) {
return { folderId, inboxTab: tab, page, mailId } const inboxTab = normalizeInboxTabSegment(tab)
const valid = defaultInboxTabIdSet()
return {
folderId,
inboxTab: valid.has(inboxTab) ? inboxTab : DEFAULT_INBOX_TAB,
page,
mailId,
}
} }
return { folderId, inboxTab: DEFAULT_INBOX_TAB, page, mailId } return { folderId, inboxTab: DEFAULT_INBOX_TAB, page, mailId }
} }
@ -92,7 +125,7 @@ export function buildMailPath(r: MailRouteState): string {
const segs: string[] = ["mail", encodeURIComponent(r.folderId)] const segs: string[] = ["mail", encodeURIComponent(r.folderId)]
if (r.folderId === "inbox") { if (r.folderId === "inbox") {
const tab = r.inboxTab || DEFAULT_INBOX_TAB const tab = normalizeInboxTabSegment(r.inboxTab || DEFAULT_INBOX_TAB)
if (tab !== DEFAULT_INBOX_TAB) { if (tab !== DEFAULT_INBOX_TAB) {
segs.push(tab) segs.push(tab)
} }

View File

@ -1,4 +1,5 @@
import type { Email } from "@/lib/email-data" import type { Email } from "@/lib/email-data"
import { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display" import { cleanSenderName } from "@/lib/sender-display"
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
@ -24,7 +25,7 @@ function buildSegments(email: Email): PrintSegment[] {
segments.push({ segments.push({
fromName: cleanSenderName(msg.sender), fromName: cleanSenderName(msg.sender),
fromEmail: msg.senderEmail, fromEmail: msg.senderEmail,
date: msg.date, date: formatMailDetailDate(msg.date),
bodyHtml: msg.body, bodyHtml: msg.body,
}) })
} }
@ -37,7 +38,7 @@ function buildSegments(email: Email): PrintSegment[] {
segments.push({ segments.push({
fromName: mainName, fromName: mainName,
fromEmail: mainEmail, fromEmail: mainEmail,
date: email.date, date: formatMailDetailDate(email.date),
bodyHtml: bodyHtml:
email.body ?? email.body ??
`<p style="color:#5f6368;margin:0;">${escapeHtml(email.preview)}</p>`, `<p style="color:#5f6368;margin:0;">${escapeHtml(email.preview)}</p>`,

View File

@ -49,8 +49,19 @@ type SidebarNavContextValue = {
renameFolderOrLabel: (id: string, newLabel: string) => void renameFolderOrLabel: (id: string, newLabel: string) => void
removeFolderOrLabelRow: (id: string) => void removeFolderOrLabelRow: (id: string) => void
moveFolder: (id: string, newParentId: string | null) => void moveFolder: (id: string, newParentId: string | null) => void
reorderLabelRows: (
draggedId: string,
targetId: string,
placement: "before" | "after"
) => void
moveFolderRelative: (
draggedId: string,
targetId: string,
placement: "before" | "after" | "inside"
) => void
addSubfolder: (parentId: string, name: string) => void addSubfolder: (parentId: string, name: string) => void
addChildLabelRow: (parentLabelRowId: string, childName: string) => void addChildLabelRow: (parentLabelRowId: string, childName: string) => void
setLabelRowEnabled: (id: string, enabled: boolean) => void
} }
const SidebarNavContext = createContext<SidebarNavContextValue | null>(null) const SidebarNavContext = createContext<SidebarNavContextValue | null>(null)
@ -151,6 +162,31 @@ export function SidebarNavProvider({
[scheduleRouteFolderIdSync] [scheduleRouteFolderIdSync]
) )
const moveFolderRelative = useCallback(
(
draggedId: string,
targetId: string,
placement: "before" | "after" | "inside"
) => {
const idMap = useNavStore
.getState()
.moveFolderRelative(draggedId, targetId, placement)
scheduleRouteFolderIdSync(idMap)
},
[scheduleRouteFolderIdSync]
)
const reorderLabelRows = useCallback(
(
draggedId: string,
targetId: string,
placement: "before" | "after"
) => {
useNavStore.getState().reorderLabelRows(draggedId, targetId, placement)
},
[]
)
const value = useMemo( const value = useMemo(
() => ({ () => ({
folderTree, folderTree,
@ -167,8 +203,11 @@ export function SidebarNavProvider({
renameFolderOrLabel, renameFolderOrLabel,
removeFolderOrLabelRow, removeFolderOrLabelRow,
moveFolder, moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder: navActions.addSubfolder, addSubfolder: navActions.addSubfolder,
addChildLabelRow: navActions.addChildLabelRow, addChildLabelRow: navActions.addChildLabelRow,
setLabelRowEnabled: navActions.setLabelRowEnabled,
}), }),
[ [
folderTree, folderTree,
@ -180,6 +219,8 @@ export function SidebarNavProvider({
renameFolderOrLabel, renameFolderOrLabel,
removeFolderOrLabelRow, removeFolderOrLabelRow,
moveFolder, moveFolder,
reorderLabelRows,
moveFolderRelative,
] ]
) )

View File

@ -43,7 +43,112 @@ export const folderTree: FolderTreeNode[] = [
{ id: "folder-factures", label: "Factures", color: "bg-amber-500", count: 42 }, { id: "folder-factures", label: "Factures", color: "bg-amber-500", count: 42 },
] ]
export const labelItems: readonly LabelRowItem[] = [ export function normalizeLabelRow(row: LabelRowItem): LabelRowItem {
return {
...row,
tabbed: row.tabbed ?? false,
favorite: row.favorite ?? false,
excludeFromPrincipal: row.excludeFromPrincipal ?? false,
showInMessageList: row.showInMessageList ?? true,
enabled: row.enabled ?? true,
}
}
/** Libellés navigation système (onglets + catégories) — ids stables proches du titre. */
export const SYSTEM_NAV_LABEL_DEFAULTS: readonly LabelRowItem[] = [
{
id: "promotions",
label: "Promotions",
color: "bg-[#1e8e3e]",
icon: "mdi:tag-outline",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
showInMessageList: false,
enabled: true,
},
{
id: "reseaux-sociaux",
label: "Réseaux sociaux",
color: "bg-[#0b57d0]",
icon: "mdi:account-group",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
showInMessageList: false,
enabled: true,
},
{
id: "newsletters",
label: "Newsletters",
color: "bg-[#e8710a]",
icon: "mdi:email-newsletter",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
showInMessageList: false,
enabled: true,
},
{
id: "mises-a-jour",
label: "Mises à jour",
color: "bg-[#c5221f]",
icon: "mdi:update",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
showInMessageList: false,
enabled: true,
},
{
id: "forums",
label: "Forums",
color: "bg-[#9334e6]",
icon: "mdi:forum",
tabbed: true,
favorite: true,
excludeFromPrincipal: true,
showInMessageList: false,
enabled: true,
},
{
id: "achats",
label: "Achats",
color: "bg-amber-600",
icon: "mdi:cart-outline",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
},
{
id: "deplacements",
label: "Déplacements",
color: "bg-teal-600",
icon: "mdi:map-marker",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
},
{
id: "finance",
label: "Finance",
color: "bg-indigo-600",
icon: "mdi:credit-card-outline",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
},
] as const
export const SYSTEM_NAV_LABEL_ID_SET = new Set(SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id))
const DEMO_IMAP_LABEL_ROWS: readonly LabelRowItem[] = [
{ id: "imap-sent", label: "[Imap]/Sent", color: "bg-gray-500" }, { id: "imap-sent", label: "[Imap]/Sent", color: "bg-gray-500" },
{ id: "imap-trash", label: "[Imap]/Trash", color: "bg-red-400", count: 4 }, { id: "imap-trash", label: "[Imap]/Trash", color: "bg-red-400", count: 4 },
{ id: "browser-alerts", label: "BrowserAlerts", color: "bg-red-400", count: 1 }, { id: "browser-alerts", label: "BrowserAlerts", color: "bg-red-400", count: 1 },
@ -52,15 +157,48 @@ export const labelItems: readonly LabelRowItem[] = [
{ id: "twitch", label: "Twitch", color: "bg-purple-500", count: 137 }, { id: "twitch", label: "Twitch", color: "bg-purple-500", count: 137 },
] ]
/** id de ligne sidebar (dossier hiérarchique ou libellé) → libellé Gmail pour matcher `email.labels`. */ /** Tous les libellés par défaut (système + démo IMAP) — ordre stable. */
export const DEFAULT_NAV_LABEL_ROWS: LabelRowItem[] = [
...SYSTEM_NAV_LABEL_DEFAULTS.map((r) => normalizeLabelRow({ ...r })),
...DEMO_IMAP_LABEL_ROWS.map((r) => normalizeLabelRow({ ...r })),
]
/** Snapshot pour filtres quand aucun contexte nav (tests, défaut). */
export const defaultNavLabelRowsSnapshot: LabelRowItem[] = DEFAULT_NAV_LABEL_ROWS.map((r) => ({ ...r }))
export function isSystemNavLabelId(id: string): boolean {
return SYSTEM_NAV_LABEL_ID_SET.has(id)
}
/** Libellés visibles en onglets boîte de réception (hors Principale). */
export function tabbedInboxLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
return rows.filter((r) => r.enabled !== false && r.tabbed === true)
}
export function favoriteNavLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
return rows.filter((r) => r.enabled !== false && r.favorite === true)
}
export function nonTabbedNavLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
return rows.filter((r) => r.enabled !== false && r.tabbed !== true && SYSTEM_NAV_LABEL_ID_SET.has(r.id))
}
export function labelRowById(
rows: readonly LabelRowItem[],
id: string
): LabelRowItem | undefined {
return rows.find((r) => r.id === id)
}
/** id de ligne sidebar (dossier hiérarchique ou libellé) → libellé pour matcher `email.labels`. */
export const sidebarNavFolderIdToLabel: Readonly<Record<string, string>> = export const sidebarNavFolderIdToLabel: Readonly<Record<string, string>> =
buildFolderIdToLabelRecord(folderTree, labelItems) buildFolderIdToLabelRecord(folderTree, defaultNavLabelRowsSnapshot)
/** Libellé Gmail (comme dans `email.labels`) → id de ligne sidebar correspondant. */ /** Libellé Gmail (comme dans `email.labels`) → id de ligne sidebar correspondant. */
export const emailLabelToSidebarFolderId: Readonly<Record<string, string>> = export const emailLabelToSidebarFolderId: Readonly<Record<string, string>> =
buildEmailLabelToSidebarFolderId(sidebarNavFolderIdToLabel) buildEmailLabelToSidebarFolderId(sidebarNavFolderIdToLabel)
/** Libellés navigation (boîtes, catégories latérales) — alignés sur la sidebar. */ /** Libellés navigation dossiers système — hors libellés définis dans `labelRows`. */
const STATIC_NAV_FOLDER_LABELS: Record<string, string> = { const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
inbox: "Boîte de réception", inbox: "Boîte de réception",
starred: "Messages suivis", starred: "Messages suivis",
@ -70,14 +208,7 @@ const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
drafts: "Brouillons", drafts: "Brouillons",
scheduled: "Planifié", scheduled: "Planifié",
spam: "Indésirables", spam: "Indésirables",
purchases: "Achats", trash: "Corbeille",
travel: "Déplacements",
social: "Réseaux sociaux",
notifications: "Notifications",
updates: "Mises à jour",
forums: "Forums",
finance: "Finance",
promotions: "Promotions",
} }
/** Libellé lisible pour id de ligne (liste vide, messages détat). Dossiers / libellés IMAP viennent de larbre. */ /** Libellé lisible pour id de ligne (liste vide, messages détat). Dossiers / libellés IMAP viennent de larbre. */
@ -93,10 +224,59 @@ export function getMailNavFolderLabel(
return folderId return folderId
} }
export function inboxTabDisplayLabel(
inboxTab: string,
labelRows: readonly LabelRowItem[],
folderIdToLabel: Record<string, string>
): string {
if (inboxTab === "primary") return "Principale"
const row = labelRowById(labelRows, inboxTab)
if (row && row.enabled !== false) return row.label
return getMailNavFolderLabel(inboxTab, folderIdToLabel)
}
export function cloneDefaultFolderTree(): FolderTreeNode[] { export function cloneDefaultFolderTree(): FolderTreeNode[] {
return structuredClone(folderTree) return structuredClone(folderTree)
} }
export function cloneDefaultLabelRows(): LabelRowItem[] { export function cloneDefaultLabelRows(): LabelRowItem[] {
return labelItems.map((r) => ({ ...r })) return DEFAULT_NAV_LABEL_ROWS.map((r) => ({ ...r }))
}
const LEGACY_LABEL_ROW_ID_MAP: Record<string, string> = {
purchases: "achats",
travel: "deplacements",
social: "reseaux-sociaux",
updates: "mises-a-jour",
notifications: "newsletters",
}
/** Fusionne la persistance avec les défauts (ids système + nouveaux champs). */
export function reconcileLabelRowsFromPersisted(persisted: LabelRowItem[] | undefined): LabelRowItem[] {
const defaults = cloneDefaultLabelRows()
const defaultIds = new Set(defaults.map((r) => r.id))
const persistByResolvedId = new Map<string, LabelRowItem>()
for (const p of persisted ?? []) {
const resolvedId = LEGACY_LABEL_ROW_ID_MAP[p.id] ?? p.id
persistByResolvedId.set(resolvedId, { ...p, id: resolvedId })
}
const merged = defaults.map((d) => {
const p = persistByResolvedId.get(d.id)
if (!p) return { ...d }
return normalizeLabelRow({ ...d, ...p, id: d.id })
})
const mergedIds = new Set(merged.map((r) => r.id))
const extras: LabelRowItem[] = []
for (const p of persisted ?? []) {
const resolvedId = LEGACY_LABEL_ROW_ID_MAP[p.id] ?? p.id
if (!mergedIds.has(resolvedId)) {
extras.push(normalizeLabelRow({ ...p, id: resolvedId }))
mergedIds.add(resolvedId)
}
}
return [...merged, ...extras]
} }

64
lib/sidebar-nav-dnd.ts Normal file
View File

@ -0,0 +1,64 @@
/** Drag-and-drop MIME + helpers for reordering sidebar labels / folders. */
export const SIDEBAR_NAV_DND_MIME = "application/x-ultimail-sidebar-nav"
export type SidebarNavDragPayload =
| { kind: "label"; id: string }
| { kind: "folder"; id: string }
export type SidebarNavDropPlacement = "before" | "after" | "inside"
export function setSidebarNavDragData(
e: React.DragEvent,
payload: SidebarNavDragPayload
) {
const encoded = JSON.stringify(payload)
e.dataTransfer.setData(SIDEBAR_NAV_DND_MIME, encoded)
/** Fallback for browsers that hide custom types until drop. */
e.dataTransfer.setData("text/plain", encoded)
e.dataTransfer.effectAllowed = "move"
}
export function readSidebarNavDragData(
e: React.DragEvent,
active: SidebarNavDragPayload | null = null
): SidebarNavDragPayload | null {
for (const mime of [SIDEBAR_NAV_DND_MIME, "text/plain"]) {
const raw = e.dataTransfer.getData(mime)
if (!raw) continue
try {
const parsed = JSON.parse(raw) as SidebarNavDragPayload
if (parsed?.kind === "label" || parsed?.kind === "folder") {
if (typeof parsed.id === "string" && parsed.id.length > 0) return parsed
}
} catch {
/* ignore */
}
}
return active
}
export function hasSidebarNavDrag(e: React.DragEvent): boolean {
return e.dataTransfer.types.includes(SIDEBAR_NAV_DND_MIME)
}
/** Prefer React state — `dataTransfer.types` is unreliable during `dragover`. */
export function isSidebarNavDragEvent(
e: React.DragEvent,
active: SidebarNavDragPayload | null
): boolean {
return active !== null || hasSidebarNavDrag(e)
}
/** Top/bottom thirds = reorder; middle third = nest (folders only). */
export function resolveNavDropPlacement(
e: React.DragEvent<Element>,
allowNest: boolean
): SidebarNavDropPlacement {
const rect = e.currentTarget.getBoundingClientRect()
const ratio = (e.clientY - rect.top) / Math.max(rect.height, 1)
if (!allowNest) return ratio < 0.5 ? "before" : "after"
if (ratio < 0.25) return "before"
if (ratio > 0.75) return "after"
return "inside"
}

View File

@ -11,6 +11,18 @@ export type LabelRowItem = {
label: string label: string
color: string color: string
count?: number count?: number
/** Icône Iconify (ex. mdi:inbox) — onglets + sidebar pour libellés système. */
icon?: string
/** Onglet boîte de réception (Principale + ces onglets). */
tabbed?: boolean
/** Bloc favoris sous les dossiers principaux. */
favorite?: boolean
/** Masque de Principale quand le libellé est sur le mail (si activé). */
excludeFromPrincipal?: boolean
/** Pastille dans la liste / en-tête message. */
showInMessageList?: boolean
/** Désactivé : pas donglet, pas de sidebar, pas de pastille, exclude ignoré. */
enabled?: boolean
} }
export function buildFolderIdToLabelRecord( export function buildFolderIdToLabelRecord(

View File

@ -6,6 +6,9 @@ import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage
import { import {
cloneDefaultFolderTree, cloneDefaultFolderTree,
cloneDefaultLabelRows, cloneDefaultLabelRows,
isSystemNavLabelId,
normalizeLabelRow,
reconcileLabelRowsFromPersisted,
type FolderTreeNode, type FolderTreeNode,
type LabelRowItem, type LabelRowItem,
} from "@/lib/sidebar-nav-data" } from "@/lib/sidebar-nav-data"
@ -102,6 +105,73 @@ function isDescendantOf(tree: FolderTreeNode[], maybeDescendantId: string, ances
return collectSubtreeIds(anc).has(maybeDescendantId) return collectSubtreeIds(anc).has(maybeDescendantId)
} }
type FolderNodeLocation = {
parentId: string | null
index: number
}
function findNodeLocation(
nodes: FolderTreeNode[],
id: string,
parentId: string | null = null
): FolderNodeLocation | null {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].id === id) return { parentId, index: i }
const children = nodes[i].children
if (children?.length) {
const hit = findNodeLocation(children, id, nodes[i].id)
if (hit) return hit
}
}
return null
}
function insertNodeAtParentIndex(
nodes: FolderTreeNode[],
parentId: string | null,
index: number,
node: FolderTreeNode
): FolderTreeNode[] {
if (parentId === null) {
const next = [...nodes]
next.splice(Math.max(0, Math.min(index, next.length)), 0, node)
return next
}
return nodes.map((n) => {
if (n.id === parentId) {
const children = [...(n.children ?? [])]
children.splice(Math.max(0, Math.min(index, children.length)), 0, node)
return { ...n, children }
}
if (n.children?.length) {
return { ...n, children: insertNodeAtParentIndex(n.children, parentId, index, node) }
}
return n
})
}
function applyFolderTreeRekey(
tree: FolderTreeNode[],
movedNodeId: string,
labelRowIds: string[],
navItemPrefs: Record<string, NavItemPrefs>
): {
tree: FolderTreeNode[]
navItemPrefs: Record<string, NavItemPrefs>
idMap: Record<string, string>
} {
const rk = rekeyFolderSubtreeAt(tree, movedNodeId, labelRowIds)
const idMap = rk?.idMap ?? {}
if (rk && Object.keys(idMap).length > 0) {
return {
tree: rk.tree,
navItemPrefs: remapNavItemPrefs(navItemPrefs, idMap),
idMap,
}
}
return { tree, navItemPrefs, idMap: {} }
}
function extractNode( function extractNode(
nodes: FolderTreeNode[], nodes: FolderTreeNode[],
id: string id: string
@ -162,7 +232,18 @@ type NavStoreActions = {
renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null } renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null }
removeFolderOrLabelRow: (id: string) => string[] removeFolderOrLabelRow: (id: string) => string[]
moveFolder: (id: string, newParentId: string | null) => Record<string, string> moveFolder: (id: string, newParentId: string | null) => Record<string, string>
reorderLabelRows: (
draggedId: string,
targetId: string,
placement: "before" | "after"
) => void
moveFolderRelative: (
draggedId: string,
targetId: string,
placement: "before" | "after" | "inside"
) => Record<string, string>
addChildLabelRow: (parentLabelRowId: string, childName: string) => void addChildLabelRow: (parentLabelRowId: string, childName: string) => void
setLabelRowEnabled: (id: string, enabled: boolean) => void
/** Derived selectors */ /** Derived selectors */
getFolderIdToLabel: () => Record<string, string> getFolderIdToLabel: () => Record<string, string>
@ -195,7 +276,17 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
if (known.has(label.toLowerCase())) return s if (known.has(label.toLowerCase())) return s
const ids = new Set(s.labelRows.map((r) => r.id)) const ids = new Set(s.labelRows.map((r) => r.id))
const id = uniqueLabelRowId(newLabelRowId(label), ids) const id = uniqueLabelRowId(newLabelRowId(label), ids)
return { labelRows: [...s.labelRows, { id, label, color: "bg-gray-500" }] } const row = normalizeLabelRow({
id,
label,
color: "bg-gray-500",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
})
return { labelRows: [...s.labelRows, row] }
}) })
}, },
@ -251,6 +342,11 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
let nextRows = prev.labelRows let nextRows = prev.labelRows
let navItemPrefs = prev.navItemPrefs let navItemPrefs = prev.navItemPrefs
if (inRow) { if (inRow) {
if (isSystemNavLabelId(id)) {
nextRows = prev.labelRows.map((r) =>
r.id === id ? { ...r, label: nextLabel } : r
)
} else {
const usedIds = new Set([ const usedIds = new Set([
...collectFolderIdsInTree(prev.folderTree), ...collectFolderIdsInTree(prev.folderTree),
...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id), ...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id),
@ -264,6 +360,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, rowMap) navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, rowMap)
resultIdMap = rowMap resultIdMap = rowMap
} }
}
} else { } else {
nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel }) nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel })
const rk = rekeyFolderSubtreeAt(nextTree, id, prev.labelRows.map((r) => r.id)) const rk = rekeyFolderSubtreeAt(nextTree, id, prev.labelRows.map((r) => r.id))
@ -281,6 +378,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
}, },
removeFolderOrLabelRow: (id) => { removeFolderOrLabelRow: (id) => {
if (isSystemNavLabelId(id)) return []
const snap = get() const snap = get()
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows) const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
const row = snap.labelRows.find((r) => r.id === id) const row = snap.labelRows.find((r) => r.id === id)
@ -339,14 +437,81 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
let tree = ex.next let tree = ex.next
tree = insertFolderChild(tree, newParentId, ex.extracted) tree = insertFolderChild(tree, newParentId, ex.extracted)
const movedOldId = ex.extracted.id const movedOldId = ex.extracted.id
const rk = rekeyFolderSubtreeAt(tree, movedOldId, prev.labelRows.map((r) => r.id)) const rekeyed = applyFolderTreeRekey(
const idMap = rk?.idMap ?? {} tree,
movedOldId,
prev.labelRows.map((r) => r.id),
prev.navItemPrefs
)
tree = rekeyed.tree
resultIdMap = rekeyed.idMap
return { folderTree: tree, navItemPrefs: rekeyed.navItemPrefs }
})
return resultIdMap
},
reorderLabelRows: (draggedId, targetId, placement) => {
if (draggedId === targetId) return
if (isSystemNavLabelId(draggedId) || isSystemNavLabelId(targetId)) return
set((prev) => {
const from = prev.labelRows.findIndex((r) => r.id === draggedId)
const targetIdx = prev.labelRows.findIndex((r) => r.id === targetId)
if (from < 0 || targetIdx < 0) return prev
let toIndex = placement === "before" ? targetIdx : targetIdx + 1
if (from < toIndex) toIndex -= 1
const next = [...prev.labelRows]
const [item] = next.splice(from, 1)
next.splice(toIndex, 0, item)
return { labelRows: next }
})
},
moveFolderRelative: (draggedId, targetId, placement) => {
let resultIdMap: Record<string, string> = {}
set((prev) => {
if (draggedId === targetId) return prev
if (isDescendantOf(prev.folderTree, targetId, draggedId)) return prev
const draggedLoc = findNodeLocation(prev.folderTree, draggedId)
const targetLoc = findNodeLocation(prev.folderTree, targetId)
if (!targetLoc) return prev
const ex = extractNode(prev.folderTree, draggedId)
if (!ex.extracted) return prev
let tree = ex.next
let navItemPrefs = prev.navItemPrefs let navItemPrefs = prev.navItemPrefs
if (rk && Object.keys(idMap).length > 0) {
tree = rk.tree if (placement === "inside") {
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, idMap) if (draggedId === targetId) return prev
resultIdMap = idMap tree = insertFolderChild(tree, targetId, ex.extracted)
} else {
let insertIndex =
placement === "before" ? targetLoc.index : targetLoc.index + 1
if (
draggedLoc &&
draggedLoc.parentId === targetLoc.parentId &&
draggedLoc.index < insertIndex
) {
insertIndex -= 1
} }
tree = insertNodeAtParentIndex(
tree,
targetLoc.parentId,
insertIndex,
ex.extracted
)
}
const rekeyed = applyFolderTreeRekey(
tree,
ex.extracted.id,
prev.labelRows.map((r) => r.id),
navItemPrefs
)
tree = rekeyed.tree
navItemPrefs = rekeyed.navItemPrefs
resultIdMap = rekeyed.idMap
return { folderTree: tree, navItemPrefs } return { folderTree: tree, navItemPrefs }
}) })
return resultIdMap return resultIdMap
@ -364,11 +529,30 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
const ids = new Set(s.labelRows.map((r) => r.id)) const ids = new Set(s.labelRows.map((r) => r.id))
const nid = uniqueLabelRowId(newLabelRowId(combined), ids) const nid = uniqueLabelRowId(newLabelRowId(combined), ids)
return { return {
labelRows: [...s.labelRows, { id: nid, label: combined, color: parent.color ?? "bg-gray-500" }], labelRows: [
...s.labelRows,
normalizeLabelRow({
id: nid,
label: combined,
color: parent.color ?? "bg-gray-500",
tabbed: false,
favorite: false,
excludeFromPrincipal: false,
showInMessageList: true,
enabled: true,
}),
],
} }
}) })
}, },
setLabelRowEnabled: (id, enabled) =>
set((s) => ({
labelRows: s.labelRows.map((r) =>
r.id === id ? normalizeLabelRow({ ...r, enabled }) : r
),
})),
getFolderIdToLabel: () => { getFolderIdToLabel: () => {
const s = get() const s = get()
return buildFolderIdToLabelRecord(s.folderTree, s.labelRows) return buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
@ -388,7 +572,19 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
{ {
name: "ultimail-nav-state", name: "ultimail-nav-state",
storage: debouncedPersistJSONStorage, storage: debouncedPersistJSONStorage,
version: 1, version: 2,
migrate: (persisted, fromVersion) => {
if (fromVersion < 2 && persisted && typeof persisted === "object") {
const p = persisted as { labelRows?: LabelRowItem[] }
if (Array.isArray(p.labelRows)) {
return {
...persisted,
labelRows: reconcileLabelRowsFromPersisted(p.labelRows),
}
}
}
return persisted
},
} }
) )
) )

View File

@ -66,11 +66,10 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
subject: payload.subject.trim() || "(Sans objet)", subject: payload.subject.trim() || "(Sans objet)",
preview: payload.previewText.slice(0, 200), preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml, body: payload.bodyHtml,
date: "", date: payload.sendAtIso,
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["scheduled"], labels: ["scheduled"],
scheduledSendAt: payload.sendAtIso, scheduledSendAt: payload.sendAtIso,
scheduledToName: toName, scheduledToName: toName,
@ -106,7 +105,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
scheduledToName: undefined, scheduledToName: undefined,
snoozeWakeAt: wake.toISOString(), snoozeWakeAt: wake.toISOString(),
sender: row.scheduledToName ?? row.sender, sender: row.scheduledToName ?? row.sender,
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
read: true, read: true,
}, },
...s.snoozedEmails, ...s.snoozedEmails,
@ -171,11 +169,10 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
subject: row.subject, subject: row.subject,
preview: row.preview, preview: row.preview,
body: row.body, body: row.body,
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }), date: now.toISOString(),
read: true, read: true,
starred: false, starred: false,
important: false, important: false,
category: "primary",
labels: ["sent"], labels: ["sent"],
}, },
...s.sentPlaceholderEmails, ...s.sentPlaceholderEmails,
@ -200,10 +197,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
snoozeWakeAt: wakeIso, snoozeWakeAt: wakeIso,
scheduledSendAt: undefined, scheduledSendAt: undefined,
scheduledToName: undefined, scheduledToName: undefined,
date: wake.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}),
read: true, read: true,
} }
return { return {

View File

@ -5,6 +5,7 @@ import type {
ThreadComposeKind, ThreadComposeKind,
} from "@/lib/compose-context" } from "@/lib/compose-context"
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context" import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
import { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display" import { cleanSenderName } from "@/lib/sender-display"
function appendDefaultSignature(html: string): string { function appendDefaultSignature(html: string): string {
@ -107,12 +108,16 @@ function inReplyToFor(email: Email): string {
return `<thread-msg-${email.id}@local>` return `<thread-msg-${email.id}@local>`
} }
function formatQuoteDate(date: string, senderEmail: string, senderName: string): string { function formatQuoteDate(
dateIso: string,
senderEmail: string,
senderName: string
): string {
const who = const who =
senderName && senderName !== senderEmail senderName && senderName !== senderEmail
? `${escapeHtml(senderName)} &lt;${escapeHtml(senderEmail)}&gt;` ? `${escapeHtml(senderName)} &lt;${escapeHtml(senderEmail)}&gt;`
: escapeHtml(senderEmail) : escapeHtml(senderEmail)
return `Le ${escapeHtml(date)}, ${who} a écrit :` return `Le ${escapeHtml(formatMailDetailDate(dateIso))}, ${who} a écrit :`
} }
function quotedBlock(html: string): string { function quotedBlock(html: string): string {
@ -146,7 +151,7 @@ function forwardConversationHtml(email: Email): string {
blocks.push( blocks.push(
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` + `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} &lt;${escapeHtml(m.senderEmail)}&gt;<br/>` + `<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} &lt;${escapeHtml(m.senderEmail)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(m.date)}</div>${m.body}</div>` `<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(m.date))}</div>${m.body}</div>`
) )
} }
const mainAddr = const mainAddr =
@ -155,7 +160,7 @@ function forwardConversationHtml(email: Email): string {
blocks.push( blocks.push(
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` + `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` + `<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(email.date)}</div>` + `<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}</div>` +
`${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>` `${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>`
) )
return blocks.join("") return blocks.join("")
@ -169,7 +174,7 @@ function forwardBodyHtml(email: Email): string {
`<p>---------- Forwarded message ---------</p>` + `<p>---------- Forwarded message ---------</p>` +
`<p style="color:#222;font-size:13px;line-height:1.5">` + `<p style="color:#222;font-size:13px;line-height:1.5">` +
`<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` + `<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} &lt;${escapeHtml(mainAddr)}&gt;<br/>` +
`<strong>Date :</strong> ${escapeHtml(email.date)}<br/>` + `<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}<br/>` +
`<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` + `<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` +
`<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>` `<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
return `<p></p>${header}${forwardConversationHtml(email)}` return `<p></p>${header}${forwardConversationHtml(email)}`

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -64,6 +64,7 @@
"cmdk": "1.1.1", "cmdk": "1.1.1",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"dayjs": "^1.11.20",
"embla-carousel-react": "8.6.0", "embla-carousel-react": "8.6.0",
"emoji-mart": "^5.6.0", "emoji-mart": "^5.6.0",
"input-otp": "1.4.2", "input-otp": "1.4.2",

View File

@ -161,6 +161,9 @@ importers:
date-fns-tz: date-fns-tz:
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0(date-fns@4.1.0) version: 3.2.0(date-fns@4.1.0)
dayjs:
specifier: ^1.11.20
version: 1.11.20
embla-carousel-react: embla-carousel-react:
specifier: 8.6.0 specifier: 8.6.0
version: 8.6.0(react@19.2.4) version: 8.6.0(react@19.2.4)
@ -1590,6 +1593,9 @@ packages:
date-fns@4.1.0: date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
dayjs@1.11.20:
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
@ -3367,6 +3373,8 @@ snapshots:
date-fns@4.1.0: {} date-fns@4.1.0: {}
dayjs@1.11.20: {}
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
detect-libc@2.1.2: {} detect-libc@2.1.2: {}

File diff suppressed because one or more lines are too long