From 721929a2a10108a16a305733fc21536cd44372c1 Mon Sep 17 00:00:00 2001 From: Slinetrac Date: Wed, 17 Dec 2025 11:06:38 +0800 Subject: [PATCH] refactor(layout/nav): extract nav menu order logic into hook --- src/pages/_layout.tsx | 131 +++--------------- src/pages/_layout/hooks/index.ts | 1 + src/pages/_layout/hooks/use-nav-menu-order.ts | 128 +++++++++++++++++ 3 files changed, 148 insertions(+), 112 deletions(-) create mode 100644 src/pages/_layout/hooks/use-nav-menu-order.ts diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 1d8e5309b..1184f72f3 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,6 +1,5 @@ import { DndContext, - DragEndEvent, KeyboardSensor, PointerSensor, closestCenter, @@ -9,7 +8,6 @@ import { } from "@dnd-kit/core"; import { SortableContext, - arrayMove, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; @@ -25,14 +23,7 @@ import { } from "@mui/material"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useNavigate } from "react-router"; @@ -58,6 +49,7 @@ import { useAppInitialization, useLayoutEvents, useLoadingOverlay, + useNavMenuOrder, } from "./_layout/hooks"; import { handleNoticeMessage } from "./_layout/utils"; import { navItems } from "./_routers"; @@ -69,52 +61,7 @@ export const portableFlag = false; type NavItem = (typeof navItems)[number]; -const createNavLookup = (items: NavItem[]) => { - const map = new Map(items.map((item) => [item.path, item])); - const defaultOrder = items.map((item) => item.path); - return { map, defaultOrder }; -}; - -const resolveMenuOrder = ( - order: string[] | null | undefined, - defaultOrder: string[], - map: Map, -) => { - const seen = new Set(); - const resolved: string[] = []; - - if (Array.isArray(order)) { - for (const path of order) { - if (map.has(path) && !seen.has(path)) { - resolved.push(path); - seen.add(path); - } - } - } - - for (const path of defaultOrder) { - if (!seen.has(path)) { - resolved.push(path); - seen.add(path); - } - } - - return resolved; -}; - -const areOrdersEqual = (a: string[], b: string[]) => - a.length === b.length && a.every((value, index) => value === b[index]); - type MenuContextPosition = { top: number; left: number }; -type MenuOrderAction = { type: "sync"; payload: string[] }; - -const menuOrderReducer = (state: string[], action: MenuOrderAction) => { - const next = action.payload; - if (areOrdersEqual(state, next)) { - return state; - } - return [...next]; -}; interface SortableNavMenuItemProps { item: NavItem; @@ -192,69 +139,29 @@ const Layout = () => { }), ); - const { map: navItemMap, defaultOrder: defaultMenuOrder } = useMemo( - () => createNavLookup(navItems), - [], - ); - - const configMenuOrder = useMemo( - () => resolveMenuOrder(verge?.menu_order, defaultMenuOrder, navItemMap), - [verge?.menu_order, defaultMenuOrder, navItemMap], - ); - - const [menuOrder, dispatchMenuOrder] = useReducer( - menuOrderReducer, - configMenuOrder, - ); - - useEffect(() => { - dispatchMenuOrder({ type: "sync", payload: configMenuOrder }); - }, [configMenuOrder]); - - const handleMenuDragEnd = useCallback( - async (event: DragEndEvent) => { - if (!menuUnlocked) { - return; - } - - const { active, over } = event; - if (!over || active.id === over.id) { - return; - } - - const activeId = String(active.id); - const overId = String(over.id); - - const oldIndex = menuOrder.indexOf(activeId); - const newIndex = menuOrder.indexOf(overId); - - if (oldIndex === -1 || newIndex === -1) { - return; - } - - const previousOrder = [...menuOrder]; - const nextOrder = arrayMove(menuOrder, oldIndex, newIndex); - - dispatchMenuOrder({ type: "sync", payload: nextOrder }); + const handleMenuOrderOptimisticUpdate = useCallback( + (order: string[]) => { mutateVerge( - (prev) => (prev ? { ...prev, menu_order: nextOrder } : prev), + (prev) => (prev ? { ...prev, menu_order: order } : prev), false, ); - - try { - await patchVerge({ menu_order: nextOrder }); - } catch (error) { - console.error("Failed to update menu order:", error); - dispatchMenuOrder({ type: "sync", payload: previousOrder }); - mutateVerge( - (prev) => (prev ? { ...prev, menu_order: previousOrder } : prev), - false, - ); - } }, - [menuUnlocked, menuOrder, mutateVerge, patchVerge], + [mutateVerge], ); + const handleMenuOrderPersist = useCallback( + (order: string[]) => patchVerge({ menu_order: order }), + [patchVerge], + ); + + const { menuOrder, navItemMap, handleMenuDragEnd } = useNavMenuOrder({ + enabled: menuUnlocked, + items: navItems, + storedOrder: verge?.menu_order, + onOptimisticUpdate: handleMenuOrderOptimisticUpdate, + onPersist: handleMenuOrderPersist, + }); + const handleMenuContextMenu = useCallback( (event: React.MouseEvent) => { event.preventDefault(); diff --git a/src/pages/_layout/hooks/index.ts b/src/pages/_layout/hooks/index.ts index 11de552f2..c3cc56ef1 100644 --- a/src/pages/_layout/hooks/index.ts +++ b/src/pages/_layout/hooks/index.ts @@ -1,3 +1,4 @@ export { useAppInitialization } from "./use-app-initialization"; export { useLayoutEvents } from "./use-layout-events"; export { useLoadingOverlay } from "./use-loading-overlay"; +export { useNavMenuOrder } from "./use-nav-menu-order"; diff --git a/src/pages/_layout/hooks/use-nav-menu-order.ts b/src/pages/_layout/hooks/use-nav-menu-order.ts new file mode 100644 index 000000000..40b84a277 --- /dev/null +++ b/src/pages/_layout/hooks/use-nav-menu-order.ts @@ -0,0 +1,128 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; +import { useCallback, useEffect, useMemo, useReducer } from "react"; + +type MenuOrderAction = { type: "sync"; payload: string[] }; + +const areOrdersEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((value, index) => value === b[index]); + +const menuOrderReducer = (state: string[], action: MenuOrderAction) => { + const next = action.payload; + if (areOrdersEqual(state, next)) { + return state; + } + return [...next]; +}; + +const createNavLookup = (items: readonly T[]) => { + const map = new Map(items.map((item) => [item.path, item] as const)); + const defaultOrder = items.map((item) => item.path); + return { map, defaultOrder }; +}; + +const resolveMenuOrder = ( + order: string[] | null | undefined, + defaultOrder: string[], + map: Map, +) => { + const seen = new Set(); + const resolved: string[] = []; + + if (Array.isArray(order)) { + for (const path of order) { + if (map.has(path) && !seen.has(path)) { + resolved.push(path); + seen.add(path); + } + } + } + + for (const path of defaultOrder) { + if (!seen.has(path)) { + resolved.push(path); + seen.add(path); + } + } + + return resolved; +}; + +interface UseNavMenuOrderOptions { + enabled: boolean; + items: readonly T[]; + storedOrder: string[] | null | undefined; + onOptimisticUpdate?: (order: string[]) => void; + onPersist: (order: string[]) => Promise; +} + +export const useNavMenuOrder = ({ + enabled, + items, + storedOrder, + onOptimisticUpdate, + onPersist, +}: UseNavMenuOrderOptions) => { + const { map: navItemMap, defaultOrder } = useMemo( + () => createNavLookup(items), + [items], + ); + + const configMenuOrder = useMemo( + () => resolveMenuOrder(storedOrder, defaultOrder, navItemMap), + [storedOrder, defaultOrder, navItemMap], + ); + + const [menuOrder, dispatchMenuOrder] = useReducer( + menuOrderReducer, + configMenuOrder, + ); + + useEffect(() => { + dispatchMenuOrder({ type: "sync", payload: configMenuOrder }); + }, [configMenuOrder]); + + const handleMenuDragEnd = useCallback( + async (event: DragEndEvent) => { + if (!enabled) { + return; + } + + const { active, over } = event; + if (!over || active.id === over.id) { + return; + } + + const activeId = String(active.id); + const overId = String(over.id); + + const oldIndex = menuOrder.indexOf(activeId); + const newIndex = menuOrder.indexOf(overId); + + if (oldIndex === -1 || newIndex === -1) { + return; + } + + const previousOrder = [...menuOrder]; + const nextOrder = arrayMove(menuOrder, oldIndex, newIndex); + + dispatchMenuOrder({ type: "sync", payload: nextOrder }); + onOptimisticUpdate?.(nextOrder); + + try { + await onPersist(nextOrder); + } catch (error) { + console.error("Failed to update menu order:", error); + dispatchMenuOrder({ type: "sync", payload: previousOrder }); + onOptimisticUpdate?.(previousOrder); + } + }, + [enabled, menuOrder, onOptimisticUpdate, onPersist], + ); + + return { + menuOrder, + navItemMap, + handleMenuDragEnd, + }; +};