Massive upgrades
This commit is contained in:
parent
6af6e62774
commit
489c0d0c5c
@ -219,3 +219,19 @@
|
|||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar nav DnD visual feedback (data-attribute driven, no React re-render) */
|
||||||
|
[data-nav-drag-source="true"] {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
[data-nav-drop="before"] {
|
||||||
|
box-shadow: inset 0 2px 0 0 #1a73e8;
|
||||||
|
}
|
||||||
|
[data-nav-drop="after"] {
|
||||||
|
box-shadow: inset 0 -2px 0 0 #1a73e8;
|
||||||
|
}
|
||||||
|
[data-nav-drop="inside"] {
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
outline: 1px solid rgba(26, 115, 232, 0.4);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type { ComponentType, ReactNode } from "react"
|
import { useLayoutEffect, useRef, type ComponentType, type ReactNode } from "react"
|
||||||
import { Check, Minus, Plus } from "lucide-react"
|
import { Check, Minus, Plus } from "lucide-react"
|
||||||
|
import { Icon } from "@iconify/react"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
|
||||||
|
|
||||||
export type CatalogLabelPresence = "none" | "some" | "all"
|
export type CatalogLabelPresence = "none" | "some" | "all"
|
||||||
|
|
||||||
@ -13,6 +15,32 @@ export type LabelPickerItemComponent = ComponentType<{
|
|||||||
className?: string
|
className?: string
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
export function LabelPickerLeadingVisual({
|
||||||
|
visual,
|
||||||
|
}: {
|
||||||
|
visual: LabelPickerVisual
|
||||||
|
}) {
|
||||||
|
if (visual.kind === "iconify") {
|
||||||
|
return (
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
<Icon
|
||||||
|
icon={visual.icon}
|
||||||
|
className="size-[18px] shrink-0 text-[#5f6368]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn("block h-3 w-3 rounded-sm", visual.colorClass)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function LabelPickerCheckboxVisual({
|
function LabelPickerCheckboxVisual({
|
||||||
checked,
|
checked,
|
||||||
}: {
|
}: {
|
||||||
@ -40,21 +68,41 @@ export function EmailLabelPickerBlock({
|
|||||||
query,
|
query,
|
||||||
onQueryChange,
|
onQueryChange,
|
||||||
catalogLabels,
|
catalogLabels,
|
||||||
|
resolveLabelVisual,
|
||||||
Item,
|
Item,
|
||||||
getLabelPresence,
|
getLabelPresence,
|
||||||
onToggleCatalogLabel,
|
onToggleCatalogLabel,
|
||||||
onCreateLabel,
|
onCreateLabel,
|
||||||
listClassName,
|
listClassName,
|
||||||
|
searchAutoFocus = true,
|
||||||
}: {
|
}: {
|
||||||
query: string
|
query: string
|
||||||
onQueryChange: (v: string) => void
|
onQueryChange: (v: string) => void
|
||||||
catalogLabels: string[]
|
catalogLabels: string[]
|
||||||
|
resolveLabelVisual: (label: string) => LabelPickerVisual
|
||||||
Item: LabelPickerItemComponent
|
Item: LabelPickerItemComponent
|
||||||
getLabelPresence: (label: string) => CatalogLabelPresence
|
getLabelPresence: (label: string) => CatalogLabelPresence
|
||||||
onToggleCatalogLabel: (label: string) => void
|
onToggleCatalogLabel: (label: string) => void
|
||||||
onCreateLabel: (label: string) => void
|
onCreateLabel: (label: string) => void
|
||||||
listClassName?: string
|
listClassName?: string
|
||||||
|
/** Focus search field when the picker mounts (submenu / sheet open). */
|
||||||
|
searchAutoFocus?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!searchAutoFocus) return
|
||||||
|
let inner = 0
|
||||||
|
const outer = requestAnimationFrame(() => {
|
||||||
|
inner = requestAnimationFrame(() => {
|
||||||
|
searchInputRef.current?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(outer)
|
||||||
|
if (inner) cancelAnimationFrame(inner)
|
||||||
|
}
|
||||||
|
}, [searchAutoFocus])
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
const filtered = catalogLabels.filter(
|
const filtered = catalogLabels.filter(
|
||||||
(l) => q.length === 0 || l.toLowerCase().includes(q)
|
(l) => q.length === 0 || l.toLowerCase().includes(q)
|
||||||
@ -72,9 +120,11 @@ export function EmailLabelPickerBlock({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => onQueryChange(e.target.value)}
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
placeholder="Rechercher ou créer un libellé…"
|
placeholder="Rechercher ou créer un libellé…"
|
||||||
|
aria-label="Rechercher ou créer un libellé"
|
||||||
className="h-8 border-[#dadce0] text-sm shadow-none"
|
className="h-8 border-[#dadce0] text-sm shadow-none"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
@ -91,7 +141,7 @@ export function EmailLabelPickerBlock({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
|
<Plus className="size-[18px] shrink-0 text-[#0b57d0]" strokeWidth={1.5} />
|
||||||
<span className="min-w-0 text-[#0b57d0]">
|
<span className="min-w-0 flex-1 text-[#0b57d0]">
|
||||||
Créer le libellé « {trimmed} »
|
Créer le libellé « {trimmed} »
|
||||||
</span>
|
</span>
|
||||||
</Item>
|
</Item>
|
||||||
@ -109,7 +159,8 @@ export function EmailLabelPickerBlock({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LabelPickerCheckboxVisual checked={boxChecked} />
|
<LabelPickerCheckboxVisual checked={boxChecked} />
|
||||||
<span className="min-w-0 truncate">{label}</span>
|
<LabelPickerLeadingVisual visual={resolveLabelVisual(label)} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
</Item>
|
</Item>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -9,10 +9,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ComponentType,
|
type ComponentType,
|
||||||
type DragEvent,
|
type DragEvent,
|
||||||
type ElementType,
|
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SVGProps,
|
|
||||||
} from "react"
|
} from "react"
|
||||||
import { Icon, addCollection } from "@iconify/react"
|
import { Icon, addCollection } from "@iconify/react"
|
||||||
import { icons as mdiIcons } from "@iconify-json/mdi"
|
import { icons as mdiIcons } from "@iconify-json/mdi"
|
||||||
@ -29,9 +27,6 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Tag,
|
Tag,
|
||||||
Users,
|
|
||||||
Info,
|
|
||||||
MessageSquare,
|
|
||||||
Reply,
|
Reply,
|
||||||
ReplyAll,
|
ReplyAll,
|
||||||
Forward,
|
Forward,
|
||||||
@ -96,31 +91,46 @@ import {
|
|||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty"
|
} from "@/components/ui/empty"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { labelPillTextClassForTailwindBgUtility } from "@/lib/label-pill-contrast"
|
||||||
import {
|
import {
|
||||||
buildLabelTextToNavColorClass,
|
buildLabelTextToNavColorClass,
|
||||||
MailLabelPillStrip,
|
MailLabelPillStrip,
|
||||||
|
mailLabelShouldShowInListStrip,
|
||||||
} from "@/components/gmail/mail-label-pills"
|
} from "@/components/gmail/mail-label-pills"
|
||||||
import { emails, type Email, type EmailAttachment } from "@/lib/email-data"
|
import { emails, type Email, type EmailAttachment } from "@/lib/email-data"
|
||||||
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
import { useScheduledMail } from "@/lib/scheduled-mail-context"
|
||||||
import { useMailStore } from "@/lib/stores/mail-store"
|
import { useMailStore } from "@/lib/stores/mail-store"
|
||||||
|
import { useScheduledStore } from "@/lib/stores/scheduled-store"
|
||||||
|
import { usePersistHydrated } from "@/hooks/use-persist-hydrated"
|
||||||
import {
|
import {
|
||||||
emailMatchesFolder,
|
emailMatchesFolder,
|
||||||
|
emailMatchesInboxPrimaryTab,
|
||||||
type MailNavFolderMaps,
|
type MailNavFolderMaps,
|
||||||
} from "@/lib/mail-folder-filter"
|
} from "@/lib/mail-folder-filter"
|
||||||
import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display"
|
import { cleanSenderName, resolveSenderEmail } from "@/lib/sender-display"
|
||||||
import { getMailNavFolderLabel, type FolderTreeNode } from "@/lib/sidebar-nav-data"
|
import {
|
||||||
|
getMailNavFolderLabel,
|
||||||
|
inboxTabDisplayLabel,
|
||||||
|
tabbedInboxLabelRows,
|
||||||
|
type FolderTreeNode,
|
||||||
|
type LabelRowItem,
|
||||||
|
} from "@/lib/sidebar-nav-data"
|
||||||
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
import { mailNavVisitKey } from "@/lib/mail-folder-display"
|
||||||
|
import { normalizeInboxTabSegment } from "@/lib/mail-url"
|
||||||
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
import { MailFolderStackIndicator } from "@/components/gmail/mail-folder-stack-indicator"
|
||||||
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
import { useSidebarNav, registerNavEmailSync } from "@/lib/sidebar-nav-context"
|
||||||
import { ContactHoverCard } from "./contact-hover-card"
|
import { ContactHoverCard } from "./contact-hover-card"
|
||||||
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
|
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
|
||||||
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
|
import type { CatalogLabelPresence } from "@/components/gmail/email-label-picker-block"
|
||||||
|
import { resolveLabelPickerVisual } from "@/lib/label-picker-visual"
|
||||||
import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets"
|
import { MobileXsBulkSheets } from "@/components/gmail/mobile-xs-bulk-sheets"
|
||||||
import {
|
import {
|
||||||
useMoveTargets,
|
useMoveTargets,
|
||||||
type MoveTarget,
|
type MoveTarget,
|
||||||
} from "@/components/gmail/move-to-menu-items"
|
} from "@/components/gmail/move-to-menu-items"
|
||||||
import { EmailView } from "./email-view"
|
import { EmailView } from "./email-view"
|
||||||
|
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||||
|
import { formatMailDetailDate } from "@/lib/mail-date"
|
||||||
import { buildListMailIndex } from "./email-list-row"
|
import { buildListMailIndex } from "./email-list-row"
|
||||||
import {
|
import {
|
||||||
useComposeActions,
|
useComposeActions,
|
||||||
@ -178,9 +188,7 @@ function collectTreeLabels(nodes: FolderTreeNode[]): string[] {
|
|||||||
|
|
||||||
function formatScheduledDateTimeDisplay(iso: string | undefined): string {
|
function formatScheduledDateTimeDisplay(iso: string | undefined): string {
|
||||||
if (!iso) return "—"
|
if (!iso) return "—"
|
||||||
const d = new Date(iso)
|
return formatMailDetailDate(iso)
|
||||||
if (Number.isNaN(d.getTime())) return "—"
|
|
||||||
return d.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
|
function scheduledIsoToDatetimeLocalValue(iso: string | undefined): string {
|
||||||
@ -286,78 +294,46 @@ function importantSignalIcon(isSpam: boolean, isImportant: boolean): string {
|
|||||||
return "mdi:label-variant-outline"
|
return "mdi:label-variant-outline"
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabBadgeTone = "green" | "blue" | "orange" | "purple"
|
type InboxTabBarItem = {
|
||||||
|
|
||||||
interface CategoryTab {
|
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
icon: ElementType
|
icon: string
|
||||||
badgeTone?: TabBadgeTone
|
badgeColor: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryTabs: CategoryTab[] = [
|
function buildInboxTabBarItems(labelRows: readonly LabelRowItem[]): InboxTabBarItem[] {
|
||||||
{ id: "primary", label: "Principale", icon: Inbox, badgeTone: "blue" },
|
return [
|
||||||
{
|
{
|
||||||
id: "promotions",
|
id: "primary",
|
||||||
label: "Promotions",
|
label: "Principale",
|
||||||
icon: Tag,
|
icon: "mdi:inbox",
|
||||||
badgeTone: "green",
|
badgeColor: "bg-[#0b57d0]",
|
||||||
},
|
},
|
||||||
{
|
...tabbedInboxLabelRows(labelRows).map((r) => ({
|
||||||
id: "social",
|
id: r.id,
|
||||||
label: "Réseaux sociaux",
|
label: r.label,
|
||||||
icon: Users,
|
icon: r.icon ?? "mdi:label-outline",
|
||||||
badgeTone: "blue",
|
badgeColor: r.color,
|
||||||
},
|
})),
|
||||||
{
|
]
|
||||||
id: "updates",
|
}
|
||||||
label: "Notifications",
|
|
||||||
icon: Info,
|
|
||||||
badgeTone: "orange",
|
|
||||||
},
|
|
||||||
{ id: "forums", label: "Forums", icon: MessageSquare, badgeTone: "purple" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const CATEGORY_TAB_ICON_STROKE = 2.5
|
function inboxTabBadgeCountClass(badgeColor: string) {
|
||||||
|
|
||||||
function categoryBadgeClass(tone: TabBadgeTone) {
|
|
||||||
return cn(
|
return cn(
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none text-white",
|
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-none",
|
||||||
tone === "green" && "bg-[#1e8e3e]",
|
badgeColor,
|
||||||
tone === "blue" && "bg-[#0b57d0]",
|
labelPillTextClassForTailwindBgUtility(badgeColor)
|
||||||
tone === "orange" && "bg-[#e8710a]",
|
|
||||||
tone === "purple" && "bg-[#9334e6]"
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryBadgeDotClass(tone: TabBadgeTone) {
|
function inboxTabBadgeDotClass(badgeColor: string) {
|
||||||
return cn(
|
return cn(
|
||||||
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white",
|
"absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-white",
|
||||||
tone === "green" && "bg-[#1e8e3e]",
|
badgeColor
|
||||||
tone === "blue" && "bg-[#0b57d0]",
|
|
||||||
tone === "orange" && "bg-[#e8710a]",
|
|
||||||
tone === "purple" && "bg-[#9334e6]"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Inbox({ className, strokeWidth = CATEGORY_TAB_ICON_STROKE, ...props }: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
||||||
<path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
||||||
</svg>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_TAB_ICON_CLASS = "h-4 w-4 shrink-0"
|
||||||
function ListAttachmentChip({ att }: { att: EmailAttachment }) {
|
function ListAttachmentChip({ att }: { att: EmailAttachment }) {
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]">
|
<span className="inline-flex max-w-[min(100%,280px)] min-w-0 shrink items-center gap-1.5 rounded-full border border-[#dadce0] bg-transparent px-2.5 py-1 text-[13px] leading-snug text-[#3c4043]">
|
||||||
@ -622,9 +598,14 @@ export function EmailList({
|
|||||||
requestRestoreSnoozedToInbox,
|
requestRestoreSnoozedToInbox,
|
||||||
} = useScheduledMail()
|
} = useScheduledMail()
|
||||||
|
|
||||||
|
const scheduledPersistHydrated = usePersistHydrated(useScheduledStore)
|
||||||
|
|
||||||
const allEmails = useMemo(
|
const allEmails = useMemo(
|
||||||
() => [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails],
|
() =>
|
||||||
[scheduledEmails, snoozedEmails, sentPlaceholderEmails]
|
scheduledPersistHydrated
|
||||||
|
? [...emails, ...scheduledEmails, ...snoozedEmails, ...sentPlaceholderEmails]
|
||||||
|
: emails,
|
||||||
|
[scheduledPersistHydrated, scheduledEmails, snoozedEmails, sentPlaceholderEmails]
|
||||||
)
|
)
|
||||||
|
|
||||||
const sidebarNav = useSidebarNav()
|
const sidebarNav = useSidebarNav()
|
||||||
@ -632,8 +613,14 @@ export function EmailList({
|
|||||||
() => ({
|
() => ({
|
||||||
folderIdToLabel: sidebarNav.folderIdToLabel,
|
folderIdToLabel: sidebarNav.folderIdToLabel,
|
||||||
folderTree: sidebarNav.folderTree,
|
folderTree: sidebarNav.folderTree,
|
||||||
|
labelRows: sidebarNav.labelRows,
|
||||||
}),
|
}),
|
||||||
[sidebarNav.folderIdToLabel, sidebarNav.folderTree]
|
[sidebarNav.folderIdToLabel, sidebarNav.folderTree, sidebarNav.labelRows]
|
||||||
|
)
|
||||||
|
|
||||||
|
const inboxTabBarItems = useMemo(
|
||||||
|
() => buildInboxTabBarItems(sidebarNav.labelRows),
|
||||||
|
[sidebarNav.labelRows]
|
||||||
)
|
)
|
||||||
|
|
||||||
const listRowLabelBgByTextLower = useMemo(
|
const listRowLabelBgByTextLower = useMemo(
|
||||||
@ -911,7 +898,35 @@ export function EmailList({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (selectedFolder === "inbox") {
|
if (selectedFolder === "inbox") {
|
||||||
rows = rows.filter((email) => email.category === inboxTab)
|
const tab = normalizeInboxTabSegment(inboxTab)
|
||||||
|
if (tab === "primary") {
|
||||||
|
rows = rows.filter((email) =>
|
||||||
|
emailMatchesInboxPrimaryTab(
|
||||||
|
email,
|
||||||
|
folderFilterCtx,
|
||||||
|
navMaps,
|
||||||
|
subtreeIdsCache
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rows = rows.filter(
|
||||||
|
(email) =>
|
||||||
|
emailMatchesFolder(
|
||||||
|
email,
|
||||||
|
"inbox",
|
||||||
|
folderFilterCtx,
|
||||||
|
navMaps,
|
||||||
|
subtreeIdsCache
|
||||||
|
) &&
|
||||||
|
emailMatchesFolder(
|
||||||
|
email,
|
||||||
|
tab,
|
||||||
|
folderFilterCtx,
|
||||||
|
navMaps,
|
||||||
|
subtreeIdsCache
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
}, [
|
}, [
|
||||||
@ -926,8 +941,13 @@ export function EmailList({
|
|||||||
])
|
])
|
||||||
|
|
||||||
const inboxCategoryTabLabel = useMemo(
|
const inboxCategoryTabLabel = useMemo(
|
||||||
() => categoryTabs.find((t) => t.id === inboxTab)?.label ?? inboxTab,
|
() =>
|
||||||
[inboxTab]
|
inboxTabDisplayLabel(
|
||||||
|
inboxTab,
|
||||||
|
sidebarNav.labelRows,
|
||||||
|
sidebarNav.folderIdToLabel
|
||||||
|
),
|
||||||
|
[inboxTab, sidebarNav.labelRows, sidebarNav.folderIdToLabel]
|
||||||
)
|
)
|
||||||
|
|
||||||
const mobileUnreadCount = useMemo(
|
const mobileUnreadCount = useMemo(
|
||||||
@ -935,13 +955,17 @@ export function EmailList({
|
|||||||
[filteredEmails, readOverrides]
|
[filteredEmails, readOverrides]
|
||||||
)
|
)
|
||||||
|
|
||||||
const mobileFolderLabel = useMemo(
|
const mobileFolderLabel = useMemo(() => {
|
||||||
() =>
|
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
|
||||||
selectedFolder === "inbox" && inboxTab !== "primary"
|
return selectedFolder === "inbox" && inboxTabNorm !== "primary"
|
||||||
? inboxCategoryTabLabel
|
? inboxCategoryTabLabel
|
||||||
: getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel),
|
: getMailNavFolderLabel(selectedFolder, sidebarNav.folderIdToLabel)
|
||||||
[selectedFolder, inboxTab, inboxCategoryTabLabel, sidebarNav.folderIdToLabel]
|
}, [
|
||||||
)
|
selectedFolder,
|
||||||
|
inboxTab,
|
||||||
|
inboxCategoryTabLabel,
|
||||||
|
sidebarNav.folderIdToLabel,
|
||||||
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileSelectionMode(false)
|
setMobileSelectionMode(false)
|
||||||
@ -1176,6 +1200,20 @@ export function EmailList({
|
|||||||
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
|
return [...s].sort((a, b) => a.localeCompare(b, "fr"))
|
||||||
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
|
}, [sidebarNav.folderTree, sidebarNav.labelRows, allEmails, labelEdits, notSpamEmailIds])
|
||||||
|
|
||||||
|
const resolveLabelVisual = useCallback(
|
||||||
|
(label: string) =>
|
||||||
|
resolveLabelPickerVisual(label, {
|
||||||
|
folderTree: sidebarNav.folderTree,
|
||||||
|
labelRows: sidebarNav.labelRows,
|
||||||
|
emailLabelToSidebarFolderId: sidebarNav.emailLabelToSidebarFolderId,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
sidebarNav.folderTree,
|
||||||
|
sidebarNav.labelRows,
|
||||||
|
sidebarNav.emailLabelToSidebarFolderId,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
const resolveLabelCasing = useCallback(
|
const resolveLabelCasing = useCallback(
|
||||||
(raw: string) => {
|
(raw: string) => {
|
||||||
const t = raw.trim()
|
const t = raw.trim()
|
||||||
@ -1407,10 +1445,21 @@ export function EmailList({
|
|||||||
)
|
)
|
||||||
const counts: Record<string, number> = {}
|
const counts: Record<string, number> = {}
|
||||||
const preview: Record<string, string> = {}
|
const preview: Record<string, string> = {}
|
||||||
for (const tab of categoryTabs) {
|
const tabCache = new Map<string, string[] | null>()
|
||||||
const rows = inboxPool.filter(
|
for (const tab of inboxTabBarItems) {
|
||||||
(e) => e.category === tab.id && !seen.has(e.id)
|
const rows = inboxPool.filter((e) => {
|
||||||
)
|
if (tab.id === "primary") {
|
||||||
|
return (
|
||||||
|
emailMatchesInboxPrimaryTab(e, folderFilterCtx, navMaps, tabCache) &&
|
||||||
|
!seen.has(e.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
emailMatchesFolder(e, "inbox", folderFilterCtx, navMaps, tabCache) &&
|
||||||
|
emailMatchesFolder(e, tab.id, folderFilterCtx, navMaps, tabCache) &&
|
||||||
|
!seen.has(e.id)
|
||||||
|
)
|
||||||
|
})
|
||||||
counts[tab.id] = rows.length
|
counts[tab.id] = rows.length
|
||||||
const chain: string[] = []
|
const chain: string[] = []
|
||||||
const used = new Set<string>()
|
const used = new Set<string>()
|
||||||
@ -1424,7 +1473,7 @@ export function EmailList({
|
|||||||
preview[tab.id] = chain.join(", ")
|
preview[tab.id] = chain.join(", ")
|
||||||
}
|
}
|
||||||
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
return { unseenInTabById: counts, tabUnseenSenderLineById: preview }
|
||||||
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds])
|
}, [folderFilterCtx, hiddenEmailIds, labelEdits, seenSerialized, allEmails, navMaps, notSpamEmailIds, inboxTabBarItems])
|
||||||
|
|
||||||
const effectiveStarred = (email: Email) =>
|
const effectiveStarred = (email: Email) =>
|
||||||
starredEmails.includes(email.id) || email.starred
|
starredEmails.includes(email.id) || email.starred
|
||||||
@ -1964,6 +2013,7 @@ export function EmailList({
|
|||||||
labelPickerQuery={labelPickerQuery}
|
labelPickerQuery={labelPickerQuery}
|
||||||
onLabelPickerQueryChange={setLabelPickerQuery}
|
onLabelPickerQueryChange={setLabelPickerQuery}
|
||||||
catalogLabels={catalogLabels}
|
catalogLabels={catalogLabels}
|
||||||
|
resolveLabelVisual={resolveLabelVisual}
|
||||||
moveTargets={moveTargets}
|
moveTargets={moveTargets}
|
||||||
onMoveTo={bulkMoveTo}
|
onMoveTo={bulkMoveTo}
|
||||||
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
|
getLabelPresence={(lab) => getCatalogLabelPresence(bulkTargetIds, lab)}
|
||||||
@ -2423,6 +2473,7 @@ export function EmailList({
|
|||||||
query={labelPickerQuery}
|
query={labelPickerQuery}
|
||||||
onQueryChange={setLabelPickerQuery}
|
onQueryChange={setLabelPickerQuery}
|
||||||
catalogLabels={catalogLabels}
|
catalogLabels={catalogLabels}
|
||||||
|
resolveLabelVisual={resolveLabelVisual}
|
||||||
Item={DropdownMenuItem}
|
Item={DropdownMenuItem}
|
||||||
getLabelPresence={(lab) =>
|
getLabelPresence={(lab) =>
|
||||||
getCatalogLabelPresence(bulkTargetIds, lab)
|
getCatalogLabelPresence(bulkTargetIds, lab)
|
||||||
@ -2597,11 +2648,12 @@ export function EmailList({
|
|||||||
<div
|
<div
|
||||||
className="grid w-full max-w-[1260px] min-w-0"
|
className="grid w-full max-w-[1260px] min-w-0"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(${categoryTabs.length}, minmax(0, 1fr))`,
|
gridTemplateColumns: `repeat(${inboxTabBarItems.length}, minmax(0, 1fr))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{categoryTabs.map((tab) => {
|
{inboxTabBarItems.map((tab) => {
|
||||||
const isActive = inboxTab === tab.id
|
const inboxTabNorm = normalizeInboxTabSegment(inboxTab)
|
||||||
|
const isActive = inboxTabNorm === tab.id
|
||||||
const isPrimaryTab = tab.id === "primary"
|
const isPrimaryTab = tab.id === "primary"
|
||||||
const unseen = unseenInTabById[tab.id] ?? 0
|
const unseen = unseenInTabById[tab.id] ?? 0
|
||||||
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
|
const senderLine = tabUnseenSenderLineById[tab.id] ?? ""
|
||||||
@ -2625,16 +2677,17 @@ export function EmailList({
|
|||||||
>
|
>
|
||||||
<div className="flex h-10 w-full items-center justify-center sm:hidden">
|
<div className="flex h-10 w-full items-center justify-center sm:hidden">
|
||||||
<div className="relative inline-flex shrink-0">
|
<div className="relative inline-flex shrink-0">
|
||||||
<tab.icon
|
<Icon
|
||||||
strokeWidth={CATEGORY_TAB_ICON_STROKE}
|
icon={tab.icon}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 shrink-0",
|
CATEGORY_TAB_ICON_CLASS,
|
||||||
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
||||||
)}
|
)}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{showMeta && unseen > 0 && tab.badgeTone ? (
|
{showMeta && unseen > 0 ? (
|
||||||
<span
|
<span
|
||||||
className={categoryBadgeDotClass(tab.badgeTone)}
|
className={inboxTabBadgeDotClass(tab.badgeColor)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@ -2642,12 +2695,14 @@ export function EmailList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
|
<div className="hidden min-w-0 flex-1 items-center gap-2 mx-2 sm:mx-3 sm:flex">
|
||||||
<tab.icon
|
<Icon
|
||||||
strokeWidth={CATEGORY_TAB_ICON_STROKE}
|
icon={tab.icon}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 shrink-0 self-center",
|
CATEGORY_TAB_ICON_CLASS,
|
||||||
|
"self-center",
|
||||||
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
isActive ? "text-[#0b57d0]" : "text-[#5f6368]"
|
||||||
)}
|
)}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 w-0 flex-1 flex-col gap-px">
|
<div className="flex min-w-0 w-0 flex-1 flex-col gap-px">
|
||||||
<div
|
<div
|
||||||
@ -2664,8 +2719,8 @@ export function EmailList({
|
|||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</span>
|
</span>
|
||||||
{showMeta && unseen > 0 && tab.badgeTone ? (
|
{showMeta && unseen > 0 ? (
|
||||||
<span className={categoryBadgeClass(tab.badgeTone)}>
|
<span className={inboxTabBadgeCountClass(tab.badgeColor)}>
|
||||||
{unseen}
|
{unseen}
|
||||||
<span className="hidden md:inline">
|
<span className="hidden md:inline">
|
||||||
{" "}
|
{" "}
|
||||||
@ -2726,11 +2781,17 @@ export function EmailList({
|
|||||||
labelBgByText={listRowLabelBgByTextLower}
|
labelBgByText={listRowLabelBgByTextLower}
|
||||||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||||||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||||||
|
folderTree={sidebarNav.folderTree}
|
||||||
|
labelRows={sidebarNav.labelRows}
|
||||||
|
currentFolderId={selectedFolder}
|
||||||
showLabelChip={(lab) => {
|
showLabelChip={(lab) => {
|
||||||
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
|
if (LABEL_PICKER_EXCLUDE.has(lab)) return true
|
||||||
const fid = sidebarNav.emailLabelToSidebarFolderId[lab]
|
return mailLabelShouldShowInListStrip(
|
||||||
if (!fid) return true
|
lab,
|
||||||
return sidebarNav.getNavItemPrefs(fid).messages !== "hide"
|
sidebarNav.emailLabelToSidebarFolderId,
|
||||||
|
sidebarNav.getNavItemPrefs,
|
||||||
|
sidebarNav.labelRows
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -3023,9 +3084,11 @@ export function EmailList({
|
|||||||
!isRead ? "text-gray-900" : "text-gray-700"
|
!isRead ? "text-gray-900" : "text-gray-700"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isScheduled
|
{isScheduled ? (
|
||||||
? formatScheduledDateTimeDisplay(email.scheduledSendAt)
|
formatScheduledDateTimeDisplay(email.scheduledSendAt)
|
||||||
: email.date}
|
) : (
|
||||||
|
<MailDateText iso={email.date} variant="list" />
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -3043,6 +3106,7 @@ export function EmailList({
|
|||||||
labelBgByText={listRowLabelBgByTextLower}
|
labelBgByText={listRowLabelBgByTextLower}
|
||||||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||||||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||||||
|
labelRows={sidebarNav.labelRows}
|
||||||
onLabelNavigate={handleNavigateToLabel}
|
onLabelNavigate={handleNavigateToLabel}
|
||||||
currentFolderId={selectedFolder}
|
currentFolderId={selectedFolder}
|
||||||
folderTree={sidebarNav.folderTree}
|
folderTree={sidebarNav.folderTree}
|
||||||
@ -3216,6 +3280,7 @@ export function EmailList({
|
|||||||
labelBgByText={listRowLabelBgByTextLower}
|
labelBgByText={listRowLabelBgByTextLower}
|
||||||
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
emailLabelToSidebarFolderId={sidebarNav.emailLabelToSidebarFolderId}
|
||||||
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
getNavItemPrefs={sidebarNav.getNavItemPrefs}
|
||||||
|
labelRows={sidebarNav.labelRows}
|
||||||
onLabelNavigate={handleNavigateToLabel}
|
onLabelNavigate={handleNavigateToLabel}
|
||||||
currentFolderId={selectedFolder}
|
currentFolderId={selectedFolder}
|
||||||
folderTree={sidebarNav.folderTree}
|
folderTree={sidebarNav.folderTree}
|
||||||
@ -3559,7 +3624,7 @@ export function EmailList({
|
|||||||
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
|
!isRead ? "font-semibold text-gray-900" : "text-gray-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{email.date}
|
<MailDateText iso={email.date} variant="list" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -4026,6 +4091,7 @@ export function EmailList({
|
|||||||
query={labelPickerQuery}
|
query={labelPickerQuery}
|
||||||
onQueryChange={setLabelPickerQuery}
|
onQueryChange={setLabelPickerQuery}
|
||||||
catalogLabels={catalogLabels}
|
catalogLabels={catalogLabels}
|
||||||
|
resolveLabelVisual={resolveLabelVisual}
|
||||||
Item={ContextMenuItem}
|
Item={ContextMenuItem}
|
||||||
getLabelPresence={(lab) =>
|
getLabelPresence={(lab) =>
|
||||||
getCatalogLabelPresence(contextTargetIds, lab)
|
getCatalogLabelPresence(contextTargetIds, lab)
|
||||||
@ -4078,6 +4144,7 @@ export function EmailList({
|
|||||||
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
|
currentKey={mailNavVisitKey(selectedFolder, inboxTab)}
|
||||||
folderTree={sidebarNav.folderTree}
|
folderTree={sidebarNav.folderTree}
|
||||||
folderIdToLabel={sidebarNav.folderIdToLabel}
|
folderIdToLabel={sidebarNav.folderIdToLabel}
|
||||||
|
labelRows={sidebarNav.labelRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -49,12 +49,14 @@ import {
|
|||||||
cleanSenderName,
|
cleanSenderName,
|
||||||
senderInitial,
|
senderInitial,
|
||||||
} from "@/lib/sender-display"
|
} from "@/lib/sender-display"
|
||||||
|
import { MailDateText } from "@/components/gmail/mail-date-text"
|
||||||
import type {
|
import type {
|
||||||
Email,
|
Email,
|
||||||
ConversationMessage,
|
ConversationMessage,
|
||||||
EmailAttachment,
|
EmailAttachment,
|
||||||
EmailAttachmentKind,
|
EmailAttachmentKind,
|
||||||
} from "@/lib/email-data"
|
} from "@/lib/email-data"
|
||||||
|
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
import {
|
import {
|
||||||
attachmentPreviewTooltip,
|
attachmentPreviewTooltip,
|
||||||
resolveAttachmentKind,
|
resolveAttachmentKind,
|
||||||
@ -88,6 +90,10 @@ interface EmailViewProps {
|
|||||||
labelBgByText?: Map<string, string>
|
labelBgByText?: Map<string, string>
|
||||||
emailLabelToSidebarFolderId?: Record<string, string>
|
emailLabelToSidebarFolderId?: Record<string, string>
|
||||||
getNavItemPrefs?: (id: string) => { messages: string }
|
getNavItemPrefs?: (id: string) => { messages: string }
|
||||||
|
folderTree?: FolderTreeNode[]
|
||||||
|
labelRows?: readonly LabelRowItem[]
|
||||||
|
/** Id dossier / libellé courant — masque la pastille du dossier actif (comme en liste). */
|
||||||
|
currentFolderId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL_DISPLAY_NAMES: Record<string, string> = {
|
const LABEL_DISPLAY_NAMES: Record<string, string> = {
|
||||||
@ -437,7 +443,11 @@ function CollapsedMessage({
|
|||||||
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
|
<span className="truncate text-sm font-semibold text-[#202124]">{name}</span>
|
||||||
</ContactHoverCard>
|
</ContactHoverCard>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<span className="text-xs text-[#5f6368]">{message.date}</span>
|
<MailDateText
|
||||||
|
iso={message.date}
|
||||||
|
variant="preview"
|
||||||
|
className="text-xs text-[#5f6368]"
|
||||||
|
/>
|
||||||
<Star
|
<Star
|
||||||
strokeWidth={1.25}
|
strokeWidth={1.25}
|
||||||
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
|
className="ml-1 size-4 fill-transparent stroke-[#c2c2c2]"
|
||||||
@ -455,7 +465,7 @@ function CollapsedMessage({
|
|||||||
function ExpandedMessage({
|
function ExpandedMessage({
|
||||||
sender,
|
sender,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
date,
|
dateIso,
|
||||||
body,
|
body,
|
||||||
isSpam,
|
isSpam,
|
||||||
isLast,
|
isLast,
|
||||||
@ -467,7 +477,7 @@ function ExpandedMessage({
|
|||||||
}: {
|
}: {
|
||||||
sender: string
|
sender: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
date: string
|
dateIso: string
|
||||||
body: string
|
body: string
|
||||||
isSpam: boolean
|
isSpam: boolean
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
@ -534,7 +544,14 @@ function ExpandedMessage({
|
|||||||
<div className="mt-1 space-y-0.5 text-xs text-[#5f6368]">
|
<div className="mt-1 space-y-0.5 text-xs text-[#5f6368]">
|
||||||
<p>de : <span className="text-[#3c4043]">{name} <{senderEmail}></span></p>
|
<p>de : <span className="text-[#3c4043]">{name} <{senderEmail}></span></p>
|
||||||
<p>à : <span className="text-[#3c4043]">moi</span></p>
|
<p>à : <span className="text-[#3c4043]">moi</span></p>
|
||||||
<p>date : <span className="text-[#3c4043]">{date}</span></p>
|
<p>
|
||||||
|
date :{" "}
|
||||||
|
<MailDateText
|
||||||
|
iso={dateIso}
|
||||||
|
variant="detail"
|
||||||
|
className="text-[#3c4043]"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
{isSpam && (
|
{isSpam && (
|
||||||
<p className="text-[#d93025]">sécurité : ce message est marqué comme spam — les images et appels externes sont bloqués</p>
|
<p className="text-[#d93025]">sécurité : ce message est marqué comme spam — les images et appels externes sont bloqués</p>
|
||||||
)}
|
)}
|
||||||
@ -543,7 +560,11 @@ function ExpandedMessage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 self-start items-center gap-1 pt-0.5">
|
<div className="flex shrink-0 self-start items-center gap-1 pt-0.5">
|
||||||
<span className="text-xs text-[#5f6368]">{date}</span>
|
<MailDateText
|
||||||
|
iso={dateIso}
|
||||||
|
variant="preview"
|
||||||
|
className="text-xs text-[#5f6368]"
|
||||||
|
/>
|
||||||
|
|
||||||
{onToggleStar && (
|
{onToggleStar && (
|
||||||
<button
|
<button
|
||||||
@ -739,6 +760,9 @@ export function EmailView({
|
|||||||
labelBgByText,
|
labelBgByText,
|
||||||
emailLabelToSidebarFolderId = {},
|
emailLabelToSidebarFolderId = {},
|
||||||
getNavItemPrefs = () => ({ messages: "show" }),
|
getNavItemPrefs = () => ({ messages: "show" }),
|
||||||
|
folderTree,
|
||||||
|
labelRows,
|
||||||
|
currentFolderId,
|
||||||
}: EmailViewProps) {
|
}: EmailViewProps) {
|
||||||
const conversation = email.conversation ?? []
|
const conversation = email.conversation ?? []
|
||||||
const hasConversation = conversation.length > 0
|
const hasConversation = conversation.length > 0
|
||||||
@ -842,6 +866,9 @@ export function EmailView({
|
|||||||
labelBgByText={labelBgByText}
|
labelBgByText={labelBgByText}
|
||||||
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
emailLabelToSidebarFolderId={emailLabelToSidebarFolderId}
|
||||||
getNavItemPrefs={getNavItemPrefs}
|
getNavItemPrefs={getNavItemPrefs}
|
||||||
|
labelRows={labelRows}
|
||||||
|
folderTree={folderTree}
|
||||||
|
currentFolderId={currentFolderId}
|
||||||
onLabelNavigate={onNavigateToLabel}
|
onLabelNavigate={onNavigateToLabel}
|
||||||
showLabel={showLabelChip}
|
showLabel={showLabelChip}
|
||||||
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
|
resolveDisplayName={(lab) => LABEL_DISPLAY_NAMES[lab] ?? lab}
|
||||||
@ -904,7 +931,7 @@ export function EmailView({
|
|||||||
<ExpandedMessage
|
<ExpandedMessage
|
||||||
sender={msg.sender}
|
sender={msg.sender}
|
||||||
senderEmail={msg.senderEmail}
|
senderEmail={msg.senderEmail}
|
||||||
date={msg.date}
|
dateIso={msg.date}
|
||||||
body={msg.body}
|
body={msg.body}
|
||||||
isSpam={false}
|
isSpam={false}
|
||||||
isLast={false}
|
isLast={false}
|
||||||
@ -931,7 +958,7 @@ export function EmailView({
|
|||||||
<ExpandedMessage
|
<ExpandedMessage
|
||||||
sender={mainSenderName}
|
sender={mainSenderName}
|
||||||
senderEmail={mainSenderAddr}
|
senderEmail={mainSenderAddr}
|
||||||
date={email.date}
|
dateIso={email.date}
|
||||||
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`}
|
body={email.body || `<p style="color:#5f6368;">${email.preview}</p>`}
|
||||||
isSpam={email.spam === true}
|
isSpam={email.spam === true}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
|
|||||||
33
components/gmail/mail-date-text.tsx
Normal file
33
components/gmail/mail-date-text.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Fragment, useMemo } from "react"
|
import { Fragment, useMemo } from "react"
|
||||||
|
import { Icon } from "@iconify/react"
|
||||||
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
||||||
import { breadcrumbSegmentsForVisitKey } from "@/lib/mail-folder-display"
|
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
|
import { breadcrumbItemsForVisitKey } from "@/lib/mail-folder-display"
|
||||||
import { resolveMailNavIcon } from "@/lib/mail-nav-icons"
|
import { resolveMailNavIcon } from "@/lib/mail-nav-icons"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -10,26 +12,29 @@ type MailFolderStackIndicatorProps = {
|
|||||||
currentKey: string
|
currentKey: string
|
||||||
folderTree: FolderTreeNode[]
|
folderTree: FolderTreeNode[]
|
||||||
folderIdToLabel: Record<string, string>
|
folderIdToLabel: Record<string, string>
|
||||||
|
labelRows?: readonly LabelRowItem[]
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function MailNavIconGlyph({
|
function MailNavIconGlyph({
|
||||||
visitKey,
|
visitKey,
|
||||||
folderTree,
|
folderTree,
|
||||||
|
labelRows,
|
||||||
}: {
|
}: {
|
||||||
visitKey: string
|
visitKey: string
|
||||||
folderTree: FolderTreeNode[]
|
folderTree: FolderTreeNode[]
|
||||||
|
labelRows?: readonly LabelRowItem[]
|
||||||
}) {
|
}) {
|
||||||
const resolved = useMemo(
|
const resolved = useMemo(
|
||||||
() => resolveMailNavIcon(visitKey, folderTree),
|
() => resolveMailNavIcon(visitKey, folderTree, labelRows),
|
||||||
[visitKey, folderTree]
|
[visitKey, folderTree, labelRows]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (resolved.kind === "folder-dot") {
|
if (resolved.kind === "folder-dot") {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block size-4 shrink-0 rounded-sm",
|
"inline-block size-4 shrink-0 rounded-full",
|
||||||
resolved.colorClass
|
resolved.colorClass
|
||||||
)}
|
)}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -37,9 +42,19 @@ function MailNavIconGlyph({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Icon } = resolved
|
if (resolved.kind === "iconify") {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
icon={resolved.icon}
|
||||||
|
className="size-4 shrink-0 text-[#5f6368]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Icon: LucideIcon } = resolved
|
||||||
return (
|
return (
|
||||||
<Icon
|
<LucideIcon
|
||||||
className="size-4 shrink-0 text-[#5f6368]"
|
className="size-4 shrink-0 text-[#5f6368]"
|
||||||
strokeWidth={1.5}
|
strokeWidth={1.5}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@ -51,14 +66,15 @@ export function MailFolderStackIndicator({
|
|||||||
currentKey,
|
currentKey,
|
||||||
folderTree,
|
folderTree,
|
||||||
folderIdToLabel,
|
folderIdToLabel,
|
||||||
|
labelRows,
|
||||||
className,
|
className,
|
||||||
}: MailFolderStackIndicatorProps) {
|
}: MailFolderStackIndicatorProps) {
|
||||||
const segments = useMemo(
|
const items = useMemo(
|
||||||
() => breadcrumbSegmentsForVisitKey(currentKey, folderTree, folderIdToLabel),
|
() => breadcrumbItemsForVisitKey(currentKey, folderTree, folderIdToLabel),
|
||||||
[currentKey, folderTree, folderIdToLabel]
|
[currentKey, folderTree, folderIdToLabel]
|
||||||
)
|
)
|
||||||
|
|
||||||
const ariaLabel = segments.join(" · ")
|
const ariaLabel = items.map((i) => i.label).join(" · ")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -66,17 +82,16 @@ export function MailFolderStackIndicator({
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={`Boîte actuelle : ${ariaLabel}`}
|
aria-label={`Boîte actuelle : ${ariaLabel}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[min(360px,calc(100vw-1rem))] items-center gap-2.5",
|
"flex max-w-[min(360px,calc(100vw-1rem))] items-center",
|
||||||
"border-t border-r border-[#dadce0]/90",
|
"border-t border-r border-[#dadce0]/90",
|
||||||
"bg-white/78 px-3.5 py-2.5 text-sm font-medium leading-snug text-[#3c4043]",
|
"bg-white/78 px-3.5 py-2.5 text-sm font-medium leading-snug text-[#3c4043]",
|
||||||
"rounded-tr-2xl shadow-sm backdrop-blur-md",
|
"rounded-tr-2xl shadow-sm backdrop-blur-md",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MailNavIconGlyph visitKey={currentKey} folderTree={folderTree} />
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
{segments.map((seg, i) => (
|
{items.map((item, i) => (
|
||||||
<Fragment key={`${seg}-${i}`}>
|
<Fragment key={`${item.visitKey}-${i}`}>
|
||||||
{i > 0 ? (
|
{i > 0 ? (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 text-xs leading-none text-[#9aa0a6]"
|
className="shrink-0 text-xs leading-none text-[#9aa0a6]"
|
||||||
@ -85,7 +100,14 @@ export function MailFolderStackIndicator({
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">{seg}</span>
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<MailNavIconGlyph
|
||||||
|
visitKey={item.visitKey}
|
||||||
|
folderTree={folderTree}
|
||||||
|
labelRows={labelRows}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</span>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -44,10 +44,20 @@ export function buildLabelTextToNavColorClass(
|
|||||||
export function mailLabelShouldShowInListStrip(
|
export function mailLabelShouldShowInListStrip(
|
||||||
lab: string,
|
lab: string,
|
||||||
emailLabelToSidebarFolderId: Record<string, string>,
|
emailLabelToSidebarFolderId: Record<string, string>,
|
||||||
getNavItemPrefs: (id: string) => { messages: string }
|
getNavItemPrefs: (id: string) => { messages: string },
|
||||||
|
labelRows?: readonly LabelRowItem[]
|
||||||
): boolean {
|
): boolean {
|
||||||
if (MAIL_LABEL_STRIP_EXCLUDE.has(lab.toLowerCase())) return false
|
if (MAIL_LABEL_STRIP_EXCLUDE.has(lab.toLowerCase())) return false
|
||||||
const fid = emailLabelToSidebarFolderId[lab]
|
const fid = emailLabelToSidebarFolderId[lab]
|
||||||
|
let row: LabelRowItem | undefined
|
||||||
|
if (fid) row = labelRows?.find((r) => r.id === fid)
|
||||||
|
if (!row && labelRows?.length) {
|
||||||
|
row = labelRows.find((r) => r.label.toLowerCase() === lab.toLowerCase())
|
||||||
|
}
|
||||||
|
if (row) {
|
||||||
|
if (row.enabled === false) return false
|
||||||
|
if (row.showInMessageList === false) return false
|
||||||
|
}
|
||||||
if (fid) return getNavItemPrefs(fid).messages !== "hide"
|
if (fid) return getNavItemPrefs(fid).messages !== "hide"
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -153,7 +163,9 @@ type MailLabelPillStripProps = {
|
|||||||
showRemoveOnPills?: boolean
|
showRemoveOnPills?: boolean
|
||||||
/** Dossier actuel (id) — son label est masqué en list, mais pas ses sous-dossiers. */
|
/** Dossier actuel (id) — son label est masqué en list, mais pas ses sous-dossiers. */
|
||||||
currentFolderId?: string
|
currentFolderId?: string
|
||||||
/** Arbre dossiers — nécessaire pour déterminer le label du dossier courant et l'ordre. */
|
/** Libellés navigation (couleurs / enabled / showInMessageList). */
|
||||||
|
labelRows?: readonly LabelRowItem[]
|
||||||
|
/** Arbre dossiers (tri pastilles liste, masquage libellé du dossier courant). */
|
||||||
folderTree?: FolderTreeNode[]
|
folderTree?: FolderTreeNode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -170,6 +182,7 @@ export function MailLabelPillStrip({
|
|||||||
showRemoveOnPills,
|
showRemoveOnPills,
|
||||||
currentFolderId,
|
currentFolderId,
|
||||||
folderTree,
|
folderTree,
|
||||||
|
labelRows,
|
||||||
}: MailLabelPillStripProps) {
|
}: MailLabelPillStripProps) {
|
||||||
const currentFolderLabel = useMemo(() => {
|
const currentFolderLabel = useMemo(() => {
|
||||||
if (!currentFolderId || !folderTree) return null
|
if (!currentFolderId || !folderTree) return null
|
||||||
@ -184,7 +197,14 @@ export function MailLabelPillStrip({
|
|||||||
const shown = useMemo(() => {
|
const shown = useMemo(() => {
|
||||||
const filtered = (labels ?? []).filter((lab) => {
|
const filtered = (labels ?? []).filter((lab) => {
|
||||||
if (variant === "list") {
|
if (variant === "list") {
|
||||||
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs)) {
|
if (
|
||||||
|
!mailLabelShouldShowInListStrip(
|
||||||
|
lab,
|
||||||
|
emailLabelToSidebarFolderId,
|
||||||
|
getNavItemPrefs,
|
||||||
|
labelRows
|
||||||
|
)
|
||||||
|
) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (currentFolderLabel && lab.toLowerCase() === currentFolderLabel.toLowerCase()) {
|
if (currentFolderLabel && lab.toLowerCase() === currentFolderLabel.toLowerCase()) {
|
||||||
@ -192,6 +212,9 @@ export function MailLabelPillStrip({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (!mailLabelShouldShowInListStrip(lab, emailLabelToSidebarFolderId, getNavItemPrefs, labelRows)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (lab === "spam" && spamChip) return false
|
if (lab === "spam" && spamChip) return false
|
||||||
if (showLabel && !showLabel(lab)) return false
|
if (showLabel && !showLabel(lab)) return false
|
||||||
return true
|
return true
|
||||||
@ -208,7 +231,7 @@ export function MailLabelPillStrip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
}, [labels, variant, emailLabelToSidebarFolderId, getNavItemPrefs, currentFolderLabel, folderTree, folderLabelsLower, spamChip, showLabel])
|
}, [labels, variant, emailLabelToSidebarFolderId, getNavItemPrefs, labelRows, currentFolderLabel, folderTree, folderLabelsLower, spamChip, showLabel])
|
||||||
|
|
||||||
const hasSpam = variant === "header" && spamChip
|
const hasSpam = variant === "header" && spamChip
|
||||||
if (shown.length === 0 && !hasSpam) return null
|
if (shown.length === 0 && !hasSpam) return null
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
|
import { EmailLabelPickerBlock } from "@/components/gmail/email-label-picker-block"
|
||||||
import type { MoveTarget } from "@/components/gmail/move-to-menu-items"
|
import type { MoveTarget } from "@/components/gmail/move-to-menu-items"
|
||||||
|
import type { LabelPickerVisual } from "@/lib/label-picker-visual"
|
||||||
|
|
||||||
function SheetActionRow({
|
function SheetActionRow({
|
||||||
children,
|
children,
|
||||||
@ -72,6 +73,7 @@ export type MobileXsBulkSheetsProps = {
|
|||||||
labelPickerQuery: string
|
labelPickerQuery: string
|
||||||
onLabelPickerQueryChange: (query: string) => void
|
onLabelPickerQueryChange: (query: string) => void
|
||||||
catalogLabels: string[]
|
catalogLabels: string[]
|
||||||
|
resolveLabelVisual: (label: string) => LabelPickerVisual
|
||||||
moveTargets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
|
moveTargets: { recents: MoveTarget[]; system: MoveTarget[]; folders: MoveTarget[] }
|
||||||
onMoveTo: (targetId: string) => void
|
onMoveTo: (targetId: string) => void
|
||||||
getLabelPresence: (label: string) => CatalogLabelPresence
|
getLabelPresence: (label: string) => CatalogLabelPresence
|
||||||
@ -87,6 +89,7 @@ export function MobileXsBulkSheets({
|
|||||||
labelPickerQuery,
|
labelPickerQuery,
|
||||||
onLabelPickerQueryChange,
|
onLabelPickerQueryChange,
|
||||||
catalogLabels,
|
catalogLabels,
|
||||||
|
resolveLabelVisual,
|
||||||
moveTargets,
|
moveTargets,
|
||||||
onMoveTo,
|
onMoveTo,
|
||||||
getLabelPresence,
|
getLabelPresence,
|
||||||
@ -176,6 +179,7 @@ export function MobileXsBulkSheets({
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="bottom"
|
side="bottom"
|
||||||
className="max-h-[min(85vh,520px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2 [&>button]:top-3.5 [&>button]:right-3.5"
|
className="max-h-[min(85vh,520px)] gap-0 overflow-hidden rounded-t-2xl border-[#dadce0] px-0 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2 [&>button]:top-3.5 [&>button]:right-3.5"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<SheetHeader className="shrink-0 border-b border-[#eceff1] px-4 pb-3 text-left">
|
<SheetHeader className="shrink-0 border-b border-[#eceff1] px-4 pb-3 text-left">
|
||||||
<SheetTitle className="flex items-center gap-2 text-base font-medium text-[#3c4043]">
|
<SheetTitle className="flex items-center gap-2 text-base font-medium text-[#3c4043]">
|
||||||
@ -188,6 +192,7 @@ export function MobileXsBulkSheets({
|
|||||||
query={labelPickerQuery}
|
query={labelPickerQuery}
|
||||||
onQueryChange={onLabelPickerQueryChange}
|
onQueryChange={onLabelPickerQueryChange}
|
||||||
catalogLabels={catalogLabels}
|
catalogLabels={catalogLabels}
|
||||||
|
resolveLabelVisual={resolveLabelVisual}
|
||||||
Item={SheetLabelItem}
|
Item={SheetLabelItem}
|
||||||
getLabelPresence={getLabelPresence}
|
getLabelPresence={getLabelPresence}
|
||||||
onToggleCatalogLabel={onToggleCatalogLabel}
|
onToggleCatalogLabel={onToggleCatalogLabel}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import {
|
|||||||
} from "@/lib/drag-pointer-store"
|
} from "@/lib/drag-pointer-store"
|
||||||
import { useIsXs } from "@/hooks/use-xs"
|
import { useIsXs } from "@/hooks/use-xs"
|
||||||
|
|
||||||
|
const DRAG_POINTER_SERVER_SNAPSHOT = { x: 0, y: 0 } as const
|
||||||
|
|
||||||
export function MoveDragIndicator() {
|
export function MoveDragIndicator() {
|
||||||
const isXs = useIsXs()
|
const isXs = useIsXs()
|
||||||
const { state } = useEmailDrag()
|
const { state } = useEmailDrag()
|
||||||
@ -19,7 +21,7 @@ export function MoveDragIndicator() {
|
|||||||
const livePointer = useSyncExternalStore(
|
const livePointer = useSyncExternalStore(
|
||||||
subscribeDragPointer,
|
subscribeDragPointer,
|
||||||
getDragPointerSnapshot,
|
getDragPointerSnapshot,
|
||||||
() => ({ x: 0, y: 0 })
|
() => DRAG_POINTER_SERVER_SNAPSHOT
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -10,30 +10,39 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
GripVertical,
|
||||||
Pencil,
|
Pencil,
|
||||||
ShoppingCart,
|
|
||||||
MapPin,
|
|
||||||
Share2,
|
|
||||||
Bell,
|
|
||||||
MessageSquare,
|
|
||||||
BadgePercent,
|
|
||||||
Plus,
|
Plus,
|
||||||
Bot,
|
Bot,
|
||||||
Folder,
|
Folder,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Sparkles,
|
|
||||||
Newspaper,
|
Newspaper,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Rss,
|
Rss,
|
||||||
CreditCard,
|
|
||||||
Mail,
|
Mail,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Check,
|
Check,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn, formatCount } from "@/lib/utils"
|
import { cn, formatCount } from "@/lib/utils"
|
||||||
import { readXsMatches } from "@/hooks/use-xs"
|
import { readXsMatches } from "@/hooks/use-xs"
|
||||||
import { useState, useRef, useEffect, useMemo, type ReactNode, type CSSProperties } from "react"
|
import {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
type ReactNode,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react"
|
||||||
import { useEmailDropTarget } from "@/lib/drag-context"
|
import { useEmailDropTarget } from "@/lib/drag-context"
|
||||||
|
import {
|
||||||
|
readSidebarNavDragData,
|
||||||
|
resolveNavDropPlacement,
|
||||||
|
setSidebarNavDragData,
|
||||||
|
type SidebarNavDragPayload,
|
||||||
|
type SidebarNavDropPlacement,
|
||||||
|
} from "@/lib/sidebar-nav-dnd"
|
||||||
import { useComposeActions } from "@/lib/compose-context"
|
import { useComposeActions } from "@/lib/compose-context"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -46,7 +55,7 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { type FolderTreeNode } from "@/lib/sidebar-nav-data"
|
import { type FolderTreeNode, isSystemNavLabelId, SYSTEM_NAV_LABEL_DEFAULTS } from "@/lib/sidebar-nav-data"
|
||||||
import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context"
|
import { folderMoveParentOptions, useSidebarNav } from "@/lib/sidebar-nav-context"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -121,24 +130,22 @@ const mainItems = [
|
|||||||
{ id: "drafts", label: "Brouillons", icon: FileText },
|
{ id: "drafts", label: "Brouillons", icon: FileText },
|
||||||
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
|
{ id: "scheduled", label: "Planifié", icon: ClockArrowUp },
|
||||||
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
|
{ id: "spam", label: "Indésirables", icon: ShieldAlert },
|
||||||
|
{ id: "trash", label: "Corbeille", icon: Trash2 },
|
||||||
]
|
]
|
||||||
|
|
||||||
const categoryItemsSource = [
|
/** Catégories système affichées sous « Plus » uniquement. */
|
||||||
{ id: "purchases", label: "Achats", icon: ShoppingCart },
|
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["mises-a-jour", "finance"])
|
||||||
{ id: "travel", label: "Déplacements", icon: MapPin },
|
|
||||||
{ id: "social", label: "Réseaux sociaux", icon: Share2 },
|
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
|
||||||
{ id: "updates", label: "Mises à jour", icon: Sparkles },
|
|
||||||
{ id: "forums", label: "Forums", icon: MessageSquare },
|
|
||||||
{ id: "finance", label: "Finance", icon: CreditCard },
|
|
||||||
{ id: "promotions", label: "Promotions", icon: BadgePercent },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Ids catégories : menu ⋮ (Afficher / Masquer) du survol. */
|
const SYSTEM_NAV_LABEL_ORDER = SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id)
|
||||||
const CATEGORY_MENU_IDS = new Set(categoryItemsSource.map((c) => c.id))
|
|
||||||
|
|
||||||
/** Catégories affichées sous « Plus » uniquement (Mises à jour, Finance, …). */
|
function sortSystemLabelRows(rows: { id: string }[]): { id: string; label: string; icon?: string }[] {
|
||||||
const CATEGORY_IDS_IN_PLUS_ONLY = new Set<string>(["updates", "finance"])
|
const copy = [...rows]
|
||||||
|
copy.sort(
|
||||||
|
(a, b) =>
|
||||||
|
SYSTEM_NAV_LABEL_ORDER.indexOf(a.id) - SYSTEM_NAV_LABEL_ORDER.indexOf(b.id)
|
||||||
|
)
|
||||||
|
return copy as { id: string; label: string; icon?: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
/** Liens secondaires sous la liste (jusqu’à Gérer les abonnements). */
|
/** Liens secondaires sous la liste (jusqu’à Gérer les abonnements). */
|
||||||
const sidebarSecondaryActions = [
|
const sidebarSecondaryActions = [
|
||||||
@ -149,7 +156,7 @@ const sidebarSecondaryActions = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
const hasPlusOnlyExtras =
|
const hasPlusOnlyExtras =
|
||||||
categoryItemsSource.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
|
SYSTEM_NAV_LABEL_DEFAULTS.some((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)) ||
|
||||||
sidebarSecondaryActions.length > 0
|
sidebarSecondaryActions.length > 0
|
||||||
|
|
||||||
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */
|
/** Pastilles sous-menu « Couleur du libellé » (démo UI). */
|
||||||
@ -245,13 +252,72 @@ function folderParentSelectOptions(tree: FolderTreeNode[]): {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryNavSourceItem = (typeof categoryItemsSource)[number]
|
type CategoryNavSourceItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Pill à droite seulement quand le fond d’accent est visible (évite frange sur fond neutre). */
|
/** Pill à droite seulement quand le fond d’accent est visible (évite frange sur fond neutre). */
|
||||||
function navRowRoundedWhenActive(active: boolean) {
|
function navRowRoundedWhenActive(active: boolean) {
|
||||||
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
|
return active ? "rounded-r-full" : "rounded-r-none hover:rounded-r-full"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark an element as the nav drag source (opacity via CSS). */
|
||||||
|
function markNavDragSource(el: HTMLElement | null) {
|
||||||
|
el?.setAttribute("data-nav-drag-source", "true")
|
||||||
|
}
|
||||||
|
function unmarkNavDragSource(el: HTMLElement | null) {
|
||||||
|
el?.removeAttribute("data-nav-drag-source")
|
||||||
|
}
|
||||||
|
/** Mark / unmark a drop indicator via data attribute (CSS driven). */
|
||||||
|
function setNavDropIndicator(
|
||||||
|
el: HTMLElement | null,
|
||||||
|
placement: SidebarNavDropPlacement | null,
|
||||||
|
) {
|
||||||
|
if (!el) return
|
||||||
|
if (placement) {
|
||||||
|
el.setAttribute("data-nav-drop", placement)
|
||||||
|
} else {
|
||||||
|
el.removeAttribute("data-nav-drop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRowActivate(
|
||||||
|
e: React.KeyboardEvent,
|
||||||
|
action: () => void
|
||||||
|
) {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault()
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNavDragHandle({
|
||||||
|
label,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
onDragStart: (e: React.DragEvent<HTMLSpanElement>) => void
|
||||||
|
onDragEnd: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
draggable
|
||||||
|
title={`Réorganiser : ${label}`}
|
||||||
|
aria-label={`Réorganiser : ${label}`}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
className="flex h-8 w-4 shrink-0 cursor-grab items-center justify-center text-gray-400 opacity-50 transition-opacity hover:opacity-100 active:cursor-grabbing group-hover/folderrow:opacity-100 group-hover/labelrow:opacity-100"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
|
/** Colonne droite : compteur et ⋮ partagent le même emplacement (style Gmail). */
|
||||||
function SidebarOverflowColumn({
|
function SidebarOverflowColumn({
|
||||||
unread,
|
unread,
|
||||||
@ -308,8 +374,8 @@ function CategoryNavRow({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
onSelectFolder,
|
onSelectFolder,
|
||||||
onHideCategory,
|
onDisableNavLabel,
|
||||||
onShowCategory,
|
onEnableNavLabel,
|
||||||
variant = "listed",
|
variant = "listed",
|
||||||
}: {
|
}: {
|
||||||
item: CategoryNavSourceItem
|
item: CategoryNavSourceItem
|
||||||
@ -317,15 +383,15 @@ function CategoryNavRow({
|
|||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
onSelectFolder: (id: string) => void
|
onSelectFolder: (id: string) => void
|
||||||
onHideCategory: (id: string) => void
|
onDisableNavLabel: (id: string) => void
|
||||||
onShowCategory: (id: string) => void
|
onEnableNavLabel: (id: string) => void
|
||||||
variant?: "listed" | "hidden"
|
variant?: "listed" | "hidden"
|
||||||
}) {
|
}) {
|
||||||
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
|
const { isOver, dropHandlers } = useEmailDropTarget(item.id, item.label)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const isHiddenRow = variant === "hidden"
|
const isHiddenRow = variant === "hidden"
|
||||||
const showCategoryMenu = CATEGORY_MENU_IDS.has(item.id) && isExpanded
|
const showCategoryMenu = isSystemNavLabelId(item.id) && isExpanded
|
||||||
const hasUnread = unreadCount > 0
|
const hasUnread = unreadCount > 0
|
||||||
|
|
||||||
const handleMenuOpenChange = (open: boolean) => {
|
const handleMenuOpenChange = (open: boolean) => {
|
||||||
@ -335,6 +401,27 @@ function CategoryNavRow({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowIcon = item.icon ? (
|
||||||
|
<Icon
|
||||||
|
icon={item.icon}
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 shrink-0",
|
||||||
|
isHiddenRow && "opacity-70",
|
||||||
|
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Folder
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 shrink-0",
|
||||||
|
isHiddenRow && "opacity-70",
|
||||||
|
hasUnread && !isSelected && !isHiddenRow && "text-gray-900"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
if (isHiddenRow) {
|
if (isHiddenRow) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -350,7 +437,7 @@ function CategoryNavRow({
|
|||||||
onClick={() => onSelectFolder(item.id)}
|
onClick={() => onSelectFolder(item.id)}
|
||||||
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
|
className="flex h-8 min-w-0 flex-1 items-center gap-4 rounded-r-none py-0 pr-1 text-left outline-none hover:rounded-r-full hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5 shrink-0 opacity-70" />
|
{rowIcon}
|
||||||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -387,14 +474,11 @@ function CategoryNavRow({
|
|||||||
<DropdownMenuContent align="end" className="min-w-40">
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onShowCategory(item.id)
|
onEnableNavLabel(item.id)
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Afficher
|
Réactiver le libellé
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem disabled className="text-gray-400">
|
|
||||||
Masquer
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -426,12 +510,7 @@ function CategoryNavRow({
|
|||||||
showCategoryMenu ? "pr-1" : "pr-3"
|
showCategoryMenu ? "pr-1" : "pr-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon
|
{rowIcon}
|
||||||
className={cn(
|
|
||||||
"h-5 w-5 shrink-0",
|
|
||||||
hasUnread && !isSelected && "text-gray-900"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
<div className="flex min-w-0 flex-1 items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
@ -484,11 +563,11 @@ function CategoryNavRow({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onHideCategory(item.id)
|
onDisableNavLabel(item.id)
|
||||||
setMenuOpen(false)
|
setMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Masquer
|
Désactiver le libellé
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@ -508,7 +587,6 @@ export function Sidebar({
|
|||||||
const [hoverExpanded, setHoverExpanded] = useState(false)
|
const [hoverExpanded, setHoverExpanded] = useState(false)
|
||||||
const [navMoreOpen, setNavMoreOpen] = useState(false)
|
const [navMoreOpen, setNavMoreOpen] = useState(false)
|
||||||
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
|
const [expandedFolderIds, setExpandedFolderIds] = useState<Set<string>>(() => new Set())
|
||||||
const [hiddenCategoryIds, setHiddenCategoryIds] = useState<Set<string>>(() => new Set())
|
|
||||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const sidebarRef = useRef<HTMLElement>(null)
|
const sidebarRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
@ -527,12 +605,86 @@ export function Sidebar({
|
|||||||
renameFolderOrLabel,
|
renameFolderOrLabel,
|
||||||
removeFolderOrLabelRow,
|
removeFolderOrLabelRow,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
|
reorderLabelRows,
|
||||||
|
moveFolderRelative,
|
||||||
addSubfolder,
|
addSubfolder,
|
||||||
addChildLabelRow,
|
addChildLabelRow,
|
||||||
|
setLabelRowEnabled,
|
||||||
} = useSidebarNav()
|
} = useSidebarNav()
|
||||||
|
|
||||||
|
const navDragRef = useRef<SidebarNavDragPayload | null>(null)
|
||||||
|
const navDragSourceElRef = useRef<HTMLElement | null>(null)
|
||||||
|
const navDropTargetElRef = useRef<HTMLElement | null>(null)
|
||||||
|
const navDropPlacementRef = useRef<SidebarNavDropPlacement | null>(null)
|
||||||
|
|
||||||
|
const beginNavDrag = useCallback(
|
||||||
|
(payload: SidebarNavDragPayload, sourceEl: HTMLElement | null) => {
|
||||||
|
navDragRef.current = payload
|
||||||
|
navDragSourceElRef.current = sourceEl
|
||||||
|
markNavDragSource(sourceEl)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearNavDrag = useCallback(() => {
|
||||||
|
unmarkNavDragSource(navDragSourceElRef.current)
|
||||||
|
setNavDropIndicator(navDropTargetElRef.current, null)
|
||||||
|
navDragRef.current = null
|
||||||
|
navDragSourceElRef.current = null
|
||||||
|
navDropTargetElRef.current = null
|
||||||
|
navDropPlacementRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateNavDropTarget = useCallback(
|
||||||
|
(el: HTMLElement, placement: SidebarNavDropPlacement) => {
|
||||||
|
if (navDropTargetElRef.current !== el) {
|
||||||
|
setNavDropIndicator(navDropTargetElRef.current, null)
|
||||||
|
}
|
||||||
|
navDropTargetElRef.current = el
|
||||||
|
navDropPlacementRef.current = placement
|
||||||
|
setNavDropIndicator(el, placement)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearNavDropTarget = useCallback((el: HTMLElement) => {
|
||||||
|
if (navDropTargetElRef.current === el) {
|
||||||
|
setNavDropIndicator(el, null)
|
||||||
|
navDropTargetElRef.current = null
|
||||||
|
navDropPlacementRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const commitNavDrop = useCallback(
|
||||||
|
(
|
||||||
|
payload: SidebarNavDragPayload,
|
||||||
|
targetId: string,
|
||||||
|
placement: SidebarNavDropPlacement,
|
||||||
|
targetKind: "label" | "folder"
|
||||||
|
) => {
|
||||||
|
clearNavDrag()
|
||||||
|
if (payload.id === targetId && placement !== "inside") return
|
||||||
|
if (targetKind === "label" && payload.kind === "label") {
|
||||||
|
if (placement === "inside") return
|
||||||
|
reorderLabelRows(payload.id, targetId, placement)
|
||||||
|
} else if (targetKind === "folder" && payload.kind === "folder") {
|
||||||
|
moveFolderRelative(payload.id, targetId, placement)
|
||||||
|
if (placement === "inside") {
|
||||||
|
setExpandedFolderIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(targetId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[clearNavDrag, moveFolderRelative, reorderLabelRows]
|
||||||
|
)
|
||||||
|
|
||||||
const visibleNavLabelRows = useMemo(() => {
|
const visibleNavLabelRows = useMemo(() => {
|
||||||
return labelRows.filter((row) => {
|
return labelRows.filter((row) => {
|
||||||
|
if (row.enabled === false) return false
|
||||||
|
if (isSystemNavLabelId(row.id)) return false
|
||||||
const p = getNavItemPrefs(row.id)
|
const p = getNavItemPrefs(row.id)
|
||||||
if (p.sidebar === "hide") return false
|
if (p.sidebar === "hide") return false
|
||||||
if (
|
if (
|
||||||
@ -548,7 +700,6 @@ export function Sidebar({
|
|||||||
const validNavFolderIds = useMemo(() => {
|
const validNavFolderIds = useMemo(() => {
|
||||||
const s = new Set<string>()
|
const s = new Set<string>()
|
||||||
for (const i of mainItems) s.add(i.id)
|
for (const i of mainItems) s.add(i.id)
|
||||||
for (const c of categoryItemsSource) s.add(c.id)
|
|
||||||
for (const k of Object.keys(folderIdToLabel)) s.add(k)
|
for (const k of Object.keys(folderIdToLabel)) s.add(k)
|
||||||
return s
|
return s
|
||||||
}, [folderIdToLabel])
|
}, [folderIdToLabel])
|
||||||
@ -573,17 +724,24 @@ export function Sidebar({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => {
|
const { primaryVisibleCategories, plusOnlyVisibleCategories } = useMemo(() => {
|
||||||
const vis = categoryItemsSource.filter((c) => !hiddenCategoryIds.has(c.id))
|
const systemEnabled = sortSystemLabelRows(
|
||||||
|
labelRows.filter((r) => r.enabled !== false && isSystemNavLabelId(r.id))
|
||||||
|
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
|
||||||
return {
|
return {
|
||||||
primaryVisibleCategories: vis.filter((c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
|
primaryVisibleCategories: systemEnabled.filter(
|
||||||
plusOnlyVisibleCategories: vis.filter((c) => CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)),
|
(c) => !CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
|
||||||
|
),
|
||||||
|
plusOnlyVisibleCategories: systemEnabled.filter((c) =>
|
||||||
|
CATEGORY_IDS_IN_PLUS_ONLY.has(c.id)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}, [hiddenCategoryIds])
|
}, [labelRows])
|
||||||
|
|
||||||
const hiddenCategoryItems = useMemo(
|
const disabledSystemNavItems = useMemo(() => {
|
||||||
() => categoryItemsSource.filter((c) => hiddenCategoryIds.has(c.id)),
|
return sortSystemLabelRows(
|
||||||
[hiddenCategoryIds]
|
labelRows.filter((r) => r.enabled === false && isSystemNavLabelId(r.id))
|
||||||
)
|
).map((r) => ({ id: r.id, label: r.label, icon: r.icon }))
|
||||||
|
}, [labelRows])
|
||||||
|
|
||||||
const visibleMainItems = useMemo(() => {
|
const visibleMainItems = useMemo(() => {
|
||||||
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
|
const scheduledTotal = folderUnreadCounts.scheduled ?? 0
|
||||||
@ -591,22 +749,6 @@ export function Sidebar({
|
|||||||
return mainItems.filter((item) => item.id !== "scheduled")
|
return mainItems.filter((item) => item.id !== "scheduled")
|
||||||
}, [folderUnreadCounts.scheduled])
|
}, [folderUnreadCounts.scheduled])
|
||||||
|
|
||||||
const hideCategory = (id: string) => {
|
|
||||||
setHiddenCategoryIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showCategory = (id: string) => {
|
|
||||||
setHiddenCategoryIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.delete(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleFolderExpanded = (id: string) => {
|
const toggleFolderExpanded = (id: string) => {
|
||||||
setExpandedFolderIds((prev) => {
|
setExpandedFolderIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@ -634,10 +776,11 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hiddenCategoryIds.has(selectedFolder)) {
|
const row = labelRows.find((r) => r.id === selectedFolder)
|
||||||
|
if (row && row.enabled === false) {
|
||||||
onSelectFolder("inbox")
|
onSelectFolder("inbox")
|
||||||
}
|
}
|
||||||
}, [hiddenCategoryIds, selectedFolder, onSelectFolder])
|
}, [labelRows, selectedFolder, onSelectFolder])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedFolder !== "scheduled") return
|
if (selectedFolder !== "scheduled") return
|
||||||
@ -770,6 +913,7 @@ export function Sidebar({
|
|||||||
const isStickyBranch = hasChildren && isBranchOpen
|
const isStickyBranch = hasChildren && isBranchOpen
|
||||||
const stickyTopPx = 32 + depth * 32
|
const stickyTopPx = 32 + depth * 32
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const [renameOpen, setRenameOpen] = useState(false)
|
const [renameOpen, setRenameOpen] = useState(false)
|
||||||
const [renameDraft, setRenameDraft] = useState(node.label)
|
const [renameDraft, setRenameDraft] = useState(node.label)
|
||||||
@ -791,6 +935,9 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowHoverHeld =
|
||||||
|
!isSelected && !isOver && (contextMenuOpen || menuOpen)
|
||||||
|
|
||||||
const prefs = getNavItemPrefs(node.id)
|
const prefs = getNavItemPrefs(node.id)
|
||||||
const moveTargets = useMemo(
|
const moveTargets = useMemo(
|
||||||
() => folderMoveParentOptions(folderTree, node.id),
|
() => folderMoveParentOptions(folderTree, node.id),
|
||||||
@ -851,13 +998,14 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rowClass = cn(
|
const rowClass = cn(
|
||||||
"group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm",
|
"group/folderrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center gap-2 pr-3 text-sm transition-colors",
|
||||||
isSelected || isOver ? "rounded-r-full" : "rounded-r-none",
|
isSelected || isOver || rowHoverHeld ? "rounded-r-full" : "rounded-r-none",
|
||||||
isStickyBranch && "sticky border-b border-gray-200/70",
|
isStickyBranch && "sticky border-b border-gray-200/70",
|
||||||
isStickyBranch && !isSelected && "bg-app-canvas",
|
isStickyBranch && !isSelected && !rowHoverHeld && "bg-app-canvas",
|
||||||
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
|
isSelected && "bg-[#d3e3fd] font-medium text-gray-900",
|
||||||
!isSelected && hasUnread && "text-gray-900",
|
!isSelected && hasUnread && "text-gray-900",
|
||||||
isOver && "bg-yellow-100 text-gray-900"
|
isOver && "bg-yellow-100 text-gray-900",
|
||||||
|
rowHoverHeld && "bg-gray-100 text-gray-900"
|
||||||
)
|
)
|
||||||
const rowStyle: CSSProperties = {
|
const rowStyle: CSSProperties = {
|
||||||
paddingLeft: 24 + depth * 16,
|
paddingLeft: 24 + depth * 16,
|
||||||
@ -971,14 +1119,86 @@ export function Sidebar({
|
|||||||
</SidebarOverflowColumn>
|
</SidebarOverflowColumn>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onFolderRowDragEnter = (e: React.DragEvent) => {
|
||||||
|
const active = navDragRef.current
|
||||||
|
if (active?.kind === "folder" && active.id !== node.id) {
|
||||||
|
e.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropHandlers.onDragEnter(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFolderRowDragOver = (e: React.DragEvent) => {
|
||||||
|
const active = navDragRef.current
|
||||||
|
if (active?.kind === "folder") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (active.id === node.id) return
|
||||||
|
const ancestors = ancestorFolderIdsForTarget(folderTree, node.id)
|
||||||
|
if (ancestors?.includes(active.id)) return
|
||||||
|
e.dataTransfer.dropEffect = "move"
|
||||||
|
updateNavDropTarget(
|
||||||
|
e.currentTarget as HTMLElement,
|
||||||
|
resolveNavDropPlacement(e, true)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropHandlers.onDragOver(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFolderRowDragLeave = (e: React.DragEvent) => {
|
||||||
|
if (navDragRef.current?.kind === "folder") {
|
||||||
|
const rt = e.relatedTarget as Node | null
|
||||||
|
if (rt && e.currentTarget instanceof Node && e.currentTarget.contains(rt)) return
|
||||||
|
clearNavDropTarget(e.currentTarget as HTMLElement)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropHandlers.onDragLeave(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFolderRowDrop = (e: React.DragEvent) => {
|
||||||
|
const payload = readSidebarNavDragData(e, navDragRef.current)
|
||||||
|
if (payload?.kind === "folder") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const placement = navDropPlacementRef.current ?? resolveNavDropPlacement(e, true)
|
||||||
|
commitNavDrop(payload, node.id, placement, "folder")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dropHandlers.onDrop(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFolderDragHandleStart = (e: React.DragEvent<HTMLSpanElement>) => {
|
||||||
|
const payload = { kind: "folder" as const, id: node.id }
|
||||||
|
setSidebarNavDragData(e, payload)
|
||||||
|
const rowEl = (e.currentTarget as HTMLElement).closest("[data-nav-row]") as HTMLElement | null
|
||||||
|
beginNavDrag(payload, rowEl)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContextMenu>
|
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div {...dropHandlers} className={rowClass} style={rowStyle}>
|
<div
|
||||||
|
data-nav-row
|
||||||
|
onDragEnter={onFolderRowDragEnter}
|
||||||
|
onDragOver={onFolderRowDragOver}
|
||||||
|
onDragLeave={onFolderRowDragLeave}
|
||||||
|
onDrop={onFolderRowDrop}
|
||||||
|
className={rowClass}
|
||||||
|
style={rowStyle}
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<SidebarNavDragHandle
|
||||||
|
label={node.label}
|
||||||
|
onDragStart={onFolderDragHandleStart}
|
||||||
|
onDragEnd={clearNavDrag}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
draggable={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
|
"flex h-8 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-gray-600 outline-none hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||||
isSelected && "text-gray-900"
|
isSelected && "text-gray-900"
|
||||||
@ -1007,14 +1227,18 @@ export function Sidebar({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => onSelectFolder(node.id)}
|
onClick={() => onSelectFolder(node.id)}
|
||||||
|
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(node.id))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors",
|
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-3 py-0 pr-1 text-left transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||||
!isSelected &&
|
!isSelected &&
|
||||||
!isOver &&
|
!isOver &&
|
||||||
|
!rowHoverHeld &&
|
||||||
"rounded-r-none hover:rounded-r-full hover:bg-gray-100",
|
"rounded-r-none hover:rounded-r-full hover:bg-gray-100",
|
||||||
|
rowHoverHeld && !isSelected && !isOver && "rounded-r-full",
|
||||||
isSelected
|
isSelected
|
||||||
? "text-gray-900"
|
? "text-gray-900"
|
||||||
: isOver
|
: isOver
|
||||||
@ -1036,7 +1260,7 @@ export function Sidebar({
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
{overflowMenu}
|
{overflowMenu}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@ -1341,6 +1565,7 @@ export function Sidebar({
|
|||||||
const isSelected = selectedFolder === item.id
|
const isSelected = selectedFolder === item.id
|
||||||
const hasUnread = unreadCount > 0
|
const hasUnread = unreadCount > 0
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const [renameOpen, setRenameOpen] = useState(false)
|
const [renameOpen, setRenameOpen] = useState(false)
|
||||||
const [renameDraft, setRenameDraft] = useState(item.label)
|
const [renameDraft, setRenameDraft] = useState(item.label)
|
||||||
@ -1348,6 +1573,7 @@ export function Sidebar({
|
|||||||
const [sublabelName, setSublabelName] = useState("")
|
const [sublabelName, setSublabelName] = useState("")
|
||||||
const labelRenameInputRef = useRef<HTMLInputElement>(null)
|
const labelRenameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
|
const sublabelNameInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const canDragLabel = labelRowExpanded && !isSystemNavLabelId(item.id)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRenameDraft(item.label)
|
setRenameDraft(item.label)
|
||||||
@ -1360,6 +1586,9 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rowHoverHeld =
|
||||||
|
!isSelected && !isOver && (contextMenuOpen || menuOpen)
|
||||||
|
|
||||||
const prefs = getNavItemPrefs(item.id)
|
const prefs = getNavItemPrefs(item.id)
|
||||||
const labelDotClass = item.color ?? "bg-gray-400"
|
const labelDotClass = item.color ?? "bg-gray-400"
|
||||||
const labelMenuSurface =
|
const labelMenuSurface =
|
||||||
@ -1415,16 +1644,76 @@ export function Sidebar({
|
|||||||
|
|
||||||
const rowClass = cn(
|
const rowClass = cn(
|
||||||
"group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
|
"group/labelrow flex h-8 w-full min-w-0 shrink-0 cursor-default items-center pl-6 pr-2 transition-colors",
|
||||||
navRowRoundedWhenActive(isSelected || isOver),
|
navRowRoundedWhenActive(isSelected || isOver || rowHoverHeld),
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
? "bg-[#d3e3fd] text-gray-900 font-medium"
|
||||||
: isOver
|
: isOver
|
||||||
? "bg-yellow-100 text-gray-900"
|
? "bg-yellow-100 text-gray-900"
|
||||||
: hasUnread
|
: rowHoverHeld
|
||||||
? "text-gray-900 hover:bg-gray-100"
|
? "bg-gray-100 text-gray-900"
|
||||||
: "text-gray-700 hover:bg-gray-100"
|
: 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 ? (
|
const overflowMenu = labelRowExpanded ? (
|
||||||
<SidebarOverflowColumn
|
<SidebarOverflowColumn
|
||||||
unread={unreadCount}
|
unread={unreadCount}
|
||||||
@ -1439,6 +1728,7 @@ export function Sidebar({
|
|||||||
<button
|
<button
|
||||||
ref={menuTriggerRef}
|
ref={menuTriggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
draggable={false}
|
||||||
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
|
className={cn(sidebarOverflowMenuButtonClass, isSelected && "text-gray-900")}
|
||||||
aria-label={`Options pour ${item.label}`}
|
aria-label={`Options pour ${item.label}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@ -1524,15 +1814,31 @@ export function Sidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ContextMenu>
|
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div {...dropHandlers} className={rowClass}>
|
<div
|
||||||
<button
|
data-nav-row
|
||||||
type="button"
|
onDragEnter={onLabelRowDragEnter}
|
||||||
|
onDragOver={onLabelRowDragOver}
|
||||||
|
onDragLeave={onLabelRowDragLeave}
|
||||||
|
onDrop={onLabelRowDrop}
|
||||||
|
className={rowClass}
|
||||||
|
>
|
||||||
|
{canDragLabel ? (
|
||||||
|
<SidebarNavDragHandle
|
||||||
|
label={item.label}
|
||||||
|
onDragStart={onLabelDragHandleStart}
|
||||||
|
onDragEnd={clearNavDrag}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
title={!labelRowExpanded ? item.label : undefined}
|
title={!labelRowExpanded ? item.label : undefined}
|
||||||
onClick={() => onSelectFolder(item.id)}
|
onClick={() => onSelectFolder(item.id)}
|
||||||
|
onKeyDown={(e) => navRowActivate(e, () => onSelectFolder(item.id))}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none",
|
"flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-4 py-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||||
labelRowExpanded ? "pr-1" : "pr-3"
|
labelRowExpanded ? "pr-1" : "pr-3"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -1551,7 +1857,7 @@ export function Sidebar({
|
|||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</div>
|
||||||
{overflowMenu}
|
{overflowMenu}
|
||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
@ -1791,8 +2097,8 @@ export function Sidebar({
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||||
onSelectFolder={onSelectFolder}
|
onSelectFolder={onSelectFolder}
|
||||||
onHideCategory={hideCategory}
|
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||||
onShowCategory={showCategory}
|
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -1845,8 +2151,8 @@ export function Sidebar({
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||||
onSelectFolder={onSelectFolder}
|
onSelectFolder={onSelectFolder}
|
||||||
onHideCategory={hideCategory}
|
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||||
onShowCategory={showCategory}
|
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
@ -1866,12 +2172,12 @@ export function Sidebar({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isExpanded && hiddenCategoryItems.length > 0 && (
|
{isExpanded && disabledSystemNavItems.length > 0 && (
|
||||||
<div className="mt-2 pt-2">
|
<div className="mt-2 pt-2">
|
||||||
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
|
<div className="mb-1 pl-6 pr-3 text-[11px] font-medium uppercase tracking-wide text-gray-500">
|
||||||
Masquées
|
Désactivées
|
||||||
</div>
|
</div>
|
||||||
{hiddenCategoryItems.map((item) => (
|
{disabledSystemNavItems.map((item) => (
|
||||||
<CategoryNavRow
|
<CategoryNavRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@ -1879,8 +2185,8 @@ export function Sidebar({
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
unreadCount={folderUnreadCounts[item.id] ?? 0}
|
||||||
onSelectFolder={onSelectFolder}
|
onSelectFolder={onSelectFolder}
|
||||||
onHideCategory={hideCategory}
|
onDisableNavLabel={(id) => setLabelRowEnabled(id, false)}
|
||||||
onShowCategory={showCategory}
|
onEnableNavLabel={(id) => setLabelRowEnabled(id, true)}
|
||||||
variant="hidden"
|
variant="hidden"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
23
hooks/use-persist-hydrated.ts
Normal file
23
hooks/use-persist-hydrated.ts
Normal 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
|
||||||
|
}
|
||||||
@ -56,11 +56,10 @@ export async function createScheduledSend(
|
|||||||
subject: payload.subject.trim() || "(Sans objet)",
|
subject: payload.subject.trim() || "(Sans objet)",
|
||||||
preview: payload.previewText.slice(0, 200),
|
preview: payload.previewText.slice(0, 200),
|
||||||
body: payload.bodyHtml,
|
body: payload.bodyHtml,
|
||||||
date: "",
|
date: payload.sendAtIso,
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["scheduled"],
|
labels: ["scheduled"],
|
||||||
scheduledSendAt: payload.sendAtIso,
|
scheduledSendAt: payload.sendAtIso,
|
||||||
scheduledToName: toName,
|
scheduledToName: toName,
|
||||||
@ -98,7 +97,6 @@ export async function snoozeScheduledSend(id: string): Promise<void> {
|
|||||||
scheduledToName: undefined,
|
scheduledToName: undefined,
|
||||||
snoozeWakeAt: wakeIso,
|
snoozeWakeAt: wakeIso,
|
||||||
sender: row.scheduledToName ?? row.sender,
|
sender: row.scheduledToName ?? row.sender,
|
||||||
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
|
||||||
read: true,
|
read: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -192,11 +190,10 @@ export async function sendScheduledNow(id: string): Promise<void> {
|
|||||||
subject: row.subject,
|
subject: row.subject,
|
||||||
preview: row.preview,
|
preview: row.preview,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }),
|
date: now.toISOString(),
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["sent"],
|
labels: ["sent"],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,16 @@ type DemoVcBrand = {
|
|||||||
/** Heure de début (Europe/Paris) — 15 mai 2026 */
|
/** Heure de début (Europe/Paris) — 15 mai 2026 */
|
||||||
startHour: number
|
startHour: number
|
||||||
startMinute?: number
|
startMinute?: number
|
||||||
dateLabel: string
|
/** Jour du mois (mai 2026, Europe/Paris) */
|
||||||
|
mailDay: number
|
||||||
read?: boolean
|
read?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fixtureParisIso(day: number, hour: number, minute = 0): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0")
|
||||||
|
return `2026-05-${p(day)}T${p(hour)}:${p(minute)}:00+02:00`
|
||||||
|
}
|
||||||
|
|
||||||
const DEMO_ORGANIZER = "demo.organisateur@ultimail.test"
|
const DEMO_ORGANIZER = "demo.organisateur@ultimail.test"
|
||||||
const DEMO_ATTENDEE = "moi@example.com"
|
const DEMO_ATTENDEE = "moi@example.com"
|
||||||
|
|
||||||
@ -85,13 +91,12 @@ function buildDemoVcEmail(brand: DemoVcBrand): Email {
|
|||||||
ven. 15 mai 2026 ${timeLabel} (Europe/Paris)</p>
|
ven. 15 mai 2026 ${timeLabel} (Europe/Paris)</p>
|
||||||
<p><a href="${brand.location}">Rejoindre</a></p>
|
<p><a href="${brand.location}">Rejoindre</a></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: brand.dateLabel,
|
date: fixtureParisIso(brand.mailDay, brand.startHour, startMinute),
|
||||||
read: brand.read ?? false,
|
read: brand.read ?? false,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
hasInvitation: true,
|
hasInvitation: true,
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Démos visio"],
|
labels: ["inbox", "Démos visio"],
|
||||||
calendarInvitation: { ics },
|
calendarInvitation: { ics },
|
||||||
attachments: [
|
attachments: [
|
||||||
@ -115,7 +120,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Point produit — Google Meet",
|
summary: "Point produit — Google Meet",
|
||||||
location: "https://meet.google.com/ultimail-demo-meet",
|
location: "https://meet.google.com/ultimail-demo-meet",
|
||||||
startHour: 9,
|
startHour: 9,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "teams",
|
provider: "teams",
|
||||||
@ -127,7 +132,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location:
|
location:
|
||||||
"https://teams.microsoft.com/l/meetup-join/19%3ameeting_ultimail_demo",
|
"https://teams.microsoft.com/l/meetup-join/19%3ameeting_ultimail_demo",
|
||||||
startHour: 10,
|
startHour: 10,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "zoom",
|
provider: "zoom",
|
||||||
@ -138,7 +143,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Stand-up équipe — Zoom",
|
summary: "Stand-up équipe — Zoom",
|
||||||
location: "https://zoom.us/j/98765432101?pwd=demo",
|
location: "https://zoom.us/j/98765432101?pwd=demo",
|
||||||
startHour: 11,
|
startHour: 11,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "skype",
|
provider: "skype",
|
||||||
@ -149,7 +154,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Entretien client — Skype",
|
summary: "Entretien client — Skype",
|
||||||
location: "https://join.skype.com/invite/ultimailDemoSkype",
|
location: "https://join.skype.com/invite/ultimailDemoSkype",
|
||||||
startHour: 12,
|
startHour: 12,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "jitsi",
|
provider: "jitsi",
|
||||||
@ -160,7 +165,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Atelier open source — Jitsi",
|
summary: "Atelier open source — Jitsi",
|
||||||
location: "https://meet.jit.si/ultimail-demo-jitsi",
|
location: "https://meet.jit.si/ultimail-demo-jitsi",
|
||||||
startHour: 13,
|
startHour: 13,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "whatsapp",
|
provider: "whatsapp",
|
||||||
@ -171,7 +176,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Groupe projet — WhatsApp",
|
summary: "Groupe projet — WhatsApp",
|
||||||
location: "https://chat.whatsapp.com/InviteUltimailDemoWA",
|
location: "https://chat.whatsapp.com/InviteUltimailDemoWA",
|
||||||
startHour: 14,
|
startHour: 14,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "signal",
|
provider: "signal",
|
||||||
@ -182,7 +187,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Comité confidentialité — Signal",
|
summary: "Comité confidentialité — Signal",
|
||||||
location: "https://signal.group/#UltimailDemoSignalGroup",
|
location: "https://signal.group/#UltimailDemoSignalGroup",
|
||||||
startHour: 15,
|
startHour: 15,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "instagram",
|
provider: "instagram",
|
||||||
@ -193,7 +198,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Live créateurs — Instagram",
|
summary: "Live créateurs — Instagram",
|
||||||
location: "https://www.instagram.com/direct/t/ultimail-demo-live",
|
location: "https://www.instagram.com/direct/t/ultimail-demo-live",
|
||||||
startHour: 16,
|
startHour: 16,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
@ -204,7 +209,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
summary: "Voice channel équipe — Discord",
|
summary: "Voice channel équipe — Discord",
|
||||||
location: "https://discord.gg/ultimail-demo-voice",
|
location: "https://discord.gg/ultimail-demo-voice",
|
||||||
startHour: 17,
|
startHour: 17,
|
||||||
dateLabel: "14 mai",
|
mailDay: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "slack",
|
provider: "slack",
|
||||||
@ -216,7 +221,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "https://app.slack.com/huddle/TULTIMail/CdemoHuddle",
|
location: "https://app.slack.com/huddle/TULTIMail/CdemoHuddle",
|
||||||
startHour: 9,
|
startHour: 9,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -229,7 +234,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "https://t.me/joinchat/UltimailDemoTelegram",
|
location: "https://t.me/joinchat/UltimailDemoTelegram",
|
||||||
startHour: 10,
|
startHour: 10,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -242,7 +247,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "https://m.me/c/ultimail-demo-room",
|
location: "https://m.me/c/ultimail-demo-room",
|
||||||
startHour: 11,
|
startHour: 11,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -255,7 +260,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "facetime://demo.organisateur@ultimail.test",
|
location: "facetime://demo.organisateur@ultimail.test",
|
||||||
startHour: 14,
|
startHour: 14,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -268,7 +273,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "tel:+33183927510",
|
location: "tel:+33183927510",
|
||||||
startHour: 15,
|
startHour: 15,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -281,7 +286,7 @@ const DEMO_BRANDS: DemoVcBrand[] = [
|
|||||||
location: "https://company.webex.com/meet/ultimail-demo-webex",
|
location: "https://company.webex.com/meet/ultimail-demo-webex",
|
||||||
startHour: 16,
|
startHour: 16,
|
||||||
startMinute: 30,
|
startMinute: 30,
|
||||||
dateLabel: "13 mai",
|
mailDay: 13,
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface ConversationMessage {
|
|||||||
id: string
|
id: string
|
||||||
sender: string
|
sender: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
|
/** ISO 8601 avec fuseau (ex. `2026-05-16T01:38:00+02:00`) */
|
||||||
date: string
|
date: string
|
||||||
body: string
|
body: string
|
||||||
preview: string
|
preview: string
|
||||||
@ -36,6 +37,7 @@ export interface Email {
|
|||||||
preview: string
|
preview: string
|
||||||
/** HTML body — rendu dans une iframe sandbox pour raisons de sécurité */
|
/** HTML body — rendu dans une iframe sandbox pour raisons de sécurité */
|
||||||
body?: string
|
body?: string
|
||||||
|
/** ISO 8601 avec fuseau — affichage via `formatMail*` / `MailDateText` */
|
||||||
date: string
|
date: string
|
||||||
read: boolean
|
read: boolean
|
||||||
starred: boolean
|
starred: boolean
|
||||||
@ -47,7 +49,8 @@ export interface Email {
|
|||||||
calendarInvitation?: CalendarInvitationMeta
|
calendarInvitation?: CalendarInvitationMeta
|
||||||
attachments?: EmailAttachment[]
|
attachments?: EmailAttachment[]
|
||||||
tag?: string
|
tag?: string
|
||||||
category: "primary" | "promotions" | "social" | "updates" | "forums"
|
/** Corbeille : pseudo-dossier `trash` quand true */
|
||||||
|
deleted?: boolean
|
||||||
/** Libellés / dossiers associés à cette conversation */
|
/** Libellés / dossiers associés à cette conversation */
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
/** Messages précédents dans la conversation (le dernier message est le body principal) */
|
/** Messages précédents dans la conversation (le dernier message est le body principal) */
|
||||||
@ -73,7 +76,7 @@ export const emails: Email[] = [
|
|||||||
<p>Closed <a href="#">#16007</a> as completed via <a href="#">#16053</a>.</p>
|
<p>Closed <a href="#">#16007</a> as completed via <a href="#">#16053</a>.</p>
|
||||||
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
|
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "01:38",
|
date: "2026-05-16T01:38:00+02:00",
|
||||||
read: false,
|
read: false,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
@ -84,14 +87,13 @@ export const emails: Email[] = [
|
|||||||
{ name: "notes.txt", kind: "other", sizeBytes: 2_048 },
|
{ name: "notes.txt", kind: "other", sizeBytes: 2_048 },
|
||||||
{ name: "build_log.pdf", kind: "pdf", sizeBytes: 1_024_512 },
|
{ name: "build_log.pdf", kind: "pdf", sizeBytes: 1_024_512 },
|
||||||
],
|
],
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox"],
|
labels: ["inbox"],
|
||||||
conversation: [
|
conversation: [
|
||||||
{
|
{
|
||||||
id: "1-a",
|
id: "1-a",
|
||||||
sender: "ronenrozn",
|
sender: "ronenrozn",
|
||||||
senderEmail: "ronenrozn@users.noreply.github.com",
|
senderEmail: "ronenrozn@users.noreply.github.com",
|
||||||
date: "lun. 12 mai 23:15",
|
date: "2026-05-12T23:15:00+02:00",
|
||||||
preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...",
|
preview: "After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon. Error: mlx_runner: failed to load model...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p>
|
<p>After upgrading to 0.23.1, the mlx runner fails to start on Apple Silicon.</p>
|
||||||
@ -109,7 +111,7 @@ export const emails: Email[] = [
|
|||||||
id: "1-b",
|
id: "1-b",
|
||||||
sender: "Daniel",
|
sender: "Daniel",
|
||||||
senderEmail: "daniel@ollama.ai",
|
senderEmail: "daniel@ollama.ai",
|
||||||
date: "mar. 13 mai 00:42",
|
date: "2026-05-13T00:42:00+02:00",
|
||||||
preview: "Thanks for reporting. This is a known regression in the MLX backend. We have a fix in #16053 that should resolve...",
|
preview: "Thanks for reporting. This is a known regression in the MLX backend. We have a fix in #16053 that should resolve...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>Thanks for reporting. This is a known regression in the MLX backend.</p>
|
<p>Thanks for reporting. This is a known regression in the MLX backend.</p>
|
||||||
@ -134,13 +136,12 @@ export const emails: Email[] = [
|
|||||||
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">12,34 €</td></tr>
|
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">12,34 €</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "7 mai",
|
date: "2026-05-07T01:11:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
attachments: [{ name: "recu_course.pdf", kind: "pdf", sizeBytes: 88_320 }],
|
attachments: [{ name: "recu_course.pdf", kind: "pdf", sizeBytes: 88_320 }],
|
||||||
category: "promotions",
|
labels: ["inbox", "Factures", "Achats", "Promotions"],
|
||||||
labels: ["inbox", "Factures", "Achats"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
@ -158,18 +159,17 @@ export const emails: Email[] = [
|
|||||||
<hr style="border:none; border-top:1px solid #d0d7de; margin:16px 0;"/>
|
<hr style="border:none; border-top:1px solid #d0d7de; margin:16px 0;"/>
|
||||||
<p style="color:#57606a; font-size:12px;">Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
|
<p style="color:#57606a; font-size:12px;">Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you commented.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "5 avr.",
|
date: "2026-04-05T12:00:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "social",
|
labels: ["inbox", "Réseaux sociaux"],
|
||||||
labels: ["inbox"],
|
|
||||||
conversation: [
|
conversation: [
|
||||||
{
|
{
|
||||||
id: "3-a",
|
id: "3-a",
|
||||||
sender: "SannyGrooves",
|
sender: "SannyGrooves",
|
||||||
senderEmail: "sannygrooves@users.noreply.github.com",
|
senderEmail: "sannygrooves@users.noreply.github.com",
|
||||||
date: "sam. 14 mars 11:55",
|
date: "2026-03-14T11:55:00+01:00",
|
||||||
preview: "SannyGrooves left a comment (IceWhaleTech/ZimaOS#5) Just wanted to say: Big shoutout to Rogger for this automated installation script. Just run in Proxmox SSH (",
|
preview: "SannyGrooves left a comment (IceWhaleTech/ZimaOS#5) Just wanted to say: Big shoutout to Rogger for this automated installation script. Just run in Proxmox SSH (",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>SannyGrooves left a comment (<a href="#">IceWhaleTech/ZimaOS#5</a>)</p>
|
<p>SannyGrooves left a comment (<a href="#">IceWhaleTech/ZimaOS#5</a>)</p>
|
||||||
@ -192,12 +192,11 @@ export const emails: Email[] = [
|
|||||||
<blockquote style="border-left:3px solid #d0d7de; padding-left:12px; margin:8px 0; color:#57606a;">This is a great idea. I've been thinking about how we could leverage MCP servers to make Parse Server more accessible to AI agents.</blockquote>
|
<blockquote style="border-left:3px solid #d0d7de; padding-left:12px; margin:8px 0; color:#57606a;">This is a great idea. I've been thinking about how we could leverage MCP servers to make Parse Server more accessible to AI agents.</blockquote>
|
||||||
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.</p>
|
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "19 mars",
|
date: "2026-03-19T10:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "updates",
|
labels: ["inbox", "Mises à jour"],
|
||||||
labels: ["inbox"],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "5",
|
id: "5",
|
||||||
@ -210,18 +209,17 @@ export const emails: Email[] = [
|
|||||||
<p>Closed <a href="#">#88</a> as completed via <a href="#">80ce59f</a>.</p>
|
<p>Closed <a href="#">#88</a> as completed via <a href="#">80ce59f</a>.</p>
|
||||||
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
|
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "28 févr.",
|
date: "2026-02-28T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: true,
|
starred: true,
|
||||||
important: false,
|
important: false,
|
||||||
category: "forums",
|
labels: ["inbox", "starred", "Travail", "Forums"],
|
||||||
labels: ["inbox", "starred", "Travail"],
|
|
||||||
conversation: [
|
conversation: [
|
||||||
{
|
{
|
||||||
id: "5-a",
|
id: "5-a",
|
||||||
sender: "Pyxage",
|
sender: "Pyxage",
|
||||||
senderEmail: "pyxage@users.noreply.github.com",
|
senderEmail: "pyxage@users.noreply.github.com",
|
||||||
date: "lun. 10 févr. 14:22",
|
date: "2026-02-10T14:22:00+01:00",
|
||||||
preview: "This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw. Blockers: 1. Plugin system...",
|
preview: "This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw. Blockers: 1. Plugin system...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw.</p>
|
<p>This issue tracks the remaining feature parity gaps between OpenClaw and ZeroClaw.</p>
|
||||||
@ -238,7 +236,7 @@ export const emails: Email[] = [
|
|||||||
id: "5-b",
|
id: "5-b",
|
||||||
sender: "Argenis",
|
sender: "Argenis",
|
||||||
senderEmail: "argenis@zeroclaw-labs.com",
|
senderEmail: "argenis@zeroclaw-labs.com",
|
||||||
date: "mer. 19 févr. 09:10",
|
date: "2026-02-19T09:10:00+01:00",
|
||||||
preview: "Update: items 1-3 are resolved. Rate limiting will ship in v2.4. Moving to close once CI passes...",
|
preview: "Update: items 1-3 are resolved. Rate limiting will ship in v2.4. Moving to close once CI passes...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>Update: items 1-3 are resolved. Rate limiting will ship in v2.4.</p>
|
<p>Update: items 1-3 are resolved. Rate limiting will ship in v2.4.</p>
|
||||||
@ -263,12 +261,11 @@ export const emails: Email[] = [
|
|||||||
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">18,50 €</td></tr>
|
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">18,50 €</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "28 févr.",
|
date: "2026-02-28T02:10:00+01:00",
|
||||||
read: false,
|
read: false,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: true,
|
important: true,
|
||||||
hasInvitation: true,
|
hasInvitation: true,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Factures"],
|
labels: ["inbox", "Factures"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -282,11 +279,10 @@ export const emails: Email[] = [
|
|||||||
<p>Merged <a href="#">#138</a> into dev.</p>
|
<p>Merged <a href="#">#138</a> into dev.</p>
|
||||||
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
|
<p>—<br/>Reply to this email directly, <a href="#">view it on GitHub</a>, or <a href="#">unsubscribe</a>.<br/>You are receiving this because you were mentioned.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "27 févr.",
|
date: "2026-02-27T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["sent", "[Imap]/Sent"],
|
labels: ["sent", "[Imap]/Sent"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -306,11 +302,10 @@ export const emails: Email[] = [
|
|||||||
<li>chore: update dependencies</li>
|
<li>chore: update dependencies</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "23 févr.",
|
date: "2026-02-23T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["drafts"],
|
labels: ["drafts"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -324,11 +319,10 @@ export const emails: Email[] = [
|
|||||||
<p>Closed <a href="#">#130</a>.</p>
|
<p>Closed <a href="#">#130</a>.</p>
|
||||||
<p>—<br/>You are receiving this because you were mentioned.<br/>Message ID: <wiarabeauty/frontend/pull/130/issue_event/22471S3></p>
|
<p>—<br/>You are receiving this because you were mentioned.<br/>Message ID: <wiarabeauty/frontend/pull/130/issue_event/22471S3></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "23 févr.",
|
date: "2026-02-23T15:30:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "CCTV"],
|
labels: ["inbox", "CCTV"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -346,11 +340,10 @@ export const emails: Email[] = [
|
|||||||
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">22,10 €</td></tr>
|
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">22,10 €</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "2 févr.",
|
date: "2026-02-02T03:55:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Déplacements"],
|
labels: ["inbox", "Déplacements"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -364,11 +357,10 @@ export const emails: Email[] = [
|
|||||||
<p><strong>leonace924</strong> left a comment on <a href="#">parse-community/parse-dashboard#482</a></p>
|
<p><strong>leonace924</strong> left a comment on <a href="#">parse-community/parse-dashboard#482</a></p>
|
||||||
<p>@mtrezza What is your working branch for this feature? I'd like to contribute some timezone utilities.</p>
|
<p>@mtrezza What is your working branch for this feature? I'd like to contribute some timezone utilities.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "30 janv.",
|
date: "2026-01-30T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Clients"],
|
labels: ["inbox", "Clients"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -389,12 +381,11 @@ export const emails: Email[] = [
|
|||||||
</ul>
|
</ul>
|
||||||
<p><a href="#" style="display:inline-block; background:#1a73e8; color:white; padding:10px 24px; border-radius:4px; text-decoration:none; font-weight:500;">Se connecter</a></p>
|
<p><a href="#" style="display:inline-block; background:#1a73e8; color:white; padding:10px 24px; border-radius:4px; text-decoration:none; font-weight:500;">Se connecter</a></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "9 janv.",
|
date: "2026-01-09T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
hasInvitation: true,
|
hasInvitation: true,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "BrowserAlerts", "Twitch"],
|
labels: ["inbox", "BrowserAlerts", "Twitch"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -413,11 +404,10 @@ export const emails: Email[] = [
|
|||||||
<li>Steps to reproduce</li>
|
<li>Steps to reproduce</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "29/12/2025",
|
date: "2025-12-29T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Q1 2026", "CMSecurity Alerts"],
|
labels: ["inbox", "Q1 2026", "CMSecurity Alerts"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -436,18 +426,17 @@ export const emails: Email[] = [
|
|||||||
<li>Mention supported Parse Server versions</li>
|
<li>Mention supported Parse Server versions</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "01/12/2025",
|
date: "2025-12-01T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Travail", "RH"],
|
labels: ["inbox", "Travail", "RH"],
|
||||||
conversation: [
|
conversation: [
|
||||||
{
|
{
|
||||||
id: "14-a",
|
id: "14-a",
|
||||||
sender: "parse-github-assistant",
|
sender: "parse-github-assistant",
|
||||||
senderEmail: "parse-github-assistant[bot]@users.noreply.github.com",
|
senderEmail: "parse-github-assistant[bot]@users.noreply.github.com",
|
||||||
date: "ven. 28 nov. 2025 10:00",
|
date: "2025-11-28T10:00:00+01:00",
|
||||||
preview: "Thanks for opening this pull request! A maintainer will review it shortly...",
|
preview: "Thanks for opening this pull request! A maintainer will review it shortly...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>Thanks for opening this pull request! 🎯</p>
|
<p>Thanks for opening this pull request! 🎯</p>
|
||||||
@ -459,7 +448,7 @@ export const emails: Email[] = [
|
|||||||
id: "14-b",
|
id: "14-b",
|
||||||
sender: "R3D347HR4Y",
|
sender: "R3D347HR4Y",
|
||||||
senderEmail: "r3d347hr4y@users.noreply.github.com",
|
senderEmail: "r3d347hr4y@users.noreply.github.com",
|
||||||
date: "ven. 28 nov. 2025 14:32",
|
date: "2025-11-28T14:32:00+01:00",
|
||||||
preview: "Here is the initial PR adding the AI Integration docs section. It covers MCP server setup, authentication...",
|
preview: "Here is the initial PR adding the AI Integration docs section. It covers MCP server setup, authentication...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>Here is the initial PR adding the AI Integration docs section. It covers:</p>
|
<p>Here is the initial PR adding the AI Integration docs section. It covers:</p>
|
||||||
@ -475,7 +464,7 @@ export const emails: Email[] = [
|
|||||||
id: "14-c",
|
id: "14-c",
|
||||||
sender: "dblythy",
|
sender: "dblythy",
|
||||||
senderEmail: "dblythy@users.noreply.github.com",
|
senderEmail: "dblythy@users.noreply.github.com",
|
||||||
date: "sam. 29 nov. 2025 08:15",
|
date: "2025-11-29T08:15:00+01:00",
|
||||||
preview: "Looks good overall! One concern: the auth section should mention that Parse Server keys are required...",
|
preview: "Looks good overall! One concern: the auth section should mention that Parse Server keys are required...",
|
||||||
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
body: `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; color: #24292f;">
|
||||||
<p>Looks good overall! One concern: the auth section should mention that Parse Server master key is <strong>never</strong> to be exposed to AI agents in production.</p>
|
<p>Looks good overall! One concern: the auth section should mention that Parse Server master key is <strong>never</strong> to be exposed to AI agents in production.</p>
|
||||||
@ -495,11 +484,10 @@ export const emails: Email[] = [
|
|||||||
<p><strong>parse-github-assistant[bot]</strong> left a comment on <a href="#">parse-community/parse-server#9889</a></p>
|
<p><strong>parse-github-assistant[bot]</strong> left a comment on <a href="#">parse-community/parse-server#9889</a></p>
|
||||||
<p>This issue has been automatically marked as stale because it has not had recent activity.</p>
|
<p>This issue has been automatically marked as stale because it has not had recent activity.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "05/11/2025",
|
date: "2025-11-05T12:00:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox"],
|
labels: ["inbox"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -513,11 +501,10 @@ export const emails: Email[] = [
|
|||||||
<p style="font-size:16px; font-weight:600;">Eliott, merci d'avoir utilisé Uber</p>
|
<p style="font-size:16px; font-weight:600;">Eliott, merci d'avoir utilisé Uber</p>
|
||||||
<p>Nous espérons que vous avez apprécié votre course ce samedi matin.</p>
|
<p>Nous espérons que vous avez apprécié votre course ce samedi matin.</p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "01/11/2025",
|
date: "2025-11-01T02:55:00+01:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Voyages"],
|
labels: ["inbox", "Voyages"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -532,7 +519,7 @@ export const emails: Email[] = [
|
|||||||
<p>To avoid any disruptions, please regenerate your token before it expires.</p>
|
<p>To avoid any disruptions, please regenerate your token before it expires.</p>
|
||||||
<p><a href="#" style="display:inline-block; background:#2da44e; color:white; padding:8px 16px; border-radius:6px; text-decoration:none; font-weight:500;">Regenerate token</a></p>
|
<p><a href="#" style="display:inline-block; background:#2da44e; color:white; padding:8px 16px; border-radius:6px; text-decoration:none; font-weight:500;">Regenerate token</a></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "30/09/2025",
|
date: "2025-09-30T12:00:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
@ -541,7 +528,6 @@ export const emails: Email[] = [
|
|||||||
{ name: "screenshot.png", kind: "image", sizeBytes: 412_000 },
|
{ name: "screenshot.png", kind: "image", sizeBytes: 412_000 },
|
||||||
{ name: "notes.txt", kind: "other", sizeBytes: 1_280 },
|
{ name: "notes.txt", kind: "other", sizeBytes: 1_280 },
|
||||||
],
|
],
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Finance"],
|
labels: ["inbox", "Finance"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -559,12 +545,11 @@ export const emails: Email[] = [
|
|||||||
jeu. 14 mai 2026 14:30 – 15:30 (heure d’Europe centrale)</p>
|
jeu. 14 mai 2026 14:30 – 15:30 (heure d’Europe centrale)</p>
|
||||||
<p><a href="#">Rejoindre la réunion</a></p>
|
<p><a href="#">Rejoindre la réunion</a></p>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "30/09/2025",
|
date: "2025-09-30T10:13:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
hasInvitation: true,
|
hasInvitation: true,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Travail"],
|
labels: ["inbox", "Travail"],
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
calendarInvitation: {
|
calendarInvitation: {
|
||||||
@ -626,13 +611,12 @@ END:VCALENDAR`,
|
|||||||
<p style="font-size:11px; color:#999;">Ne pas répondre à ce message.</p>
|
<p style="font-size:11px; color:#999;">Ne pas répondre à ce message.</p>
|
||||||
<img src="http://tracking.scam-domain.xyz/pixel.gif" width="1" height="1" />
|
<img src="http://tracking.scam-domain.xyz/pixel.gif" width="1" height="1" />
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "26/09/2025",
|
date: "2025-09-26T12:00:00+02:00",
|
||||||
read: false,
|
read: false,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
spam: true,
|
spam: true,
|
||||||
labels: ["spam"],
|
labels: ["spam"],
|
||||||
category: "primary"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "20",
|
id: "20",
|
||||||
@ -649,11 +633,10 @@ END:VCALENDAR`,
|
|||||||
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">66,61 €</td></tr>
|
<tr style="border-top:1px solid #dadce0;"><td style="padding:8px 0; font-weight:600;">Total</td><td style="padding:8px 0; text-align:right; font-weight:600;">66,61 €</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "21/09/2025",
|
date: "2025-09-21T12:00:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["inbox", "Famille"],
|
labels: ["inbox", "Famille"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -671,12 +654,44 @@ END:VCALENDAR`,
|
|||||||
<p style="font-size:10px; color:#999;">Offre limitée. Désabonnement impossible.</p>
|
<p style="font-size:10px; color:#999;">Offre limitée. Désabonnement impossible.</p>
|
||||||
<img src="http://tracking.lottovip-scam.net/pixel.gif" width="1" height="1" />
|
<img src="http://tracking.lottovip-scam.net/pixel.gif" width="1" height="1" />
|
||||||
</div>`,
|
</div>`,
|
||||||
date: "20/09/2025",
|
date: "2025-09-20T12:00:00+02:00",
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: true,
|
important: true,
|
||||||
spam: true,
|
spam: true,
|
||||||
labels: ["spam"],
|
labels: ["spam"],
|
||||||
category: "primary"
|
},
|
||||||
|
{
|
||||||
|
id: "22",
|
||||||
|
sender: "Les Amis du Louvre",
|
||||||
|
senderEmail: "newsletter@louvre.fr",
|
||||||
|
subject: "Lettre des Amis du Louvre — mai 2026",
|
||||||
|
preview: "- Expositions, conférences et coulisses du musée : découvrez le programme du mois.",
|
||||||
|
body: `<div style="font-family: Georgia, serif; font-size: 15px; line-height: 1.6; color: #222;">
|
||||||
|
<p>Bonjour,</p>
|
||||||
|
<p>Voici la <strong>lettre de mai</strong> des Amis du Louvre : parcours Napoléon, nocturnes et ateliers jeune public.</p>
|
||||||
|
<p><a href="#">Lire la lettre en ligne</a></p>
|
||||||
|
</div>`,
|
||||||
|
date: "2026-05-12T08:00:00+02:00",
|
||||||
|
read: false,
|
||||||
|
starred: false,
|
||||||
|
important: false,
|
||||||
|
labels: ["inbox", "Newsletters"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "23",
|
||||||
|
sender: "The Washington Post",
|
||||||
|
senderEmail: "newsletters@washpost.com",
|
||||||
|
subject: "The Daily 202 : ce qu’il 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 — here’s what we’re watching today on Capitol Hill and beyond.</p>
|
||||||
|
<p><a href="#">Read today’s edition</a></p>
|
||||||
|
</div>`,
|
||||||
|
date: "2026-05-11T07:00:00+02:00",
|
||||||
|
read: true,
|
||||||
|
starred: false,
|
||||||
|
important: false,
|
||||||
|
labels: ["inbox", "Newsletters"],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
63
lib/label-picker-visual.tsx
Normal file
63
lib/label-picker-visual.tsx
Normal 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
106
lib/mail-date.ts
Normal 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 d’un 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,12 +2,9 @@ import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
|||||||
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
|
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
|
||||||
import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
|
import { getMailNavFolderLabel } from "@/lib/sidebar-nav-data"
|
||||||
|
|
||||||
|
/** @deprecated Utiliser `getMailNavFolderLabel(inboxTab, folderIdToLabel)` ou `inboxTabDisplayLabel`. */
|
||||||
export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = {
|
export const INBOX_CATEGORY_TAB_LABELS: Record<string, string> = {
|
||||||
primary: "Principale",
|
primary: "Principale",
|
||||||
promotions: "Promotions",
|
|
||||||
social: "Réseaux sociaux",
|
|
||||||
updates: "Notifications",
|
|
||||||
forums: "Forums",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clé stable pour historique navigation (dossier + onglet boîte de réception). */
|
/** Clé stable pour historique navigation (dossier + onglet boîte de réception). */
|
||||||
@ -28,29 +25,85 @@ export function parseMailNavVisitKey(key: string): {
|
|||||||
return { folderId: key }
|
return { folderId: key }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMailNavFolderBreadcrumbSegments(
|
export type MailNavBreadcrumbItem = {
|
||||||
|
label: string
|
||||||
|
visitKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMailNavFolderBreadcrumbItems(
|
||||||
folderId: string,
|
folderId: string,
|
||||||
folderTree: FolderTreeNode[],
|
folderTree: FolderTreeNode[],
|
||||||
folderIdToLabel?: Record<string, string>,
|
folderIdToLabel?: Record<string, string>,
|
||||||
inboxCategoryLabel?: string | null
|
inboxCategory?: { tabId: string; label: string } | null
|
||||||
): string[] {
|
): MailNavBreadcrumbItem[] {
|
||||||
if (folderId === "inbox") {
|
if (folderId === "inbox") {
|
||||||
const base = getMailNavFolderLabel(folderId, folderIdToLabel)
|
const base = getMailNavFolderLabel(folderId, folderIdToLabel)
|
||||||
if (
|
if (inboxCategory && inboxCategory.label !== "Principale") {
|
||||||
inboxCategoryLabel &&
|
return [
|
||||||
inboxCategoryLabel !== INBOX_CATEGORY_TAB_LABELS.primary
|
{ label: base, visitKey: mailNavVisitKey("inbox") },
|
||||||
) {
|
{
|
||||||
return [base, inboxCategoryLabel]
|
label: inboxCategory.label,
|
||||||
|
visitKey: mailNavVisitKey("inbox", inboxCategory.tabId),
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
return [base]
|
return [{ label: base, visitKey: mailNavVisitKey(folderId) }]
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = findFolderPath(folderTree, folderId)
|
const path = findFolderPath(folderTree, folderId)
|
||||||
if (path?.length) {
|
if (path?.length) {
|
||||||
return path.map((n) => n.label)
|
return path.map((n) => ({ label: n.label, visitKey: n.id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
return [getMailNavFolderLabel(folderId, folderIdToLabel)]
|
return [
|
||||||
|
{
|
||||||
|
label: getMailNavFolderLabel(folderId, folderIdToLabel),
|
||||||
|
visitKey: folderId,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMailNavFolderBreadcrumbSegments(
|
||||||
|
folderId: string,
|
||||||
|
folderTree: FolderTreeNode[],
|
||||||
|
folderIdToLabel?: Record<string, string>,
|
||||||
|
inboxCategoryLabel?: string | null,
|
||||||
|
inboxTabId?: string | null
|
||||||
|
): string[] {
|
||||||
|
const inboxCategory =
|
||||||
|
folderId === "inbox" &&
|
||||||
|
inboxTabId &&
|
||||||
|
inboxCategoryLabel &&
|
||||||
|
inboxCategoryLabel !== "Principale"
|
||||||
|
? { tabId: inboxTabId, label: inboxCategoryLabel }
|
||||||
|
: null
|
||||||
|
return getMailNavFolderBreadcrumbItems(
|
||||||
|
folderId,
|
||||||
|
folderTree,
|
||||||
|
folderIdToLabel,
|
||||||
|
inboxCategory
|
||||||
|
).map((i) => i.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbItemsForVisitKey(
|
||||||
|
key: string,
|
||||||
|
folderTree: FolderTreeNode[],
|
||||||
|
folderIdToLabel?: Record<string, string>
|
||||||
|
): MailNavBreadcrumbItem[] {
|
||||||
|
const { folderId, inboxTab } = parseMailNavVisitKey(key)
|
||||||
|
const inboxCategory =
|
||||||
|
folderId === "inbox" && inboxTab && inboxTab !== "primary"
|
||||||
|
? {
|
||||||
|
tabId: inboxTab,
|
||||||
|
label: getMailNavFolderLabel(inboxTab, folderIdToLabel),
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
return getMailNavFolderBreadcrumbItems(
|
||||||
|
folderId,
|
||||||
|
folderTree,
|
||||||
|
folderIdToLabel,
|
||||||
|
inboxCategory
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function breadcrumbSegmentsForVisitKey(
|
export function breadcrumbSegmentsForVisitKey(
|
||||||
@ -58,16 +111,8 @@ export function breadcrumbSegmentsForVisitKey(
|
|||||||
folderTree: FolderTreeNode[],
|
folderTree: FolderTreeNode[],
|
||||||
folderIdToLabel?: Record<string, string>
|
folderIdToLabel?: Record<string, string>
|
||||||
): string[] {
|
): string[] {
|
||||||
const { folderId, inboxTab } = parseMailNavVisitKey(key)
|
return breadcrumbItemsForVisitKey(key, folderTree, folderIdToLabel).map(
|
||||||
const cat =
|
(i) => i.label
|
||||||
folderId === "inbox" && inboxTab
|
|
||||||
? INBOX_CATEGORY_TAB_LABELS[inboxTab] ?? inboxTab
|
|
||||||
: null
|
|
||||||
return getMailNavFolderBreadcrumbSegments(
|
|
||||||
folderId,
|
|
||||||
folderTree,
|
|
||||||
folderIdToLabel,
|
|
||||||
cat
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import type { Email } from "@/lib/email-data"
|
|||||||
import {
|
import {
|
||||||
folderTree as defaultFolderTree,
|
folderTree as defaultFolderTree,
|
||||||
sidebarNavFolderIdToLabel as defaultSidebarNavFolderIdToLabel,
|
sidebarNavFolderIdToLabel as defaultSidebarNavFolderIdToLabel,
|
||||||
type FolderTreeNode,
|
defaultNavLabelRowsSnapshot,
|
||||||
|
type LabelRowItem,
|
||||||
} from "@/lib/sidebar-nav-data"
|
} from "@/lib/sidebar-nav-data"
|
||||||
import { collectSubtreeFolderIds } from "@/lib/sidebar-nav-maps"
|
import { collectSubtreeFolderIds } from "@/lib/sidebar-nav-maps"
|
||||||
|
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
||||||
|
|
||||||
export type MailFolderFilterCtx = {
|
export type MailFolderFilterCtx = {
|
||||||
starredEmailIds: string[]
|
starredEmailIds: string[]
|
||||||
@ -15,20 +17,14 @@ export type MailFolderFilterCtx = {
|
|||||||
export type MailNavFolderMaps = {
|
export type MailNavFolderMaps = {
|
||||||
folderIdToLabel: Record<string, string>
|
folderIdToLabel: Record<string, string>
|
||||||
folderTree: FolderTreeNode[]
|
folderTree: FolderTreeNode[]
|
||||||
|
labelRows: LabelRowItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_EMAIL_TAB_IDS = new Set([
|
function hasFutureScheduledSend(email: Email): boolean {
|
||||||
"social",
|
if (!email.scheduledSendAt) return false
|
||||||
"promotions",
|
const t = new Date(email.scheduledSendAt).getTime()
|
||||||
"updates",
|
if (!Number.isFinite(t)) return false
|
||||||
"forums",
|
return t > Date.now()
|
||||||
])
|
|
||||||
|
|
||||||
/** Catégories latérales (sidebar) → libellé optionnel sur `email.labels`. */
|
|
||||||
const SIDEBAR_CATEGORY_EXTRA_LABEL: Partial<Record<string, string>> = {
|
|
||||||
purchases: "Achats",
|
|
||||||
travel: "Déplacements",
|
|
||||||
finance: "Finance",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveStarred(email: Email, ctx: MailFolderFilterCtx): boolean {
|
function effectiveStarred(email: Email, ctx: MailFolderFilterCtx): boolean {
|
||||||
@ -40,17 +36,27 @@ function effectiveImportant(email: Email, ctx: MailFolderFilterCtx): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isInInbox(email: Email): boolean {
|
function isInInbox(email: Email): boolean {
|
||||||
|
if (email.deleted) return false
|
||||||
if (email.spam) return false
|
if (email.spam) return false
|
||||||
|
if (hasFutureScheduledSend(email)) return false
|
||||||
const ls = email.labels
|
const ls = email.labels
|
||||||
if (!ls?.length) return true
|
if (!ls?.length) return true
|
||||||
return ls.includes("inbox")
|
return ls.includes("inbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelRowForNavId(
|
||||||
|
folderId: string,
|
||||||
|
rows: readonly LabelRowItem[]
|
||||||
|
): LabelRowItem | undefined {
|
||||||
|
return rows.find((r) => r.id === folderId)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveNavMaps(maps?: MailNavFolderMaps | null): MailNavFolderMaps {
|
function resolveNavMaps(maps?: MailNavFolderMaps | null): MailNavFolderMaps {
|
||||||
if (maps) return maps
|
if (maps) return maps
|
||||||
return {
|
return {
|
||||||
folderIdToLabel: defaultSidebarNavFolderIdToLabel as Record<string, string>,
|
folderIdToLabel: defaultSidebarNavFolderIdToLabel as Record<string, string>,
|
||||||
folderTree: defaultFolderTree,
|
folderTree: defaultFolderTree,
|
||||||
|
labelRows: defaultNavLabelRowsSnapshot,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,6 +72,8 @@ function matchesFolderLabelRow(
|
|||||||
folderId: string,
|
folderId: string,
|
||||||
maps: MailNavFolderMaps
|
maps: MailNavFolderMaps
|
||||||
): boolean {
|
): boolean {
|
||||||
|
const row = labelRowForNavId(folderId, maps.labelRows)
|
||||||
|
if (row && row.enabled === false) return false
|
||||||
const label = maps.folderIdToLabel[folderId]
|
const label = maps.folderIdToLabel[folderId]
|
||||||
if (!label) return false
|
if (!label) return false
|
||||||
return emailHasAnyLabel(email, [label])
|
return emailHasAnyLabel(email, [label])
|
||||||
@ -96,6 +104,24 @@ function matchesLabelNav(
|
|||||||
return matchesFolderLabelRow(email, folderId, maps)
|
return matchesFolderLabelRow(email, folderId, maps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Onglet Principale : boîte sans libellés système « exclude » actifs. */
|
||||||
|
export function emailMatchesInboxPrimaryTab(
|
||||||
|
email: Email,
|
||||||
|
ctx: MailFolderFilterCtx,
|
||||||
|
maps: MailNavFolderMaps,
|
||||||
|
subtreeIdsCache?: Map<string, string[] | null>
|
||||||
|
): boolean {
|
||||||
|
if (!emailMatchesFolder(email, "inbox", ctx, maps, subtreeIdsCache)) return false
|
||||||
|
if (email.deleted) return false
|
||||||
|
if (hasFutureScheduledSend(email)) return false
|
||||||
|
for (const row of maps.labelRows) {
|
||||||
|
if (row.enabled === false) continue
|
||||||
|
if (!row.excludeFromPrincipal) continue
|
||||||
|
if (emailHasAnyLabel(email, [row.label])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
export function emailMatchesFolder(
|
export function emailMatchesFolder(
|
||||||
email: Email,
|
email: Email,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
@ -106,39 +132,38 @@ export function emailMatchesFolder(
|
|||||||
): boolean {
|
): boolean {
|
||||||
const nav = resolveNavMaps(maps)
|
const nav = resolveNavMaps(maps)
|
||||||
|
|
||||||
|
if (email.deleted && folderId !== "trash") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
switch (folderId) {
|
switch (folderId) {
|
||||||
case "inbox":
|
case "inbox":
|
||||||
return isInInbox(email)
|
return isInInbox(email)
|
||||||
case "starred":
|
case "starred":
|
||||||
return effectiveStarred(email, ctx)
|
return effectiveStarred(email, ctx)
|
||||||
case "snoozed":
|
case "snoozed":
|
||||||
return email.labels?.includes("snoozed") ?? false
|
return (email.labels?.includes("snoozed") ?? false) && !email.deleted
|
||||||
case "important":
|
case "important":
|
||||||
return effectiveImportant(email, ctx)
|
return effectiveImportant(email, ctx)
|
||||||
case "sent":
|
case "sent":
|
||||||
return email.labels?.includes("sent") ?? false
|
return (email.labels?.includes("sent") ?? false) && !email.deleted
|
||||||
case "drafts":
|
case "drafts":
|
||||||
return email.labels?.includes("drafts") ?? false
|
return (email.labels?.includes("drafts") ?? false) && !email.deleted
|
||||||
case "scheduled":
|
case "scheduled":
|
||||||
return email.labels?.includes("scheduled") ?? false
|
return !email.deleted && hasFutureScheduledSend(email)
|
||||||
case "spam":
|
case "spam":
|
||||||
return email.spam === true || (email.labels?.includes("spam") ?? false)
|
return (
|
||||||
case "notifications":
|
!email.deleted &&
|
||||||
return email.category === "updates"
|
(email.spam === true || (email.labels?.includes("spam") ?? false))
|
||||||
case "purchases":
|
)
|
||||||
case "travel":
|
case "trash":
|
||||||
case "finance": {
|
return email.deleted === true
|
||||||
const extra = SIDEBAR_CATEGORY_EXTRA_LABEL[folderId]
|
|
||||||
if (!extra) return false
|
|
||||||
return email.labels?.includes(extra) ?? false
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CATEGORY_EMAIL_TAB_IDS.has(folderId)) {
|
const row = labelRowForNavId(folderId, nav.labelRows)
|
||||||
return email.category === folderId
|
if (row && row.enabled === false) return false
|
||||||
}
|
|
||||||
|
|
||||||
if (nav.folderIdToLabel[folderId]) {
|
if (nav.folderIdToLabel[folderId]) {
|
||||||
return matchesLabelNav(email, folderId, nav, subtreeIdsCache)
|
return matchesLabelNav(email, folderId, nav, subtreeIdsCache)
|
||||||
|
|||||||
@ -10,13 +10,13 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
Folder,
|
Folder,
|
||||||
Users,
|
|
||||||
Info,
|
|
||||||
MessageSquare,
|
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
import type { FolderTreeNode } from "@/lib/sidebar-nav-maps"
|
||||||
|
import type { LabelRowItem } from "@/lib/sidebar-nav-data"
|
||||||
|
import { defaultNavLabelRowsSnapshot, labelRowById } from "@/lib/sidebar-nav-data"
|
||||||
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
|
import { findFolderPath } from "@/lib/sidebar-nav-folder-ids"
|
||||||
import { parseMailNavVisitKey } from "@/lib/mail-folder-display"
|
import { parseMailNavVisitKey } from "@/lib/mail-folder-display"
|
||||||
|
import { normalizeInboxTabSegment } from "@/lib/mail-url"
|
||||||
|
|
||||||
const SYSTEM_ICONS: Record<string, LucideIcon> = {
|
const SYSTEM_ICONS: Record<string, LucideIcon> = {
|
||||||
inbox: Inbox,
|
inbox: Inbox,
|
||||||
@ -30,32 +30,32 @@ const SYSTEM_ICONS: Record<string, LucideIcon> = {
|
|||||||
trash: Trash2,
|
trash: Trash2,
|
||||||
}
|
}
|
||||||
|
|
||||||
const INBOX_TAB_ICONS: Record<string, LucideIcon> = {
|
|
||||||
primary: Inbox,
|
|
||||||
promotions: Tag,
|
|
||||||
social: Users,
|
|
||||||
updates: Info,
|
|
||||||
forums: MessageSquare,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MailNavIcon =
|
export type MailNavIcon =
|
||||||
| { kind: "lucide"; Icon: LucideIcon }
|
| { kind: "lucide"; Icon: LucideIcon }
|
||||||
| { kind: "folder-dot"; colorClass: string }
|
| { kind: "folder-dot"; colorClass: string }
|
||||||
|
| { kind: "iconify"; icon: string }
|
||||||
|
|
||||||
export function resolveMailNavIcon(
|
export function resolveMailNavIcon(
|
||||||
visitKey: string,
|
visitKey: string,
|
||||||
folderTree: FolderTreeNode[]
|
folderTree: FolderTreeNode[],
|
||||||
|
labelRows: readonly LabelRowItem[] = defaultNavLabelRowsSnapshot
|
||||||
): MailNavIcon {
|
): MailNavIcon {
|
||||||
const { folderId, inboxTab } = parseMailNavVisitKey(visitKey)
|
const { folderId, inboxTab } = parseMailNavVisitKey(visitKey)
|
||||||
|
|
||||||
if (folderId === "inbox") {
|
if (folderId === "inbox") {
|
||||||
const tab = inboxTab ?? "primary"
|
const tab = normalizeInboxTabSegment(inboxTab ?? "primary")
|
||||||
return { kind: "lucide", Icon: INBOX_TAB_ICONS[tab] ?? Inbox }
|
if (tab === "primary") return { kind: "lucide", Icon: Inbox }
|
||||||
|
const row = labelRowById(labelRows, tab)
|
||||||
|
if (row?.icon) return { kind: "iconify", icon: row.icon }
|
||||||
|
return { kind: "lucide", Icon: Inbox }
|
||||||
}
|
}
|
||||||
|
|
||||||
const system = SYSTEM_ICONS[folderId]
|
const system = SYSTEM_ICONS[folderId]
|
||||||
if (system) return { kind: "lucide", Icon: system }
|
if (system) return { kind: "lucide", Icon: system }
|
||||||
|
|
||||||
|
const navRow = labelRowById(labelRows, folderId)
|
||||||
|
if (navRow?.icon) return { kind: "iconify", icon: navRow.icon }
|
||||||
|
|
||||||
const path = findFolderPath(folderTree, folderId)
|
const path = findFolderPath(folderTree, folderId)
|
||||||
if (path?.length) {
|
if (path?.length) {
|
||||||
const leaf = path[path.length - 1]!
|
const leaf = path[path.length - 1]!
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type { LabelEditState } from "@/lib/stores/mail-store"
|
|||||||
import {
|
import {
|
||||||
folderTree as defaultFolderTree,
|
folderTree as defaultFolderTree,
|
||||||
sidebarNavFolderIdToLabel,
|
sidebarNavFolderIdToLabel,
|
||||||
|
defaultNavLabelRowsSnapshot,
|
||||||
type FolderTreeNode,
|
type FolderTreeNode,
|
||||||
} from "@/lib/sidebar-nav-data"
|
} from "@/lib/sidebar-nav-data"
|
||||||
|
|
||||||
@ -21,17 +22,7 @@ export const MAIN_NAV_FOLDER_IDS = [
|
|||||||
"drafts",
|
"drafts",
|
||||||
"scheduled",
|
"scheduled",
|
||||||
"spam",
|
"spam",
|
||||||
] as const
|
"trash",
|
||||||
|
|
||||||
export const CATEGORY_NAV_IDS = [
|
|
||||||
"purchases",
|
|
||||||
"travel",
|
|
||||||
"social",
|
|
||||||
"notifications",
|
|
||||||
"updates",
|
|
||||||
"forums",
|
|
||||||
"finance",
|
|
||||||
"promotions",
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function collectTreeIds(nodes: FolderTreeNode[]): string[] {
|
function collectTreeIds(nodes: FolderTreeNode[]): string[] {
|
||||||
@ -44,21 +35,23 @@ function collectTreeIds(nodes: FolderTreeNode[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MAIN_SET = new Set<string>(MAIN_NAV_FOLDER_IDS)
|
const MAIN_SET = new Set<string>(MAIN_NAV_FOLDER_IDS)
|
||||||
const CATEGORY_SET = new Set<string>(CATEGORY_NAV_IDS)
|
|
||||||
|
|
||||||
/** Tous les ids de lignes sidebar pour lesquelles on calcule un décompte « non lus ». */
|
/** Tous les ids de lignes sidebar pour lesquelles on calcule un décompte « non lus ». */
|
||||||
export function allSidebarNavFolderIds(maps?: MailNavFolderMaps | null): string[] {
|
export function allSidebarNavFolderIds(maps?: MailNavFolderMaps | null): string[] {
|
||||||
const tree = maps?.folderTree ?? defaultFolderTree
|
const tree = maps?.folderTree ?? defaultFolderTree
|
||||||
|
const rows = maps?.labelRows ?? defaultNavLabelRowsSnapshot
|
||||||
const idToLabel =
|
const idToLabel =
|
||||||
maps?.folderIdToLabel ?? (sidebarNavFolderIdToLabel as Record<string, string>)
|
maps?.folderIdToLabel ?? (sidebarNavFolderIdToLabel as Record<string, string>)
|
||||||
const treeIds = collectTreeIds(tree)
|
const treeIds = collectTreeIds(tree)
|
||||||
const labelRowIds = Object.keys(idToLabel).filter((id) => {
|
const labelRowIds = Object.keys(idToLabel).filter((id) => {
|
||||||
if (MAIN_SET.has(id) || CATEGORY_SET.has(id)) return false
|
if (MAIN_SET.has(id)) return false
|
||||||
if (id === "scheduled") return false
|
if (id === "scheduled") return false
|
||||||
if (treeIds.includes(id)) return false
|
if (treeIds.includes(id)) return false
|
||||||
|
const row = rows.find((r) => r.id === id)
|
||||||
|
if (row && row.enabled === false) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return [...MAIN_NAV_FOLDER_IDS, ...CATEGORY_NAV_IDS, ...treeIds, ...labelRowIds]
|
return [...MAIN_NAV_FOLDER_IDS, ...treeIds, ...labelRowIds]
|
||||||
}
|
}
|
||||||
|
|
||||||
function effectiveRead(
|
function effectiveRead(
|
||||||
|
|||||||
@ -1,16 +1,42 @@
|
|||||||
/** Routage URL sous `/mail` : dossier, onglet boîte de réception, page, message ouvert. */
|
/** Routage URL sous `/mail` : dossier, onglet boîte de réception, page, message ouvert. */
|
||||||
|
|
||||||
|
import {
|
||||||
|
cloneDefaultLabelRows,
|
||||||
|
tabbedInboxLabelRows,
|
||||||
|
} from "@/lib/sidebar-nav-data"
|
||||||
|
|
||||||
export const DEFAULT_MAIL_FOLDER = "inbox"
|
export const DEFAULT_MAIL_FOLDER = "inbox"
|
||||||
export const DEFAULT_INBOX_TAB = "primary"
|
export const DEFAULT_INBOX_TAB = "primary"
|
||||||
|
|
||||||
/** Onglets catégories boîte de réception (alignés sur `categoryTabs` dans email-list). */
|
/** Segments d’URL historiques → ids libellés actuels. */
|
||||||
export const INBOX_CATEGORY_TAB_IDS = new Set([
|
const LEGACY_INBOX_TAB_SEGMENT: Record<string, string> = {
|
||||||
"primary",
|
updates: "mises-a-jour",
|
||||||
"promotions",
|
notifications: "newsletters",
|
||||||
"social",
|
social: "reseaux-sociaux",
|
||||||
"updates",
|
purchases: "achats",
|
||||||
"forums",
|
travel: "deplacements",
|
||||||
])
|
}
|
||||||
|
|
||||||
|
export function normalizeInboxTabSegment(tab: string): string {
|
||||||
|
return LEGACY_INBOX_TAB_SEGMENT[tab] ?? tab
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultInboxTabIdSet(): Set<string> {
|
||||||
|
const s = new Set<string>([DEFAULT_INBOX_TAB])
|
||||||
|
for (const r of tabbedInboxLabelRows(cloneDefaultLabelRows())) {
|
||||||
|
s.add(r.id)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Onglets boîte de réception reconnus dans l’URL (ids courants + segments legacy). */
|
||||||
|
export const INBOX_CATEGORY_TAB_IDS = (() => {
|
||||||
|
const s = defaultInboxTabIdSet()
|
||||||
|
for (const legacy of Object.keys(LEGACY_INBOX_TAB_SEGMENT)) {
|
||||||
|
s.add(legacy)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
})()
|
||||||
|
|
||||||
export type MailRouteState = {
|
export type MailRouteState = {
|
||||||
folderId: string
|
folderId: string
|
||||||
@ -69,7 +95,14 @@ export function parseMailSegments(
|
|||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
const tab = parts[1]!
|
const tab = parts[1]!
|
||||||
if (INBOX_CATEGORY_TAB_IDS.has(tab)) {
|
if (INBOX_CATEGORY_TAB_IDS.has(tab)) {
|
||||||
return { folderId, inboxTab: tab, page, mailId }
|
const inboxTab = normalizeInboxTabSegment(tab)
|
||||||
|
const valid = defaultInboxTabIdSet()
|
||||||
|
return {
|
||||||
|
folderId,
|
||||||
|
inboxTab: valid.has(inboxTab) ? inboxTab : DEFAULT_INBOX_TAB,
|
||||||
|
page,
|
||||||
|
mailId,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { folderId, inboxTab: DEFAULT_INBOX_TAB, page, mailId }
|
return { folderId, inboxTab: DEFAULT_INBOX_TAB, page, mailId }
|
||||||
}
|
}
|
||||||
@ -92,7 +125,7 @@ export function buildMailPath(r: MailRouteState): string {
|
|||||||
const segs: string[] = ["mail", encodeURIComponent(r.folderId)]
|
const segs: string[] = ["mail", encodeURIComponent(r.folderId)]
|
||||||
|
|
||||||
if (r.folderId === "inbox") {
|
if (r.folderId === "inbox") {
|
||||||
const tab = r.inboxTab || DEFAULT_INBOX_TAB
|
const tab = normalizeInboxTabSegment(r.inboxTab || DEFAULT_INBOX_TAB)
|
||||||
if (tab !== DEFAULT_INBOX_TAB) {
|
if (tab !== DEFAULT_INBOX_TAB) {
|
||||||
segs.push(tab)
|
segs.push(tab)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Email } from "@/lib/email-data"
|
import type { Email } from "@/lib/email-data"
|
||||||
|
import { formatMailDetailDate } from "@/lib/mail-date"
|
||||||
import { cleanSenderName } from "@/lib/sender-display"
|
import { cleanSenderName } from "@/lib/sender-display"
|
||||||
|
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
@ -24,7 +25,7 @@ function buildSegments(email: Email): PrintSegment[] {
|
|||||||
segments.push({
|
segments.push({
|
||||||
fromName: cleanSenderName(msg.sender),
|
fromName: cleanSenderName(msg.sender),
|
||||||
fromEmail: msg.senderEmail,
|
fromEmail: msg.senderEmail,
|
||||||
date: msg.date,
|
date: formatMailDetailDate(msg.date),
|
||||||
bodyHtml: msg.body,
|
bodyHtml: msg.body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -37,7 +38,7 @@ function buildSegments(email: Email): PrintSegment[] {
|
|||||||
segments.push({
|
segments.push({
|
||||||
fromName: mainName,
|
fromName: mainName,
|
||||||
fromEmail: mainEmail,
|
fromEmail: mainEmail,
|
||||||
date: email.date,
|
date: formatMailDetailDate(email.date),
|
||||||
bodyHtml:
|
bodyHtml:
|
||||||
email.body ??
|
email.body ??
|
||||||
`<p style="color:#5f6368;margin:0;">${escapeHtml(email.preview)}</p>`,
|
`<p style="color:#5f6368;margin:0;">${escapeHtml(email.preview)}</p>`,
|
||||||
|
|||||||
@ -49,8 +49,19 @@ type SidebarNavContextValue = {
|
|||||||
renameFolderOrLabel: (id: string, newLabel: string) => void
|
renameFolderOrLabel: (id: string, newLabel: string) => void
|
||||||
removeFolderOrLabelRow: (id: string) => void
|
removeFolderOrLabelRow: (id: string) => void
|
||||||
moveFolder: (id: string, newParentId: string | null) => void
|
moveFolder: (id: string, newParentId: string | null) => void
|
||||||
|
reorderLabelRows: (
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after"
|
||||||
|
) => void
|
||||||
|
moveFolderRelative: (
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after" | "inside"
|
||||||
|
) => void
|
||||||
addSubfolder: (parentId: string, name: string) => void
|
addSubfolder: (parentId: string, name: string) => void
|
||||||
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
||||||
|
setLabelRowEnabled: (id: string, enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarNavContext = createContext<SidebarNavContextValue | null>(null)
|
const SidebarNavContext = createContext<SidebarNavContextValue | null>(null)
|
||||||
@ -151,6 +162,31 @@ export function SidebarNavProvider({
|
|||||||
[scheduleRouteFolderIdSync]
|
[scheduleRouteFolderIdSync]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const moveFolderRelative = useCallback(
|
||||||
|
(
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after" | "inside"
|
||||||
|
) => {
|
||||||
|
const idMap = useNavStore
|
||||||
|
.getState()
|
||||||
|
.moveFolderRelative(draggedId, targetId, placement)
|
||||||
|
scheduleRouteFolderIdSync(idMap)
|
||||||
|
},
|
||||||
|
[scheduleRouteFolderIdSync]
|
||||||
|
)
|
||||||
|
|
||||||
|
const reorderLabelRows = useCallback(
|
||||||
|
(
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after"
|
||||||
|
) => {
|
||||||
|
useNavStore.getState().reorderLabelRows(draggedId, targetId, placement)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
folderTree,
|
folderTree,
|
||||||
@ -167,8 +203,11 @@ export function SidebarNavProvider({
|
|||||||
renameFolderOrLabel,
|
renameFolderOrLabel,
|
||||||
removeFolderOrLabelRow,
|
removeFolderOrLabelRow,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
|
reorderLabelRows,
|
||||||
|
moveFolderRelative,
|
||||||
addSubfolder: navActions.addSubfolder,
|
addSubfolder: navActions.addSubfolder,
|
||||||
addChildLabelRow: navActions.addChildLabelRow,
|
addChildLabelRow: navActions.addChildLabelRow,
|
||||||
|
setLabelRowEnabled: navActions.setLabelRowEnabled,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
folderTree,
|
folderTree,
|
||||||
@ -180,6 +219,8 @@ export function SidebarNavProvider({
|
|||||||
renameFolderOrLabel,
|
renameFolderOrLabel,
|
||||||
removeFolderOrLabelRow,
|
removeFolderOrLabelRow,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
|
reorderLabelRows,
|
||||||
|
moveFolderRelative,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -43,7 +43,112 @@ export const folderTree: FolderTreeNode[] = [
|
|||||||
{ id: "folder-factures", label: "Factures", color: "bg-amber-500", count: 42 },
|
{ id: "folder-factures", label: "Factures", color: "bg-amber-500", count: 42 },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const labelItems: readonly LabelRowItem[] = [
|
export function normalizeLabelRow(row: LabelRowItem): LabelRowItem {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
tabbed: row.tabbed ?? false,
|
||||||
|
favorite: row.favorite ?? false,
|
||||||
|
excludeFromPrincipal: row.excludeFromPrincipal ?? false,
|
||||||
|
showInMessageList: row.showInMessageList ?? true,
|
||||||
|
enabled: row.enabled ?? true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libellés navigation système (onglets + catégories) — ids stables proches du titre. */
|
||||||
|
export const SYSTEM_NAV_LABEL_DEFAULTS: readonly LabelRowItem[] = [
|
||||||
|
{
|
||||||
|
id: "promotions",
|
||||||
|
label: "Promotions",
|
||||||
|
color: "bg-[#1e8e3e]",
|
||||||
|
icon: "mdi:tag-outline",
|
||||||
|
tabbed: true,
|
||||||
|
favorite: true,
|
||||||
|
excludeFromPrincipal: true,
|
||||||
|
showInMessageList: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reseaux-sociaux",
|
||||||
|
label: "Réseaux sociaux",
|
||||||
|
color: "bg-[#0b57d0]",
|
||||||
|
icon: "mdi:account-group",
|
||||||
|
tabbed: true,
|
||||||
|
favorite: true,
|
||||||
|
excludeFromPrincipal: true,
|
||||||
|
showInMessageList: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "newsletters",
|
||||||
|
label: "Newsletters",
|
||||||
|
color: "bg-[#e8710a]",
|
||||||
|
icon: "mdi:email-newsletter",
|
||||||
|
tabbed: true,
|
||||||
|
favorite: true,
|
||||||
|
excludeFromPrincipal: true,
|
||||||
|
showInMessageList: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mises-a-jour",
|
||||||
|
label: "Mises à jour",
|
||||||
|
color: "bg-[#c5221f]",
|
||||||
|
icon: "mdi:update",
|
||||||
|
tabbed: true,
|
||||||
|
favorite: true,
|
||||||
|
excludeFromPrincipal: true,
|
||||||
|
showInMessageList: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "forums",
|
||||||
|
label: "Forums",
|
||||||
|
color: "bg-[#9334e6]",
|
||||||
|
icon: "mdi:forum",
|
||||||
|
tabbed: true,
|
||||||
|
favorite: true,
|
||||||
|
excludeFromPrincipal: true,
|
||||||
|
showInMessageList: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "achats",
|
||||||
|
label: "Achats",
|
||||||
|
color: "bg-amber-600",
|
||||||
|
icon: "mdi:cart-outline",
|
||||||
|
tabbed: false,
|
||||||
|
favorite: false,
|
||||||
|
excludeFromPrincipal: false,
|
||||||
|
showInMessageList: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deplacements",
|
||||||
|
label: "Déplacements",
|
||||||
|
color: "bg-teal-600",
|
||||||
|
icon: "mdi:map-marker",
|
||||||
|
tabbed: false,
|
||||||
|
favorite: false,
|
||||||
|
excludeFromPrincipal: false,
|
||||||
|
showInMessageList: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "finance",
|
||||||
|
label: "Finance",
|
||||||
|
color: "bg-indigo-600",
|
||||||
|
icon: "mdi:credit-card-outline",
|
||||||
|
tabbed: false,
|
||||||
|
favorite: false,
|
||||||
|
excludeFromPrincipal: false,
|
||||||
|
showInMessageList: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const SYSTEM_NAV_LABEL_ID_SET = new Set(SYSTEM_NAV_LABEL_DEFAULTS.map((r) => r.id))
|
||||||
|
|
||||||
|
const DEMO_IMAP_LABEL_ROWS: readonly LabelRowItem[] = [
|
||||||
{ id: "imap-sent", label: "[Imap]/Sent", color: "bg-gray-500" },
|
{ id: "imap-sent", label: "[Imap]/Sent", color: "bg-gray-500" },
|
||||||
{ id: "imap-trash", label: "[Imap]/Trash", color: "bg-red-400", count: 4 },
|
{ id: "imap-trash", label: "[Imap]/Trash", color: "bg-red-400", count: 4 },
|
||||||
{ id: "browser-alerts", label: "BrowserAlerts", color: "bg-red-400", count: 1 },
|
{ id: "browser-alerts", label: "BrowserAlerts", color: "bg-red-400", count: 1 },
|
||||||
@ -52,15 +157,48 @@ export const labelItems: readonly LabelRowItem[] = [
|
|||||||
{ id: "twitch", label: "Twitch", color: "bg-purple-500", count: 137 },
|
{ id: "twitch", label: "Twitch", color: "bg-purple-500", count: 137 },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** id de ligne sidebar (dossier hiérarchique ou libellé) → libellé Gmail pour matcher `email.labels`. */
|
/** Tous les libellés par défaut (système + démo IMAP) — ordre stable. */
|
||||||
|
export const DEFAULT_NAV_LABEL_ROWS: LabelRowItem[] = [
|
||||||
|
...SYSTEM_NAV_LABEL_DEFAULTS.map((r) => normalizeLabelRow({ ...r })),
|
||||||
|
...DEMO_IMAP_LABEL_ROWS.map((r) => normalizeLabelRow({ ...r })),
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Snapshot pour filtres quand aucun contexte nav (tests, défaut). */
|
||||||
|
export const defaultNavLabelRowsSnapshot: LabelRowItem[] = DEFAULT_NAV_LABEL_ROWS.map((r) => ({ ...r }))
|
||||||
|
|
||||||
|
export function isSystemNavLabelId(id: string): boolean {
|
||||||
|
return SYSTEM_NAV_LABEL_ID_SET.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Libellés visibles en onglets boîte de réception (hors Principale). */
|
||||||
|
export function tabbedInboxLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
|
||||||
|
return rows.filter((r) => r.enabled !== false && r.tabbed === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function favoriteNavLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
|
||||||
|
return rows.filter((r) => r.enabled !== false && r.favorite === true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nonTabbedNavLabelRows(rows: readonly LabelRowItem[]): LabelRowItem[] {
|
||||||
|
return rows.filter((r) => r.enabled !== false && r.tabbed !== true && SYSTEM_NAV_LABEL_ID_SET.has(r.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelRowById(
|
||||||
|
rows: readonly LabelRowItem[],
|
||||||
|
id: string
|
||||||
|
): LabelRowItem | undefined {
|
||||||
|
return rows.find((r) => r.id === id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** id de ligne sidebar (dossier hiérarchique ou libellé) → libellé pour matcher `email.labels`. */
|
||||||
export const sidebarNavFolderIdToLabel: Readonly<Record<string, string>> =
|
export const sidebarNavFolderIdToLabel: Readonly<Record<string, string>> =
|
||||||
buildFolderIdToLabelRecord(folderTree, labelItems)
|
buildFolderIdToLabelRecord(folderTree, defaultNavLabelRowsSnapshot)
|
||||||
|
|
||||||
/** Libellé Gmail (comme dans `email.labels`) → id de ligne sidebar correspondant. */
|
/** Libellé Gmail (comme dans `email.labels`) → id de ligne sidebar correspondant. */
|
||||||
export const emailLabelToSidebarFolderId: Readonly<Record<string, string>> =
|
export const emailLabelToSidebarFolderId: Readonly<Record<string, string>> =
|
||||||
buildEmailLabelToSidebarFolderId(sidebarNavFolderIdToLabel)
|
buildEmailLabelToSidebarFolderId(sidebarNavFolderIdToLabel)
|
||||||
|
|
||||||
/** Libellés navigation (boîtes, catégories latérales) — alignés sur la sidebar. */
|
/** Libellés navigation dossiers système — hors libellés définis dans `labelRows`. */
|
||||||
const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
|
const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
|
||||||
inbox: "Boîte de réception",
|
inbox: "Boîte de réception",
|
||||||
starred: "Messages suivis",
|
starred: "Messages suivis",
|
||||||
@ -70,14 +208,7 @@ const STATIC_NAV_FOLDER_LABELS: Record<string, string> = {
|
|||||||
drafts: "Brouillons",
|
drafts: "Brouillons",
|
||||||
scheduled: "Planifié",
|
scheduled: "Planifié",
|
||||||
spam: "Indésirables",
|
spam: "Indésirables",
|
||||||
purchases: "Achats",
|
trash: "Corbeille",
|
||||||
travel: "Déplacements",
|
|
||||||
social: "Réseaux sociaux",
|
|
||||||
notifications: "Notifications",
|
|
||||||
updates: "Mises à jour",
|
|
||||||
forums: "Forums",
|
|
||||||
finance: "Finance",
|
|
||||||
promotions: "Promotions",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Libellé lisible pour id de ligne (liste vide, messages d’état). Dossiers / libellés IMAP viennent de l’arbre. */
|
/** Libellé lisible pour id de ligne (liste vide, messages d’état). Dossiers / libellés IMAP viennent de l’arbre. */
|
||||||
@ -93,10 +224,59 @@ export function getMailNavFolderLabel(
|
|||||||
return folderId
|
return folderId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function inboxTabDisplayLabel(
|
||||||
|
inboxTab: string,
|
||||||
|
labelRows: readonly LabelRowItem[],
|
||||||
|
folderIdToLabel: Record<string, string>
|
||||||
|
): string {
|
||||||
|
if (inboxTab === "primary") return "Principale"
|
||||||
|
const row = labelRowById(labelRows, inboxTab)
|
||||||
|
if (row && row.enabled !== false) return row.label
|
||||||
|
return getMailNavFolderLabel(inboxTab, folderIdToLabel)
|
||||||
|
}
|
||||||
|
|
||||||
export function cloneDefaultFolderTree(): FolderTreeNode[] {
|
export function cloneDefaultFolderTree(): FolderTreeNode[] {
|
||||||
return structuredClone(folderTree)
|
return structuredClone(folderTree)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cloneDefaultLabelRows(): LabelRowItem[] {
|
export function cloneDefaultLabelRows(): LabelRowItem[] {
|
||||||
return labelItems.map((r) => ({ ...r }))
|
return DEFAULT_NAV_LABEL_ROWS.map((r) => ({ ...r }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_LABEL_ROW_ID_MAP: Record<string, string> = {
|
||||||
|
purchases: "achats",
|
||||||
|
travel: "deplacements",
|
||||||
|
social: "reseaux-sociaux",
|
||||||
|
updates: "mises-a-jour",
|
||||||
|
notifications: "newsletters",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fusionne la persistance avec les défauts (ids système + nouveaux champs). */
|
||||||
|
export function reconcileLabelRowsFromPersisted(persisted: LabelRowItem[] | undefined): LabelRowItem[] {
|
||||||
|
const defaults = cloneDefaultLabelRows()
|
||||||
|
const defaultIds = new Set(defaults.map((r) => r.id))
|
||||||
|
|
||||||
|
const persistByResolvedId = new Map<string, LabelRowItem>()
|
||||||
|
for (const p of persisted ?? []) {
|
||||||
|
const resolvedId = LEGACY_LABEL_ROW_ID_MAP[p.id] ?? p.id
|
||||||
|
persistByResolvedId.set(resolvedId, { ...p, id: resolvedId })
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = defaults.map((d) => {
|
||||||
|
const p = persistByResolvedId.get(d.id)
|
||||||
|
if (!p) return { ...d }
|
||||||
|
return normalizeLabelRow({ ...d, ...p, id: d.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
const mergedIds = new Set(merged.map((r) => r.id))
|
||||||
|
const extras: LabelRowItem[] = []
|
||||||
|
for (const p of persisted ?? []) {
|
||||||
|
const resolvedId = LEGACY_LABEL_ROW_ID_MAP[p.id] ?? p.id
|
||||||
|
if (!mergedIds.has(resolvedId)) {
|
||||||
|
extras.push(normalizeLabelRow({ ...p, id: resolvedId }))
|
||||||
|
mergedIds.add(resolvedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...merged, ...extras]
|
||||||
}
|
}
|
||||||
|
|||||||
64
lib/sidebar-nav-dnd.ts
Normal file
64
lib/sidebar-nav-dnd.ts
Normal 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"
|
||||||
|
}
|
||||||
@ -11,6 +11,18 @@ export type LabelRowItem = {
|
|||||||
label: string
|
label: string
|
||||||
color: string
|
color: string
|
||||||
count?: number
|
count?: number
|
||||||
|
/** Icône Iconify (ex. mdi:inbox) — onglets + sidebar pour libellés système. */
|
||||||
|
icon?: string
|
||||||
|
/** Onglet boîte de réception (Principale + ces onglets). */
|
||||||
|
tabbed?: boolean
|
||||||
|
/** Bloc favoris sous les dossiers principaux. */
|
||||||
|
favorite?: boolean
|
||||||
|
/** Masque de Principale quand le libellé est sur le mail (si activé). */
|
||||||
|
excludeFromPrincipal?: boolean
|
||||||
|
/** Pastille dans la liste / en-tête message. */
|
||||||
|
showInMessageList?: boolean
|
||||||
|
/** Désactivé : pas d’onglet, pas de sidebar, pas de pastille, exclude ignoré. */
|
||||||
|
enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFolderIdToLabelRecord(
|
export function buildFolderIdToLabelRecord(
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage
|
|||||||
import {
|
import {
|
||||||
cloneDefaultFolderTree,
|
cloneDefaultFolderTree,
|
||||||
cloneDefaultLabelRows,
|
cloneDefaultLabelRows,
|
||||||
|
isSystemNavLabelId,
|
||||||
|
normalizeLabelRow,
|
||||||
|
reconcileLabelRowsFromPersisted,
|
||||||
type FolderTreeNode,
|
type FolderTreeNode,
|
||||||
type LabelRowItem,
|
type LabelRowItem,
|
||||||
} from "@/lib/sidebar-nav-data"
|
} from "@/lib/sidebar-nav-data"
|
||||||
@ -102,6 +105,73 @@ function isDescendantOf(tree: FolderTreeNode[], maybeDescendantId: string, ances
|
|||||||
return collectSubtreeIds(anc).has(maybeDescendantId)
|
return collectSubtreeIds(anc).has(maybeDescendantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FolderNodeLocation = {
|
||||||
|
parentId: string | null
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNodeLocation(
|
||||||
|
nodes: FolderTreeNode[],
|
||||||
|
id: string,
|
||||||
|
parentId: string | null = null
|
||||||
|
): FolderNodeLocation | null {
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
if (nodes[i].id === id) return { parentId, index: i }
|
||||||
|
const children = nodes[i].children
|
||||||
|
if (children?.length) {
|
||||||
|
const hit = findNodeLocation(children, id, nodes[i].id)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertNodeAtParentIndex(
|
||||||
|
nodes: FolderTreeNode[],
|
||||||
|
parentId: string | null,
|
||||||
|
index: number,
|
||||||
|
node: FolderTreeNode
|
||||||
|
): FolderTreeNode[] {
|
||||||
|
if (parentId === null) {
|
||||||
|
const next = [...nodes]
|
||||||
|
next.splice(Math.max(0, Math.min(index, next.length)), 0, node)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return nodes.map((n) => {
|
||||||
|
if (n.id === parentId) {
|
||||||
|
const children = [...(n.children ?? [])]
|
||||||
|
children.splice(Math.max(0, Math.min(index, children.length)), 0, node)
|
||||||
|
return { ...n, children }
|
||||||
|
}
|
||||||
|
if (n.children?.length) {
|
||||||
|
return { ...n, children: insertNodeAtParentIndex(n.children, parentId, index, node) }
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFolderTreeRekey(
|
||||||
|
tree: FolderTreeNode[],
|
||||||
|
movedNodeId: string,
|
||||||
|
labelRowIds: string[],
|
||||||
|
navItemPrefs: Record<string, NavItemPrefs>
|
||||||
|
): {
|
||||||
|
tree: FolderTreeNode[]
|
||||||
|
navItemPrefs: Record<string, NavItemPrefs>
|
||||||
|
idMap: Record<string, string>
|
||||||
|
} {
|
||||||
|
const rk = rekeyFolderSubtreeAt(tree, movedNodeId, labelRowIds)
|
||||||
|
const idMap = rk?.idMap ?? {}
|
||||||
|
if (rk && Object.keys(idMap).length > 0) {
|
||||||
|
return {
|
||||||
|
tree: rk.tree,
|
||||||
|
navItemPrefs: remapNavItemPrefs(navItemPrefs, idMap),
|
||||||
|
idMap,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tree, navItemPrefs, idMap: {} }
|
||||||
|
}
|
||||||
|
|
||||||
function extractNode(
|
function extractNode(
|
||||||
nodes: FolderTreeNode[],
|
nodes: FolderTreeNode[],
|
||||||
id: string
|
id: string
|
||||||
@ -162,7 +232,18 @@ type NavStoreActions = {
|
|||||||
renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null }
|
renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record<string, string>; emailRename: { from: string; to: string } | null }
|
||||||
removeFolderOrLabelRow: (id: string) => string[]
|
removeFolderOrLabelRow: (id: string) => string[]
|
||||||
moveFolder: (id: string, newParentId: string | null) => Record<string, string>
|
moveFolder: (id: string, newParentId: string | null) => Record<string, string>
|
||||||
|
reorderLabelRows: (
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after"
|
||||||
|
) => void
|
||||||
|
moveFolderRelative: (
|
||||||
|
draggedId: string,
|
||||||
|
targetId: string,
|
||||||
|
placement: "before" | "after" | "inside"
|
||||||
|
) => Record<string, string>
|
||||||
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
addChildLabelRow: (parentLabelRowId: string, childName: string) => void
|
||||||
|
setLabelRowEnabled: (id: string, enabled: boolean) => void
|
||||||
|
|
||||||
/** Derived selectors */
|
/** Derived selectors */
|
||||||
getFolderIdToLabel: () => Record<string, string>
|
getFolderIdToLabel: () => Record<string, string>
|
||||||
@ -195,7 +276,17 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
if (known.has(label.toLowerCase())) return s
|
if (known.has(label.toLowerCase())) return s
|
||||||
const ids = new Set(s.labelRows.map((r) => r.id))
|
const ids = new Set(s.labelRows.map((r) => r.id))
|
||||||
const id = uniqueLabelRowId(newLabelRowId(label), ids)
|
const id = uniqueLabelRowId(newLabelRowId(label), ids)
|
||||||
return { labelRows: [...s.labelRows, { id, label, color: "bg-gray-500" }] }
|
const row = normalizeLabelRow({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
color: "bg-gray-500",
|
||||||
|
tabbed: false,
|
||||||
|
favorite: false,
|
||||||
|
excludeFromPrincipal: false,
|
||||||
|
showInMessageList: true,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
return { labelRows: [...s.labelRows, row] }
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -251,18 +342,24 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
let nextRows = prev.labelRows
|
let nextRows = prev.labelRows
|
||||||
let navItemPrefs = prev.navItemPrefs
|
let navItemPrefs = prev.navItemPrefs
|
||||||
if (inRow) {
|
if (inRow) {
|
||||||
const usedIds = new Set([
|
if (isSystemNavLabelId(id)) {
|
||||||
...collectFolderIdsInTree(prev.folderTree),
|
nextRows = prev.labelRows.map((r) =>
|
||||||
...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id),
|
r.id === id ? { ...r, label: nextLabel } : r
|
||||||
])
|
)
|
||||||
const newRowId = uniqueLabelRowId(newLabelRowId(nextLabel), usedIds)
|
} else {
|
||||||
const rowMap: Record<string, string> = newRowId !== id ? { [id]: newRowId } : {}
|
const usedIds = new Set([
|
||||||
nextRows = prev.labelRows.map((r) =>
|
...collectFolderIdsInTree(prev.folderTree),
|
||||||
r.id === id ? { ...r, id: newRowId, label: nextLabel } : r
|
...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id),
|
||||||
)
|
])
|
||||||
if (Object.keys(rowMap).length > 0) {
|
const newRowId = uniqueLabelRowId(newLabelRowId(nextLabel), usedIds)
|
||||||
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, rowMap)
|
const rowMap: Record<string, string> = newRowId !== id ? { [id]: newRowId } : {}
|
||||||
resultIdMap = rowMap
|
nextRows = prev.labelRows.map((r) =>
|
||||||
|
r.id === id ? { ...r, id: newRowId, label: nextLabel } : r
|
||||||
|
)
|
||||||
|
if (Object.keys(rowMap).length > 0) {
|
||||||
|
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, rowMap)
|
||||||
|
resultIdMap = rowMap
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel })
|
nextTree = updateNodeInTree(prev.folderTree, id, { label: nextLabel })
|
||||||
@ -281,6 +378,7 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
removeFolderOrLabelRow: (id) => {
|
removeFolderOrLabelRow: (id) => {
|
||||||
|
if (isSystemNavLabelId(id)) return []
|
||||||
const snap = get()
|
const snap = get()
|
||||||
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
|
const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows)
|
||||||
const row = snap.labelRows.find((r) => r.id === id)
|
const row = snap.labelRows.find((r) => r.id === id)
|
||||||
@ -339,14 +437,81 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
let tree = ex.next
|
let tree = ex.next
|
||||||
tree = insertFolderChild(tree, newParentId, ex.extracted)
|
tree = insertFolderChild(tree, newParentId, ex.extracted)
|
||||||
const movedOldId = ex.extracted.id
|
const movedOldId = ex.extracted.id
|
||||||
const rk = rekeyFolderSubtreeAt(tree, movedOldId, prev.labelRows.map((r) => r.id))
|
const rekeyed = applyFolderTreeRekey(
|
||||||
const idMap = rk?.idMap ?? {}
|
tree,
|
||||||
|
movedOldId,
|
||||||
|
prev.labelRows.map((r) => r.id),
|
||||||
|
prev.navItemPrefs
|
||||||
|
)
|
||||||
|
tree = rekeyed.tree
|
||||||
|
resultIdMap = rekeyed.idMap
|
||||||
|
return { folderTree: tree, navItemPrefs: rekeyed.navItemPrefs }
|
||||||
|
})
|
||||||
|
return resultIdMap
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderLabelRows: (draggedId, targetId, placement) => {
|
||||||
|
if (draggedId === targetId) return
|
||||||
|
if (isSystemNavLabelId(draggedId) || isSystemNavLabelId(targetId)) return
|
||||||
|
set((prev) => {
|
||||||
|
const from = prev.labelRows.findIndex((r) => r.id === draggedId)
|
||||||
|
const targetIdx = prev.labelRows.findIndex((r) => r.id === targetId)
|
||||||
|
if (from < 0 || targetIdx < 0) return prev
|
||||||
|
let toIndex = placement === "before" ? targetIdx : targetIdx + 1
|
||||||
|
if (from < toIndex) toIndex -= 1
|
||||||
|
const next = [...prev.labelRows]
|
||||||
|
const [item] = next.splice(from, 1)
|
||||||
|
next.splice(toIndex, 0, item)
|
||||||
|
return { labelRows: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
moveFolderRelative: (draggedId, targetId, placement) => {
|
||||||
|
let resultIdMap: Record<string, string> = {}
|
||||||
|
set((prev) => {
|
||||||
|
if (draggedId === targetId) return prev
|
||||||
|
if (isDescendantOf(prev.folderTree, targetId, draggedId)) return prev
|
||||||
|
|
||||||
|
const draggedLoc = findNodeLocation(prev.folderTree, draggedId)
|
||||||
|
const targetLoc = findNodeLocation(prev.folderTree, targetId)
|
||||||
|
if (!targetLoc) return prev
|
||||||
|
|
||||||
|
const ex = extractNode(prev.folderTree, draggedId)
|
||||||
|
if (!ex.extracted) return prev
|
||||||
|
|
||||||
|
let tree = ex.next
|
||||||
let navItemPrefs = prev.navItemPrefs
|
let navItemPrefs = prev.navItemPrefs
|
||||||
if (rk && Object.keys(idMap).length > 0) {
|
|
||||||
tree = rk.tree
|
if (placement === "inside") {
|
||||||
navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, idMap)
|
if (draggedId === targetId) return prev
|
||||||
resultIdMap = idMap
|
tree = insertFolderChild(tree, targetId, ex.extracted)
|
||||||
|
} else {
|
||||||
|
let insertIndex =
|
||||||
|
placement === "before" ? targetLoc.index : targetLoc.index + 1
|
||||||
|
if (
|
||||||
|
draggedLoc &&
|
||||||
|
draggedLoc.parentId === targetLoc.parentId &&
|
||||||
|
draggedLoc.index < insertIndex
|
||||||
|
) {
|
||||||
|
insertIndex -= 1
|
||||||
|
}
|
||||||
|
tree = insertNodeAtParentIndex(
|
||||||
|
tree,
|
||||||
|
targetLoc.parentId,
|
||||||
|
insertIndex,
|
||||||
|
ex.extracted
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rekeyed = applyFolderTreeRekey(
|
||||||
|
tree,
|
||||||
|
ex.extracted.id,
|
||||||
|
prev.labelRows.map((r) => r.id),
|
||||||
|
navItemPrefs
|
||||||
|
)
|
||||||
|
tree = rekeyed.tree
|
||||||
|
navItemPrefs = rekeyed.navItemPrefs
|
||||||
|
resultIdMap = rekeyed.idMap
|
||||||
return { folderTree: tree, navItemPrefs }
|
return { folderTree: tree, navItemPrefs }
|
||||||
})
|
})
|
||||||
return resultIdMap
|
return resultIdMap
|
||||||
@ -364,11 +529,30 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
const ids = new Set(s.labelRows.map((r) => r.id))
|
const ids = new Set(s.labelRows.map((r) => r.id))
|
||||||
const nid = uniqueLabelRowId(newLabelRowId(combined), ids)
|
const nid = uniqueLabelRowId(newLabelRowId(combined), ids)
|
||||||
return {
|
return {
|
||||||
labelRows: [...s.labelRows, { id: nid, label: combined, color: parent.color ?? "bg-gray-500" }],
|
labelRows: [
|
||||||
|
...s.labelRows,
|
||||||
|
normalizeLabelRow({
|
||||||
|
id: nid,
|
||||||
|
label: combined,
|
||||||
|
color: parent.color ?? "bg-gray-500",
|
||||||
|
tabbed: false,
|
||||||
|
favorite: false,
|
||||||
|
excludeFromPrincipal: false,
|
||||||
|
showInMessageList: true,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setLabelRowEnabled: (id, enabled) =>
|
||||||
|
set((s) => ({
|
||||||
|
labelRows: s.labelRows.map((r) =>
|
||||||
|
r.id === id ? normalizeLabelRow({ ...r, enabled }) : r
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
getFolderIdToLabel: () => {
|
getFolderIdToLabel: () => {
|
||||||
const s = get()
|
const s = get()
|
||||||
return buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
|
return buildFolderIdToLabelRecord(s.folderTree, s.labelRows)
|
||||||
@ -388,7 +572,19 @@ export const useNavStore = create<NavStoreState & NavStoreActions>()(
|
|||||||
{
|
{
|
||||||
name: "ultimail-nav-state",
|
name: "ultimail-nav-state",
|
||||||
storage: debouncedPersistJSONStorage,
|
storage: debouncedPersistJSONStorage,
|
||||||
version: 1,
|
version: 2,
|
||||||
|
migrate: (persisted, fromVersion) => {
|
||||||
|
if (fromVersion < 2 && persisted && typeof persisted === "object") {
|
||||||
|
const p = persisted as { labelRows?: LabelRowItem[] }
|
||||||
|
if (Array.isArray(p.labelRows)) {
|
||||||
|
return {
|
||||||
|
...persisted,
|
||||||
|
labelRows: reconcileLabelRowsFromPersisted(p.labelRows),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return persisted
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -66,11 +66,10 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
|||||||
subject: payload.subject.trim() || "(Sans objet)",
|
subject: payload.subject.trim() || "(Sans objet)",
|
||||||
preview: payload.previewText.slice(0, 200),
|
preview: payload.previewText.slice(0, 200),
|
||||||
body: payload.bodyHtml,
|
body: payload.bodyHtml,
|
||||||
date: "",
|
date: payload.sendAtIso,
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["scheduled"],
|
labels: ["scheduled"],
|
||||||
scheduledSendAt: payload.sendAtIso,
|
scheduledSendAt: payload.sendAtIso,
|
||||||
scheduledToName: toName,
|
scheduledToName: toName,
|
||||||
@ -106,7 +105,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
|||||||
scheduledToName: undefined,
|
scheduledToName: undefined,
|
||||||
snoozeWakeAt: wake.toISOString(),
|
snoozeWakeAt: wake.toISOString(),
|
||||||
sender: row.scheduledToName ?? row.sender,
|
sender: row.scheduledToName ?? row.sender,
|
||||||
date: wake.toLocaleString("fr-FR", { dateStyle: "medium", timeStyle: "short" }),
|
|
||||||
read: true,
|
read: true,
|
||||||
},
|
},
|
||||||
...s.snoozedEmails,
|
...s.snoozedEmails,
|
||||||
@ -171,11 +169,10 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
|||||||
subject: row.subject,
|
subject: row.subject,
|
||||||
preview: row.preview,
|
preview: row.preview,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
date: now.toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }),
|
date: now.toISOString(),
|
||||||
read: true,
|
read: true,
|
||||||
starred: false,
|
starred: false,
|
||||||
important: false,
|
important: false,
|
||||||
category: "primary",
|
|
||||||
labels: ["sent"],
|
labels: ["sent"],
|
||||||
},
|
},
|
||||||
...s.sentPlaceholderEmails,
|
...s.sentPlaceholderEmails,
|
||||||
@ -200,10 +197,6 @@ export const useScheduledStore = create<ScheduledStoreState & ScheduledStoreActi
|
|||||||
snoozeWakeAt: wakeIso,
|
snoozeWakeAt: wakeIso,
|
||||||
scheduledSendAt: undefined,
|
scheduledSendAt: undefined,
|
||||||
scheduledToName: undefined,
|
scheduledToName: undefined,
|
||||||
date: wake.toLocaleString("fr-FR", {
|
|
||||||
dateStyle: "medium",
|
|
||||||
timeStyle: "short",
|
|
||||||
}),
|
|
||||||
read: true,
|
read: true,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import type {
|
|||||||
ThreadComposeKind,
|
ThreadComposeKind,
|
||||||
} from "@/lib/compose-context"
|
} from "@/lib/compose-context"
|
||||||
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
|
import { DEFAULT_IDENTITIES, SIGNATURES } from "@/lib/compose-context"
|
||||||
|
import { formatMailDetailDate } from "@/lib/mail-date"
|
||||||
import { cleanSenderName } from "@/lib/sender-display"
|
import { cleanSenderName } from "@/lib/sender-display"
|
||||||
|
|
||||||
function appendDefaultSignature(html: string): string {
|
function appendDefaultSignature(html: string): string {
|
||||||
@ -107,12 +108,16 @@ function inReplyToFor(email: Email): string {
|
|||||||
return `<thread-msg-${email.id}@local>`
|
return `<thread-msg-${email.id}@local>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatQuoteDate(date: string, senderEmail: string, senderName: string): string {
|
function formatQuoteDate(
|
||||||
|
dateIso: string,
|
||||||
|
senderEmail: string,
|
||||||
|
senderName: string
|
||||||
|
): string {
|
||||||
const who =
|
const who =
|
||||||
senderName && senderName !== senderEmail
|
senderName && senderName !== senderEmail
|
||||||
? `${escapeHtml(senderName)} <${escapeHtml(senderEmail)}>`
|
? `${escapeHtml(senderName)} <${escapeHtml(senderEmail)}>`
|
||||||
: escapeHtml(senderEmail)
|
: escapeHtml(senderEmail)
|
||||||
return `Le ${escapeHtml(date)}, ${who} a écrit :`
|
return `Le ${escapeHtml(formatMailDetailDate(dateIso))}, ${who} a écrit :`
|
||||||
}
|
}
|
||||||
|
|
||||||
function quotedBlock(html: string): string {
|
function quotedBlock(html: string): string {
|
||||||
@ -146,7 +151,7 @@ function forwardConversationHtml(email: Email): string {
|
|||||||
blocks.push(
|
blocks.push(
|
||||||
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
||||||
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} <${escapeHtml(m.senderEmail)}><br/>` +
|
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(m.sender))} <${escapeHtml(m.senderEmail)}><br/>` +
|
||||||
`<strong>Date :</strong> ${escapeHtml(m.date)}</div>${m.body}</div>`
|
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(m.date))}</div>${m.body}</div>`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const mainAddr =
|
const mainAddr =
|
||||||
@ -155,7 +160,7 @@ function forwardConversationHtml(email: Email): string {
|
|||||||
blocks.push(
|
blocks.push(
|
||||||
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
`<div style="margin-top:12px;padding-top:12px;border-top:1px solid #e0e0e0">` +
|
||||||
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
`<div style="color:#666;font-size:12px;margin-bottom:8px"><strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
||||||
`<strong>Date :</strong> ${escapeHtml(email.date)}</div>` +
|
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}</div>` +
|
||||||
`${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>`
|
`${email.body ?? `<p style="color:#5f6368">${escapeHtml(email.preview)}</p>`}</div>`
|
||||||
)
|
)
|
||||||
return blocks.join("")
|
return blocks.join("")
|
||||||
@ -169,7 +174,7 @@ function forwardBodyHtml(email: Email): string {
|
|||||||
`<p>---------- Forwarded message ---------</p>` +
|
`<p>---------- Forwarded message ---------</p>` +
|
||||||
`<p style="color:#222;font-size:13px;line-height:1.5">` +
|
`<p style="color:#222;font-size:13px;line-height:1.5">` +
|
||||||
`<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
`<strong>De :</strong> ${escapeHtml(cleanSenderName(email.sender))} <${escapeHtml(mainAddr)}><br/>` +
|
||||||
`<strong>Date :</strong> ${escapeHtml(email.date)}<br/>` +
|
`<strong>Date :</strong> ${escapeHtml(formatMailDetailDate(email.date))}<br/>` +
|
||||||
`<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` +
|
`<strong>Objet :</strong> ${escapeHtml(email.subject)}<br/>` +
|
||||||
`<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
|
`<strong>${escapeHtml(forwardParticipantsLine(email))}</strong></p>`
|
||||||
return `<p></p>${header}${forwardConversationHtml(email)}`
|
return `<p></p>${header}${forwardConversationHtml(email)}`
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/dev/types/routes.d.ts";
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -64,6 +64,7 @@
|
|||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"date-fns-tz": "^3.2.0",
|
"date-fns-tz": "^3.2.0",
|
||||||
|
"dayjs": "^1.11.20",
|
||||||
"embla-carousel-react": "8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"emoji-mart": "^5.6.0",
|
"emoji-mart": "^5.6.0",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
|
|||||||
@ -161,6 +161,9 @@ importers:
|
|||||||
date-fns-tz:
|
date-fns-tz:
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0(date-fns@4.1.0)
|
version: 3.2.0(date-fns@4.1.0)
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.20
|
||||||
|
version: 1.11.20
|
||||||
embla-carousel-react:
|
embla-carousel-react:
|
||||||
specifier: 8.6.0
|
specifier: 8.6.0
|
||||||
version: 8.6.0(react@19.2.4)
|
version: 8.6.0(react@19.2.4)
|
||||||
@ -1590,6 +1593,9 @@ packages:
|
|||||||
date-fns@4.1.0:
|
date-fns@4.1.0:
|
||||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||||
|
|
||||||
|
dayjs@1.11.20:
|
||||||
|
resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
|
||||||
|
|
||||||
decimal.js-light@2.5.1:
|
decimal.js-light@2.5.1:
|
||||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
@ -3367,6 +3373,8 @@ snapshots:
|
|||||||
|
|
||||||
date-fns@4.1.0: {}
|
date-fns@4.1.0: {}
|
||||||
|
|
||||||
|
dayjs@1.11.20: {}
|
||||||
|
|
||||||
decimal.js-light@2.5.1: {}
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user