feat(ui): redesign the overview page as a trend-first command deck

Replace the ten-small-cards overview with an action bar, four vitals
tiles carrying 72-sample sparklines seeded from /server/history, a
two-series throughput chart, a TCP/UDP connections chart, and a
grouped system strip (uptime xray|os, panel ram|threads, ip
addresses). StatusCard and XrayStatusCard are deleted; every modal
stays reachable from the action bar, the Xray error message moves
into a tooltip on the state pill, and the panel version text keeps
opening the update modal (the dev-channel switch lives there) even
when no update is available. Live values sit beside the
upload/download and tcp/udp legends, a health sentence appears only
when a vital crosses the shared warn/crit thresholds now exported
from models/status, and load average is left to System History.

The sidebar becomes an auto-collapsed 72px icon rail that expands as
an overlay on hover: rail width, brand-row height and menu paddings
are pinned so nothing shifts during the transition, the collapsed-menu
tooltips are disabled, hover state survives the per-page sidebar
remounts (with a matches(':hover') resync), and the manual collapse
trigger is gone.

Sparkline gains rgb()/rgba() support in its fill gradient, a
showLegend prop so pages stop reaching into its internals, and loses
a dependency-less repaint effect that doubled canvas paints. Chart
tooltips show clock time via the new TimeFormatter.formatClock;
accents come from theme tokens instead of status.cpu.color. Verified
by screenshot at 390/800/1150/1280/1400/1600px in light and dark,
en and fa-IR, plus programmatic geometry checks on the sidebar.
Locale files gain 8 keys and lose 9 dead ones across all 13
languages.
This commit is contained in:
Sanaei
2026-07-29 20:17:37 +02:00
parent 87ebcc7a6f
commit c3fa73d5a0
30 changed files with 1467 additions and 765 deletions
+27 -19
View File
@@ -1,3 +1,10 @@
.ant-sidebar {
flex: 0 0 var(--sider-rail, 72px);
width: var(--sider-rail, 72px);
position: relative;
z-index: 210;
}
.ant-sidebar > .ant-layout-sider {
position: sticky;
top: 0;
@@ -5,6 +12,16 @@
align-self: flex-start;
}
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
}
.sider-nav .ant-menu-item .anticon,
.sider-nav .ant-menu-submenu-title .anticon,
.sider-utility .ant-menu-item .anticon {
font-size: 16px;
}
.sider-brand,
.drawer-brand {
font-weight: 600;
@@ -18,16 +35,12 @@
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 14px 16px 14px 24px;
height: 58px;
padding: 0 16px 0 24px;
border-bottom: 1px solid var(--ant-color-border-secondary);
user-select: none;
}
.sider-brand-collapsed {
justify-content: center;
font-size: 16px;
padding: 14px 4px;
letter-spacing: 0;
white-space: nowrap;
overflow: hidden;
}
.brand-block {
@@ -37,10 +50,6 @@
line-height: 1.1;
}
.sider-brand-collapsed .brand-block {
flex: 0 0 auto;
}
.brand-actions {
display: inline-flex;
align-items: center;
@@ -246,11 +255,6 @@
outline: none;
}
.sider-version.is-collapsed {
justify-content: center;
padding: 8px 0;
}
.drawer-footer {
flex: 0 0 auto;
padding: 8px 8px 12px;
@@ -261,8 +265,7 @@
display: inline-flex;
}
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
.ant-sidebar > .ant-layout-sider .ant-layout-sider-trigger {
.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
display: none;
}
@@ -272,6 +275,11 @@
min-width: 0 !important;
width: 0 !important;
}
.ant-sidebar {
flex: 0 0 0;
width: 0;
}
}
body.dark .ant-drawer-content,
+38 -31
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ComponentType, CSSProperties } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Drawer, Layout, Menu } from 'antd';
@@ -39,11 +39,14 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/';
const DOCS_URL = 'https://docs.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__';
const RAIL_WIDTH = 72;
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties;
let hoveredAcrossRemounts = false;
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
@@ -62,14 +65,6 @@ const iconByName: Record<IconName, ComponentType> = {
routing: SwapOutlined,
};
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return (
<a
@@ -108,7 +103,7 @@ function VersionBadge({ version, collapsed }: { version: string; collapsed?: boo
href={REPO_URL}
target="_blank"
rel="noopener noreferrer"
className={`sider-version${collapsed ? ' is-collapsed' : ''}`}
className="sider-version"
aria-label={`GitHub ${label}`}
title={label}
>
@@ -148,8 +143,23 @@ export default function AppSidebar() {
const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered;
const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => {
hoveredAcrossRemounts = value;
setHovered(value);
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
const el = rootRef.current;
if (el) updateHovered(el.matches(':hover'));
}, 150);
return () => window.clearTimeout(timer);
}, [updateHovered]);
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || '';
@@ -218,7 +228,7 @@ export default function AppSidebar() {
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title };
return { key: tab.key, icon: <Icon />, label: tab.title, title: '' };
}),
[settingsChildren, xrayChildren]);
@@ -235,13 +245,6 @@ export default function AppSidebar() {
openLink(String(key));
}, [openLink]);
const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
if (type === 'clickTrigger') {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
setCollapsed(isCollapsed);
}
}, []);
const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id);
if (!isDark) {
@@ -256,20 +259,24 @@ export default function AppSidebar() {
}, [isDark, isUltra, toggleTheme, toggleUltra]);
return (
<div className="ant-sidebar">
<div
ref={rootRef}
className="ant-sidebar"
style={railStyle}
onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)}
>
<Layout.Sider
theme={currentTheme}
width={220}
collapsible
collapsed={collapsed}
breakpoint="md"
onCollapse={onSiderCollapse}
collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed}
>
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
<div className="sider-brand">
<div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
<span className="brand-text">{railCollapsed ? '3X' : '3X-UI'}</span>
</div>
{!collapsed && (
{!railCollapsed && (
<div className="brand-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
@@ -287,7 +294,7 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
openKeys={railCollapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
@@ -302,7 +309,7 @@ export default function AppSidebar() {
onClick={onMenuClick}
/>
<div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} />
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
</div>
</Layout.Sider>