mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
Merge pull request #6161 from PathGao/feat-sidebar-pinning
feat(ui): let users pin the sidebar
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
align-self: flex-start;
|
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);
|
box-shadow: 0 0 32px rgba(0, 0, 0, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +53,18 @@
|
|||||||
.brand-actions {
|
.brand-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 0;
|
||||||
flex-shrink: 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 {
|
.sidebar-donate {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -230,6 +238,34 @@
|
|||||||
padding: 8px 8px 12px;
|
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 {
|
.sider-version {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -245,6 +281,11 @@
|
|||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ant-layout-sider-collapsed .sider-version {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sider-version .anticon {
|
.sider-version .anticon {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
MoonFilled,
|
MoonFilled,
|
||||||
MoonOutlined,
|
MoonOutlined,
|
||||||
|
PushpinFilled,
|
||||||
|
PushpinOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -44,7 +46,8 @@ const DOCS_URL = 'https://docs.sanaei.dev/';
|
|||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
const RAIL_WIDTH = 72;
|
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;
|
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() {
|
export default function AppSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
@@ -144,8 +161,13 @@ export default function AppSidebar() {
|
|||||||
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
|
||||||
|
|
||||||
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
const [hovered, setHovered] = useState(() => hoveredAcrossRemounts);
|
||||||
|
const [pinned, setPinned] = useState(readSidebarPinned);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
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 rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const updateHovered = useCallback((value: boolean) => {
|
const updateHovered = useCallback((value: boolean) => {
|
||||||
@@ -153,6 +175,12 @@ export default function AppSidebar() {
|
|||||||
setHovered(value);
|
setHovered(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const togglePinned = useCallback(() => {
|
||||||
|
const next = !pinned;
|
||||||
|
saveSidebarPinned(next);
|
||||||
|
setPinned(next);
|
||||||
|
}, [pinned]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
const el = rootRef.current;
|
const el = rootRef.current;
|
||||||
@@ -261,14 +289,14 @@ export default function AppSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
className="ant-sidebar"
|
className={`ant-sidebar${pinned ? ' sidebar-pinned' : ''}`}
|
||||||
style={railStyle}
|
style={railStyle}
|
||||||
onMouseEnter={() => updateHovered(true)}
|
onMouseEnter={() => updateHovered(true)}
|
||||||
onMouseLeave={() => updateHovered(false)}
|
onMouseLeave={() => updateHovered(false)}
|
||||||
>
|
>
|
||||||
<Layout.Sider
|
<Layout.Sider
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
width={220}
|
width={SIDER_WIDTH}
|
||||||
collapsedWidth={RAIL_WIDTH}
|
collapsedWidth={RAIL_WIDTH}
|
||||||
collapsed={railCollapsed}
|
collapsed={railCollapsed}
|
||||||
>
|
>
|
||||||
@@ -278,6 +306,16 @@ export default function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
{!railCollapsed && (
|
{!railCollapsed && (
|
||||||
<div className="brand-actions">
|
<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'} />
|
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
|
||||||
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
|
||||||
<ThemeCycleButton
|
<ThemeCycleButton
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "تبرع",
|
"donate": "تبرع",
|
||||||
"hosts": "المضيفات",
|
"hosts": "المضيفات",
|
||||||
"docs": "التوثيق",
|
"docs": "التوثيق",
|
||||||
"openMenu": "فتح القائمة"
|
"openMenu": "فتح القائمة",
|
||||||
|
"pinSidebar": "تثبيت الشريط الجانبي",
|
||||||
|
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"apiDocs": "API Docs",
|
"apiDocs": "API Docs",
|
||||||
"donate": "Donate",
|
"donate": "Donate",
|
||||||
"docs": "Documentation",
|
"docs": "Documentation",
|
||||||
"openMenu": "Open menu"
|
"openMenu": "Open menu",
|
||||||
|
"pinSidebar": "Pin sidebar",
|
||||||
|
"unpinSidebar": "Unpin sidebar"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Donar",
|
"donate": "Donar",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Documentación",
|
"docs": "Documentación",
|
||||||
"openMenu": "Abrir menú"
|
"openMenu": "Abrir menú",
|
||||||
|
"pinSidebar": "Fijar barra lateral",
|
||||||
|
"unpinSidebar": "Desfijar barra lateral"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "حمایت مالی",
|
"donate": "حمایت مالی",
|
||||||
"hosts": "میزبانها",
|
"hosts": "میزبانها",
|
||||||
"docs": "مستندات",
|
"docs": "مستندات",
|
||||||
"openMenu": "باز کردن منو"
|
"openMenu": "باز کردن منو",
|
||||||
|
"pinSidebar": "ثابت کردن نوار کناری",
|
||||||
|
"unpinSidebar": "برداشتن تثبیت نوار کناری"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Donasi",
|
"donate": "Donasi",
|
||||||
"hosts": "Host",
|
"hosts": "Host",
|
||||||
"docs": "Dokumentasi",
|
"docs": "Dokumentasi",
|
||||||
"openMenu": "Buka menu"
|
"openMenu": "Buka menu",
|
||||||
|
"pinSidebar": "Sematkan bilah sisi",
|
||||||
|
"unpinSidebar": "Lepas sematan bilah sisi"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "寄付",
|
"donate": "寄付",
|
||||||
"hosts": "ホスト",
|
"hosts": "ホスト",
|
||||||
"docs": "ドキュメント",
|
"docs": "ドキュメント",
|
||||||
"openMenu": "メニューを開く"
|
"openMenu": "メニューを開く",
|
||||||
|
"pinSidebar": "サイドバーを固定",
|
||||||
|
"unpinSidebar": "サイドバーの固定を解除"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Doar",
|
"donate": "Doar",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Documentação",
|
"docs": "Documentação",
|
||||||
"openMenu": "Abrir menu"
|
"openMenu": "Abrir menu",
|
||||||
|
"pinSidebar": "Fixar barra lateral",
|
||||||
|
"unpinSidebar": "Desafixar barra lateral"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Поддержать",
|
"donate": "Поддержать",
|
||||||
"hosts": "Хосты",
|
"hosts": "Хосты",
|
||||||
"docs": "Документация",
|
"docs": "Документация",
|
||||||
"openMenu": "Открыть меню"
|
"openMenu": "Открыть меню",
|
||||||
|
"pinSidebar": "Закрепить боковую панель",
|
||||||
|
"unpinSidebar": "Открепить боковую панель"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Bağış Yap",
|
"donate": "Bağış Yap",
|
||||||
"hosts": "Host'lar",
|
"hosts": "Host'lar",
|
||||||
"docs": "Belgeler",
|
"docs": "Belgeler",
|
||||||
"openMenu": "Menüyü aç"
|
"openMenu": "Menüyü aç",
|
||||||
|
"pinSidebar": "Kenar çubuğunu sabitle",
|
||||||
|
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Підтримати",
|
"donate": "Підтримати",
|
||||||
"hosts": "Хости",
|
"hosts": "Хости",
|
||||||
"docs": "Документація",
|
"docs": "Документація",
|
||||||
"openMenu": "Відкрити меню"
|
"openMenu": "Відкрити меню",
|
||||||
|
"pinSidebar": "Закріпити бічну панель",
|
||||||
|
"unpinSidebar": "Відкріпити бічну панель"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "Quyên góp",
|
"donate": "Quyên góp",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "Tài liệu",
|
"docs": "Tài liệu",
|
||||||
"openMenu": "Mở menu"
|
"openMenu": "Mở menu",
|
||||||
|
"pinSidebar": "Ghim thanh bên",
|
||||||
|
"unpinSidebar": "Bỏ ghim thanh bên"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "捐赠",
|
"donate": "捐赠",
|
||||||
"hosts": "主机",
|
"hosts": "主机",
|
||||||
"docs": "文档",
|
"docs": "文档",
|
||||||
"openMenu": "打开菜单"
|
"openMenu": "打开菜单",
|
||||||
|
"pinSidebar": "固定侧边栏",
|
||||||
|
"unpinSidebar": "取消固定侧边栏"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
@@ -109,7 +109,9 @@
|
|||||||
"donate": "捐贈",
|
"donate": "捐贈",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"docs": "文件",
|
"docs": "文件",
|
||||||
"openMenu": "開啟選單"
|
"openMenu": "開啟選單",
|
||||||
|
"pinSidebar": "固定側邊欄",
|
||||||
|
"unpinSidebar": "取消固定側邊欄"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"login": {
|
"login": {
|
||||||
|
|||||||
Reference in New Issue
Block a user