Merge pull request #6161 from PathGao/feat-sidebar-pinning

feat(ui): let users pin the sidebar
This commit is contained in:
PathGao
2026-07-30 23:37:47 +08:00
committed by GitHub
16 changed files with 191 additions and 19 deletions
+43 -2
View File
@@ -12,7 +12,7 @@
align-self: flex-start; align-self: flex-start;
} }
.ant-sidebar > .ant-layout-sider:not(.ant-layout-sider-collapsed) { .ant-sidebar:not(.sidebar-pinned) > .ant-layout-sider:not(.ant-layout-sider-collapsed) {
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22); box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
} }
@@ -53,10 +53,18 @@
.brand-actions { .brand-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 2px; gap: 0;
flex-shrink: 0; flex-shrink: 0;
} }
.brand-actions .sidebar-pin,
.brand-actions .sidebar-docs,
.brand-actions .sidebar-donate,
.brand-actions .sidebar-theme-cycle {
width: 26px;
height: 26px;
}
.sidebar-donate { .sidebar-donate {
background: transparent; background: transparent;
border: none; border: none;
@@ -230,6 +238,34 @@
padding: 8px 8px 12px; padding: 8px 8px 12px;
} }
.sidebar-pin {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--ant-color-text-secondary);
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.2s, transform 0.15s, color 0.2s;
}
.sidebar-pin:hover,
.sidebar-pin:focus-visible {
background-color: color-mix(in srgb, var(--ant-color-primary) 10%, transparent);
color: var(--ant-color-primary);
transform: scale(1.08);
outline: none;
}
.sidebar-pin .anticon {
font-size: 16px;
}
.sider-version { .sider-version {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -245,6 +281,11 @@
transition: color 0.2s; transition: color 0.2s;
} }
.ant-layout-sider-collapsed .sider-version {
justify-content: center;
padding: 8px 0;
}
.sider-version .anticon { .sider-version .anticon {
font-size: 16px; font-size: 16px;
} }
+42 -4
View File
@@ -23,6 +23,8 @@ import {
MessageOutlined, MessageOutlined,
MoonFilled, MoonFilled,
MoonOutlined, MoonOutlined,
PushpinFilled,
PushpinOutlined,
ReadOutlined, ReadOutlined,
SafetyOutlined, SafetyOutlined,
SettingOutlined, SettingOutlined,
@@ -44,7 +46,8 @@ const DOCS_URL = 'https://docs.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__';
const RAIL_WIDTH = 72; const RAIL_WIDTH = 72;
const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties; const SIDER_WIDTH = 220;
const SIDEBAR_PINNED_KEY = 'sidebar-pinned';
let hoveredAcrossRemounts = false; let hoveredAcrossRemounts = false;
@@ -135,6 +138,20 @@ function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
); );
} }
function readSidebarPinned() {
try {
return localStorage.getItem(SIDEBAR_PINNED_KEY) === 'true';
} catch {
return false;
}
}
function saveSidebarPinned(pinned: boolean) {
try {
localStorage.setItem(SIDEBAR_PINNED_KEY, String(pinned));
} catch {}
}
export default function AppSidebar() { export default function AppSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
@@ -144,8 +161,13 @@ export default function AppSidebar() {
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts); const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
const [pinned, setPinned] = useState(readSidebarPinned);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const railCollapsed = !hovered; const railCollapsed = !hovered && !pinned;
const railStyle = useMemo(
() => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties,
[pinned],
);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const updateHovered = useCallback((value: boolean) => { const updateHovered = useCallback((value: boolean) => {
@@ -153,6 +175,12 @@ export default function AppSidebar() {
setHovered(value); setHovered(value);
}, []); }, []);
const togglePinned = useCallback(() => {
const next = !pinned;
saveSidebarPinned(next);
setPinned(next);
}, [pinned]);
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
const el = rootRef.current; const el = rootRef.current;
@@ -261,14 +289,14 @@ export default function AppSidebar() {
return ( return (
<div <div
ref={rootRef} ref={rootRef}
className="ant-sidebar" className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
style={railStyle} style={railStyle}
onMouseEnter={() => updateHovered(true)} onMouseEnter={() => updateHovered(true)}
onMouseLeave={() => updateHovered(false)} onMouseLeave={() => updateHovered(false)}
> >
<Layout.Sider <Layout.Sider
theme={currentTheme} theme={currentTheme}
width={220} width={SIDER_WIDTH}
collapsedWidth={RAIL_WIDTH} collapsedWidth={RAIL_WIDTH}
collapsed={railCollapsed} collapsed={railCollapsed}
> >
@@ -278,6 +306,16 @@ export default function AppSidebar() {
</div> </div>
{!railCollapsed && ( {!railCollapsed && (
<div className="brand-actions"> <div className="brand-actions">
<button
type="button"
className="sidebar-pin"
aria-label={t('menu.pinSidebar')}
aria-pressed={pinned}
title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
onClick={togglePinned}
>
{pinned ? <PushpinFilled /> : <PushpinOutlined />}
</button>
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} /> <DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} /> <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton <ThemeCycleButton
+67
View File
@@ -0,0 +1,67 @@
import { fireEvent, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { afterEach, expect, test, vi } from 'vitest';
import AppSidebar from '@/layouts/AppSidebar';
import { renderWithProviders } from './test-utils';
vi.mock('@/api/queries/useAllSettings', () => ({
useAllSettings: () => ({ allSetting: {} }),
}));
afterEach(() => {
localStorage.clear();
});
function renderSidebar() {
return renderWithProviders(
<MemoryRouter>
<AppSidebar />
</MemoryRouter>,
);
}
test('keeps the sidebar expanded after pinning it from the header and restores the choice', () => {
const first = renderSidebar();
const sidebar = first.container.querySelector('.ant-layout-sider');
const sidebarRoot = first.container.querySelector('.ant-sidebar');
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
fireEvent.mouseEnter(sidebarRoot!);
const pinButton = screen.getByRole('button', { name: 'Pin sidebar' });
expect(pinButton.closest('.brand-actions')).not.toBeNull();
fireEvent.click(pinButton);
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(localStorage.getItem('sidebar-pinned')).toBe('true');
first.unmount();
const second = renderSidebar();
const restoredSidebar = second.container.querySelector('.ant-layout-sider');
const restoredSidebarRoot = second.container.querySelector('.ant-sidebar');
expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false);
expect(restoredSidebarRoot?.getAttribute('style')).toContain('--sider-rail: 220px');
expect(screen.getByRole('button', { name: 'Pin sidebar' })).not.toBeNull();
});
test('returns to the compact rail after unpinning', () => {
const view = renderSidebar();
const sidebar = view.container.querySelector('.ant-layout-sider');
const sidebarRoot = view.container.querySelector('.ant-sidebar');
fireEvent.mouseEnter(sidebarRoot!);
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' }));
fireEvent.mouseLeave(sidebarRoot!);
expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true);
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
});
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "تبرع", "donate": "تبرع",
"hosts": "المضيفات", "hosts": "المضيفات",
"docs": "التوثيق", "docs": "التوثيق",
"openMenu": "فتح القائمة" "openMenu": "فتح القائمة",
"pinSidebar": "تثبيت الشريط الجانبي",
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"apiDocs": "API Docs", "apiDocs": "API Docs",
"donate": "Donate", "donate": "Donate",
"docs": "Documentation", "docs": "Documentation",
"openMenu": "Open menu" "openMenu": "Open menu",
"pinSidebar": "Pin sidebar",
"unpinSidebar": "Unpin sidebar"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Donar", "donate": "Donar",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Documentación", "docs": "Documentación",
"openMenu": "Abrir menú" "openMenu": "Abrir menú",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "حمایت مالی", "donate": "حمایت مالی",
"hosts": "میزبان‌ها", "hosts": "میزبان‌ها",
"docs": "مستندات", "docs": "مستندات",
"openMenu": "باز کردن منو" "openMenu": "باز کردن منو",
"pinSidebar": "ثابت کردن نوار کناری",
"unpinSidebar": "برداشتن تثبیت نوار کناری"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Donasi", "donate": "Donasi",
"hosts": "Host", "hosts": "Host",
"docs": "Dokumentasi", "docs": "Dokumentasi",
"openMenu": "Buka menu" "openMenu": "Buka menu",
"pinSidebar": "Sematkan bilah sisi",
"unpinSidebar": "Lepas sematan bilah sisi"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "寄付", "donate": "寄付",
"hosts": "ホスト", "hosts": "ホスト",
"docs": "ドキュメント", "docs": "ドキュメント",
"openMenu": "メニューを開く" "openMenu": "メニューを開く",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Doar", "donate": "Doar",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Documentação", "docs": "Documentação",
"openMenu": "Abrir menu" "openMenu": "Abrir menu",
"pinSidebar": "Fixar barra lateral",
"unpinSidebar": "Desafixar barra lateral"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Поддержать", "donate": "Поддержать",
"hosts": "Хосты", "hosts": "Хосты",
"docs": "Документация", "docs": "Документация",
"openMenu": "Открыть меню" "openMenu": "Открыть меню",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Bağış Yap", "donate": "Bağış Yap",
"hosts": "Host'lar", "hosts": "Host'lar",
"docs": "Belgeler", "docs": "Belgeler",
"openMenu": "Menüyü aç" "openMenu": "Menüyü aç",
"pinSidebar": "Kenar çubuğunu sabitle",
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Підтримати", "donate": "Підтримати",
"hosts": "Хости", "hosts": "Хости",
"docs": "Документація", "docs": "Документація",
"openMenu": "Відкрити меню" "openMenu": "Відкрити меню",
"pinSidebar": "Закріпити бічну панель",
"unpinSidebar": "Відкріпити бічну панель"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "Quyên góp", "donate": "Quyên góp",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Tài liệu", "docs": "Tài liệu",
"openMenu": "Mở menu" "openMenu": "Mở menu",
"pinSidebar": "Ghim thanh bên",
"unpinSidebar": "Bỏ ghim thanh bên"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "捐赠", "donate": "捐赠",
"hosts": "主机", "hosts": "主机",
"docs": "文档", "docs": "文档",
"openMenu": "打开菜单" "openMenu": "打开菜单",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏"
}, },
"pages": { "pages": {
"login": { "login": {
+3 -1
View File
@@ -109,7 +109,9 @@
"donate": "捐贈", "donate": "捐贈",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "文件", "docs": "文件",
"openMenu": "開啟選單" "openMenu": "開啟選單",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄"
}, },
"pages": { "pages": {
"login": { "login": {