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