"use client" import { create } from "zustand" import { persist } from "zustand/middleware" import { debouncedPersistJSONStorage } from "@/lib/stores/debounced-json-storage" import { cloneDefaultFolderTree, cloneDefaultLabelRows, isSystemNavLabelId, normalizeLabelRow, reconcileLabelRowsFromPersisted, type FolderTreeNode, type LabelRowItem, } from "@/lib/sidebar-nav-data" import { collectFolderIdsInTree, proposeNewFolderId, rekeyFolderSubtreeAt, slugifyNavSegment, } from "@/lib/sidebar-nav-folder-ids" import { buildEmailLabelToSidebarFolderId, buildFolderIdToLabelRecord, } from "@/lib/sidebar-nav-maps" export type LabelListSidebarVisibility = "show" | "showUnread" | "hide" export type LabelInMessageListVisibility = "show" | "hide" export type NavItemPrefs = { sidebar?: LabelListSidebarVisibility messages?: LabelInMessageListVisibility } type NavStoreState = { folderTree: FolderTreeNode[] labelRows: LabelRowItem[] navItemPrefs: Record } function newLabelRowId(label: string): string { const slug = slugifyNavSegment(label) return `lbl-${slug || "libelle"}` } function allLabelTextsLower(tree: FolderTreeNode[], rows: LabelRowItem[]): Set { const s = new Set() const walk = (nodes: FolderTreeNode[]) => { for (const n of nodes) { s.add(n.label.toLowerCase()) if (n.children?.length) walk(n.children) } } walk(tree) for (const r of rows) s.add(r.label.toLowerCase()) return s } function insertFolderChild( nodes: FolderTreeNode[], parentId: string | null, child: FolderTreeNode ): FolderTreeNode[] { if (parentId === null) return [...nodes, child] return nodes.map((n) => { if (n.id === parentId) return { ...n, children: [...(n.children ?? []), child] } if (n.children?.length) return { ...n, children: insertFolderChild(n.children, parentId, child) } return n }) } function uniqueLabelRowId(base: string, existingIds: Set): string { let id = base let n = 0 while (existingIds.has(id)) { n += 1 id = `${base}-${n}` } return id } function findNodeInTree(nodes: FolderTreeNode[], id: string): FolderTreeNode | null { for (const n of nodes) { if (n.id === id) return n if (n.children?.length) { const hit = findNodeInTree(n.children, id) if (hit) return hit } } return null } function collectSubtreeIds(node: FolderTreeNode): Set { const s = new Set([node.id]) if (node.children?.length) { for (const c of node.children) { for (const x of collectSubtreeIds(c)) s.add(x) } } return s } function isDescendantOf(tree: FolderTreeNode[], maybeDescendantId: string, ancestorId: string): boolean { const anc = findNodeInTree(tree, ancestorId) if (!anc) return false 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 ): { tree: FolderTreeNode[] navItemPrefs: Record idMap: Record } { 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 ): { next: FolderTreeNode[]; extracted: FolderTreeNode | null } { for (let i = 0; i < nodes.length; i++) { const n = nodes[i] if (n.id === id) { return { next: [...nodes.slice(0, i), ...nodes.slice(i + 1)], extracted: n } } if (n.children?.length) { const inner = extractNode(n.children, id) if (inner.extracted) { return { next: nodes.map((x, j) => (j === i ? { ...x, children: inner.next } : x)), extracted: inner.extracted, } } } } return { next: nodes, extracted: null } } function updateNodeInTree( nodes: FolderTreeNode[], id: string, patch: Partial> ): FolderTreeNode[] { return nodes.map((n) => { if (n.id === id) return { ...n, ...patch } if (n.children?.length) return { ...n, children: updateNodeInTree(n.children, id, patch) } return n }) } function remapNavItemPrefs( prefs: Record, idMap: Record ): Record { if (Object.keys(idMap).length === 0) return prefs const next = { ...prefs } for (const [oldId, newId] of Object.entries(idMap)) { const slice = next[oldId] if (!slice) continue delete next[oldId] next[newId] = { ...next[newId], ...slice } } return next } type NavStoreActions = { setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void ensureLabelRowForLabelText: (raw: string) => void addLabelRowFromSidebar: (raw: string) => void addFolder: (parentId: string | null, name: string) => void addSubfolder: (parentId: string, name: string) => void updateFolderOrLabelColor: (id: string, color: string) => void renameFolderOrLabel: (id: string, newLabel: string) => { idMap: Record; emailRename: { from: string; to: string } | null } removeFolderOrLabelRow: (id: string) => string[] moveFolder: (id: string, newParentId: string | null) => Record reorderLabelRows: ( draggedId: string, targetId: string, placement: "before" | "after" ) => void moveFolderRelative: ( draggedId: string, targetId: string, placement: "before" | "after" | "inside" ) => Record addChildLabelRow: (parentLabelRowId: string, childName: string) => void setLabelRowEnabled: (id: string, enabled: boolean) => void /** Derived selectors */ getFolderIdToLabel: () => Record getEmailLabelToSidebarFolderId: () => Record getNavItemPrefs: (id: string) => Required> hydrateFolderTreeFromApi: (tree: FolderTreeNode[]) => void hydrateLabelRowsFromApi: (rows: LabelRowItem[]) => void } export const useNavStore = create()( persist( (set, get) => ({ folderTree: cloneDefaultFolderTree(), labelRows: cloneDefaultLabelRows(), navItemPrefs: {}, setNavItemSidebarVisibility: (id, v) => set((s) => ({ navItemPrefs: { ...s.navItemPrefs, [id]: { ...s.navItemPrefs[id], sidebar: v } }, })), setNavItemMessageVisibility: (id, v) => set((s) => ({ navItemPrefs: { ...s.navItemPrefs, [id]: { ...s.navItemPrefs[id], messages: v } }, })), ensureLabelRowForLabelText: (raw) => { const label = raw.trim() if (!label) return set((s) => { const known = allLabelTextsLower(s.folderTree, s.labelRows) if (known.has(label.toLowerCase())) return s const ids = new Set(s.labelRows.map((r) => r.id)) const id = uniqueLabelRowId(newLabelRowId(label), ids) const row = normalizeLabelRow({ id, label, color: "bg-gray-500", tabbed: false, favorite: false, excludeFromPrincipal: false, showInMessageList: true, enabled: true, }) return { labelRows: [...s.labelRows, row] } }) }, addLabelRowFromSidebar: (raw) => { get().ensureLabelRowForLabelText(raw) }, addFolder: (parentId, name) => { const label = name.trim() if (!label) return set((s) => { const fid = proposeNewFolderId( s.folderTree, s.labelRows.map((r) => r.id), parentId, label ) const node: FolderTreeNode = { id: fid, label, color: "bg-slate-400" } return { folderTree: insertFolderChild(s.folderTree, parentId, node) } }) }, addSubfolder: (parentId, name) => { get().addFolder(parentId, name) }, updateFolderOrLabelColor: (id, color) => set((s) => { const inRow = s.labelRows.some((r) => r.id === id) if (inRow) { return { labelRows: s.labelRows.map((r) => (r.id === id ? { ...r, color } : r)) } } return { folderTree: updateNodeInTree(s.folderTree, id, { color }) } }), renameFolderOrLabel: (id, raw) => { const nextLabel = raw.trim() if (!nextLabel) return { idMap: {}, emailRename: null } const snap = get() const snapMap = buildFolderIdToLabelRecord(snap.folderTree, snap.labelRows) const oldLabelForSync = snapMap[id] const emailRename = oldLabelForSync && oldLabelForSync !== nextLabel ? { from: oldLabelForSync, to: nextLabel } : null let resultIdMap: Record = {} set((prev) => { const inRow = prev.labelRows.some((r) => r.id === id) let nextTree = prev.folderTree let nextRows = prev.labelRows let navItemPrefs = prev.navItemPrefs if (inRow) { if (isSystemNavLabelId(id)) { nextRows = prev.labelRows.map((r) => r.id === id ? { ...r, label: nextLabel } : r ) } else { const usedIds = new Set([ ...collectFolderIdsInTree(prev.folderTree), ...prev.labelRows.filter((r) => r.id !== id).map((r) => r.id), ]) const newRowId = uniqueLabelRowId(newLabelRowId(nextLabel), usedIds) const rowMap: Record = 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 }) const rk = rekeyFolderSubtreeAt(nextTree, id, prev.labelRows.map((r) => r.id)) const idMap = rk?.idMap ?? {} if (rk && Object.keys(idMap).length > 0) { nextTree = rk.tree navItemPrefs = remapNavItemPrefs(prev.navItemPrefs, idMap) resultIdMap = idMap } } return { folderTree: nextTree, labelRows: nextRows, navItemPrefs } }) return { idMap: resultIdMap, emailRename } }, 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) let labelsToRemove: string[] = [] if (row) { const lab = snapMap[id] if (lab) labelsToRemove = [lab] } else { const extracted = extractNode(snap.folderTree, id) if (extracted.extracted) { const uniq = new Set() const collectLabs = (n: FolderTreeNode) => { uniq.add(n.label) n.children?.forEach(collectLabs) } collectLabs(extracted.extracted) labelsToRemove = [...uniq] } } set((prev) => { const rowInner = prev.labelRows.find((r) => r.id === id) if (rowInner) { return { labelRows: prev.labelRows.filter((r) => r.id !== id), navItemPrefs: Object.fromEntries( Object.entries(prev.navItemPrefs).filter(([k]) => k !== id) ), } } const extracted = extractNode(prev.folderTree, id) if (extracted.extracted) { const dropIds = collectSubtreeIds(extracted.extracted) return { folderTree: extracted.next, navItemPrefs: Object.fromEntries( Object.entries(prev.navItemPrefs).filter(([k]) => !dropIds.has(k)) ), } } return prev }) return labelsToRemove }, moveFolder: (id, newParentId) => { let resultIdMap: Record = {} set((prev) => { if (newParentId !== null) { if (newParentId === id) return prev if (isDescendantOf(prev.folderTree, newParentId, id)) return prev } const ex = extractNode(prev.folderTree, id) if (!ex.extracted) return prev let tree = ex.next tree = insertFolderChild(tree, newParentId, ex.extracted) const movedOldId = ex.extracted.id 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 = {} 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 (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 }, addChildLabelRow: (parentLabelRowId, childName) => { const sub = childName.trim() if (!sub) return set((s) => { const parent = s.labelRows.find((r) => r.id === parentLabelRowId) if (!parent) return s const combined = `${parent.label}/${sub}` const known = allLabelTextsLower(s.folderTree, s.labelRows) if (known.has(combined.toLowerCase())) return s const ids = new Set(s.labelRows.map((r) => r.id)) const nid = uniqueLabelRowId(newLabelRowId(combined), ids) return { 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 ), })), hydrateFolderTreeFromApi: (tree) => set({ folderTree: tree }), hydrateLabelRowsFromApi: (rows) => set((s) => { const systemRows = s.labelRows.filter((r) => isSystemNavLabelId(r.id)) const apiIds = new Set(rows.map((r) => r.id)) const mergedSystem = systemRows.filter((r) => !apiIds.has(r.id)) return { labelRows: [ ...mergedSystem, ...rows.map((r) => normalizeLabelRow(r)), ], } }), getFolderIdToLabel: () => { const s = get() return buildFolderIdToLabelRecord(s.folderTree, s.labelRows) }, getEmailLabelToSidebarFolderId: () => { const s = get() const fToL = buildFolderIdToLabelRecord(s.folderTree, s.labelRows) return buildEmailLabelToSidebarFolderId(fToL) }, getNavItemPrefs: (id) => { const p = get().navItemPrefs[id] return { sidebar: p?.sidebar ?? "show", messages: p?.messages ?? "show" } }, }), { name: "ultimail-nav-state", storage: debouncedPersistJSONStorage, 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 }, } ) )