mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +08:00
refactor(layout/nav): extract nav menu order logic into hook
This commit is contained in:
parent
b35d0ac16f
commit
721929a2a1
@ -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<string, NavItem>,
|
||||
) => {
|
||||
const seen = new Set<string>();
|
||||
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<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@ -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";
|
||||
|
||||
128
src/pages/_layout/hooks/use-nav-menu-order.ts
Normal file
128
src/pages/_layout/hooks/use-nav-menu-order.ts
Normal file
@ -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 = <T extends { path: string }>(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 = <T extends { path: string }>(
|
||||
order: string[] | null | undefined,
|
||||
defaultOrder: string[],
|
||||
map: Map<string, T>,
|
||||
) => {
|
||||
const seen = new Set<string>();
|
||||
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<T extends { path: string }> {
|
||||
enabled: boolean;
|
||||
items: readonly T[];
|
||||
storedOrder: string[] | null | undefined;
|
||||
onOptimisticUpdate?: (order: string[]) => void;
|
||||
onPersist: (order: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNavMenuOrder = <T extends { path: string }>({
|
||||
enabled,
|
||||
items,
|
||||
storedOrder,
|
||||
onOptimisticUpdate,
|
||||
onPersist,
|
||||
}: UseNavMenuOrderOptions<T>) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user