refactor(ServerSidebar): implement new filter section and sidebar styling for improved user interaction and organization
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s

This commit is contained in:
2026-02-16 16:03:28 +07:00
parent af8af84ab7
commit 3a7578ce26
2 changed files with 203 additions and 326 deletions
+36
View File
@@ -256,6 +256,42 @@
/* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler /* Убрали глобальную перекраску .badge, чтобы работали текстовые цвета Tabler
(например, badge bg-blue-lt text-blue) */ (например, badge bg-blue-lt text-blue) */
/* --- Server sidebar (фильтры на /servers) — Tabler card + list-group --- */
.server-sidebar {
width: 280px;
min-width: 280px;
max-height: calc(100vh - 180px);
display: flex;
flex-direction: column;
}
.server-sidebar .card-header {
flex-shrink: 0;
}
.server-sidebar .card-body.border-bottom,
.server-sidebar .card-footer {
flex-shrink: 0;
}
.server-sidebar-body {
flex: 1;
min-height: 0;
max-height: 50vh;
}
.server-sidebar .filter-section .list-group-header {
cursor: pointer;
font-size: 0.8125rem;
font-weight: 600;
text-transform: none;
color: var(--tblr-body-color);
}
.server-sidebar .filter-section .list-group-header:hover {
background-color: var(--tblr-bg-surface-tertiary);
}
.server-sidebar .list-group-item {
font-size: 0.8125rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
/* Responsive navbar */ /* Responsive navbar */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.navbar-collapse { .navbar-collapse {
+167 -326
View File
@@ -6,29 +6,63 @@ import {
IconNetwork, IconNetwork,
IconChevronDown, IconChevronDown,
IconChevronRight, IconChevronRight,
IconX,
IconSearch,
IconFilter, IconFilter,
IconDatabase IconSearch,
IconDatabase,
IconX
} from '@tabler/icons-react'; } from '@tabler/icons-react';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) { function countryToFlag(isoCode) {
if (!isoCode) return ''; if (!isoCode) return '';
const codeMap = { const codeMap = {
'SWE': 'SE', SWE: 'SE',
'RUS': 'RU', RUS: 'RU',
'USA': 'US', USA: 'US',
'GER': 'DE', GER: 'DE',
'FIN': 'FI', FIN: 'FI',
'NLD': 'NL' NLD: 'NL'
}; };
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase(); const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
if (!code || code.length !== 2) return isoCode; if (!code || code.length !== 2) return isoCode;
return code return code.replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt()));
.replace(/./g, char => }
String.fromCodePoint(127397 + char.charCodeAt())
); function FilterSection({ title, icon: Icon, iconColor, expanded, onToggle, children }) {
return (
<div className="filter-section border-bottom">
<button
type="button"
className="list-group-header d-flex align-items-center justify-content-between w-100 border-0 bg-transparent py-2 px-3 text-start"
onClick={onToggle}
aria-expanded={expanded}
>
<span className="d-flex align-items-center">
<Icon size={16} className={`me-2 ${iconColor || 'text-muted'}`} />
<span>{title}</span>
</span>
{expanded ? <IconChevronDown size={16} className="text-muted" /> : <IconChevronRight size={16} className="text-muted" />}
</button>
{expanded && <div className="list-group list-group-flush list-group-transparent">{children}</div>}
</div>
);
}
function FilterItem({ label, count, active, onClick, leftAddon }) {
return (
<button
type="button"
className={`list-group-item list-group-item-action d-flex align-items-center justify-content-between py-2 px-3 text-start ${active ? 'active' : ''}`}
onClick={onClick}
>
<span className="d-flex align-items-center text-truncate">
{leftAddon}
<span className="text-truncate">{label}</span>
</span>
<span className={`badge ms-2 flex-shrink-0 ${active ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}>
{count}
</span>
</button>
);
} }
export default function ServerSidebar({ export default function ServerSidebar({
@@ -46,378 +80,185 @@ export default function ServerSidebar({
tunnel: false tunnel: false
}); });
// Подсчёт серверов по категориям
const stats = useMemo(() => { const stats = useMemo(() => {
const byCountry = {}; const byCountry = {};
const byProvider = {}; const byProvider = {};
const byType = {}; const byType = {};
const byTunnel = {}; const byTunnel = {};
servers.forEach(srv => { servers.forEach(srv => {
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1; byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1; byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
byType[srv.type] = (byType[srv.type] || 0) + 1; byType[srv.type] = (byType[srv.type] || 0) + 1;
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1; byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
}); });
return { byCountry, byProvider, byType, byTunnel }; return { byCountry, byProvider, byType, byTunnel };
}, [servers]); }, [servers]);
const toggleSection = (section) => { const toggleSection = section => {
setExpandedSections(prev => ({ setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
...prev,
[section]: !prev[section]
}));
}; };
const handleFilterClick = (category, value) => { const handleFilterClick = (category, value) => {
const currentValue = filters[category]; const current = filters[category];
const newValue = currentValue === value ? '' : value; onFiltersChange({ ...filters, [category]: current === value ? '' : value });
onFiltersChange({ ...filters, [category]: newValue });
}; };
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0); const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
const typeLabels = { jumphost: 'Jumphost', exit: 'Выходная нода' };
const typeLabels = {
'jumphost': 'Jumphost',
'exit': 'Выходная нода'
};
return ( return (
<div <div className="server-sidebar card flex-shrink-0 mb-0">
className="server-sidebar d-flex flex-column" <div className="card-header py-3 d-flex align-items-center justify-content-between">
style={{
width: '260px',
minWidth: '260px',
background: '#fff',
borderRadius: '12px',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
height: 'fit-content',
maxHeight: 'calc(100vh - 200px)',
overflow: 'hidden'
}}
>
{/* Заголовок */}
<div
className="d-flex align-items-center justify-content-between px-3 py-3"
style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}
>
<div className="d-flex align-items-center"> <div className="d-flex align-items-center">
<IconFilter size={18} className="text-primary me-2" /> <IconFilter size={18} className="text-primary me-2" />
<span className="fw-semibold">Фильтры</span> <span className="fw-semibold">Фильтры</span>
{activeFiltersCount > 0 && ( {activeFiltersCount > 0 && (
<span <span className="badge bg-primary-lt text-primary ms-2">{activeFiltersCount}</span>
className="badge bg-primary ms-2"
style={{ fontSize: '0.7rem', padding: '3px 6px' }}
>
{activeFiltersCount}
</span>
)} )}
</div> </div>
{activeFiltersCount > 0 && ( {activeFiltersCount > 0 && (
<button <button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm" className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={onReset} onClick={onReset}
title="Сбросить" title="Сбросить фильтры"
style={{ width: 28, height: 28 }} aria-label="Сбросить фильтры"
> >
<IconX size={14} /> <IconX size={16} />
</button> </button>
)} )}
</div> </div>
{/* Поиск */} <div className="card-body py-2 border-bottom">
<div className="px-3 py-2" style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<div className="input-icon"> <div className="input-icon">
<span className="input-icon-addon"> <span className="input-icon-addon">
<IconSearch size={14} className="text-muted" /> <IconSearch size={16} className="text-muted" />
</span> </span>
<input <input
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
placeholder="Поиск..." placeholder="Поиск..."
value={searchTerm} value={searchTerm}
onChange={(e) => onSearch(e.target.value)} onChange={e => onSearch(e.target.value)}
style={{ aria-label="Поиск серверов"
background: 'rgba(0,0,0,0.02)',
border: '1px solid rgba(0,0,0,0.06)',
borderRadius: '8px',
fontSize: '0.85rem'
}}
/> />
</div> </div>
</div> </div>
{/* Скроллируемый контент */} <div className="server-sidebar-body overflow-auto">
<div style={{ overflowY: 'auto', flex: 1 }}> <FilterSection
{/* Секция: Страны */} title="Страны"
<div className="filter-section"> icon={IconWorld}
<div iconColor="text-blue"
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer" expanded={expandedSections.country}
onClick={() => toggleSection('country')} onToggle={() => toggleSection('country')}
style={{ >
background: 'rgba(0,0,0,0.02)', {Object.entries(stats.byCountry)
borderBottom: '1px solid rgba(0,0,0,0.04)' .sort((a, b) => b[1] - a[1])
}} .map(([country, count]) => (
> <FilterItem
<div className="d-flex align-items-center"> key={country}
<IconWorld size={15} className="me-2" style={{ color: '#3b82f6' }} /> label={country}
<span className="fw-medium small">Страны</span> count={count}
</div> active={filters.country === country}
{expandedSections.country ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />} onClick={() => handleFilterClick('country', country)}
</div> leftAddon={
{expandedSections.country && ( <span className="me-2" style={{ fontSize: '1rem' }} aria-hidden>
<div className="p-2"> {countryToFlag(country)}
{Object.entries(stats.byCountry) </span>
.sort((a, b) => b[1] - a[1]) }
.map(([country, count]) => ( />
<button ))}
key={country} </FilterSection>
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.country === country ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('country', country)}
style={{
background: filters.country === country ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px',
transition: 'background 0.15s'
}}
onMouseEnter={(e) => {
if (filters.country !== country) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.country !== country) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className="d-flex align-items-center">
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(country)}</span>
<span className={filters.country === country ? 'text-white' : ''}>{country}</span>
</span>
<span
className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Провайдеры */} <FilterSection
<div className="filter-section"> title="Провайдеры"
<div icon={IconBuildingSkyscraper}
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer" iconColor="text-purple"
onClick={() => toggleSection('provider')} expanded={expandedSections.provider}
style={{ onToggle={() => toggleSection('provider')}
background: 'rgba(0,0,0,0.02)', >
borderBottom: '1px solid rgba(0,0,0,0.04)' {Object.entries(stats.byProvider)
}} .sort((a, b) => b[1] - a[1])
> .map(([provider, count]) => (
<div className="d-flex align-items-center"> <FilterItem
<IconBuildingSkyscraper size={15} className="me-2" style={{ color: '#8b5cf6' }} /> key={provider}
<span className="fw-medium small">Провайдеры</span> label={provider}
</div> count={count}
{expandedSections.provider ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />} active={filters.provider === provider}
</div> onClick={() => handleFilterClick('provider', provider)}
{expandedSections.provider && ( />
<div className="p-2"> ))}
{Object.entries(stats.byProvider) </FilterSection>
.sort((a, b) => b[1] - a[1])
.map(([provider, count]) => (
<button
key={provider}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.provider === provider ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('provider', provider)}
style={{
background: filters.provider === provider ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
}}
onMouseEnter={(e) => {
if (filters.provider !== provider) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.provider !== provider) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className={filters.provider === provider ? 'text-white' : ''}>{provider}</span>
<span
className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Типы */} <FilterSection
<div className="filter-section"> title="Тип сервера"
<div icon={IconServer}
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer" iconColor="text-green"
onClick={() => toggleSection('type')} expanded={expandedSections.type}
style={{ onToggle={() => toggleSection('type')}
background: 'rgba(0,0,0,0.02)', >
borderBottom: '1px solid rgba(0,0,0,0.04)' {Object.entries(stats.byType)
}} .sort((a, b) => b[1] - a[1])
> .map(([type, count]) => (
<div className="d-flex align-items-center"> <FilterItem
<IconServer size={15} className="me-2" style={{ color: '#10b981' }} /> key={type}
<span className="fw-medium small">Тип сервера</span> label={typeLabels[type] || type}
</div> count={count}
{expandedSections.type ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />} active={filters.type === type}
</div> onClick={() => handleFilterClick('type', type)}
{expandedSections.type && ( leftAddon={
<div className="p-2"> <span
{Object.entries(stats.byType) className="rounded-circle me-2 flex-shrink-0"
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<button
key={type}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.type === type ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('type', type)}
style={{ style={{
background: filters.type === type ? undefined : 'transparent', width: 8,
border: 'none', height: 8,
borderRadius: '8px', background: type === 'exit' ? 'var(--tblr-red)' : 'var(--tblr-primary)'
padding: '8px 10px'
}} }}
onMouseEnter={(e) => { aria-hidden
if (filters.type !== type) { />
e.currentTarget.style.background = 'rgba(0,0,0,0.04)'; }
} />
}} ))}
onMouseLeave={(e) => { </FilterSection>
if (filters.type !== type) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className="d-flex align-items-center">
<span
className="rounded-circle me-2"
style={{
width: 8,
height: 8,
background: type === 'exit' ? '#ef4444' : '#3b82f6'
}}
/>
<span className={filters.type === type ? 'text-white' : ''}>
{typeLabels[type] || type}
</span>
</span>
<span
className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Туннели */} <FilterSection
<div className="filter-section"> title="Туннель"
<div icon={IconNetwork}
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer" iconColor="text-orange"
onClick={() => toggleSection('tunnel')} expanded={expandedSections.tunnel}
style={{ onToggle={() => toggleSection('tunnel')}
background: 'rgba(0,0,0,0.02)', >
borderBottom: '1px solid rgba(0,0,0,0.04)' {Object.entries(stats.byTunnel)
}} .sort((a, b) => b[1] - a[1])
> .map(([tunnel, count]) => (
<div className="d-flex align-items-center"> <FilterItem
<IconNetwork size={15} className="me-2" style={{ color: '#f59e0b' }} /> key={tunnel}
<span className="fw-medium small">Туннель</span> label={tunnel}
</div> count={count}
{expandedSections.tunnel ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />} active={filters.tunnel === tunnel}
</div> onClick={() => handleFilterClick('tunnel', tunnel)}
{expandedSections.tunnel && ( />
<div className="p-2"> ))}
{Object.entries(stats.byTunnel) </FilterSection>
.sort((a, b) => b[1] - a[1])
.map(([tunnel, count]) => (
<button
key={tunnel}
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.tunnel === tunnel ? 'btn-primary' : ''
}`}
onClick={() => handleFilterClick('tunnel', tunnel)}
style={{
background: filters.tunnel === tunnel ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
}}
onMouseEnter={(e) => {
if (filters.tunnel !== tunnel) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
if (filters.tunnel !== tunnel) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<span className={filters.tunnel === tunnel ? 'text-white' : ''}>{tunnel}</span>
<span
className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
>
{count}
</span>
</button>
))}
</div>
)}
</div>
</div> </div>
{/* Статистика внизу */} <div className="card-footer py-3 bg-secondary-lt">
<div
className="px-3 py-3"
style={{
borderTop: '1px solid rgba(0,0,0,0.06)',
background: 'rgba(0,0,0,0.01)'
}}
>
<div className="d-flex align-items-center mb-2"> <div className="d-flex align-items-center mb-2">
<IconDatabase size={14} className="text-muted me-2" /> <IconDatabase size={16} className="text-muted me-2" />
<span className="text-muted small">Статистика</span> <span className="text-muted small fw-medium">Статистика</span>
</div> </div>
<div className="d-flex justify-content-between mb-1"> <div className="d-flex justify-content-between small mb-1">
<span className="small text-muted">Всего серверов</span> <span className="text-muted">Всего серверов</span>
<span className="small fw-bold">{servers.length}</span> <span className="fw-semibold">{servers.length}</span>
</div> </div>
<div className="d-flex justify-content-between mb-1"> <div className="d-flex justify-content-between small mb-1">
<span className="small text-muted">Выходных нод</span> <span className="text-muted">Выходных нод</span>
<span className="small fw-bold" style={{ color: '#ef4444' }}>{stats.byType['exit'] || 0}</span> <span className="fw-semibold text-danger">{stats.byType['exit'] || 0}</span>
</div> </div>
<div className="d-flex justify-content-between"> <div className="d-flex justify-content-between small">
<span className="small text-muted">Jumphosts</span> <span className="text-muted">Jumphosts</span>
<span className="small fw-bold" style={{ color: '#3b82f6' }}>{stats.byType['jumphost'] || 0}</span> <span className="fw-semibold text-primary">{stats.byType['jumphost'] || 0}</span>
</div> </div>
</div> </div>
</div> </div>