feat: Update ServerCard and ServerSidebar components for improved UI and functionality, including enhanced filtering options, updated country flag handling, and refined layout for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s

This commit is contained in:
2026-01-20 13:47:42 +07:00
parent acd4e80435
commit 6a96c368e3
3 changed files with 629 additions and 339 deletions
+316 -188
View File
@@ -6,18 +6,26 @@ import {
IconNetwork,
IconChevronDown,
IconChevronRight,
IconCheck,
IconX,
IconSearch,
IconFilter,
IconRefresh
IconDatabase
} from '@tabler/icons-react';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
return isoCode
.toUpperCase()
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())
);
@@ -76,220 +84,340 @@ export default function ServerSidebar({
};
return (
<div className="server-sidebar" style={{
width: '280px',
minWidth: '280px',
borderRight: '1px solid var(--tblr-border-color)',
height: 'calc(100vh - 180px)',
overflowY: 'auto',
background: 'var(--tblr-bg-surface)'
}}>
{/* Заголовок с кнопкой сброса */}
<div className="d-flex align-items-center justify-content-between p-3 border-bottom">
<div
className="server-sidebar d-flex flex-column"
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">
<IconFilter size={18} className="me-2 text-muted" />
<IconFilter size={18} className="text-primary me-2" />
<span className="fw-semibold">Фильтры</span>
{activeFiltersCount > 0 && (
<span className="badge bg-primary ms-2">{activeFiltersCount}</span>
<span
className="badge bg-primary ms-2"
style={{ fontSize: '0.7rem', padding: '3px 6px' }}
>
{activeFiltersCount}
</span>
)}
</div>
{activeFiltersCount > 0 && (
<button
className="btn btn-ghost-secondary btn-sm btn-icon"
className="btn btn-ghost-secondary btn-icon btn-sm"
onClick={onReset}
title="Сбросить все фильтры"
title="Сбросить"
style={{ width: 28, height: 28 }}
>
<IconX size={16} />
<IconX size={14} />
</button>
)}
</div>
{/* Поиск */}
<div className="p-3 border-bottom">
<div className="px-3 py-2" style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<div className="input-icon">
<span className="input-icon-addon">
<IconSearch size={16} />
<IconSearch size={14} className="text-muted" />
</span>
<input
type="text"
className="form-control form-control-sm"
placeholder="Поиск серверов..."
placeholder="Поиск..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
style={{
background: 'rgba(0,0,0,0.02)',
border: '1px solid rgba(0,0,0,0.06)',
borderRadius: '8px',
fontSize: '0.85rem'
}}
/>
{searchTerm && (
<span
className="input-icon-addon cursor-pointer"
onClick={() => onSearch('')}
style={{ right: 0 }}
>
<IconX size={14} />
</span>
</div>
</div>
{/* Скроллируемый контент */}
<div style={{ overflowY: 'auto', flex: 1 }}>
{/* Секция: Страны */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('country')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconWorld size={15} className="me-2" style={{ color: '#3b82f6' }} />
<span className="fw-medium small">Страны</span>
</div>
{expandedSections.country ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.country && (
<div className="p-2">
{Object.entries(stats.byCountry)
.sort((a, b) => b[1] - a[1])
.map(([country, count]) => (
<button
key={country}
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>
{/* Секция: Провайдеры */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('provider')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconBuildingSkyscraper size={15} className="me-2" style={{ color: '#8b5cf6' }} />
<span className="fw-medium small">Провайдеры</span>
</div>
{expandedSections.provider ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.provider && (
<div className="p-2">
{Object.entries(stats.byProvider)
.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>
{/* Секция: Типы */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('type')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconServer size={15} className="me-2" style={{ color: '#10b981' }} />
<span className="fw-medium small">Тип сервера</span>
</div>
{expandedSections.type ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.type && (
<div className="p-2">
{Object.entries(stats.byType)
.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={{
background: filters.type === type ? undefined : 'transparent',
border: 'none',
borderRadius: '8px',
padding: '8px 10px'
}}
onMouseEnter={(e) => {
if (filters.type !== type) {
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
}
}}
onMouseLeave={(e) => {
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>
{/* Секция: Туннели */}
<div className="filter-section">
<div
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('tunnel')}
style={{
background: 'rgba(0,0,0,0.02)',
borderBottom: '1px solid rgba(0,0,0,0.04)'
}}
>
<div className="d-flex align-items-center">
<IconNetwork size={15} className="me-2" style={{ color: '#f59e0b' }} />
<span className="fw-medium small">Туннель</span>
</div>
{expandedSections.tunnel ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</div>
{expandedSections.tunnel && (
<div className="p-2">
{Object.entries(stats.byTunnel)
.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 className="filter-section">
<div
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('country')}
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-azure" />
<span className="fw-medium small">Страны</span>
</div>
{expandedSections.country ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
{/* Статистика внизу */}
<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">
<IconDatabase size={14} className="text-muted me-2" />
<span className="text-muted small">Статистика</span>
</div>
{expandedSections.country && (
<div className="p-2">
{Object.entries(stats.byCountry)
.sort((a, b) => b[1] - a[1])
.map(([country, count]) => (
<button
key={country}
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.country === country ? 'btn-primary' : 'btn-ghost-secondary'
}`}
onClick={() => handleFilterClick('country', country)}
>
<span>
<span className="me-2">{countryToFlag(country)}</span>
{country}
</span>
<span className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Провайдеры */}
<div className="filter-section">
<div
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('provider')}
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
>
<div className="d-flex align-items-center">
<IconBuildingSkyscraper size={16} className="me-2 text-purple" />
<span className="fw-medium small">Провайдеры</span>
</div>
{expandedSections.provider ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<div className="d-flex justify-content-between mb-1">
<span className="small text-muted">Всего серверов</span>
<span className="small fw-bold">{servers.length}</span>
</div>
{expandedSections.provider && (
<div className="p-2">
{Object.entries(stats.byProvider)
.sort((a, b) => b[1] - a[1])
.map(([provider, count]) => (
<button
key={provider}
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.provider === provider ? 'btn-primary' : 'btn-ghost-secondary'
}`}
onClick={() => handleFilterClick('provider', provider)}
>
<span>{provider}</span>
<span className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Типы серверов */}
<div className="filter-section">
<div
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('type')}
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
>
<div className="d-flex align-items-center">
<IconServer size={16} className="me-2 text-green" />
<span className="fw-medium small">Тип сервера</span>
</div>
{expandedSections.type ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
<div className="d-flex justify-content-between mb-1">
<span className="small text-muted">Выходных нод</span>
<span className="small fw-bold" style={{ color: '#ef4444' }}>{stats.byType['exit'] || 0}</span>
</div>
{expandedSections.type && (
<div className="p-2">
{Object.entries(stats.byType)
.sort((a, b) => b[1] - a[1])
.map(([type, count]) => (
<button
key={type}
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.type === type ? 'btn-primary' : 'btn-ghost-secondary'
}`}
onClick={() => handleFilterClick('type', type)}
>
<span className="d-flex align-items-center">
<span className={`status-dot me-2 ${type === 'exit' ? 'bg-danger' : 'bg-azure'}`}></span>
{typeLabels[type] || type}
</span>
<span className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Секция: Туннели */}
<div className="filter-section">
<div
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
onClick={() => toggleSection('tunnel')}
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
>
<div className="d-flex align-items-center">
<IconNetwork size={16} className="me-2 text-orange" />
<span className="fw-medium small">Тип туннеля</span>
</div>
{expandedSections.tunnel ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
</div>
{expandedSections.tunnel && (
<div className="p-2">
{Object.entries(stats.byTunnel)
.sort((a, b) => b[1] - a[1])
.map(([tunnel, count]) => (
<button
key={tunnel}
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
filters.tunnel === tunnel ? 'btn-primary' : 'btn-ghost-secondary'
}`}
onClick={() => handleFilterClick('tunnel', tunnel)}
>
<span>{tunnel}</span>
<span className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
{count}
</span>
</button>
))}
</div>
)}
</div>
{/* Статистика */}
<div className="p-3 border-top mt-auto">
<div className="text-muted small">
<div className="d-flex justify-content-between mb-1">
<span>Всего серверов:</span>
<span className="fw-bold text-body">{servers.length}</span>
</div>
<div className="d-flex justify-content-between">
<span>Выходных нод:</span>
<span className="fw-bold text-danger">{stats.byType['exit'] || 0}</span>
</div>
<div className="d-flex justify-content-between">
<span>Jumphosts:</span>
<span className="fw-bold text-azure">{stats.byType['jumphost'] || 0}</span>
</div>
<div className="d-flex justify-content-between">
<span className="small text-muted">Jumphosts</span>
<span className="small fw-bold" style={{ color: '#3b82f6' }}>{stats.byType['jumphost'] || 0}</span>
</div>
</div>
</div>