import { DndContext, KeyboardSensor, PointerSensor, closestCenter, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, sortableKeyboardCoordinates, useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Box, List, Menu, MenuItem, Paper, SvgIcon, ThemeProvider, } from "@mui/material"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { CSSProperties } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Outlet, useNavigate } from "react-router"; import { SWRConfig } from "swr"; 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 } from "@/components/base"; import { LayoutItem } from "@/components/layout/layout-item"; import { LayoutTraffic } from "@/components/layout/layout-traffic"; import { NoticeManager } from "@/components/layout/notice-manager"; import { UpdateButton } from "@/components/layout/update-button"; import { WindowControls } from "@/components/layout/window-controller"; import { useI18n } from "@/hooks/use-i18n"; import { useVerge } from "@/hooks/use-verge"; import { useWindowDecorations } from "@/hooks/use-window"; import { useThemeMode } from "@/services/states"; import getSystem from "@/utils/get-system"; import { useAppInitialization, useCustomTheme, useLayoutEvents, useLoadingOverlay, useNavMenuOrder, } from "./_layout/hooks"; import { handleNoticeMessage } from "./_layout/utils"; import { navItems } from "./_routers"; import "dayjs/locale/ru"; import "dayjs/locale/zh-cn"; export const portableFlag = false; type NavItem = (typeof navItems)[number]; type MenuContextPosition = { top: number; left: number }; interface SortableNavMenuItemProps { item: NavItem; label: string; } const SortableNavMenuItem = ({ item, label }: SortableNavMenuItemProps) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: item.path, }); const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition, }; if (isDragging) { style.zIndex = 100; } return ( {label} ); }; dayjs.extend(relativeTime); const OS = getSystem(); const Layout = () => { const mode = useThemeMode(); const isDark = mode !== "light"; const { t } = useTranslation(); const { theme } = useCustomTheme(); const { verge, mutateVerge, patchVerge } = 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 sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); 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 ? (
) : null, [decorated], ); useLoadingOverlay(themeReady); useAppInitialization(); const handleNotice = useCallback( (payload: [string, string]) => { const [status, msg] = payload; try { handleNoticeMessage(status, msg, t, navigate); } catch (error) { console.error("[通知处理] 失败:", error); } }, [t, navigate], ); useLayoutEvents(handleNotice); useEffect(() => { if (language) { dayjs.locale(language === "zh" ? "zh-cn" : language); switchLanguage(language); } }, [language, switchLanguage]); if (!themeReady) { return (
); } return ( { // FIXME the condition should not be handle gllobally if (key !== "getAutotemProxy") { console.error(`SWR Error for ${key}:`, error); return; } // FIXME we need a better way to handle the retry when first booting app const silentKeys = [ "getVersion", "getClashConfig", "getAutotemProxy", ]; if (silentKeys.includes(key)) return; console.error(`[SWR Error] Key: ${key}, Error:`, error); }, dedupingInterval: 2000, }} > {/* 左侧底部窗口控制按钮 */}
{ if ( OS === "windows" && !["input", "textarea"].includes( e.currentTarget.tagName.toLowerCase(), ) && !e.currentTarget.isContentEditable ) { e.preventDefault(); } }} sx={[ ({ palette }) => ({ bgcolor: palette.background.paper }), OS === "linux" ? { borderRadius: "8px", width: "100vw", height: "100vh", } : {}, ]} > {/* Custom titlebar - rendered only when decorated is false, memoized for performance */} {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")} )} {menuUnlocked ? ( {menuOrder.map((path) => { const item = navItemMap.get(path); if (!item) { return null; } return ( ); })} ) : ( {menuOrder.map((path) => { const item = navItemMap.get(path); if (!item) { return null; } return ( {t(item.label)} ); })} )} {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")}
); }; export default Layout;