diff --git a/src/components/layout/layout-sidebar.tsx b/src/components/layout/layout-sidebar.tsx new file mode 100644 index 000000000..e01734dca --- /dev/null +++ b/src/components/layout/layout-sidebar.tsx @@ -0,0 +1,226 @@ +import { DragDropProvider, KeyboardSensor, PointerSensor } from '@dnd-kit/react' +import { Box, List, Menu, MenuItem, SvgIcon } from '@mui/material' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import iconDark from '@/assets/image/icon_dark.svg?react' +import iconLight from '@/assets/image/icon_light.svg?react' +import LogoSvg from '@/assets/image/logo.svg?react' +import { useVerge } from '@/hooks/use-verge' +import { useNavMenuOrder } from '@/pages/_layout/hooks' +import { navItems } from '@/pages/_navigation' + +import { SortableItem } from '../base' + +import { LayoutItem } from './layout-item' +import { LayoutTraffic } from './layout-traffic' +import { UpdateButton } from './update-button' + +type MenuContextPosition = { top: number; left: number } + +interface LayoutSidebarProps { + isDark: boolean + isCollapsed: boolean +} + +const SENSORS = [PointerSensor, KeyboardSensor] + +export const LayoutSidebar = (props: LayoutSidebarProps) => { + const { isDark, isCollapsed } = props + const { t } = useTranslation() + const { verge, mutateVerge, patchVerge } = useVerge() + const [menuUnlocked, setMenuUnlocked] = useState(false) + const [menuContextPosition, setMenuContextPosition] = + useState(null) + + const handleMenuOrderOptimisticUpdate = useCallback( + (order: string[]) => { + mutateVerge( + (prev) => (prev ? { ...prev, menu_order: order } : prev), + false, + ) + }, + [mutateVerge], + ) + + const handleMenuOrderPersist = useCallback( + (order: string[]) => patchVerge({ menu_order: order }), + [patchVerge], + ) + + const { + menuOrder, + navItemMap, + handleMenuDragEnd, + isDefaultOrder, + resetMenuOrder, + } = useNavMenuOrder({ + enabled: menuUnlocked, + items: navItems, + storedOrder: verge?.menu_order, + onOptimisticUpdate: handleMenuOrderOptimisticUpdate, + onPersist: handleMenuOrderPersist, + }) + + const handleMenuContextMenu = useCallback( + (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + setMenuContextPosition({ top: event.clientY, left: event.clientX }) + }, + [], + ) + + const handleMenuContextClose = useCallback(() => { + setMenuContextPosition(null) + }, []) + + const handleResetMenuOrder = useCallback(() => { + setMenuContextPosition(null) + void resetMenuOrder() + }, [resetMenuOrder]) + + const handleUnlockMenu = useCallback(() => { + setMenuUnlocked(true) + setMenuContextPosition(null) + }, []) + + const handleLockMenu = useCallback(() => { + setMenuUnlocked(false) + setMenuContextPosition(null) + }, []) + + const handleToggleNavCollapsed = useCallback(() => { + setMenuContextPosition(null) + void patchVerge({ collapse_navbar: !isCollapsed }) + }, [isCollapsed, patchVerge]) + + // Navigation menu items + const navMenuItems = menuOrder.map((path, index) => { + const item = navItemMap.get(path) + if (!item) return null + + return ( + + {(sortable) => ( + + {t(item.label)} + + )} + + ) + }) + + return ( +
+ {/* Logo */} +
+
+ + +
+ +
+ + {/* Edit navigation menu badge */} + {menuUnlocked && ( + ({ + px: 1.5, + py: 0.75, + mx: 'auto', + mb: 1, + maxWidth: 250, + borderRadius: 1.5, + fontSize: 12, + fontWeight: 600, + textAlign: 'center', + color: theme.palette.warning.contrastText, + bgcolor: + theme.palette.mode === 'light' + ? theme.palette.warning.main + : theme.palette.warning.dark, + })} + > + {t('layout.components.navigation.menu.reorderMode')} + + )} + + {/* Navigation menu */} + + + {navMenuItems} + + + + {/* Context menu */} + + + {isCollapsed + ? t('layout.components.navigation.menu.expandNavBar') + : t('layout.components.navigation.menu.collapseNavBar')} + + + {menuUnlocked + ? t('layout.components.navigation.menu.lock') + : t('layout.components.navigation.menu.unlock')} + + + {t('layout.components.navigation.menu.restoreDefaultOrder')} + + + + {/* Traffic */} +
+ +
+
+ ) +} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 505ffba04..646fd9ae5 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -1,29 +1,15 @@ -import { DragDropProvider, KeyboardSensor, PointerSensor } from '@dnd-kit/react' -import { - Box, - List, - Menu, - MenuItem, - Paper, - SvgIcon, - ThemeProvider, -} from '@mui/material' +import { Paper, ThemeProvider } from '@mui/material' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useNavigate } from 'react-router' -import iconDark from '@/assets/image/icon_dark.svg?react' -import iconLight from '@/assets/image/icon_light.svg?react' -import LogoSvg from '@/assets/image/logo.svg?react' -import { BaseErrorBoundary, SortableItem } from '@/components/base' -import { LayoutItem } from '@/components/layout/layout-item' -import { LayoutTraffic } from '@/components/layout/layout-traffic' +import { BaseErrorBoundary } from '@/components/base' +import { LayoutSidebar } from '@/components/layout/layout-sidebar' import { NoticeManager } from '@/components/layout/notice-manager' import { ServiceMigrationDialog } from '@/components/layout/service-migration-dialog' import { SysproxyPrivilegeDialog } from '@/components/layout/sysproxy-privilege-dialog' -import { UpdateButton } from '@/components/layout/update-button' import { WindowControls, WindowResizeHandles, @@ -38,103 +24,31 @@ import { useCustomTheme, useLayoutEvents, useLoadingOverlay, - useNavMenuOrder, usePendingFailures, } from './_layout/hooks' import { handleNoticeMessage } from './_layout/utils' -import { navItems } from './_navigation' import 'dayjs/locale/ru' import 'dayjs/locale/zh-cn' -type MenuContextPosition = { top: number; left: number } - dayjs.extend(relativeTime) const OS = getSystem() -const SENSORS = [PointerSensor, KeyboardSensor] const Layout = () => { const mode = useThemeMode() const isDark = mode !== 'light' const { t } = useTranslation() const { theme } = useCustomTheme() - const { verge, mutateVerge, patchVerge } = useVerge() + const { verge } = useVerge() const { language } = verge ?? {} const navCollapsed = verge?.collapse_navbar ?? false const { switchLanguage } = useI18n() const navigate = useNavigate() const themeReady = useMemo(() => Boolean(theme), [theme]) - - const [menuUnlocked, setMenuUnlocked] = useState(false) - const [menuContextPosition, setMenuContextPosition] = - useState(null) - const windowControlsRef = useRef(null) const { decorated } = useWindowDecorations() - const handleMenuOrderOptimisticUpdate = useCallback( - (order: string[]) => { - mutateVerge( - (prev) => (prev ? { ...prev, menu_order: order } : prev), - false, - ) - }, - [mutateVerge], - ) - - const handleMenuOrderPersist = useCallback( - (order: string[]) => patchVerge({ menu_order: order }), - [patchVerge], - ) - - const { - menuOrder, - navItemMap, - handleMenuDragEnd, - isDefaultOrder, - resetMenuOrder, - } = useNavMenuOrder({ - enabled: menuUnlocked, - items: navItems, - storedOrder: verge?.menu_order, - onOptimisticUpdate: handleMenuOrderOptimisticUpdate, - onPersist: handleMenuOrderPersist, - }) - - const handleMenuContextMenu = useCallback( - (event: React.MouseEvent) => { - event.preventDefault() - event.stopPropagation() - setMenuContextPosition({ top: event.clientY, left: event.clientX }) - }, - [], - ) - - const handleMenuContextClose = useCallback(() => { - setMenuContextPosition(null) - }, []) - - const handleResetMenuOrder = useCallback(() => { - setMenuContextPosition(null) - void resetMenuOrder() - }, [resetMenuOrder]) - - const handleUnlockMenu = useCallback(() => { - setMenuUnlocked(true) - setMenuContextPosition(null) - }, []) - - const handleLockMenu = useCallback(() => { - setMenuUnlocked(false) - setMenuContextPosition(null) - }, []) - - const handleToggleNavCollapsed = useCallback(() => { - setMenuContextPosition(null) - void patchVerge({ collapse_navbar: !navCollapsed }) - }, [navCollapsed, patchVerge]) - const customTitlebar = useMemo( () => decorated === false ? ( @@ -190,27 +104,6 @@ const Layout = () => { ) } - // Navigation menu items - const navMenuItems = menuOrder.map((path, index) => { - const item = navItemMap.get(path) - if (!item) return null - - return ( - - {(sortable) => ( - - {t(item.label)} - - )} - - ) - }) - return ( {/* 左侧底部窗口控制按钮 */} @@ -267,107 +160,7 @@ const Layout = () => { {customTitlebar}
-
-
-
- - -
- -
- - {menuUnlocked && ( - ({ - px: 1.5, - py: 0.75, - mx: 'auto', - mb: 1, - maxWidth: 250, - borderRadius: 1.5, - fontSize: 12, - fontWeight: 600, - textAlign: 'center', - color: theme.palette.warning.contrastText, - bgcolor: - theme.palette.mode === 'light' - ? theme.palette.warning.main - : theme.palette.warning.dark, - })} - > - {t('layout.components.navigation.menu.reorderMode')} - - )} - - {/* Navigation menu */} - - - {navMenuItems} - - - - - - {navCollapsed - ? t('layout.components.navigation.menu.expandNavBar') - : t('layout.components.navigation.menu.collapseNavBar')} - - - {menuUnlocked - ? t('layout.components.navigation.menu.lock') - : t('layout.components.navigation.menu.unlock')} - - - {t('layout.components.navigation.menu.restoreDefaultOrder')} - - - -
- -
-
+