diff --git a/frontend/src/components/utility/LazyMount.tsx b/frontend/src/components/utility/LazyMount.tsx index a1ffdd5c8..a5af3da6c 100644 --- a/frontend/src/components/utility/LazyMount.tsx +++ b/frontend/src/components/utility/LazyMount.tsx @@ -1,4 +1,5 @@ import { Suspense, useEffect, useState, type ReactNode } from 'react'; +import { Spin } from 'antd'; interface LazyMountProps { when: boolean; @@ -10,7 +11,7 @@ interface LazyMountProps { // thereafter, so React.lazy modals get loaded on demand but their close // animations still play out. Pair with `lazy(() => import(...))` modal imports // on heavy list pages to keep the initial bundle small. -export default function LazyMount({ when, fallback = null, children }: LazyMountProps) { +export default function LazyMount({ when, fallback = , children }: LazyMountProps) { const [mounted, setMounted] = useState(when); useEffect(() => { if (when && !mounted) setMounted(true); diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index 3d9b846e9..6c07ad6dc 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -1,15 +1,27 @@ import { useEffect, useState } from 'react'; -const MOBILE_BREAKPOINT_PX = 768; +export const MOBILE_BREAKPOINT_PX = 768; +/** + * Tracks whether the viewport is narrower than `breakpoint`. + * + * Uses the native `matchMedia` change event instead of the `resize` event so + * that state updates fire only when the query actually flips, not on every + * pixel change during a window drag. + */ export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) { - const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint); + const query = `(max-width: ${breakpoint}px)`; + const [isMobile, setIsMobile] = useState(() => + typeof window !== 'undefined' ? window.matchMedia(query).matches : false, + ); useEffect(() => { - const onResize = () => setIsMobile(window.innerWidth <= breakpoint); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, [breakpoint]); + const mql = window.matchMedia(query); + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mql.addEventListener('change', onChange); + setIsMobile(mql.matches); + return () => mql.removeEventListener('change', onChange); + }, [query]); return { isMobile }; } diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index a13fa0a75..a9497a84b 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -1350,7 +1350,8 @@ export default function ClientsPage() { rowSelection={rowSelection} pagination={tablePagination} size="small" - scroll={{ x: 1200 }} + scroll={{ x: 1200, y: 'calc(100vh - 380px)' }} + virtual onChange={onTableChange} locale={{ emptyText: ( diff --git a/frontend/src/pages/hosts/HostList.tsx b/frontend/src/pages/hosts/HostList.tsx index 88d2524e9..d8557e7e7 100644 --- a/frontend/src/pages/hosts/HostList.tsx +++ b/frontend/src/pages/hosts/HostList.tsx @@ -231,7 +231,8 @@ export default function HostList(props: HostListProps) { columns={columns} dataSource={sorted} pagination={false} - scroll={{ x: 'max-content' }} + scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }} + virtual rowSelection={{ selectedRowKeys: selectedGroupIds, onChange: (keys) => onSelectionChange(keys as string[]), diff --git a/frontend/src/pages/inbounds/list/InboundList.tsx b/frontend/src/pages/inbounds/list/InboundList.tsx index a196944d5..7d4e8ab18 100644 --- a/frontend/src/pages/inbounds/list/InboundList.tsx +++ b/frontend/src/pages/inbounds/list/InboundList.tsx @@ -286,7 +286,8 @@ export default function InboundList({ onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]), }} pagination={paginationFor(visibleInbounds)} - scroll={{ x: tableScrollX }} + scroll={{ x: tableScrollX, y: 'calc(100vh - 320px)' }} + virtual style={{ marginTop: 10 }} size="small" locale={{ diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index aec149054..8e7ed8fe6 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -648,7 +648,8 @@ export default function NodeList({ columns={columns} pagination={false} loading={loading} - scroll={{ x: 'max-content' }} + scroll={{ x: 'max-content', y: 'calc(100vh - 320px)' }} + virtual size="middle" rowKey="key" rowSelection={dataSource.length > 1 ? { diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx index 338599960..82f981f0d 100644 --- a/frontend/src/pages/sub/SubPage.tsx +++ b/frontend/src/pages/sub/SubPage.tsx @@ -38,6 +38,7 @@ import { LinkTags, parseLinkParts } from '@/lib/xray/link-label'; import ConfigBlock from '@/components/clients/ConfigBlock'; import { setMessageInstance } from '@/utils/messageBus'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import SubUsageSummary from './SubUsageSummary'; import './SubPage.css'; @@ -84,16 +85,9 @@ export default function SubPage() { const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const [messageApi, messageContextHolder] = message.useMessage(); useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); - - const [isMobile, setIsMobile] = useState(() => window.innerWidth < 576); + const { isMobile } = useMediaQuery(576); const [lang, setLang] = useState(() => LanguageManager.getLanguage()); - useEffect(() => { - const onResize = () => setIsMobile(window.innerWidth < 576); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); - }, []); - const onLangChange = useCallback((next: string) => { setLang(next); LanguageManager.setLanguage(next); diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index f8c4185d1..88e967989 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -1,5 +1,6 @@ import { lazy, Suspense } from 'react'; import { createBrowserRouter, type RouteObject } from 'react-router'; +import { Spin } from 'antd'; import PanelLayout from '@/layouts/PanelLayout'; @@ -14,7 +15,17 @@ const XrayPage = lazy(() => import('@/pages/xray/XrayPage')); const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage')); function withSuspense(node: React.ReactNode) { - return {node}; + return ( + + + + } + > + {node} + + ); } const routes: RouteObject[] = [