diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 18d61a6a2..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); } @@ -53,10 +53,18 @@ .brand-actions { display: inline-flex; align-items: center; - gap: 2px; + gap: 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 { background: transparent; border: none; @@ -230,6 +238,34 @@ 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 { display: flex; align-items: center; @@ -245,6 +281,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 fef4f72db..c464fc329 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,7 +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 railStyle = { '--sider-rail': `${RAIL_WIDTH}px` } as CSSProperties; +const SIDER_WIDTH = 220; +const SIDEBAR_PINNED_KEY = 'sidebar-pinned'; 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,13 @@ 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 railStyle = useMemo( + () => ({ '--sider-rail': `${pinned ? SIDER_WIDTH : RAIL_WIDTH}px` }) as CSSProperties, + [pinned], + ); const rootRef = useRef(null); const updateHovered = useCallback((value: boolean) => { @@ -153,6 +175,12 @@ export default function AppSidebar() { setHovered(value); }, []); + const togglePinned = useCallback(() => { + const next = !pinned; + saveSidebarPinned(next); + setPinned(next); + }, [pinned]); + useEffect(() => { const timer = window.setTimeout(() => { const el = rootRef.current; @@ -261,14 +289,14 @@ export default function AppSidebar() { return (
updateHovered(true)} onMouseLeave={() => updateHovered(false)} > @@ -278,6 +306,16 @@ export default function AppSidebar() {
{!railCollapsed && (
+ ({ + useAllSettings: () => ({ allSetting: {} }), +})); + +afterEach(() => { + localStorage.clear(); +}); + +function renderSidebar() { + return renderWithProviders( + + + , + ); +} + +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'); +}); 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": {