mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 05:10:58 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user