diff --git a/frontend/src/EasySwitchManager.jsx b/frontend/src/EasySwitchManager.jsx
index 6ebb9a6..526d8ee 100644
--- a/frontend/src/EasySwitchManager.jsx
+++ b/frontend/src/EasySwitchManager.jsx
@@ -15,11 +15,12 @@ import {
IconDeviceFloppy,
IconBolt,
IconChevronDown,
- IconChevronUp
+ IconChevronUp,
+ IconArrowsExchange
} from '@tabler/icons-react';
import PageHeader from './components/PageHeader.jsx';
-// Функция для получения флага по коду страны
+// Флаг по коду страны
const countryToFlag = (code) => {
if (!code || typeof code !== 'string') return '';
const cc = code.trim().toUpperCase();
@@ -28,6 +29,198 @@ const countryToFlag = (code) => {
return String.fromCodePoint(...codePoints);
};
+/** Карточка шлюза в стиле Tabler: компактная, с флагом, пингом, состоянием active */
+function GatewayCard({ gateway, isActive, isFastest, ping, countryFlag, onClick }) {
+ const flag = countryToFlag(gateway.country) || countryFlag || '🌐';
+ return (
+
e.key === 'Enter' && onClick()}
+ role="button"
+ tabIndex={0}
+ aria-pressed={isActive}
+ >
+
+ {isActive && (
+
+
+
+ )}
+ {isFastest && !isActive && (
+
+
+ Fast
+
+ )}
+
+
+ {flag}
+
+
+
+ {gateway.comment || gateway.name}
+
+
+
+ {gateway.ip && (
+
+ {gateway.ip}
+
+ )}
+
+ {ping != null ? ping : '—'}
+
+
+
+ );
+}
+
+/** Рендер списка communities по тегам (группировка) для одного сервера */
+function EasySwitchServerContentGrouped({
+ server,
+ filteredCommunitiesForServer,
+ activeGateways,
+ handleGatewaySelect,
+ getServerMetadata,
+ pingMap,
+ countryToFlag,
+}) {
+ const country = getServerMetadata(server.name)?.country || '';
+ const groupedByTags = new Map();
+ const noTagCommunities = [];
+ filteredCommunitiesForServer.forEach((comm) => {
+ const tags = Array.isArray(comm.tags) ? comm.tags : (comm.tags ? String(comm.tags).split(',').map((t) => t.trim()).filter(Boolean) : []);
+ if (tags.length === 0) noTagCommunities.push(comm);
+ else tags.forEach((tag) => { if (!groupedByTags.has(tag)) groupedByTags.set(tag, []); groupedByTags.get(tag).push(comm); });
+ });
+ const sortedTags = Array.from(groupedByTags.keys()).sort();
+
+ const renderCommunityBlock = (comm) => {
+ const activeKey = `${server.id}:${comm.value}`;
+ const activeGw = activeGateways[activeKey];
+ const hasFilterForComm = server.filtersMap.has(comm.value);
+ const inv = getServerMetadata(server.name);
+ const routerId = inv?.id || inv?.dns || inv?.ip;
+ return (
+
+
+
+ {comm.value}
+ {comm.name || comm.description || ''}
+ {hasFilterForComm && настроен}
+ ({server.gateways.length})
+
+
+ {server.gateways.map((gw, idx) => {
+ const pingKey = routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
+ const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
+ return (
+
+ handleGatewaySelect(server.id, comm.value, gw.name)}
+ />
+
+ );
+ })}
+
+
+
+ );
+ };
+
+ return (
+ <>
+ {sortedTags.map((tag) => (
+
+
+ {tag}
+ {groupedByTags.get(tag).length} communities
+
+
{groupedByTags.get(tag).map(renderCommunityBlock)}
+
+ ))}
+ {noTagCommunities.length > 0 && (
+
+
+ Без тегов
+ {noTagCommunities.length} communities
+
+
{noTagCommunities.map(renderCommunityBlock)}
+
+ )}
+ >
+ );
+}
+
+/** Рендер списка communities без группировки для одного сервера */
+function EasySwitchServerContentFlat({
+ server,
+ filteredCommunitiesForServer,
+ activeGateways,
+ handleGatewaySelect,
+ getServerMetadata,
+ pingMap,
+ countryToFlag,
+}) {
+ const country = getServerMetadata(server.name)?.country || '';
+ const inv = getServerMetadata(server.name);
+ const routerId = inv?.id || inv?.dns || inv?.ip;
+ return (
+
+ {filteredCommunitiesForServer.map((comm) => {
+ const activeKey = `${server.id}:${comm.value}`;
+ const activeGw = activeGateways[activeKey];
+ const hasFilterForComm = server.filtersMap.has(comm.value);
+ return (
+
+
+
+ {comm.value}
+ {comm.name || comm.description || ''}
+ {hasFilterForComm && настроен}
+ ({server.gateways.length})
+
+
+ {server.gateways.map((gw, idx) => {
+ const pingKey = routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
+ const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
+ return (
+
+ handleGatewaySelect(server.id, comm.value, gw.name)}
+ />
+
+ );
+ })}
+
+
+
+ );
+ })}
+
+ );
+}
+
function EasySwitchManager() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -508,16 +701,15 @@ function EasySwitchManager() {
}
+ icon={
}
meta="Быстрое переключение шлюзов"
/>
-
-
+
Загрузка...
-
Загрузка данных...
+
Загрузка данных...
@@ -528,734 +720,259 @@ function EasySwitchManager() {
}
+ icon={
}
meta={`Быстрое переключение шлюзов • ${servers.length} ${servers.length === 1 ? 'сервер' : 'серверов'} • ${communitiesDirectory.length} communities`}
actions={
{hasChanges && (
-
}
/>
- {/* Вкладки */}
-
-
- -
- setActiveTab('proxies')}
- type="button"
- >
- Прокси ({communitiesDirectory.length})
-
-
- -
- setActiveTab('providers')}
- type="button"
- >
- Провайдер прокси (1)
-
-
-
-
-
- {/* Уведомления */}
- {error && (
-
-
-
setError('')}>
+ {/* Вкладки в стиле Tabler: card + card-header-tabs */}
+
+
+
+ -
+ setActiveTab('proxies')}
+ role="tab"
+ aria-selected={activeTab === 'proxies'}
+ >
+ Прокси ({communitiesDirectory.length})
+
+
+ -
+ setActiveTab('providers')}
+ role="tab"
+ aria-selected={activeTab === 'providers'}
+ >
+ Провайдер прокси (1)
+
+
+
- )}
-
- {success && (
-
- )}
-
- {/* Панель фильтров */}
-
-
- {/* Переключатель "Только настроенные" */}
-
-
-
-
- {/* Переключатель "Группировка по тегам" */}
-
-
-
-
- {/* Поиск */}
-
-
-
-
-
-
setSearchQuery(e.target.value)}
- />
- {searchQuery && (
-
setSearchQuery('')}
- >
-
-
- )}
+
+ {/* Уведомления в стиле Tabler (alert-icon + dismissible) */}
+ {error && (
+
+
+
{error}
+
setError('')} aria-label="Закрыть" />
-
-
- {/* Инструменты */}
-
- {
- if (expandedServers.size === filteredServers.length) {
- setExpandedServers(new Set());
- } else {
- setExpandedServers(new Set(filteredServers.map(s => s.id)));
- }
- }}
- title={expandedServers.size === filteredServers.length ? "Свернуть все" : "Развернуть все"}
- >
- {expandedServers.size === filteredServers.length ? (
- <>
-
- Свернуть все
- >
- ) : (
- <>
-
- Развернуть все
- >
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ )}
+ {success && (
+
+
+
{success}
+
setSuccess('')} aria-label="Закрыть" />
+
+ )}
- {/* Список серверов с communities и шлюзами */}
- {filteredServers.length === 0 ? (
-
-
-
-
Ничего не найдено
-
- {searchQuery ? 'Попробуйте изменить критерии поиска' : 'Нет доступных серверов'}
-
-
-
- ) : (
-
- {filteredServers.map((server) => {
- const serverMeta = getServerMetadata(server.name);
- const country = serverMeta?.country || '';
- const flag = countryToFlag(country);
-
- // Фильтруем communities для этого сервера
- const filteredCommunitiesForServer = communitiesDirectory.filter((comm) => {
- if (showOnlyConfigured) {
- return server.filtersMap.has(comm.value);
- }
- return true;
- });
-
- // Не показываем сервер, если нет отфильтрованных communities
- if (filteredCommunitiesForServer.length === 0) return null;
-
- const isExpanded = expandedServers.has(server.id);
-
- return (
-
- {/* Заголовок сервера (кликабельный) */}
-
toggleServerExpanded(server.id)}
- >
-
- {/* Иконка сворачивания */}
- {isExpanded ? (
-
- ) : (
-
- )}
-
- {flag &&
{flag}}
-
-
{server.name}
- {server.description && (
-
— {server.description}
- )}
-
- {filteredCommunitiesForServer.length} communities • {server.gateways.length} gateways
-
+ {activeTab === 'proxies' && (
+ <>
+ {/* Панель фильтров — Tabler form-check, input-icon, btn-list */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ aria-label="Поиск"
+ style={searchQuery ? { paddingRight: '2.5rem' } : undefined}
+ />
+ {searchQuery && (
+ setSearchQuery('')}
+ onKeyDown={(e) => e.key === 'Enter' && setSearchQuery('')}
+ role="button"
+ tabIndex={0}
+ aria-label="Очистить"
+ >
+
+
+ )}
+
+
+
+ {
+ if (expandedServers.size === filteredServers.length) {
+ setExpandedServers(new Set());
+ } else {
+ setExpandedServers(new Set(filteredServers.map((s) => s.id)));
+ }
+ }}
+ title={expandedServers.size === filteredServers.length ? 'Свернуть все' : 'Развернуть все'}
+ >
+ {expandedServers.size === filteredServers.length ? (
+ <>
+
+ Свернуть все
+ >
+ ) : (
+ <>
+
+ Развернуть все
+ >
+ )}
+
+
+
+
+
+
+
-
- {/* Контент (сворачиваемый) */}
- {isExpanded && (
-
- {/* Условный рендеринг: группировка по тегам или обычный вид */}
- {groupByTags ? (
- /* Группируем communities по тегам */
- (() => {
- // Группируем по тегам
- const groupedByTags = new Map();
- const noTagCommunities = [];
-
- filteredCommunitiesForServer.forEach(comm => {
- const tags = Array.isArray(comm.tags) ? comm.tags :
- (comm.tags ? String(comm.tags).split(',').map(t => t.trim()).filter(Boolean) : []);
-
- if (tags.length === 0) {
- noTagCommunities.push(comm);
- } else {
- tags.forEach(tag => {
- if (!groupedByTags.has(tag)) {
- groupedByTags.set(tag, []);
- }
- groupedByTags.get(tag).push(comm);
- });
- }
- });
-
- // Сортируем теги
- const sortedTags = Array.from(groupedByTags.keys()).sort();
-
- return (
- <>
- {/* Communities с тегами */}
- {sortedTags.map(tag => {
- const tagCommunities = groupedByTags.get(tag);
- return (
-
- {/* Заголовок группы тегов */}
-
-
- {tag}
-
-
- {tagCommunities.length} {tagCommunities.length === 1 ? 'community' : 'communities'}
-
-
-
- {/* Сетка Communities для этого тега */}
-
- {tagCommunities.map((comm) => {
- const activeKey = `${server.id}:${comm.value}`;
- const activeGw = activeGateways[activeKey];
- const hasFilterForComm = server.filtersMap.has(comm.value);
-
- return (
-
-
- {/* Заголовок community */}
-
-
- {comm.value}
-
- {comm.name || comm.description || ''}
- {hasFilterForComm && (
-
- настроен
-
- )}
-
- ({server.gateways.length})
-
-
-
- {/* Сетка карточек шлюзов для этого community */}
-
- {server.gateways.map((gw, idx) => {
- const isActive = activeGw === gw.name;
- const isFastest = idx === 0;
- const pingKey = (() => {
- const inv = getServerMetadata(server.name);
- const routerId = inv?.id || inv?.dns || inv?.ip;
- return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
- })();
- const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
-
- return (
-
-
handleGatewaySelect(server.id, comm.value, gw.name)}
- onMouseEnter={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
- e.currentTarget.style.transform = 'translateY(-1px)';
- }
- }}
- onMouseLeave={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = 'none';
- e.currentTarget.style.transform = 'translateY(0)';
- }
- }}
- >
-
- {/* Галочка (абсолютное позиционирование) */}
- {isActive && (
-
-
-
- )}
-
- {/* Метка Fastest */}
-
- {isFastest && !isActive && (
-
-
- Fast
-
- )}
-
-
- {/* Флаг и название (комментарий) шлюза */}
-
-
- {countryToFlag(gw.country || country || '') || '🌐'}
-
-
-
- {gw.comment || gw.name}
-
-
-
- {/* IP шлюза ниже */}
- {gw.ip && (
-
- {gw.ip}
-
- )}
-
- {/* Метрика (пинг) */}
-
- {ping != null ? ping : '—'}
-
-
-
-
- );
- })}
-
-
-
- );
- })}
-
-
- );
- })}
-
- {/* Communities без тегов */}
- {noTagCommunities.length > 0 && (
-
- {/* Заголовок группы без тегов */}
-
-
- Без тегов
-
-
- {noTagCommunities.length} {noTagCommunities.length === 1 ? 'community' : 'communities'}
-
-
-
- {/* Сетка Communities без тегов */}
-
- {noTagCommunities.map((comm) => {
- const activeKey = `${server.id}:${comm.value}`;
- const activeGw = activeGateways[activeKey];
- const hasFilterForComm = server.filtersMap.has(comm.value);
-
- return (
-
-
- {/* Заголовок community */}
-
-
- {comm.value}
-
- {comm.name || comm.description || ''}
- {hasFilterForComm && (
-
- настроен
-
- )}
-
- ({server.gateways.length})
-
-
-
- {/* Сетка карточек шлюзов для этого community */}
-
- {server.gateways.map((gw, idx) => {
- const isActive = activeGw === gw.name;
- const isFastest = idx === 0;
- const pingKey = (() => {
- const inv = getServerMetadata(server.name);
- const routerId = inv?.id || inv?.dns || inv?.ip;
- return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
- })();
- const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
-
- return (
-
-
handleGatewaySelect(server.id, comm.value, gw.name)}
- onMouseEnter={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
- e.currentTarget.style.transform = 'translateY(-1px)';
- }
- }}
- onMouseLeave={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = 'none';
- e.currentTarget.style.transform = 'translateY(0)';
- }
- }}
- >
-
- {/* Галочка (абсолютное позиционирование) */}
- {isActive && (
-
-
-
- )}
-
- {/* Метка Fastest */}
-
- {isFastest && !isActive && (
-
-
- Fast
-
- )}
-
-
- {/* Флаг и название (комментарий) шлюза */}
-
-
- {countryToFlag(gw.country || country || '') || '🌐'}
-
-
-
- {gw.comment || gw.name}
-
-
-
- {/* IP шлюза ниже */}
- {gw.ip && (
-
- {gw.ip}
-
- )}
-
- {/* Метрика (пинг) */}
-
- {ping != null ? ping : '—'}
-
-
-
-
- );
- })}
-
-
-
- );
- })}
-
-
- )}
- >
- );
- })()
- ) : (
- /* Обычный вид без группировки */
-
- {filteredCommunitiesForServer.map((comm) => {
- const activeKey = `${server.id}:${comm.value}`;
- const activeGw = activeGateways[activeKey];
- const hasFilterForComm = server.filtersMap.has(comm.value);
-
- return (
-
-
- {/* Заголовок community */}
-
-
- {comm.value}
-
- {comm.name || comm.description || ''}
- {hasFilterForComm && (
-
- настроен
-
- )}
-
- ({server.gateways.length})
-
-
-
- {/* Сетка карточек шлюзов для этого community */}
-
- {server.gateways.map((gw, idx) => {
- const isActive = activeGw === gw.name;
- const isFastest = idx === 0;
- const pingKey = (() => {
- const inv = getServerMetadata(server.name);
- const routerId = inv?.id || inv?.dns || inv?.ip;
- return routerId && gw.ip ? `${routerId}:${gw.ip}` : null;
- })();
- const ping = pingKey && pingMap[pingKey] !== undefined ? pingMap[pingKey] : null;
-
- return (
-
-
handleGatewaySelect(server.id, comm.value, gw.name)}
- onMouseEnter={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = '0 2px 6px rgba(0,0,0,0.1)';
- e.currentTarget.style.transform = 'translateY(-1px)';
- }
- }}
- onMouseLeave={(e) => {
- if (!isActive) {
- e.currentTarget.style.boxShadow = 'none';
- e.currentTarget.style.transform = 'translateY(0)';
- }
- }}
- >
-
- {/* Галочка (абсолютное позиционирование) */}
- {isActive && (
-
-
-
- )}
-
- {/* Метка Fastest */}
-
- {isFastest && !isActive && (
-
-
- Fast
-
- )}
-
-
- {/* Флаг и название (комментарий) шлюза */}
-
-
- {countryToFlag(gw.country || country || '') || '🌐'}
-
-
-
- {gw.comment || gw.name}
-
-
-
- {/* IP шлюза ниже */}
- {gw.ip && (
-
- {gw.ip}
-
- )}
-
- {/* Метрика (пинг) */}
-
- {ping != null ? ping : '—'}
-
-
-
-
- );
- })}
-
-
-
- );
- })}
-
- )}
-
- )}
- );
- })}
+
+ {/* Список серверов */}
+ {filteredServers.length === 0 ? (
+
+
+
+
Ничего не найдено
+
+ {searchQuery ? 'Измените критерии поиска' : 'Нет доступных серверов с шлюзами'}
+
+
+
+ ) : (
+
+ {filteredServers.map((server) => {
+ const serverMeta = getServerMetadata(server.name);
+ const country = serverMeta?.country || '';
+ const flag = countryToFlag(country);
+ const filteredCommunitiesForServer = communitiesDirectory.filter((comm) => {
+ if (showOnlyConfigured) return server.filtersMap.has(comm.value);
+ return true;
+ });
+ if (filteredCommunitiesForServer.length === 0) return null;
+ const isExpanded = expandedServers.has(server.id);
+
+ return (
+
+
+
toggleServerExpanded(server.id)}
+ onKeyDown={(e) => e.key === 'Enter' && toggleServerExpanded(server.id)}
+ role="button"
+ tabIndex={0}
+ aria-expanded={isExpanded}
+ >
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+ {flag && {flag}}
+
+
+
+
{server.name}
+ {server.description && (
+ — {server.description}
+ )}
+
+ {filteredCommunitiesForServer.length} communities · {server.gateways.length} gateways
+
+
+
+ {isExpanded && (
+
+ {groupByTags ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ {activeTab === 'providers' && (
+
+
+
+
Провайдер прокси
+
Раздел в разработке
+
+
+ )}
- )}
+
);
}