From 5373786faa1dc0ffc64a32bfe86a2071514233ca Mon Sep 17 00:00:00 2001 From: PathGao Date: Thu, 30 Jul 2026 14:39:55 +0800 Subject: [PATCH 1/4] feat(ui): let users pin the sidebar Restore a persistent expanded-sidebar choice while preserving the compact hover rail as the default. --- frontend/src/layouts/AppSidebar.css | 33 ++++++++++++++++++++ frontend/src/layouts/AppSidebar.tsx | 39 ++++++++++++++++++++++- frontend/src/test/app-sidebar.test.tsx | 43 ++++++++++++++++++++++++++ internal/web/translation/ar-EG.json | 4 ++- internal/web/translation/en-US.json | 4 ++- internal/web/translation/es-ES.json | 4 ++- internal/web/translation/fa-IR.json | 4 ++- internal/web/translation/id-ID.json | 4 ++- internal/web/translation/ja-JP.json | 4 ++- internal/web/translation/pt-BR.json | 4 ++- internal/web/translation/ru-RU.json | 4 ++- internal/web/translation/tr-TR.json | 4 ++- internal/web/translation/uk-UA.json | 4 ++- internal/web/translation/vi-VN.json | 4 ++- internal/web/translation/zh-CN.json | 4 ++- internal/web/translation/zh-TW.json | 4 ++- 16 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 frontend/src/test/app-sidebar.test.tsx diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 18d61a6a2..0376aec34 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -230,6 +230,39 @@ padding: 8px 8px 12px; } +.sidebar-pin { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 34px; + padding: 0 16px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--ant-color-text-secondary); + cursor: pointer; + font-size: 13px; + text-align: left; + transition: background-color 0.2s, 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); + outline: none; +} + +.sidebar-pin .anticon { + font-size: 16px; +} + +.ant-layout-sider-collapsed .sidebar-pin { + justify-content: center; + padding: 0; +} + .sider-version { display: flex; align-items: center; diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index fef4f72db..b4af01295 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -23,6 +23,8 @@ import { MessageOutlined, MoonFilled, MoonOutlined, + PushpinFilled, + PushpinOutlined, ReadOutlined, SafetyOutlined, SettingOutlined, @@ -44,6 +46,7 @@ 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 SIDEBAR_PINNED_KEY = 'sidebar-pinned'; const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties; 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() { const { t } = useTranslation(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); @@ -144,8 +161,9 @@ export default function AppSidebar() { const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable); const [hovered, setHovered] = useState(() => hoveredAcrossRemounts); + const [pinned, setPinned] = useState(readSidebarPinned); const [drawerOpen, setDrawerOpen] = useState(false); - const railCollapsed = !hovered; + const railCollapsed = !hovered && !pinned; const rootRef = useRef(null); const updateHovered = useCallback((value: boolean) => { @@ -153,6 +171,14 @@ export default function AppSidebar() { setHovered(value); }, []); + const togglePinned = useCallback(() => { + setPinned((value) => { + const next = !value; + saveSidebarPinned(next); + return next; + }); + }, []); + useEffect(() => { const timer = window.setTimeout(() => { const el = rootRef.current; @@ -309,6 +335,17 @@ export default function AppSidebar() { onClick={onMenuClick} />
+
diff --git a/frontend/src/test/app-sidebar.test.tsx b/frontend/src/test/app-sidebar.test.tsx new file mode 100644 index 000000000..a50c66b6d --- /dev/null +++ b/frontend/src/test/app-sidebar.test.tsx @@ -0,0 +1,43 @@ +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( + + + , + ); +} + +test('keeps the sidebar expanded after pinning it and restores the choice', () => { + const first = renderSidebar(); + const sidebar = first.container.querySelector('.ant-layout-sider'); + + expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: 'Pin sidebar' })); + fireEvent.mouseLeave(first.container.querySelector('.ant-sidebar')!); + + expect(sidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false); + expect(localStorage.getItem('sidebar-pinned')).toBe('true'); + + first.unmount(); + + const second = renderSidebar(); + const restoredSidebar = second.container.querySelector('.ant-layout-sider'); + + expect(restoredSidebar?.classList.contains('ant-layout-sider-collapsed')).toBe(false); + expect(screen.getByRole('button', { name: 'Unpin sidebar' })).not.toBeNull(); +}); diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 514b02947..d827d3bbc 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -109,7 +109,9 @@ "donate": "تبرع", "hosts": "المضيفات", "docs": "التوثيق", - "openMenu": "فتح القائمة" + "openMenu": "فتح القائمة", + "pinSidebar": "تثبيت الشريط الجانبي", + "unpinSidebar": "إلغاء تثبيت الشريط الجانبي" }, "pages": { "login": { diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 458fb1ed9..e1e03db68 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -109,7 +109,9 @@ "apiDocs": "API Docs", "donate": "Donate", "docs": "Documentation", - "openMenu": "Open menu" + "openMenu": "Open menu", + "pinSidebar": "Pin sidebar", + "unpinSidebar": "Unpin sidebar" }, "pages": { "login": { diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 0403c65d0..388d28caf 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -109,7 +109,9 @@ "donate": "Donar", "hosts": "Hosts", "docs": "Documentación", - "openMenu": "Abrir menú" + "openMenu": "Abrir menú", + "pinSidebar": "Fijar barra lateral", + "unpinSidebar": "Desfijar barra lateral" }, "pages": { "login": { diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 7181a9a95..d337883f3 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -109,7 +109,9 @@ "donate": "حمایت مالی", "hosts": "میزبان‌ها", "docs": "مستندات", - "openMenu": "باز کردن منو" + "openMenu": "باز کردن منو", + "pinSidebar": "ثابت کردن نوار کناری", + "unpinSidebar": "برداشتن تثبیت نوار کناری" }, "pages": { "login": { diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 9dd125bae..c82dab734 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -109,7 +109,9 @@ "donate": "Donasi", "hosts": "Host", "docs": "Dokumentasi", - "openMenu": "Buka menu" + "openMenu": "Buka menu", + "pinSidebar": "Sematkan bilah sisi", + "unpinSidebar": "Lepas sematan bilah sisi" }, "pages": { "login": { diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index c59fc5aa1..3e34e6a9f 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -109,7 +109,9 @@ "donate": "寄付", "hosts": "ホスト", "docs": "ドキュメント", - "openMenu": "メニューを開く" + "openMenu": "メニューを開く", + "pinSidebar": "サイドバーを固定", + "unpinSidebar": "サイドバーの固定を解除" }, "pages": { "login": { diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index f32291c49..56944a760 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -109,7 +109,9 @@ "donate": "Doar", "hosts": "Hosts", "docs": "Documentação", - "openMenu": "Abrir menu" + "openMenu": "Abrir menu", + "pinSidebar": "Fixar barra lateral", + "unpinSidebar": "Desafixar barra lateral" }, "pages": { "login": { diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 1acd7a0bf..6369f60fe 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -109,7 +109,9 @@ "donate": "Поддержать", "hosts": "Хосты", "docs": "Документация", - "openMenu": "Открыть меню" + "openMenu": "Открыть меню", + "pinSidebar": "Закрепить боковую панель", + "unpinSidebar": "Открепить боковую панель" }, "pages": { "login": { diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index c8f189e35..c23405b7e 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -109,7 +109,9 @@ "donate": "Bağış Yap", "hosts": "Host'lar", "docs": "Belgeler", - "openMenu": "Menüyü aç" + "openMenu": "Menüyü aç", + "pinSidebar": "Kenar çubuğunu sabitle", + "unpinSidebar": "Kenar çubuğu sabitlemesini kaldır" }, "pages": { "login": { diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 7463a9b72..ea4246863 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -109,7 +109,9 @@ "donate": "Підтримати", "hosts": "Хости", "docs": "Документація", - "openMenu": "Відкрити меню" + "openMenu": "Відкрити меню", + "pinSidebar": "Закріпити бічну панель", + "unpinSidebar": "Відкріпити бічну панель" }, "pages": { "login": { diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 28792d39c..c028ec577 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -109,7 +109,9 @@ "donate": "Quyên góp", "hosts": "Hosts", "docs": "Tài liệu", - "openMenu": "Mở menu" + "openMenu": "Mở menu", + "pinSidebar": "Ghim thanh bên", + "unpinSidebar": "Bỏ ghim thanh bên" }, "pages": { "login": { diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6215f3662..f08fb22cd 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -109,7 +109,9 @@ "donate": "捐赠", "hosts": "主机", "docs": "文档", - "openMenu": "打开菜单" + "openMenu": "打开菜单", + "pinSidebar": "固定侧边栏", + "unpinSidebar": "取消固定侧边栏" }, "pages": { "login": { diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 18fb0495c..b9896c1f6 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -109,7 +109,9 @@ "donate": "捐贈", "hosts": "Hosts", "docs": "文件", - "openMenu": "開啟選單" + "openMenu": "開啟選單", + "pinSidebar": "固定側邊欄", + "unpinSidebar": "取消固定側邊欄" }, "pages": { "login": { From b2fe23310862fe46479bb72511232bf21243cbdf Mon Sep 17 00:00:00 2001 From: PathGao Date: Thu, 30 Jul 2026 14:46:43 +0800 Subject: [PATCH 2/4] fix(ui): align sidebar pin controls Keep the pin with the expanded header actions and center the collapsed version link with the navigation rail. --- frontend/src/layouts/AppSidebar.css | 34 +++++++++++++------------- frontend/src/layouts/AppSidebar.tsx | 21 ++++++++-------- frontend/src/test/app-sidebar.test.tsx | 12 ++++++--- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 0376aec34..914c4afd0 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -34,9 +34,9 @@ display: flex; align-items: center; justify-content: space-between; - gap: 8px; + gap: 4px; height: 58px; - padding: 0 16px 0 24px; + padding: 0 12px 0 16px; border-bottom: 1px solid var(--ant-color-border-secondary); user-select: none; white-space: nowrap; @@ -53,7 +53,7 @@ .brand-actions { display: inline-flex; align-items: center; - gap: 2px; + gap: 0; flex-shrink: 0; } @@ -231,26 +231,26 @@ } .sidebar-pin { - display: flex; + display: inline-flex; align-items: center; - gap: 10px; - width: 100%; - height: 34px; - padding: 0 16px; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; border: none; - border-radius: 6px; + border-radius: 50%; background: transparent; color: var(--ant-color-text-secondary); cursor: pointer; - font-size: 13px; - text-align: left; - transition: background-color 0.2s, color 0.2s; + 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; } @@ -258,11 +258,6 @@ font-size: 16px; } -.ant-layout-sider-collapsed .sidebar-pin { - justify-content: center; - padding: 0; -} - .sider-version { display: flex; align-items: center; @@ -278,6 +273,11 @@ transition: color 0.2s; } +.ant-layout-sider-collapsed .sider-version { + justify-content: center; + padding: 8px 0; +} + .sider-version .anticon { font-size: 16px; } diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index b4af01295..9a2965c4f 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -304,6 +304,16 @@ export default function AppSidebar() { {!railCollapsed && (
+
-
diff --git a/frontend/src/test/app-sidebar.test.tsx b/frontend/src/test/app-sidebar.test.tsx index a50c66b6d..2bcd82cac 100644 --- a/frontend/src/test/app-sidebar.test.tsx +++ b/frontend/src/test/app-sidebar.test.tsx @@ -21,14 +21,20 @@ function renderSidebar() { ); } -test('keeps the sidebar expanded after pinning it and restores the choice', () => { +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.click(screen.getByRole('button', { name: 'Pin sidebar' })); - fireEvent.mouseLeave(first.container.querySelector('.ant-sidebar')!); + 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(localStorage.getItem('sidebar-pinned')).toBe('true'); From 91c5d7b19fc43fd915d38e871fef7693a2904e5d Mon Sep 17 00:00:00 2001 From: PathGao Date: Thu, 30 Jul 2026 14:48:03 +0800 Subject: [PATCH 3/4] style(ui): preserve sidebar header spacing Keep the original title alignment while fitting the pin with the existing header actions. --- frontend/src/layouts/AppSidebar.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 914c4afd0..392b5b6a3 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -34,9 +34,9 @@ display: flex; align-items: center; justify-content: space-between; - gap: 4px; + gap: 8px; height: 58px; - padding: 0 12px 0 16px; + padding: 0 16px 0 24px; border-bottom: 1px solid var(--ant-color-border-secondary); user-select: none; white-space: nowrap; @@ -57,6 +57,14 @@ 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 { background: transparent; border: none; From ac584cfc90b9bf1a146c617a96043434974781fc Mon Sep 17 00:00:00 2001 From: PathGao Date: Thu, 30 Jul 2026 14:52:38 +0800 Subject: [PATCH 4/4] fix(ui): reserve space for pinned sidebar Keep page content accessible when the desktop sidebar remains expanded and cover the complete pin lifecycle. --- frontend/src/layouts/AppSidebar.css | 2 +- frontend/src/layouts/AppSidebar.tsx | 22 ++++++++++++---------- frontend/src/test/app-sidebar.test.tsx | 20 +++++++++++++++++++- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 392b5b6a3..6a54a8b00 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -12,7 +12,7 @@ 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); } diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 9a2965c4f..c464fc329 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -46,8 +46,8 @@ 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 SIDER_WIDTH = 220; const SIDEBAR_PINNED_KEY = 'sidebar-pinned'; -const railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties; let hoveredAcrossRemounts = false; @@ -164,6 +164,10 @@ export default function AppSidebar() { const [pinned, setPinned] = useState(readSidebarPinned); const [drawerOpen, setDrawerOpen] = useState(false); const railCollapsed = !hovered && !pinned; + const railStyle = useMemo( + () => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties, + [pinned], + ); const rootRef = useRef(null); const updateHovered = useCallback((value: boolean) => { @@ -172,12 +176,10 @@ export default function AppSidebar() { }, []); const togglePinned = useCallback(() => { - setPinned((value) => { - const next = !value; - saveSidebarPinned(next); - return next; - }); - }, []); + const next = !pinned; + saveSidebarPinned(next); + setPinned(next); + }, [pinned]); useEffect(() => { const timer = window.setTimeout(() => { @@ -287,14 +289,14 @@ export default function AppSidebar() { return (
updateHovered(true)} onMouseLeave={() => updateHovered(false)} > @@ -307,7 +309,7 @@ export default function AppSidebar() {