Files
router-lists-ui/frontend/src/components/server/ServerSidebar.jsx
T

280 lines
9.4 KiB
React

import { useState, useMemo } from 'react';
import {
IconServer,
IconWorld,
IconBuildingSkyscraper,
IconNetwork,
IconChevronDown,
IconChevronRight,
IconFilter,
IconSearch,
IconDatabase,
IconX
} from '@tabler/icons-react';
function countryToFlag(isoCode) {
if (!isoCode) return '';
const codeMap = {
SWE: 'SE',
RUS: 'RU',
USA: 'US',
GER: 'DE',
FIN: 'FI',
NLD: 'NL'
};
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
if (!code || code.length !== 2) return isoCode;
return code.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 min-w-0 flex-grow-1" style={{ overflow: 'hidden' }}>
{leftAddon}
<span className="text-break" style={{ wordBreak: 'break-word' }}>{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({
servers = [],
filters = {},
onFiltersChange,
onSearch,
searchTerm = '',
onReset,
/** В режиме true рендерится только контент без обёртки card (для использования внутри offcanvas) */
embedded = false
}) {
const [expandedSections, setExpandedSections] = useState({
country: true,
provider: true,
type: true,
tunnel: false
});
const stats = useMemo(() => {
const byCountry = {};
const byProvider = {};
const byType = {};
const byTunnel = {};
servers.forEach(srv => {
byCountry[srv.country] = (byCountry[srv.country] || 0) + 1;
byProvider[srv.provider] = (byProvider[srv.provider] || 0) + 1;
byType[srv.type] = (byType[srv.type] || 0) + 1;
byTunnel[srv.tunnel] = (byTunnel[srv.tunnel] || 0) + 1;
});
return { byCountry, byProvider, byType, byTunnel };
}, [servers]);
const toggleSection = section => {
setExpandedSections(prev => ({ ...prev, [section]: !prev[section] }));
};
const handleFilterClick = (category, value) => {
const current = filters[category];
onFiltersChange({ ...filters, [category]: current === value ? '' : value });
};
const activeFiltersCount = Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0);
const typeLabels = { jumphost: 'Jumphost', exit: 'Выходная нода' };
const content = (
<>
{!embedded && (
<div className="card-header py-3 d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center">
<IconFilter size={18} className="text-primary me-2" />
<span className="fw-semibold">Фильтры</span>
{activeFiltersCount > 0 && (
<span className="badge bg-primary-lt text-primary ms-2">{activeFiltersCount}</span>
)}
</div>
{activeFiltersCount > 0 && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={onReset}
title="Сбросить фильтры"
aria-label="Сбросить фильтры"
>
<IconX size={16} />
</button>
)}
</div>
)}
<div className={embedded ? 'py-2' : 'card-body py-2 border-bottom'}>
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={16} className="text-muted" />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск..."
value={searchTerm}
onChange={e => onSearch(e.target.value)}
aria-label="Поиск серверов"
/>
</div>
</div>
<div className={embedded ? 'overflow-auto flex-grow-1 min-h-0' : 'server-sidebar-body overflow-auto'}>
<FilterSection
title="Страны"
icon={IconWorld}
iconColor="text-blue"
expanded={expandedSections.country}
onToggle={() => toggleSection('country')}
>
{Object.entries(stats.byCountry)
.sort((a, b) => b[1] - a[1])
.map(([country, count]) => (
<FilterItem
key={country}
label={country}
count={count}
active={filters.country === country}
onClick={() => handleFilterClick('country', country)}
leftAddon={
<span className="me-2" style={{ fontSize: '1rem' }} aria-hidden>
{countryToFlag(country)}
</span>
}
/>
))}
</FilterSection>
<FilterSection
title="Провайдеры"
icon={IconBuildingSkyscraper}
iconColor="text-purple"
expanded={expandedSections.provider}
onToggle={() => toggleSection('provider')}
>
{Object.entries(stats.byProvider)
.sort((a, b) => b[1] - a[1])
.map(([provider, count]) => (
<FilterItem
key={provider}
label={provider}
count={count}
active={filters.provider === provider}
onClick={() => handleFilterClick('provider', provider)}
/>
))}
</FilterSection>
<FilterSection
title="Тип сервера"
icon={IconServer}
iconColor="text-green"
expanded={expandedSections.type}
onToggle={() => toggleSection('type')}
>
{Object.entries(stats.byType)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<FilterItem
key={type}
label={typeLabels[type] || type}
count={count}
active={filters.type === type}
onClick={() => handleFilterClick('type', type)}
leftAddon={
<span
className="rounded-circle me-2 flex-shrink-0"
style={{
width: 8,
height: 8,
background: type === 'exit' ? 'var(--tblr-red)' : 'var(--tblr-primary)'
}}
aria-hidden
/>
}
/>
))}
</FilterSection>
<FilterSection
title="Туннель"
icon={IconNetwork}
iconColor="text-orange"
expanded={expandedSections.tunnel}
onToggle={() => toggleSection('tunnel')}
>
{Object.entries(stats.byTunnel)
.sort((a, b) => b[1] - a[1])
.map(([tunnel, count]) => (
<FilterItem
key={tunnel}
label={tunnel}
count={count}
active={filters.tunnel === tunnel}
onClick={() => handleFilterClick('tunnel', tunnel)}
/>
))}
</FilterSection>
</div>
<div className={embedded ? 'py-3 mt-2 pt-3 border-top' : 'card-footer py-3 bg-secondary-lt'}>
<div className="d-flex align-items-center mb-2">
<IconDatabase size={16} className="text-muted me-2" />
<span className="text-muted small fw-medium">Статистика</span>
</div>
<div className="d-flex justify-content-between small mb-1">
<span className="text-muted">Всего серверов</span>
<span className="fw-semibold">{servers.length}</span>
</div>
<div className="d-flex justify-content-between small mb-1">
<span className="text-muted">Выходных нод</span>
<span className="fw-semibold text-danger">{stats.byType['exit'] || 0}</span>
</div>
<div className="d-flex justify-content-between small">
<span className="text-muted">Jumphosts</span>
<span className="fw-semibold text-primary">{stats.byType['jumphost'] || 0}</span>
</div>
</div>
</>
);
if (embedded) {
return <div className="d-flex flex-column h-100">{content}</div>;
}
return (
<div className="server-sidebar card flex-shrink-0 mb-0">
{content}
</div>
);
}