ultisuite-client/lib/sidebar-nav-context.tsx
2026-05-25 13:52:40 +02:00

454 lines
13 KiB
TypeScript

"use client"
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react"
import type { FolderTreeNode, LabelRowItem } from "@/lib/sidebar-nav-data"
import { isSystemNavLabelId } from "@/lib/sidebar-nav-data"
import {
useNavStore,
type LabelListSidebarVisibility,
type LabelInMessageListVisibility,
type NavItemPrefs,
} from "@/lib/stores/nav-store"
import {
buildEmailLabelToSidebarFolderId,
buildFolderIdToLabelRecord,
} from "@/lib/sidebar-nav-maps"
import { useSidebarNavApiSync } from "@/lib/hooks/use-sidebar-nav-api"
import { isServerNavId } from "@/lib/sidebar-nav-server-id"
import { DEFAULT_NAV_COLOR } from "@/lib/nav-color"
import { findFolderNodeById } from "@/lib/sidebar-folder-tree-utils"
import {
folderTreeToReorderItems,
labelRowsToReorderItems,
} from "@/lib/nav-reorder-plan"
export type { LabelListSidebarVisibility, LabelInMessageListVisibility, NavItemPrefs }
export type NavEmailSync = {
renameLabel: (from: string, to: string) => void
removeLabel: (label: string) => void
}
const navEmailSyncRef = { current: null as NavEmailSync | null }
export function registerNavEmailSync(cb: NavEmailSync | null) {
navEmailSyncRef.current = cb
}
type SidebarNavContextValue = {
folderTree: FolderTreeNode[]
labelRows: LabelRowItem[]
folderIdToLabel: Record<string, string>
emailLabelToSidebarFolderId: Record<string, string>
getNavItemPrefs: (id: string) => Required<Pick<NavItemPrefs, "sidebar" | "messages">>
setNavItemSidebarVisibility: (id: string, v: LabelListSidebarVisibility) => void
setNavItemMessageVisibility: (id: string, v: LabelInMessageListVisibility) => void
ensureLabelRowForLabelText: (label: string) => void
addLabelRowFromSidebar: (label: string, color?: string) => void
addFolder: (parentId: string | null, name: string) => void
updateFolderOrLabelColor: (id: string, color: string) => void
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)
type SidebarNavProviderProps = {
children: ReactNode
routeFolderId?: string | null
onRouteFolderIdChange?: (nextFolderId: string) => void
}
export function SidebarNavProvider({
children,
routeFolderId,
onRouteFolderIdChange,
}: SidebarNavProviderProps) {
const routeSyncRef = useRef({
routeFolderId: null as string | null | undefined,
onRouteFolderIdChange: undefined as
| ((nextFolderId: string) => void)
| undefined,
})
useEffect(() => {
routeSyncRef.current = { routeFolderId, onRouteFolderIdChange }
}, [routeFolderId, onRouteFolderIdChange])
const navApi = useSidebarNavApiSync()
const scheduleRouteFolderIdSync = useCallback(
(idMap: Record<string, string>) => {
if (Object.keys(idMap).length === 0) return
queueMicrotask(() => {
const { routeFolderId: rid, onRouteFolderIdChange: on } =
routeSyncRef.current
if (!rid || !on) return
const next = idMap[rid]
if (next) on(next)
})
},
[]
)
const folderTree = useNavStore((s) => s.folderTree)
const labelRows = useNavStore((s) => s.labelRows)
const navItemPrefs = useNavStore((s) => s.navItemPrefs)
const navActions = useNavStore.getState()
const folderIdToLabel = useMemo(
() => buildFolderIdToLabelRecord(folderTree, labelRows),
[folderTree, labelRows]
)
const emailLabelToSidebarFolderId = useMemo(
() => buildEmailLabelToSidebarFolderId(folderIdToLabel),
[folderIdToLabel]
)
const getNavItemPrefs = useCallback(
(id: string): Required<Pick<NavItemPrefs, "sidebar" | "messages">> => {
const p = navItemPrefs[id]
return {
sidebar: p?.sidebar ?? "show",
messages: p?.messages ?? "show",
}
},
[navItemPrefs]
)
const isFolderId = useCallback(
(id: string) => findFolderNodeById(folderTree, id) != null,
[folderTree]
)
const getLabelRow = useCallback(
(id: string) => labelRows.find((r) => r.id === id),
[labelRows]
)
const addFolder = useCallback(
(parentId: string | null, name: string) => {
if (navApi.apiEnabled) {
navApi.createFolder({
name,
color: "bg-slate-400",
parent_id: parentId ?? undefined,
})
return
}
navActions.addFolder(parentId, name)
},
[navApi]
)
const addSubfolder = useCallback(
(parentId: string, name: string) => {
addFolder(parentId, name)
},
[addFolder]
)
const addLabelRowFromSidebar = useCallback(
(label: string, color = DEFAULT_NAV_COLOR) => {
if (navApi.apiEnabled) {
navApi.createLabel({ name: label.trim(), color })
return
}
navActions.addLabelRowFromSidebar(label)
},
[navApi]
)
const addChildLabelRow = useCallback(
(parentLabelRowId: string, childName: string) => {
const parent = getLabelRow(parentLabelRowId)
const child = childName.trim()
if (!child) return
const fullName = parent ? `${parent.label}/${child}` : child
if (navApi.apiEnabled) {
navApi.createLabel({
name: fullName,
color: parent?.color ?? DEFAULT_NAV_COLOR,
})
return
}
navActions.addChildLabelRow(parentLabelRowId, childName)
},
[getLabelRow, navApi]
)
const updateFolderOrLabelColor = useCallback(
(id: string, color: string) => {
if (navApi.apiEnabled && isServerNavId(id)) {
if (isFolderId(id)) {
const node = findFolderNodeById(folderTree, id)
if (node) {
navApi.updateFolder({
id,
name: node.label,
color,
parent_id: undefined,
})
return
}
}
const row = getLabelRow(id)
if (row && !isSystemNavLabelId(id)) {
navApi.updateLabel({ id, name: row.label, color })
return
}
}
navActions.updateFolderOrLabelColor(id, color)
},
[folderTree, getLabelRow, isFolderId, navApi]
)
const renameFolderOrLabel = useCallback(
(id: string, newLabel: string) => {
if (navApi.apiEnabled && isServerNavId(id)) {
if (isFolderId(id)) {
const node = findFolderNodeById(folderTree, id)
if (node) {
navApi.updateFolder({
id,
name: newLabel.trim(),
color: node.color ?? "bg-slate-400",
})
return
}
}
const row = getLabelRow(id)
if (row && !isSystemNavLabelId(id)) {
const oldLabel = row.label
navApi.updateLabel({ id, name: newLabel.trim(), color: row.color })
queueMicrotask(() => {
navEmailSyncRef.current?.renameLabel(oldLabel, newLabel.trim())
})
return
}
}
const { idMap, emailRename } = useNavStore.getState().renameFolderOrLabel(id, newLabel)
scheduleRouteFolderIdSync(idMap)
if (emailRename) {
queueMicrotask(() => {
navEmailSyncRef.current?.renameLabel(emailRename.from, emailRename.to)
})
}
},
[folderTree, getLabelRow, isFolderId, navApi, scheduleRouteFolderIdSync]
)
const removeFolderOrLabelRow = useCallback(
(id: string) => {
if (navApi.apiEnabled && isServerNavId(id)) {
if (isFolderId(id)) {
navApi.deleteFolder(id)
return
}
const row = getLabelRow(id)
if (row && !isSystemNavLabelId(id)) {
navApi.deleteLabel(id)
queueMicrotask(() => {
navEmailSyncRef.current?.removeLabel(row.label)
})
return
}
}
const labelsToRemove = useNavStore.getState().removeFolderOrLabelRow(id)
if (labelsToRemove.length > 0) {
queueMicrotask(() => {
for (const lab of labelsToRemove) {
navEmailSyncRef.current?.removeLabel(lab)
}
})
}
},
[getLabelRow, isFolderId, navApi]
)
const syncLabelOrder = useCallback(() => {
if (!navApi.apiEnabled) return
const items = labelRowsToReorderItems(useNavStore.getState().labelRows)
navApi.reorderLabels(items)
}, [navApi])
const syncFolderOrder = useCallback(() => {
if (!navApi.apiEnabled) return
const items = folderTreeToReorderItems(useNavStore.getState().folderTree)
navApi.reorderFolders(items)
}, [navApi])
const moveFolder = useCallback(
(id: string, newParentId: string | null) => {
if (navApi.apiEnabled && isServerNavId(id)) {
const node = findFolderNodeById(folderTree, id)
if (node) {
useNavStore.getState().moveFolder(id, newParentId)
queueMicrotask(syncFolderOrder)
return
}
}
const idMap = useNavStore.getState().moveFolder(id, newParentId)
scheduleRouteFolderIdSync(idMap)
},
[folderTree, navApi, scheduleRouteFolderIdSync, syncFolderOrder]
)
const moveFolderRelative = useCallback(
(
draggedId: string,
targetId: string,
placement: "before" | "after" | "inside"
) => {
if (navApi.apiEnabled && isServerNavId(draggedId)) {
const idMap = useNavStore
.getState()
.moveFolderRelative(draggedId, targetId, placement)
scheduleRouteFolderIdSync(idMap)
queueMicrotask(syncFolderOrder)
return
}
const idMap = useNavStore
.getState()
.moveFolderRelative(draggedId, targetId, placement)
scheduleRouteFolderIdSync(idMap)
},
[navApi, scheduleRouteFolderIdSync, syncFolderOrder]
)
const reorderLabelRows = useCallback(
(
draggedId: string,
targetId: string,
placement: "before" | "after"
) => {
useNavStore.getState().reorderLabelRows(draggedId, targetId, placement)
queueMicrotask(syncLabelOrder)
},
[syncLabelOrder]
)
const value = useMemo(
() => ({
folderTree,
labelRows,
folderIdToLabel,
emailLabelToSidebarFolderId,
getNavItemPrefs,
setNavItemSidebarVisibility: navActions.setNavItemSidebarVisibility,
setNavItemMessageVisibility: navActions.setNavItemMessageVisibility,
ensureLabelRowForLabelText: navActions.ensureLabelRowForLabelText,
addLabelRowFromSidebar,
addFolder,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder,
addChildLabelRow,
setLabelRowEnabled: navActions.setLabelRowEnabled,
}),
[
folderTree,
labelRows,
folderIdToLabel,
emailLabelToSidebarFolderId,
getNavItemPrefs,
navActions,
addLabelRowFromSidebar,
addFolder,
updateFolderOrLabelColor,
renameFolderOrLabel,
removeFolderOrLabelRow,
moveFolder,
reorderLabelRows,
moveFolderRelative,
addSubfolder,
addChildLabelRow,
]
)
return (
<SidebarNavContext.Provider value={value}>
{children}
</SidebarNavContext.Provider>
)
}
export function useSidebarNav(): SidebarNavContextValue {
const ctx = useContext(SidebarNavContext)
if (!ctx) {
throw new Error("useSidebarNav must be used within SidebarNavProvider")
}
return ctx
}
export function folderMoveParentOptions(
tree: FolderTreeNode[],
excludeId: string
): { value: string; label: string; depth: number }[] {
function findNode(nodes: FolderTreeNode[], id: string): FolderTreeNode | null {
for (const n of nodes) {
if (n.id === id) return n
if (n.children?.length) {
const hit = findNode(n.children, id)
if (hit) return hit
}
}
return null
}
function collectIds(node: FolderTreeNode): Set<string> {
const s = new Set<string>([node.id])
if (node.children?.length) {
for (const c of node.children) {
for (const x of collectIds(c)) s.add(x)
}
}
return s
}
const ex = findNode(tree, excludeId)
const banned = ex ? collectIds(ex) : new Set([excludeId])
const out: { value: string; label: string; depth: number }[] = [
{ value: "__root__", label: "Racine", depth: 0 },
]
const walk = (nodes: FolderTreeNode[], depth: number) => {
for (const n of nodes) {
if (banned.has(n.id)) continue
out.push({
value: n.id,
label: `${"\u2003".repeat(depth * 2)}${n.label}`,
depth,
})
if (n.children?.length) walk(n.children, depth + 1)
}
}
walk(tree, 0)
return out
}