feat(SettingsPage): implement sidebar navigation with search functionality for improved user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s

This commit is contained in:
2026-02-17 14:54:18 +07:00
parent f67a8f888c
commit 18926a7274
+98 -37
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import api from './lib/api.js';
import {
IconSettings,
@@ -8,7 +9,7 @@ import {
IconCloud,
IconChartPie,
IconWorld,
IconChevronDown,
IconSearch,
} from '@tabler/icons-react';
import FormField from './components/FormField';
import ErrorAlert from './components/ErrorAlert';
@@ -19,7 +20,18 @@ import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
* Страница «Настройки интерфейса» — отдельный раздел в стиле Tabler UI и UniFi:
* коллапсируемые карточки-секции, иконки, чёткая структура.
*/
// Разделы бокового меню (как в Tabler / UniFi)
const SIDEBAR_SECTIONS = [
{ id: 'live-doh', title: 'BGP Live и DoH', icon: IconPlugConnected },
{ id: 'network-as', title: 'Сеть и AS', icon: IconNetwork },
{ id: 'ping', title: 'Пинг через MikroTik', icon: IconCloud },
{ id: 'ping-services', title: 'Пинг на главной', icon: IconChartPie },
{ id: 'ptr-zone', title: 'PTR зона', icon: IconWorld },
];
export default function SettingsPage() {
const location = useLocation();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -38,6 +50,11 @@ export default function SettingsPage() {
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
const [serversList, setServersList] = useState([]);
const [sidebarSearch, setSidebarSearch] = useState('');
const [activeSection, setActiveSection] = useState(() => {
const hash = (typeof location.hash === 'string' && location.hash.slice(1)) || '';
return SIDEBAR_SECTIONS.some((s) => s.id === hash) ? hash : SIDEBAR_SECTIONS[0].id;
});
const routerServersForPing = useMemo(() => {
return (serversList || []).filter(
@@ -48,6 +65,26 @@ export default function SettingsPage() {
);
}, [serversList]);
const sidebarItems = useMemo(() => {
const q = (sidebarSearch || '').trim().toLowerCase();
if (!q) return SIDEBAR_SECTIONS;
return SIDEBAR_SECTIONS.filter(
(s) => s.title.toLowerCase().includes(q) || s.id.toLowerCase().includes(q)
);
}, [sidebarSearch]);
useEffect(() => {
const hash = (location.hash || '').slice(1);
if (hash && SIDEBAR_SECTIONS.some((s) => s.id === hash)) {
setActiveSection(hash);
}
}, [location.hash]);
const goToSection = (id) => {
setActiveSection(id);
navigate(`/settings#${id}`, { replace: true });
};
useEffect(() => {
setError('');
setSuccess('');
@@ -173,19 +210,11 @@ export default function SettingsPage() {
}
};
// Коллапсируемая карточка в стиле UniFi
function SectionCard({ id, title, icon: Icon, defaultOpen = true, children }) {
const [open, setOpen] = useState(defaultOpen);
// Карточка секции (контент без коллапса — разделы переключаются через боковое меню)
function SectionCard({ id, title, icon: Icon, children }) {
return (
<div className="card mb-3">
<div
className="card-header d-flex align-items-center justify-content-between cursor-pointer py-3"
role="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={`settings-section-${id}`}
id={`settings-heading-${id}`}
>
<div className="card mb-0" data-section-id={id}>
<div className="card-header py-3">
<h3 className="card-title m-0 d-flex align-items-center">
{Icon && (
<span className="me-2 d-flex align-items-center text-muted">
@@ -194,20 +223,8 @@ export default function SettingsPage() {
)}
{title}
</h3>
<IconChevronDown
className={`icon ms-2 text-muted transition ${open ? 'rotate-180' : ''}`}
size={20}
/>
</div>
{open && (
<div
className="card-body pt-0"
id={`settings-section-${id}`}
aria-labelledby={`settings-heading-${id}`}
>
{children}
</div>
)}
<div className="card-body">{children}</div>
</div>
);
}
@@ -285,13 +302,56 @@ export default function SettingsPage() {
)}
<div className="row">
<div className="col-12">
{/* Секция: BGP Live и DoH */}
{/* Боковое меню в стиле Tabler / UniFi */}
<div className="col-lg-3 col-xl-2 mb-4 mb-lg-0">
<div className="card sticky-top" style={{ top: '1rem' }}>
<div className="card-body py-2">
<div className="input-icon mb-2">
<span className="input-icon-addon">
<IconSearch size={18} className="text-muted" />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск разделов"
value={sidebarSearch}
onChange={(e) => setSidebarSearch(e.target.value)}
aria-label="Поиск разделов"
/>
</div>
<nav className="nav flex-column">
{sidebarItems.map((section) => {
const Icon = section.icon;
const isActive = activeSection === section.id;
return (
<button
key={section.id}
type="button"
className={`nav-link d-flex align-items-center text-start border-0 rounded mb-1 ${isActive ? 'active bg-primary text-white' : ''}`}
onClick={() => goToSection(section.id)}
>
<span className="me-2 d-flex">
<Icon size={18} />
</span>
{section.title}
</button>
);
})}
</nav>
{sidebarItems.length === 0 && (
<p className="text-muted small mb-0 mt-2">Нет подходящих разделов</p>
)}
</div>
</div>
</div>
{/* Контент выбранного раздела */}
<div className="col-lg-9 col-xl-10">
{activeSection === 'live-doh' && (
<SectionCard
id="live-doh"
title="BGP Live и DNS (DoH)"
icon={IconPlugConnected}
defaultOpen={true}
>
<div className="row g-3">
<div className="col-12">
@@ -322,13 +382,13 @@ export default function SettingsPage() {
</div>
</div>
</SectionCard>
)}
{/* Секция: Сеть и AS */}
{activeSection === 'network-as' && (
<SectionCard
id="network-as"
title="Сеть и AS"
icon={IconNetwork}
defaultOpen={true}
>
<div className="row g-3">
<div className="col-md-6">
@@ -345,13 +405,13 @@ export default function SettingsPage() {
</div>
</div>
</SectionCard>
)}
{/* Секция: Пинг (домен и кеш) */}
{activeSection === 'ping' && (
<SectionCard
id="ping"
title="Пинг через MikroTik"
icon={IconCloud}
defaultOpen={true}
>
<div className="row g-3">
<div className="col-md-6">
@@ -381,13 +441,13 @@ export default function SettingsPage() {
</div>
</div>
</SectionCard>
)}
{/* Секция: Пинг сервисов на главной */}
{activeSection === 'ping-services' && (
<SectionCard
id="ping-services"
title="Пинг сервисов на главной"
icon={IconChartPie}
defaultOpen={true}
>
<div className="mb-3">
<label className="form-label">Источник пинга</label>
@@ -456,13 +516,13 @@ export default function SettingsPage() {
</div>
)}
</SectionCard>
)}
{/* Секция: PTR зона */}
{activeSection === 'ptr-zone' && (
<SectionCard
id="ptr-zone"
title="Настройка PTR зоны"
icon={IconWorld}
defaultOpen={true}
>
<div className="row g-3">
<div className="col-md-6">
@@ -491,6 +551,7 @@ export default function SettingsPage() {
</div>
</div>
</SectionCard>
)}
</div>
</div>