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;
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"
import type { ComponentType, ReactNode } from "react"
import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
import { Check, Minus, Plus } from "lucide-react"
import { Icon } from "@iconify/react"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
export type CatalogLabelPresence = "none" | "some" | "all"
@ -13,6 +15,32 @@ export type LabelPickerItemComponent = ComponentType<{
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({
checked,
}: {
@ -40,21 +68,41 @@ export function EmailLabelPickerBlock({
query,
onQueryChange,
catalogLabels,
resolveLabelVisual,
Item,
getLabelPresence,
onToggleCatalogLabel,
onCreateLabel,
listClassName,
searchAutoFocus = true,
}: {
query: string
onQueryChange: (v: string) => void
catalogLabels: string[]
resolveLabelVisual: (label: string) => LabelPickerVisual
Item: LabelPickerItemComponent
getLabelPresence: (label: string) => CatalogLabelPresence
onToggleCatalogLabel: (label: string) => void
onCreateLabel: (label: string) => void
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 filtered = catalogLabels.filter(
(l) => q.length === 0 || l.toLowerCase().includes(q)
@ -72,9 +120,11 @@ export function EmailLabelPickerBlock({
onPointerDown={(e) => e.stopPropagation()}
>
<Input
ref={searchInputRef}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Rechercher ou créer un libellé…"
aria-label="Rechercher ou créer un libellé"
className="h-8 border-[#dadce0] text-sm shadow-none"
autoComplete="off"
onPointerDown={(e) => e.stopPropagation()}
@ -91,7 +141,7 @@ export function EmailLabelPickerBlock({
}}
>
<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} »
</span>
</Item>
@ -109,7 +159,8 @@ export function EmailLabelPickerBlock({
}}
>
<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>
)
})}

View File

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

View File

@ -49,12 +49,14 @@ import {
cleanSenderName,
senderInitial,
} from "@/lib/sender-display"
import { MailDateText } from "@/components/gmail/mail-date-text"
import type {
Email,
ConversationMessage,
EmailAttachment,
EmailAttachmentKind,
} from "@/lib/email-data"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import {
attachmentPreviewTooltip,
resolveAttachmentKind,
@ -88,6 +90,10 @@ interface EmailViewProps {
labelBgByText?: Map<string, string>
emailLabelToSidebarFolderId?: Record<string, 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> = {
@ -437,7 +443,11 @@ function CollapsedMessage({
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
</ContactHoverCard>
<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
strokeWidth={1.25}
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
@ -455,7 +465,7 @@ function CollapsedMessage({
function ExpandedMessage({
sender,
senderEmail,
date,
dateIso,
body,
isSpam,
isLast,
@ -467,7 +477,7 @@ function ExpandedMessage({
}: {
sender: string
senderEmail: string
date: string
dateIso: string
body: string
isSpam: boolean
isLast: boolean
@ -534,7 +544,14 @@ function ExpandedMessage({
<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>à : <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 && (
<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 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 && (
<button
@ -739,6 +760,9 @@ export function EmailView({
labelBgByText,
emailLabelToSidebarFolderId = {},
getNavItemPrefs = () => ({ messages: "show" }),
folderTree,
labelRows,
currentFolderId,
}: EmailViewProps) {
const conversation = email.conversation ?? []
const hasConversation = conversation.length > 0
@ -842,6 +866,9 @@ export function EmailView({
labelBgByText={labelBgByText}
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
getNavItemPrefs={getNavItemPrefs}
labelRows={labelRows}
folderTree={folderTree}
currentFolderId={currentFolderId}
onLabelNavigate={onNavigateToLabel}
showLabel={showLabelChip}
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
@ -904,7 +931,7 @@ export function EmailView({
<ExpandedMessage
sender={msg.sender}
senderEmail={msg.senderEmail}
date={msg.date}
dateIso={msg.date}
body={msg.body}
isSpam={false}
isLast={false}
@ -931,7 +958,7 @@ export function EmailView({
<ExpandedMessage
sender={mainSenderName}
senderEmail={mainSenderAddr}
date={email.date}
dateIso={email.date}
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`}
isSpam={email.spam === 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"
import { Fragment, useMemo } from "react"
import { Icon } from "@iconify/react"
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 { cn } from "@/lib/utils"
@ -10,26 +12,29 @@ type MailFolderStackIndicatorProps = {
currentKey: string
folderTree: FolderTreeNode[]
folderIdToLabel: Record<string, string>
labelRows?: readonly LabelRowItem[]
className?: string
}
function MailNavIconGlyph({
visitKey,
folderTree,
labelRows,
}: {
visitKey: string
folderTree: FolderTreeNode[]
labelRows?: readonly LabelRowItem[]
}) {
const resolved = useMemo(
() => resolveMailNavIcon(visitKey, folderTree),
[visitKey, folderTree]
() => resolveMailNavIcon(visitKey, folderTree, labelRows),
[visitKey, folderTree, labelRows]
)
if (resolved.kind === "folder-dot") {
return (
<span
className={cn(
"inline-block size-4 shrink-0 rounded-sm",
"inline-block size-4 shrink-0 rounded-full",
resolved.colorClass
)}
aria-hidden
@ -37,9 +42,19 @@ function MailNavIconGlyph({
)
}
const { Icon } = resolved
if (resolved.kind === "iconify") {
return (
<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]"
strokeWidth={1.5}
aria-hidden
@ -51,14 +66,15 @@ export function MailFolderStackIndicator({
currentKey,
folderTree,
folderIdToLabel,
labelRows,
className,
}: MailFolderStackIndicatorProps) {
const segments = useMemo(
() => breadcrumbSegmentsForVisitKey(currentKey, folderTree, folderIdToLabel),
const items = useMemo(
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
[currentKey, folderTree, folderIdToLabel]
)
const ariaLabel = segments.join(" · ")
const ariaLabel = items.map((i) => i.label).join(" · ")
return (
<div
@ -66,17 +82,16 @@ export function MailFolderStackIndicator({
aria-live="polite"
aria-label={`Boîte actuelle : ${ariaLabel}`}
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",
"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",
className
)}
>
<MailNavIconGlyph visitKey={currentKey} folderTree={folderTree} />
<span className="flex min-w-0 items-center gap-1.5">
{segments.map((seg, i) => (
<Fragment key={`${seg}-${i}`}>
{items.map((item, i) => (
<Fragment key={`${item.visitKey}-${i}`}>
{i > 0 ? (
<span
className="shrink-0 text-xs leading-none text-[#9aa0a6]"
@ -85,7 +100,14 @@ export function MailFolderStackIndicator({
·
</span>
) : 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>
))}
</span>

View File

@ -44,10 +44,20 @@ export function buildLabelTextToNavColorClass(
export function mailLabelShouldShowInListStrip(
lab: string,
emailLabelToSidebarFolderId: Record<string, string>,
getNavItemPrefs: (id: string) => { messages: string }
getNavItemPrefs: (id: string) => { messages: string },
labelRows?: readonly LabelRowItem[]
): boolean {
if (MAIL_LABEL_STRIP_EXCLUDE.has(lab.toLowerCase())) return false
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"
return true
}
@ -153,7 +163,9 @@ type MailLabelPillStripProps = {
showRemoveOnPills?: boolean
/** Dossier actuel (id) — son label est masqué en list, mais pas ses sous-dossiers. */
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[]
}
@ -170,6 +182,7 @@ export function MailLabelPillStrip({
showRemoveOnPills,
currentFolderId,
folderTree,
labelRows,
}: MailLabelPillStripProps) {
const currentFolderLabel = useMemo(() => {
if (!currentFolderId || !folderTree) return null
@ -184,7 +197,14 @@ export function MailLabelPillStrip({
const shown = useMemo(() => {
const filtered = (labels ?? []).filter((lab) => {
if (variant === "list") {
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs)) {
if (
!mailLabelShouldShowInListStrip(
lab,
emailLabelToSidebarFolderId,
getNavItemPrefs,
labelRows
)
) {
return false
}
if (currentFolderLabel && lab.toLowerCase() === currentFolderLabel.toLowerCase()) {
@ -192,6 +212,9 @@ export function MailLabelPillStrip({
}
return true
}
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs, labelRows)) {
return false
}
if (lab === "spam" && spamChip) return false
if (showLabel && !showLabel(lab)) return false
return true
@ -208,7 +231,7 @@ export function MailLabelPillStrip({
}
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
if (shown.length === 0 && !hasSpam) return null

View File

@ -11,6 +11,7 @@ import {
import { cn } from "@/lib/utils"
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
import type { MoveTarget } from "@/components/gmail/move-to-menu-items"
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
function SheetActionRow({
children,
@ -72,6 +73,7 @@ export type MobileXsBulkSheetsProps = {
labelPickerQuery: string
onLabelPickerQueryChange: (query: string) => void
catalogLabels: string[]
resolveLabelVisual: (label: string) => LabelPickerVisual
moveTargets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
onMoveTo: (targetId: string) => void
getLabelPresence: (label: string) => CatalogLabelPresence
@ -87,6 +89,7 @@ export function MobileXsBulkSheets({
labelPickerQuery,
onLabelPickerQueryChange,
catalogLabels,
resolveLabelVisual,
moveTargets,
onMoveTo,
getLabelPresence,
@ -176,6 +179,7 @@ export function MobileXsBulkSheets({
<SheetContent
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"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<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]">
@ -188,6 +192,7 @@ export function MobileXsBulkSheets({
query={labelPickerQuery}
onQueryChange={onLabelPickerQueryChange}
catalogLabels={catalogLabels}
resolveLabelVisual={resolveLabelVisual}
Item={SheetLabelItem}
getLabelPresence={getLabelPresence}
onToggleCatalogLabel={onToggleCatalogLabel}

View File

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

View File

@ -10,30 +10,39 @@ import {
Tag,
ChevronDown,
ChevronRight,
GripVertical,
Pencil,
ShoppingCart,
MapPin,
Share2,
Bell,
MessageSquare,
BadgePercent,
Plus,
Bot,
Folder,
MoreVertical,
Sparkles,
Newspaper,
LayoutGrid,
Rss,
CreditCard,
Mail,
ShieldAlert,
Check,
Trash2,
} from "lucide-react"
import { cn, formatCount } from "@/lib/utils"
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 {
readSidebarNavDragData,
resolveNavDropPlacement,
setSidebarNavDragData,
type SidebarNavDragPayload,
type SidebarNavDropPlacement,
} from "@/lib/sidebar-nav-dnd"
import { useComposeActions } from "@/lib/compose-context"
import {
DropdownMenu,
@ -46,7 +55,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} 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 {
Dialog,
@ -121,24 +130,22 @@ const mainItems = [
{ id: "drafts", label: "Brouillons", icon: FileText },
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
{ id: "trash", label: "Corbeille", icon: Trash2 },
]
const categoryItemsSource = [
{ id: "purchases", label: "Achats", icon: ShoppingCart },
{ 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 },
]
/** Catégories système affichées sous « Plus » uniquement. */
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["mises-a-jour", "finance"])
/** Ids catégories : menu ⋮ (Afficher / Masquer) du survol. */
const CATEGORY_MENU_IDS = new Set(categoryItemsSource.map((c) => c.id))
const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
/** Catégories affichées sous « Plus » uniquement (Mises à jour, Finance, …). */
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["updates", "finance"])
function sortSystemLabelRows(rows: { id: string }[]): { id: string; label: string; icon?: string }[] {
const copy = [...rows]
copy.sort(
(a, b) =>
SYSTEM_NAV_LABEL_ORDER.indexOf(a.id) - SYSTEM_NAV_LABEL_ORDER.indexOf(b.id)
)
return copy as { id: string; label: string; icon?: string }[]
}
/** Liens secondaires sous la liste (jusquà Gérer les abonnements). */
const sidebarSecondaryActions = [
@ -149,7 +156,7 @@ const sidebarSecondaryActions = [
] as const
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
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */
@ -245,13 +252,72 @@ function folderParentSelectOptions(tree: FolderTreeNode[]): {
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). */
function navRowRoundedWhenActive(active: boolean) {
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
}
/** Mark an element as the nav drag source (opacity via CSS). */
function markNavDragSource(el: HTMLElement | null) {
el?.setAttribute("data-nav-drag-source", "true")
}
function unmarkNavDragSource(el: HTMLElement | null) {
el?.removeAttribute("data-nav-drag-source")
}
/** Mark / unmark a drop indicator via data attribute (CSS driven). */
function setNavDropIndicator(
el: HTMLElement | null,
placement: SidebarNavDropPlacement | null,
) {
if (!el) return
if (placement) {
el.setAttribute("data-nav-drop", placement)
} else {
el.removeAttribute("data-nav-drop")
}
}
function navRowActivate(
e: React.KeyboardEvent,
action: () => void
) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
action()
}
}
function SidebarNavDragHandle({
label,
onDragStart,
onDragEnd,
}: {
label: string
onDragStart: (e: React.DragEvent<HTMLSpanElement>) => void
onDragEnd: () => void
}) {
return (
<span
draggable
title={`Réorganiser : ${label}`}
aria-label={`Réorganiser : ${label}`}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
className="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). */
function SidebarOverflowColumn({
unread,
@ -308,8 +374,8 @@ function CategoryNavRow({
isExpanded,
unreadCount,
onSelectFolder,
onHideCategory,
onShowCategory,
onDisableNavLabel,
onEnableNavLabel,
variant = "listed",
}: {
item: CategoryNavSourceItem
@ -317,15 +383,15 @@ function CategoryNavRow({
isExpanded: boolean
unreadCount: number
onSelectFolder: (id: string) => void
onHideCategory: (id: string) => void
onShowCategory: (id: string) => void
onDisableNavLabel: (id: string) => void
onEnableNavLabel: (id: string) => void
variant?: "listed" | "hidden"
}) {
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
const [menuOpen, setMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const isHiddenRow = variant === "hidden"
const showCategoryMenu = CATEGORY_MENU_IDS.has(item.id) && isExpanded
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
const hasUnread = unreadCount > 0
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) {
return (
<div
@ -350,7 +437,7 @@ function CategoryNavRow({
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"
>
<item.icon className="h-5 w-5 shrink-0 opacity-70" />
{rowIcon}
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
className={cn(
@ -387,14 +474,11 @@ function CategoryNavRow({
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuItem
onClick={() => {
onShowCategory(item.id)
onEnableNavLabel(item.id)
setMenuOpen(false)
}}
>
Afficher
</DropdownMenuItem>
<DropdownMenuItem disabled className="text-gray-400">
Masquer
Réactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -426,12 +510,7 @@ function CategoryNavRow({
showCategoryMenu ? "pr-1" : "pr-3"
)}
>
<item.icon
className={cn(
"h-5 w-5 shrink-0",
hasUnread && !isSelected && "text-gray-900"
)}
/>
{rowIcon}
{isExpanded && (
<div className="flex min-w-0 flex-1 items-baseline gap-4">
<span
@ -484,11 +563,11 @@ function CategoryNavRow({
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
onHideCategory(item.id)
onDisableNavLabel(item.id)
setMenuOpen(false)
}}
>
Masquer
Désactiver le libellé
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -508,7 +587,6 @@ export function Sidebar({
const [hoverExpanded, setHoverExpanded] = useState(false)
const [navMoreOpen, setNavMoreOpen] = useState(false)
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
const [hiddenCategoryIds, setHiddenCategoryIds] = useState<Set<string>>(() => new Set())
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const sidebarRef = useRef<HTMLElement>(null)
@ -527,12 +605,86 @@ export function Sidebar({
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder,
addChildLabelRow,
setLabelRowEnabled,
} = useSidebarNav()
const navDragRef = useRef<SidebarNavDragPayload | null>(null)
const navDragSourceElRef = useRef<HTMLElement | null>(null)
const navDropTargetElRef = useRef<HTMLElement | null>(null)
const navDropPlacementRef = useRef<SidebarNavDropPlacement | null>(null)
const beginNavDrag = useCallback(
(payload: SidebarNavDragPayload, sourceEl: HTMLElement | null) => {
navDragRef.current = payload
navDragSourceElRef.current = sourceEl
markNavDragSource(sourceEl)
},
[]
)
const clearNavDrag = useCallback(() => {
unmarkNavDragSource(navDragSourceElRef.current)
setNavDropIndicator(navDropTargetElRef.current, null)
navDragRef.current = null
navDragSourceElRef.current = null
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}, [])
const updateNavDropTarget = useCallback(
(el: HTMLElement, placement: SidebarNavDropPlacement) => {
if (navDropTargetElRef.current !== el) {
setNavDropIndicator(navDropTargetElRef.current, null)
}
navDropTargetElRef.current = el
navDropPlacementRef.current = placement
setNavDropIndicator(el, placement)
},
[]
)
const clearNavDropTarget = useCallback((el: HTMLElement) => {
if (navDropTargetElRef.current === el) {
setNavDropIndicator(el, null)
navDropTargetElRef.current = null
navDropPlacementRef.current = null
}
}, [])
const commitNavDrop = useCallback(
(
payload: SidebarNavDragPayload,
targetId: string,
placement: SidebarNavDropPlacement,
targetKind: "label" | "folder"
) => {
clearNavDrag()
if (payload.id === targetId && placement !== "inside") return
if (targetKind === "label" && payload.kind === "label") {
if (placement === "inside") return
reorderLabelRows(payload.id, targetId, placement)
} else if (targetKind === "folder" && payload.kind === "folder") {
moveFolderRelative(payload.id, targetId, placement)
if (placement === "inside") {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
next.add(targetId)
return next
})
}
}
},
[clearNavDrag, moveFolderRelative, reorderLabelRows]
)
const visibleNavLabelRows = useMemo(() => {
return labelRows.filter((row) => {
if (row.enabled === false) return false
if (isSystemNavLabelId(row.id)) return false
const p = getNavItemPrefs(row.id)
if (p.sidebar === "hide") return false
if (
@ -548,7 +700,6 @@ export function Sidebar({
const validNavFolderIds = useMemo(() => {
const s = new Set<string>()
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)
return s
}, [folderIdToLabel])
@ -573,17 +724,24 @@ export function Sidebar({
)
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 {
primaryVisibleCategories: vis.filter((c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
plusOnlyVisibleCategories: vis.filter((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
primaryVisibleCategories: systemEnabled.filter(
(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(
() => categoryItemsSource.filter((c) => hiddenCategoryIds.has(c.id)),
[hiddenCategoryIds]
)
const disabledSystemNavItems = useMemo(() => {
return sortSystemLabelRows(
labelRows.filter((r) => r.enabled === false && isSystemNavLabelId(r.id))
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
}, [labelRows])
const visibleMainItems = useMemo(() => {
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
@ -591,22 +749,6 @@ export function Sidebar({
return mainItems.filter((item) => item.id !== "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) => {
setExpandedFolderIds((prev) => {
const next = new Set(prev)
@ -634,10 +776,11 @@ export function Sidebar({
}
useEffect(() => {
if (hiddenCategoryIds.has(selectedFolder)) {
const row = labelRows.find((r) => r.id === selectedFolder)
if (row && row.enabled === false) {
onSelectFolder("inbox")
}
}, [hiddenCategoryIds, selectedFolder, onSelectFolder])
}, [labelRows, selectedFolder, onSelectFolder])
useEffect(() => {
if (selectedFolder !== "scheduled") return
@ -770,6 +913,7 @@ export function Sidebar({
const isStickyBranch = hasChildren && isBranchOpen
const stickyTopPx = 32 + depth * 32
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(node.label)
@ -791,6 +935,9 @@ export function Sidebar({
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
const prefs = getNavItemPrefs(node.id)
const moveTargets = useMemo(
() => folderMoveParentOptions(folderTree, node.id),
@ -851,13 +998,14 @@ export function Sidebar({
}
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",
isSelected || isOver ? "rounded-r-full" : "rounded-r-none",
"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 || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
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 && 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 = {
paddingLeft: 24 + depth * 16,
@ -971,14 +1119,86 @@ export function Sidebar({
</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 (
<>
<ContextMenu>
<ContextMenu onOpenChange={setContextMenuOpen}>
<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 ? (
<button
type="button"
draggable={false}
className={cn(
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
isSelected && "text-gray-900"
@ -1007,14 +1227,18 @@ export function Sidebar({
aria-hidden
/>
)}
<button
type="button"
<div
role="button"
tabIndex={0}
onClick={() => onSelectFolder(node.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors",
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
!isSelected &&
!isOver &&
!rowHoverHeld &&
"rounded-r-none hover:rounded-r-full hover:bg-gray-100",
rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
isSelected
? "text-gray-900"
: isOver
@ -1036,7 +1260,7 @@ export function Sidebar({
</span>
</span>
</div>
</button>
</div>
{overflowMenu}
</div>
</ContextMenuTrigger>
@ -1341,6 +1565,7 @@ export function Sidebar({
const isSelected = selectedFolder === item.id
const hasUnread = unreadCount > 0
const [menuOpen, setMenuOpen] = useState(false)
const [contextMenuOpen, setContextMenuOpen] = useState(false)
const menuTriggerRef = useRef<HTMLButtonElement>(null)
const [renameOpen, setRenameOpen] = useState(false)
const [renameDraft, setRenameDraft] = useState(item.label)
@ -1348,6 +1573,7 @@ export function Sidebar({
const [sublabelName, setSublabelName] = useState("")
const labelRenameInputRef = useRef<HTMLInputElement>(null)
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
useEffect(() => {
setRenameDraft(item.label)
@ -1360,6 +1586,9 @@ export function Sidebar({
}
}
const rowHoverHeld =
!isSelected && !isOver && (contextMenuOpen || menuOpen)
const prefs = getNavItemPrefs(item.id)
const labelDotClass = item.color ?? "bg-gray-400"
const labelMenuSurface =
@ -1415,16 +1644,76 @@ export function Sidebar({
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",
navRowRoundedWhenActive(isSelected || isOver),
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
isSelected
? "bg-[#d3e3fd] text-gray-900 font-medium"
: isOver
? "bg-yellow-100 text-gray-900"
: rowHoverHeld
? "bg-gray-100 text-gray-900"
: hasUnread
? "text-gray-900 hover:bg-gray-100"
: "text-gray-700 hover:bg-gray-100"
)
const onLabelRowDragEnter = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label" && active.id !== item.id) {
e.preventDefault()
return
}
dropHandlers.onDragEnter(e)
}
const onLabelRowDragOver = (e: React.DragEvent) => {
const active = navDragRef.current
if (active?.kind === "label") {
e.preventDefault()
e.stopPropagation()
if (active.id === item.id) return
e.dataTransfer.dropEffect = "move"
updateNavDropTarget(
e.currentTarget as HTMLElement,
resolveNavDropPlacement(e, false)
)
return
}
dropHandlers.onDragOver(e)
}
const onLabelRowDragLeave = (e: React.DragEvent) => {
if (navDragRef.current?.kind === "label") {
const rt = e.relatedTarget as Node | null
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
clearNavDropTarget(e.currentTarget as HTMLElement)
return
}
dropHandlers.onDragLeave(e)
}
const onLabelRowDrop = (e: React.DragEvent) => {
const payload = readSidebarNavDragData(e, navDragRef.current)
if (payload?.kind === "label") {
e.preventDefault()
e.stopPropagation()
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, false)
if (placement !== "inside") {
commitNavDrop(payload, item.id, placement, "label")
} else {
clearNavDrag()
}
return
}
dropHandlers.onDrop(e)
}
const onLabelDragHandleStart = (e: React.DragEvent<HTMLSpanElement>) => {
const payload = { kind: "label" as const, id: item.id }
setSidebarNavDragData(e, payload)
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
beginNavDrag(payload, rowEl)
}
const overflowMenu = labelRowExpanded ? (
<SidebarOverflowColumn
unread={unreadCount}
@ -1439,6 +1728,7 @@ export function Sidebar({
<button
ref={menuTriggerRef}
type="button"
draggable={false}
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
aria-label={`Options pour ${item.label}`}
onClick={(e) => e.stopPropagation()}
@ -1524,15 +1814,31 @@ export function Sidebar({
return (
<>
<ContextMenu>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger asChild>
<div {...dropHandlers} className={rowClass}>
<button
type="button"
<div
data-nav-row
onDragEnter={onLabelRowDragEnter}
onDragOver={onLabelRowDragOver}
onDragLeave={onLabelRowDragLeave}
onDrop={onLabelRowDrop}
className={rowClass}
>
{canDragLabel ? (
<SidebarNavDragHandle
label={item.label}
onDragStart={onLabelDragHandleStart}
onDragEnd={clearNavDrag}
/>
) : null}
<div
role="button"
tabIndex={0}
title={!labelRowExpanded ? item.label : undefined}
onClick={() => onSelectFolder(item.id)}
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))}
className={cn(
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none",
"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"
)}
>
@ -1551,7 +1857,7 @@ export function Sidebar({
{item.label}
</span>
)}
</button>
</div>
{overflowMenu}
</div>
</ContextMenuTrigger>
@ -1791,8 +2097,8 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
onHideCategory={hideCategory}
onShowCategory={showCategory}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
@ -1845,8 +2151,8 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
onHideCategory={hideCategory}
onShowCategory={showCategory}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
/>
))}
{isExpanded && (
@ -1866,12 +2172,12 @@ export function Sidebar({
})}
</div>
)}
{isExpanded && hiddenCategoryItems.length > 0 && (
{isExpanded && disabledSystemNavItems.length > 0 && (
<div className="mt-2 pt-2">
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
Masquées
Désactivées
</div>
{hiddenCategoryItems.map((item) => (
{disabledSystemNavItems.map((item) => (
<CategoryNavRow
key={item.id}
item={item}
@ -1879,8 +2185,8 @@ export function Sidebar({
isExpanded={isExpanded}
unreadCount={folderUnreadCounts[item.id] ?? 0}
onSelectFolder={onSelectFolder}
onHideCategory={hideCategory}
onShowCategory={showCategory}
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
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)",
preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml,
date: "",
date: payload.sendAtIso,
read: true,
starred: false,
important: false,
category: "primary",
labels: ["scheduled"],
scheduledSendAt: payload.sendAtIso,
scheduledToName: toName,
@ -98,7 +97,6 @@ export async function snoozeScheduledSend(id: string): Promise<void> {
scheduledToName: undefined,
snoozeWakeAt: wakeIso,
sender: row.scheduledToName ?? row.sender,
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
read: true,
})
}
@ -192,11 +190,10 @@ export async function sendScheduledNow(id: string): Promise<void> {
subject: row.subject,
preview: row.preview,
body: row.body,
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }),
date: now.toISOString(),
read: true,
starred: false,
important: false,
category: "primary",
labels: ["sent"],
})
}

View File

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

View File

@ -21,6 +21,7 @@ export interface ConversationMessage {
id: string
sender: string
senderEmail: string
/** ISO 8601 avec fuseau (ex. `2026-05-16T01:38:00+02:00`) */
date: string
body: string
preview: string
@ -36,6 +37,7 @@ export interface Email {
preview: string
/** HTML body — rendu dans une iframe sandbox pour raisons de sécurité */
body?: string
/** ISO 8601 avec fuseau — affichage via `formatMail*` / `MailDateText` */
date: string
read: boolean
starred: boolean
@ -47,7 +49,8 @@ export interface Email {
calendarInvitation?: CalendarInvitationMeta
attachments?: EmailAttachment[]
tag?: string
category: "primary" | "promotions" | "social" | "updates" | "forums"
/** Corbeille : pseudo-dossier `trash` quand true */
deleted?: boolean
/** Libellés / dossiers associés à cette conversation */
labels?: string[]
/** 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><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>`,
date: "01:38",
date: "2026-05-16T01:38:00+02:00",
read: false,
starred: false,
important: false,
@ -84,14 +87,13 @@ export const emails: Email[] = [
{ name: "notes.txt", kind: "other", sizeBytes: 2_048 },
{ name: "build_log.pdf", kind: "pdf", sizeBytes: 1_024_512 },
],
category: "primary",
labels: ["inbox"],
conversation: [
{
id: "1-a",
sender: "ronenrozn",
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...",
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>
@ -109,7 +111,7 @@ export const emails: Email[] = [
id: "1-b",
sender: "Daniel",
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...",
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>
@ -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>
</table>
</div>`,
date: "7 mai",
date: "2026-05-07T01:11:00+02:00",
read: true,
starred: false,
important: false,
attachments: [{ name: "recu_course.pdf", kind: "pdf", sizeBytes: 88_320 }],
category: "promotions",
labels: ["inbox", "Factures", "Achats"],
labels: ["inbox", "Factures", "Achats", "Promotions"],
},
{
id: "3",
@ -158,18 +159,17 @@ export const emails: Email[] = [
<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>
</div>`,
date: "5 avr.",
date: "2026-04-05T12:00:00+02:00",
read: true,
starred: false,
important: false,
category: "social",
labels: ["inbox"],
labels: ["inbox", "Réseaux sociaux"],
conversation: [
{
id: "3-a",
sender: "SannyGrooves",
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 (",
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>
@ -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>
<p><br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.</p>
</div>`,
date: "19 mars",
date: "2026-03-19T10:00:00+01:00",
read: true,
starred: false,
important: false,
category: "updates",
labels: ["inbox"],
labels: ["inbox", "Mises à jour"],
},
{
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><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>`,
date: "28 févr.",
date: "2026-02-28T12:00:00+01:00",
read: true,
starred: true,
important: false,
category: "forums",
labels: ["inbox", "starred", "Travail"],
labels: ["inbox", "starred", "Travail", "Forums"],
conversation: [
{
id: "5-a",
sender: "Pyxage",
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...",
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>
@ -238,7 +236,7 @@ export const emails: Email[] = [
id: "5-b",
sender: "Argenis",
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...",
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>
@ -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>
</table>
</div>`,
date: "28 févr.",
date: "2026-02-28T02:10:00+01:00",
read: false,
starred: false,
important: true,
hasInvitation: true,
category: "primary",
labels: ["inbox", "Factures"],
},
{
@ -282,11 +279,10 @@ export const emails: Email[] = [
<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>
</div>`,
date: "27 févr.",
date: "2026-02-27T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["sent", "[Imap]/Sent"],
},
{
@ -306,11 +302,10 @@ export const emails: Email[] = [
<li>chore: update dependencies</li>
</ul>
</div>`,
date: "23 févr.",
date: "2026-02-23T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["drafts"],
},
{
@ -324,11 +319,10 @@ export const emails: Email[] = [
<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>
</div>`,
date: "23 févr.",
date: "2026-02-23T15:30:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
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>
</table>
</div>`,
date: "2 févr.",
date: "2026-02-02T03:55:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
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>@mtrezza What is your working branch for this feature? I'd like to contribute some timezone utilities.</p>
</div>`,
date: "30 janv.",
date: "2026-01-30T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["inbox", "Clients"],
},
{
@ -389,12 +381,11 @@ export const emails: Email[] = [
</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>
</div>`,
date: "9 janv.",
date: "2026-01-09T12:00:00+01:00",
read: true,
starred: false,
important: false,
hasInvitation: true,
category: "primary",
labels: ["inbox", "BrowserAlerts", "Twitch"],
},
{
@ -413,11 +404,10 @@ export const emails: Email[] = [
<li>Steps to reproduce</li>
</ul>
</div>`,
date: "29/12/2025",
date: "2025-12-29T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["inbox", "Q1 2026", "CMSecurity Alerts"],
},
{
@ -436,18 +426,17 @@ export const emails: Email[] = [
<li>Mention supported Parse Server versions</li>
</ol>
</div>`,
date: "01/12/2025",
date: "2025-12-01T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["inbox", "Travail", "RH"],
conversation: [
{
id: "14-a",
sender: "parse-github-assistant",
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...",
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>
@ -459,7 +448,7 @@ export const emails: Email[] = [
id: "14-b",
sender: "R3D347HR4Y",
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...",
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>
@ -475,7 +464,7 @@ export const emails: Email[] = [
id: "14-c",
sender: "dblythy",
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...",
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>
@ -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>This issue has been automatically marked as stale because it has not had recent activity.</p>
</div>`,
date: "05/11/2025",
date: "2025-11-05T12:00:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
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>Nous espérons que vous avez apprécié votre course ce samedi matin.</p>
</div>`,
date: "01/11/2025",
date: "2025-11-01T02:55:00+01:00",
read: true,
starred: false,
important: false,
category: "primary",
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><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>`,
date: "30/09/2025",
date: "2025-09-30T12:00:00+02:00",
read: true,
starred: false,
important: false,
@ -541,7 +528,6 @@ export const emails: Email[] = [
{ name: "screenshot.png", kind: "image", sizeBytes: 412_000 },
{ name: "notes.txt", kind: "other", sizeBytes: 1_280 },
],
category: "primary",
labels: ["inbox", "Finance"],
},
{
@ -559,12 +545,11 @@ export const emails: Email[] = [
jeu. 14 mai 2026 14:30 15:30 (heure dEurope centrale)</p>
<p><a href="#">Rejoindre la réunion</a></p>
</div>`,
date: "30/09/2025",
date: "2025-09-30T10:13:00+02:00",
read: true,
starred: false,
important: false,
hasInvitation: true,
category: "primary",
labels: ["inbox", "Travail"],
hasAttachment: true,
calendarInvitation: {
@ -626,13 +611,12 @@ END:VCALENDAR`,
<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" />
</div>`,
date: "26/09/2025",
date: "2025-09-26T12:00:00+02:00",
read: false,
starred: false,
important: false,
spam: true,
labels: ["spam"],
category: "primary"
},
{
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>
</table>
</div>`,
date: "21/09/2025",
date: "2025-09-21T12:00:00+02:00",
read: true,
starred: false,
important: false,
category: "primary",
labels: ["inbox", "Famille"],
},
{
@ -671,12 +654,44 @@ END:VCALENDAR`,
<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" />
</div>`,
date: "20/09/2025",
date: "2025-09-20T12:00:00+02:00",
read: true,
starred: false,
important: true,
spam: true,
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 { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
/** @deprecated Utiliser `getMailNavFolderLabel(inboxTab, folderIdToLabel)` ou `inboxTabDisplayLabel`. */
export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = {
primary: "Principale",
promotions: "Promotions",
social: "Réseaux sociaux",
updates: "Notifications",
forums: "Forums",
}
/** Clé stable pour historique navigation (dossier + onglet boîte de réception). */
@ -28,29 +25,85 @@ export function parseMailNavVisitKey(key: string): {
return { folderId: key }
}
export function getMailNavFolderBreadcrumbSegments(
export type MailNavBreadcrumbItem = {
label: string
visitKey: string
}
export function getMailNavFolderBreadcrumbItems(
folderId: string,
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>,
inboxCategoryLabel?: string | null
): string[] {
inboxCategory?: { tabId: string; label: string } | null
): MailNavBreadcrumbItem[] {
if (folderId === "inbox") {
const base = getMailNavFolderLabel(folderId, folderIdToLabel)
if (
inboxCategoryLabel &&
inboxCategoryLabel !== INBOX_CATEGORY_TAB_LABELS.primary
) {
return [base, inboxCategoryLabel]
if (inboxCategory && inboxCategory.label !== "Principale") {
return [
{ label: base, visitKey: mailNavVisitKey("inbox") },
{
label: inboxCategory.label,
visitKey: mailNavVisitKey("inbox", inboxCategory.tabId),
},
]
}
return [base]
return [{ label: base, visitKey: mailNavVisitKey(folderId) }]
}
const path = findFolderPath(folderTree, folderId)
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(
@ -58,16 +111,8 @@ export function breadcrumbSegmentsForVisitKey(
folderTree: FolderTreeNode[],
folderIdToLabel?: Record<string, string>
): string[] {
const { folderId, inboxTab } = parseMailNavVisitKey(key)
const cat =
folderId === "inbox" && inboxTab
? INBOX_CATEGORY_TAB_LABELS[inboxTab] ?? inboxTab
: null
return getMailNavFolderBreadcrumbSegments(
folderId,
folderTree,
folderIdToLabel,
cat
return breadcrumbItemsForVisitKey(key, folderTree, folderIdToLabel).map(
(i) => i.label
)
}

View File

@ -2,9 +2,11 @@ import type { Email } from "@/lib/email-data"
import {
folderTree as defaultFolderTree,
sidebarNavFolderIdToLabel as defaultSidebarNavFolderIdToLabel,
type FolderTreeNode,
defaultNavLabelRowsSnapshot,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
import { collectSubtreeFolderIds } from "@/lib/sidebar-nav-maps"
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
export type MailFolderFilterCtx = {
starredEmailIds: string[]
@ -15,20 +17,14 @@ export type MailFolderFilterCtx = {
export type MailNavFolderMaps = {
folderIdToLabel: Record<string, string>
folderTree: FolderTreeNode[]
labelRows: LabelRowItem[]
}
const CATEGORY_EMAIL_TAB_IDS = new Set([
"social",
"promotions",
"updates",
"forums",
])
/** 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 hasFutureScheduledSend(email: Email): boolean {
if (!email.scheduledSendAt) return false
const t = new Date(email.scheduledSendAt).getTime()
if (!Number.isFinite(t)) return false
return t > Date.now()
}
function effectiveStarred(email: Email, ctx: MailFolderFilterCtx): boolean {
@ -40,17 +36,27 @@ function effectiveImportant(email: Email, ctx: MailFolderFilterCtx): boolean {
}
function isInInbox(email: Email): boolean {
if (email.deleted) return false
if (email.spam) return false
if (hasFutureScheduledSend(email)) return false
const ls = email.labels
if (!ls?.length) return true
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 {
if (maps) return maps
return {
folderIdToLabel: defaultSidebarNavFolderIdToLabel as Record<string, string>,
folderTree: defaultFolderTree,
labelRows: defaultNavLabelRowsSnapshot,
}
}
@ -66,6 +72,8 @@ function matchesFolderLabelRow(
folderId: string,
maps: MailNavFolderMaps
): boolean {
const row = labelRowForNavId(folderId, maps.labelRows)
if (row && row.enabled === false) return false
const label = maps.folderIdToLabel[folderId]
if (!label) return false
return emailHasAnyLabel(email, [label])
@ -96,6 +104,24 @@ function matchesLabelNav(
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(
email: Email,
folderId: string,
@ -106,39 +132,38 @@ export function emailMatchesFolder(
): boolean {
const nav = resolveNavMaps(maps)
if (email.deleted && folderId !== "trash") {
return false
}
switch (folderId) {
case "inbox":
return isInInbox(email)
case "starred":
return effectiveStarred(email, ctx)
case "snoozed":
return email.labels?.includes("snoozed") ?? false
return (email.labels?.includes("snoozed") ?? false) && !email.deleted
case "important":
return effectiveImportant(email, ctx)
case "sent":
return email.labels?.includes("sent") ?? false
return (email.labels?.includes("sent") ?? false) && !email.deleted
case "drafts":
return email.labels?.includes("drafts") ?? false
return (email.labels?.includes("drafts") ?? false) && !email.deleted
case "scheduled":
return email.labels?.includes("scheduled") ?? false
return !email.deleted && hasFutureScheduledSend(email)
case "spam":
return email.spam === true || (email.labels?.includes("spam") ?? false)
case "notifications":
return email.category === "updates"
case "purchases":
case "travel":
case "finance": {
const extra = SIDEBAR_CATEGORY_EXTRA_LABEL[folderId]
if (!extra) return false
return email.labels?.includes(extra) ?? false
}
return (
!email.deleted &&
(email.spam === true || (email.labels?.includes("spam") ?? false))
)
case "trash":
return email.deleted === true
default:
break
}
if (CATEGORY_EMAIL_TAB_IDS.has(folderId)) {
return email.category === folderId
}
const row = labelRowForNavId(folderId, nav.labelRows)
if (row && row.enabled === false) return false
if (nav.folderIdToLabel[folderId]) {
return matchesLabelNav(email, folderId, nav, subtreeIdsCache)

View File

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

View File

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

View File

@ -1,16 +1,42 @@
/** 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_INBOX_TAB = "primary"
/** Onglets catégories boîte de réception (alignés sur `categoryTabs` dans email-list). */
export const INBOX_CATEGORY_TAB_IDS = new Set([
"primary",
"promotions",
"social",
"updates",
"forums",
])
/** Segments dURL historiques → ids libellés actuels. */
const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = {
updates: "mises-a-jour",
notifications: "newsletters",
social: "reseaux-sociaux",
purchases: "achats",
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 = {
folderId: string
@ -69,7 +95,14 @@ export function parseMailSegments(
if (parts.length >= 2) {
const tab = parts[1]!
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 }
}
@ -92,7 +125,7 @@ export function buildMailPath(r: MailRouteState): string {
const segs: string[] = ["mail", encodeURIComponent(r.folderId)]
if (r.folderId === "inbox") {
const tab = r.inboxTab || DEFAULT_INBOX_TAB
const tab = normalizeInboxTabSegment(r.inboxTab || DEFAULT_INBOX_TAB)
if (tab !== DEFAULT_INBOX_TAB) {
segs.push(tab)
}

View File

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

View File

@ -49,8 +49,19 @@ type SidebarNavContextValue = {
renameFolderOrLabel: (id: string, newLabel: string) => void
removeFolderOrLabelRow: (id: string) => 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
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
setLabelRowEnabled: (id: string, enabled: boolean) => void
}
const SidebarNavContext = createContext<SidebarNavContextValue | null>(null)
@ -151,6 +162,31 @@ export function SidebarNavProvider({
[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(
() => ({
folderTree,
@ -167,8 +203,11 @@ export function SidebarNavProvider({
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder: navActions.addSubfolder,
addChildLabelRow: navActions.addChildLabelRow,
setLabelRowEnabled: navActions.setLabelRowEnabled,
}),
[
folderTree,
@ -180,6 +219,8 @@ export function SidebarNavProvider({
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
]
)

View File

@ -43,7 +43,112 @@ export const folderTree: FolderTreeNode[] = [
{ 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-trash", label: "[Imap]/Trash", color: "bg-red-400", count: 4 },
{ 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 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>> =
buildFolderIdToLabelRecord(folderTree, labelItems)
buildFolderIdToLabelRecord(folderTree, defaultNavLabelRowsSnapshot)
/** Libellé Gmail (comme dans `email.labels`) → id de ligne sidebar correspondant. */
export const emailLabelToSidebarFolderId: Readonly<Record<string, string>> =
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> = {
inbox: "Boîte de réception",
starred: "Messages suivis",
@ -70,14 +208,7 @@ const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
drafts: "Brouillons",
scheduled: "Planifié",
spam: "Indésirables",
purchases: "Achats",
travel: "Déplacements",
social: "Réseaux sociaux",
notifications: "Notifications",
updates: "Mises à jour",
forums: "Forums",
finance: "Finance",
promotions: "Promotions",
trash: "Corbeille",
}
/** 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
}
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[] {
return structuredClone(folderTree)
}
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
color: string
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(

View File

@ -6,6 +6,9 @@ import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage
import {
cloneDefaultFolderTree,
cloneDefaultLabelRows,
isSystemNavLabelId,
normalizeLabelRow,
reconcileLabelRowsFromPersisted,
type FolderTreeNode,
type LabelRowItem,
} from "@/lib/sidebar-nav-data"
@ -102,6 +105,73 @@ function isDescendantOf(tree: FolderTreeNode[], maybeDescendantId: string, ances
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(
nodes: FolderTreeNode[],
id: string
@ -162,7 +232,18 @@ type NavStoreActions = {
renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null }
removeFolderOrLabelRow: (id: 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
setLabelRowEnabled: (id: string, enabled: boolean) => void
/** Derived selectors */
getFolderIdToLabel: () => Record<string, string>
@ -195,7 +276,17 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
if (known.has(label.toLowerCase())) return s
const ids = new Set(s.labelRows.map((r) => r.id))
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 navItemPrefs = prev.navItemPrefs
if (inRow) {
if (isSystemNavLabelId(id)) {
nextRows = prev.labelRows.map((r) =>
r.id === id ? { ...r, label: nextLabel } : r
)
} else {
const usedIds = new Set([
...collectFolderIdsInTree(prev.folderTree),
...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)
resultIdMap = rowMap
}
}
} else {
nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel })
const rk = rekeyFolderSubtreeAt(nextTree, id, prev.labelRows.map((r) => r.id))
@ -281,6 +378,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
},
removeFolderOrLabelRow: (id) => {
if (isSystemNavLabelId(id)) return []
const snap = get()
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
const row = snap.labelRows.find((r) => r.id === id)
@ -339,14 +437,81 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
let tree = ex.next
tree = insertFolderChild(tree, newParentId, ex.extracted)
const movedOldId = ex.extracted.id
const rk = rekeyFolderSubtreeAt(tree, movedOldId, prev.labelRows.map((r) => r.id))
const idMap = rk?.idMap ?? {}
const rekeyed = applyFolderTreeRekey(
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
if (rk && Object.keys(idMap).length > 0) {
tree = rk.tree
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, idMap)
resultIdMap = idMap
if (placement === "inside") {
if (draggedId === targetId) return prev
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 resultIdMap
@ -364,11 +529,30 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
const ids = new Set(s.labelRows.map((r) => r.id))
const nid = uniqueLabelRowId(newLabelRowId(combined), ids)
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: () => {
const s = get()
return buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
@ -388,7 +572,19 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
{
name: "ultimail-nav-state",
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)",
preview: payload.previewText.slice(0, 200),
body: payload.bodyHtml,
date: "",
date: payload.sendAtIso,
read: true,
starred: false,
important: false,
category: "primary",
labels: ["scheduled"],
scheduledSendAt: payload.sendAtIso,
scheduledToName: toName,
@ -106,7 +105,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
scheduledToName: undefined,
snoozeWakeAt: wake.toISOString(),
sender: row.scheduledToName ?? row.sender,
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
read: true,
},
...s.snoozedEmails,
@ -171,11 +169,10 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
subject: row.subject,
preview: row.preview,
body: row.body,
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }),
date: now.toISOString(),
read: true,
starred: false,
important: false,
category: "primary",
labels: ["sent"],
},
...s.sentPlaceholderEmails,
@ -200,10 +197,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
snoozeWakeAt: wakeIso,
scheduledSendAt: undefined,
scheduledToName: undefined,
date: wake.toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}),
read: true,
}
return {

View File

@ -5,6 +5,7 @@ import type {
ThreadComposeKind,
} from "@/lib/compose-context"
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
import { formatMailDetailDate } from "@/lib/mail-date"
import { cleanSenderName } from "@/lib/sender-display"
function appendDefaultSignature(html: string): string {
@ -107,12 +108,16 @@ function inReplyToFor(email: Email): string {
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 =
senderName && senderName !== senderEmail
? `${escapeHtml(senderName)} &lt;${escapeHtml(senderEmail)}&gt;`
: escapeHtml(senderEmail)
return `Le ${escapeHtml(date)}, ${who} a écrit :`
return `Le ${escapeHtml(formatMailDetailDate(dateIso))}, ${who} a écrit :`
}
function quotedBlock(html: string): string {
@ -146,7 +151,7 @@ function forwardConversationHtml(email: Email): string {
blocks.push(
`<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/>` +
`<strong>Date :</strong> ${escapeHtml(m.date)}</div>${m.body}</div>`
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(m.date))}</div>${m.body}</div>`
)
}
const mainAddr =
@ -155,7 +160,7 @@ function forwardConversationHtml(email: Email): string {
blocks.push(
`<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/>` +
`<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>`
)
return blocks.join("")
@ -169,7 +174,7 @@ function forwardBodyHtml(email: Email): string {
`<p>---------- Forwarded message ---------</p>` +
`<p style="color:#222;font-size:13px;line-height:1.5">` +
`<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>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
return `<p></p>${header}${forwardConversationHtml(email)}`

2
next-env.d.ts vendored
View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <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
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

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

View File

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

File diff suppressed because one or more lines are too long