feat(sidebar): collapse to icon rail, expand on hover

Sidebar is icon-only by default and expands as an overlay on hover, so the dashboard content underneath no longer reflows. Drops the persisted collapse state and the click trigger that conflicted with hover.
This commit is contained in:
MHSanaei 2026-06-03 15:24:55 +02:00
parent db5ce06256
commit 573c43e445
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 37 additions and 27 deletions

View file

@ -5,6 +5,20 @@
align-self: flex-start; align-self: flex-start;
} }
.ant-sidebar.is-rail {
flex: 0 0 80px;
width: 80px;
overflow: visible;
}
.ant-sidebar.is-rail > .ant-layout-sider {
z-index: 100;
}
.ant-sidebar.is-rail:hover > .ant-layout-sider {
box-shadow: 2px 0 16px rgba(0, 0, 0, 0.18);
}
.sider-brand, .sider-brand,
.drawer-brand { .drawer-brand {
font-weight: 600; font-weight: 600;
@ -245,6 +259,15 @@
min-width: 0 !important; min-width: 0 !important;
width: 0 !important; width: 0 !important;
} }
.ant-sidebar,
.ant-sidebar.is-rail {
flex: 0 0 0 !important;
width: 0 !important;
min-width: 0 !important;
max-width: 0 !important;
overflow: hidden !important;
}
} }
body.dark .ant-drawer-content, body.dark .ant-drawer-content,

View file

@ -35,7 +35,6 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { useAllSettings } from '@/api/queries/useAllSettings'; import { useAllSettings } from '@/api/queries/useAllSettings';
import './AppSidebar.css'; import './AppSidebar.css';
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const DONATE_URL = 'https://donate.sanaei.dev/'; const DONATE_URL = 'https://donate.sanaei.dev/';
const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
const LOGOUT_KEY = '__logout__'; const LOGOUT_KEY = '__logout__';
@ -54,14 +53,6 @@ const iconByName: Record<IconName, ComponentType> = {
apidocs: ApiOutlined, apidocs: ApiOutlined,
}; };
function readCollapsed(): boolean {
try {
return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
} catch {
return false;
}
}
function DonateButton({ ariaLabel }: { ariaLabel: string }) { function DonateButton({ ariaLabel }: { ariaLabel: string }) {
return ( return (
<a <a
@ -125,8 +116,9 @@ export default function AppSidebar() {
const { allSetting } = useAllSettings(); const { allSetting } = useAllSettings();
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed()); const [hovered, setHovered] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const collapsedView = !hovered;
const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light'; const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
const panelVersion = window.X_UI_CUR_VER || ''; const panelVersion = window.X_UI_CUR_VER || '';
@ -210,13 +202,6 @@ export default function AppSidebar() {
openLink(String(key)); openLink(String(key));
}, [openLink]); }, [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) => { const cycleTheme = useCallback((id: string) => {
pauseAnimationsUntilLeave(id); pauseAnimationsUntilLeave(id);
if (!isDark) { if (!isDark) {
@ -231,19 +216,21 @@ export default function AppSidebar() {
}, [isDark, isUltra, toggleTheme, toggleUltra]); }, [isDark, isUltra, toggleTheme, toggleUltra]);
return ( return (
<div className="ant-sidebar"> <div
className="ant-sidebar is-rail"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Layout.Sider <Layout.Sider
theme={currentTheme} theme={currentTheme}
collapsible collapsed={collapsedView}
collapsed={collapsed} trigger={null}
breakpoint="md"
onCollapse={onSiderCollapse}
> >
<div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}> <div className={`sider-brand${collapsedView ? ' sider-brand-collapsed' : ''}`}>
<div className="brand-block"> <div className="brand-block">
<span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span> <span className="brand-text">{collapsedView ? '3X' : '3X-UI'}</span>
</div> </div>
{!collapsed && ( {!collapsedView && (
<div className="brand-actions"> <div className="brand-actions">
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} /> <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton <ThemeCycleButton
@ -260,7 +247,7 @@ export default function AppSidebar() {
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys} openKeys={collapsedView ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])} onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav" className="sider-nav"
items={toMenuItems(navItems)} items={toMenuItems(navItems)}
@ -275,7 +262,7 @@ export default function AppSidebar() {
onClick={onMenuClick} onClick={onMenuClick}
/> />
<div className="sider-footer"> <div className="sider-footer">
<VersionBadge version={panelVersion} collapsed={collapsed} /> <VersionBadge version={panelVersion} collapsed={collapsedView} />
</div> </div>
</Layout.Sider> </Layout.Sider>