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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m58s
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import api from './lib/api.js';
|
import api from './lib/api.js';
|
||||||
import {
|
import {
|
||||||
IconSettings,
|
IconSettings,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
IconCloud,
|
IconCloud,
|
||||||
IconChartPie,
|
IconChartPie,
|
||||||
IconWorld,
|
IconWorld,
|
||||||
IconChevronDown,
|
IconSearch,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FormField from './components/FormField';
|
import FormField from './components/FormField';
|
||||||
import ErrorAlert from './components/ErrorAlert';
|
import ErrorAlert from './components/ErrorAlert';
|
||||||
@@ -19,7 +20,18 @@ import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
|||||||
* Страница «Настройки интерфейса» — отдельный раздел в стиле Tabler UI и UniFi:
|
* Страница «Настройки интерфейса» — отдельный раздел в стиле 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() {
|
export default function SettingsPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -38,6 +50,11 @@ export default function SettingsPage() {
|
|||||||
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
const [pingServicesGatewayIp, setPingServicesGatewayIp] = useState('');
|
||||||
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
const [pingServicesCacheSeconds, setPingServicesCacheSeconds] = useState('');
|
||||||
const [serversList, setServersList] = 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(() => {
|
const routerServersForPing = useMemo(() => {
|
||||||
return (serversList || []).filter(
|
return (serversList || []).filter(
|
||||||
@@ -48,6 +65,26 @@ export default function SettingsPage() {
|
|||||||
);
|
);
|
||||||
}, [serversList]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
@@ -173,19 +210,11 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Коллапсируемая карточка в стиле UniFi
|
// Карточка секции (контент без коллапса — разделы переключаются через боковое меню)
|
||||||
function SectionCard({ id, title, icon: Icon, defaultOpen = true, children }) {
|
function SectionCard({ id, title, icon: Icon, children }) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
|
||||||
return (
|
return (
|
||||||
<div className="card mb-3">
|
<div className="card mb-0" data-section-id={id}>
|
||||||
<div
|
<div className="card-header py-3">
|
||||||
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}`}
|
|
||||||
>
|
|
||||||
<h3 className="card-title m-0 d-flex align-items-center">
|
<h3 className="card-title m-0 d-flex align-items-center">
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<span className="me-2 d-flex align-items-center text-muted">
|
<span className="me-2 d-flex align-items-center text-muted">
|
||||||
@@ -194,20 +223,8 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<IconChevronDown
|
|
||||||
className={`icon ms-2 text-muted transition ${open ? 'rotate-180' : ''}`}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
<div className="card-body">{children}</div>
|
||||||
<div
|
|
||||||
className="card-body pt-0"
|
|
||||||
id={`settings-section-${id}`}
|
|
||||||
aria-labelledby={`settings-heading-${id}`}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -285,13 +302,56 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-12">
|
{/* Боковое меню в стиле Tabler / UniFi */}
|
||||||
{/* Секция: BGP Live и DoH */}
|
<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
|
<SectionCard
|
||||||
id="live-doh"
|
id="live-doh"
|
||||||
title="BGP Live и DNS (DoH)"
|
title="BGP Live и DNS (DoH)"
|
||||||
icon={IconPlugConnected}
|
icon={IconPlugConnected}
|
||||||
defaultOpen={true}
|
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
@@ -322,13 +382,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Секция: Сеть и AS */}
|
{activeSection === 'network-as' && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
id="network-as"
|
id="network-as"
|
||||||
title="Сеть и AS"
|
title="Сеть и AS"
|
||||||
icon={IconNetwork}
|
icon={IconNetwork}
|
||||||
defaultOpen={true}
|
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
@@ -345,13 +405,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Секция: Пинг (домен и кеш) */}
|
{activeSection === 'ping' && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
id="ping"
|
id="ping"
|
||||||
title="Пинг через MikroTik"
|
title="Пинг через MikroTik"
|
||||||
icon={IconCloud}
|
icon={IconCloud}
|
||||||
defaultOpen={true}
|
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
@@ -381,13 +441,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Секция: Пинг сервисов на главной */}
|
{activeSection === 'ping-services' && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
id="ping-services"
|
id="ping-services"
|
||||||
title="Пинг сервисов на главной"
|
title="Пинг сервисов на главной"
|
||||||
icon={IconChartPie}
|
icon={IconChartPie}
|
||||||
defaultOpen={true}
|
|
||||||
>
|
>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Источник пинга</label>
|
<label className="form-label">Источник пинга</label>
|
||||||
@@ -456,13 +516,13 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Секция: PTR зона */}
|
{activeSection === 'ptr-zone' && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
id="ptr-zone"
|
id="ptr-zone"
|
||||||
title="Настройка PTR зоны"
|
title="Настройка PTR зоны"
|
||||||
icon={IconWorld}
|
icon={IconWorld}
|
||||||
defaultOpen={true}
|
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
@@ -491,6 +551,7 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user