refactor: extract sidebar into standalone component (#7859)

This commit is contained in:
Nikolai A.
2026-09-08 12:31:44 +00:00
committed by GitHub
parent f754ee53a0
commit c3ceea6258
2 changed files with 232 additions and 213 deletions
+226
View File
@@ -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<MenuContextPosition | null>(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<HTMLElement>) => {
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 (
<SortableItem
key={item.path}
id={item.path}
index={index}
disabled={!menuUnlocked}
>
{(sortable) => (
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
{t(item.label)}
</LayoutItem>
)}
</SortableItem>
)
})
return (
<div className="layout-content__left">
{/* Logo */}
<div className="the-logo" data-tauri-drag-region="false">
<div
data-tauri-drag-region="true"
style={{
height: '27px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<SvgIcon
component={isDark ? iconDark : iconLight}
style={{
height: '36px',
width: '36px',
marginTop: '-3px',
marginRight: '5px',
marginLeft: '-3px',
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? 'white' : 'black'} />
</div>
<UpdateButton className="the-newbtn" />
</div>
{/* Edit navigation menu badge */}
{menuUnlocked && (
<Box
sx={(theme) => ({
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')}
</Box>
)}
{/* Navigation menu */}
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
<DragDropProvider sensors={SENSORS} onDragEnd={handleMenuDragEnd}>
{navMenuItems}
</DragDropProvider>
</List>
{/* Context menu */}
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem onClick={handleToggleNavCollapsed} dense>
{isCollapsed
? t('layout.components.navigation.menu.expandNavBar')
: t('layout.components.navigation.menu.collapseNavBar')}
</MenuItem>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked
? t('layout.components.navigation.menu.lock')
: t('layout.components.navigation.menu.unlock')}
</MenuItem>
<MenuItem
onClick={handleResetMenuOrder}
dense
disabled={isDefaultOrder}
>
{t('layout.components.navigation.menu.restoreDefaultOrder')}
</MenuItem>
</Menu>
{/* Traffic */}
<div className="the-traffic">
<LayoutTraffic />
</div>
</div>
)
}
+6 -213
View File
@@ -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<MenuContextPosition | null>(null)
const windowControlsRef = useRef<any>(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<HTMLElement>) => {
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 (
<SortableItem
key={item.path}
id={item.path}
index={index}
disabled={!menuUnlocked}
>
{(sortable) => (
<LayoutItem to={item.path} icon={item.icon} sortable={sortable}>
{t(item.label)}
</LayoutItem>
)}
</SortableItem>
)
})
return (
<ThemeProvider theme={theme}>
{/* 左侧底部窗口控制按钮 */}
@@ -267,107 +160,7 @@ const Layout = () => {
{customTitlebar}
<div className="layout-content">
<div className="layout-content__left">
<div className="the-logo" data-tauri-drag-region="false">
<div
data-tauri-drag-region="true"
style={{
height: '27px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<SvgIcon
component={isDark ? iconDark : iconLight}
style={{
height: '36px',
width: '36px',
marginTop: '-3px',
marginRight: '5px',
marginLeft: '-3px',
}}
inheritViewBox
/>
<LogoSvg fill={isDark ? 'white' : 'black'} />
</div>
<UpdateButton className="the-newbtn" />
</div>
{menuUnlocked && (
<Box
sx={(theme) => ({
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')}
</Box>
)}
{/* Navigation menu */}
<List className="the-menu" onContextMenu={handleMenuContextMenu}>
<DragDropProvider sensors={SENSORS} onDragEnd={handleMenuDragEnd}>
{navMenuItems}
</DragDropProvider>
</List>
<Menu
open={Boolean(menuContextPosition)}
onClose={handleMenuContextClose}
anchorReference="anchorPosition"
anchorPosition={
menuContextPosition
? {
top: menuContextPosition.top,
left: menuContextPosition.left,
}
: undefined
}
transitionDuration={200}
slotProps={{
list: {
sx: { py: 0.5 },
},
}}
>
<MenuItem onClick={handleToggleNavCollapsed} dense>
{navCollapsed
? t('layout.components.navigation.menu.expandNavBar')
: t('layout.components.navigation.menu.collapseNavBar')}
</MenuItem>
<MenuItem
onClick={menuUnlocked ? handleLockMenu : handleUnlockMenu}
dense
>
{menuUnlocked
? t('layout.components.navigation.menu.lock')
: t('layout.components.navigation.menu.unlock')}
</MenuItem>
<MenuItem
onClick={handleResetMenuOrder}
dense
disabled={isDefaultOrder}
>
{t('layout.components.navigation.menu.restoreDefaultOrder')}
</MenuItem>
</Menu>
<div className="the-traffic">
<LayoutTraffic />
</div>
</div>
<LayoutSidebar isDark={isDark} isCollapsed={navCollapsed} />
<div className="layout-content__right">
<div className="the-bar"></div>