1
0

5 Commits c377dca27c ... 264f61eb90

Autor SHA1 Nachricht Datum
  PathGao 264f61eb90 Merge pull request #6161 from PathGao/feat-sidebar-pinning vor 14 Stunden
  PathGao ac584cfc90 fix(ui): reserve space for pinned sidebar vor 22 Stunden
  PathGao 91c5d7b19f style(ui): preserve sidebar header spacing vor 22 Stunden
  PathGao b2fe233108 fix(ui): align sidebar pin controls vor 22 Stunden
  PathGao 5373786faa feat(ui): let users pin the sidebar vor 23 Stunden

+ 43 - 2
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;
 }

+ 42 - 4
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<HTMLDivElement>(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 (
     <div
       ref={rootRef}
-      className="ant-sidebar"
+      className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
       style={railStyle}
       onMouseEnter={() => updateHovered(true)}
       onMouseLeave={() => updateHovered(false)}
     >
       <Layout.Sider
         theme={currentTheme}
-        width={220}
+        width={SIDER_WIDTH}
         collapsedWidth={RAIL_WIDTH}
         collapsed={railCollapsed}
       >
@@ -278,6 +306,16 @@ export default function AppSidebar() {
           </div>
           {!railCollapsed && (
             <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'} />
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
               <ThemeCycleButton

+ 67 - 0
frontend/src/test/app-sidebar.test.tsx

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