Explorar o código

feat(ui): let users pin the sidebar

Restore a persistent expanded-sidebar choice while preserving the compact hover rail as the default.
PathGao hai 22 horas
pai
achega
5373786faa

+ 33 - 0
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;

+ 38 - 1
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<HTMLDivElement>(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}
         />
         <div className="sider-footer">
+          <button
+            type="button"
+            className="sidebar-pin"
+            aria-label={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
+            aria-pressed={pinned}
+            title={t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}
+            onClick={togglePinned}
+          >
+            {pinned ? <PushpinFilled /> : <PushpinOutlined />}
+            {!railCollapsed && <span>{t(pinned ? 'menu.unpinSidebar' : 'menu.pinSidebar')}</span>}
+          </button>
           <VersionBadge version={panelVersion} collapsed={railCollapsed} />
         </div>
       </Layout.Sider>

+ 43 - 0
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(
+    <MemoryRouter>
+      <AppSidebar />
+    </MemoryRouter>,
+  );
+}
+
+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();
+});

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -109,7 +109,9 @@
     "donate": "تبرع",
     "hosts": "المضيفات",
     "docs": "التوثيق",
-    "openMenu": "فتح القائمة"
+    "openMenu": "فتح القائمة",
+    "pinSidebar": "تثبيت الشريط الجانبي",
+    "unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
   },
   "pages": {
     "login": {

+ 3 - 1
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": {

+ 3 - 1
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": {

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -109,7 +109,9 @@
     "donate": "حمایت مالی",
     "hosts": "میزبان‌ها",
     "docs": "مستندات",
-    "openMenu": "باز کردن منو"
+    "openMenu": "باز کردن منو",
+    "pinSidebar": "ثابت کردن نوار کناری",
+    "unpinSidebar": "برداشتن تثبیت نوار کناری"
   },
   "pages": {
     "login": {

+ 3 - 1
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": {

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -109,7 +109,9 @@
     "donate": "寄付",
     "hosts": "ホスト",
     "docs": "ドキュメント",
-    "openMenu": "メニューを開く"
+    "openMenu": "メニューを開く",
+    "pinSidebar": "サイドバーを固定",
+    "unpinSidebar": "サイドバーの固定を解除"
   },
   "pages": {
     "login": {

+ 3 - 1
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": {

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -109,7 +109,9 @@
     "donate": "Поддержать",
     "hosts": "Хосты",
     "docs": "Документация",
-    "openMenu": "Открыть меню"
+    "openMenu": "Открыть меню",
+    "pinSidebar": "Закрепить боковую панель",
+    "unpinSidebar": "Открепить боковую панель"
   },
   "pages": {
     "login": {

+ 3 - 1
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": {

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -109,7 +109,9 @@
     "donate": "Підтримати",
     "hosts": "Хости",
     "docs": "Документація",
-    "openMenu": "Відкрити меню"
+    "openMenu": "Відкрити меню",
+    "pinSidebar": "Закріпити бічну панель",
+    "unpinSidebar": "Відкріпити бічну панель"
   },
   "pages": {
     "login": {

+ 3 - 1
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": {

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -109,7 +109,9 @@
     "donate": "捐赠",
     "hosts": "主机",
     "docs": "文档",
-    "openMenu": "打开菜单"
+    "openMenu": "打开菜单",
+    "pinSidebar": "固定侧边栏",
+    "unpinSidebar": "取消固定侧边栏"
   },
   "pages": {
     "login": {

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -109,7 +109,9 @@
     "donate": "捐贈",
     "hosts": "Hosts",
     "docs": "文件",
-    "openMenu": "開啟選單"
+    "openMenu": "開啟選單",
+    "pinSidebar": "固定側邊欄",
+    "unpinSidebar": "取消固定側邊欄"
   },
   "pages": {
     "login": {