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": {