From ba20b127df68254dcd72f81d040bb41aca6e4149 Mon Sep 17 00:00:00 2001 From: shats Date: Tue, 24 Feb 2026 10:58:27 +0700 Subject: [PATCH] feat(EasySwitchManager): add GatewayCard component and enhance server content rendering with grouped and flat views for communities --- frontend/src/EasySwitchManager.jsx | 1145 +++++++++++----------------- 1 file changed, 431 insertions(+), 714 deletions(-) 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 && ( - )} -
} /> - {/* Вкладки */} -
-
    -
  • - -
  • -
  • - -
  • -
-
- - {/* Уведомления */} - {error && ( -
-
- -
{error}
-
- + {/* Вкладки в стиле Tabler: card + card-header-tabs */} +
+
+
    +
  • + +
  • +
  • + +
  • +
- )} - - {success && ( -
-
- -
{success}
-
- -
- )} - - {/* Панель фильтров */} -
-
- {/* Переключатель "Только настроенные" */} -
- -
- - {/* Переключатель "Группировка по тегам" */} -
- -
- - {/* Поиск */} -
-
- - - - setSearchQuery(e.target.value)} - /> - {searchQuery && ( - setSearchQuery('')} - > - - - )} +
+ {/* Уведомления в стиле Tabler (alert-icon + dismissible) */} + {error && ( +
+ +
{error}
+
-
- - {/* Инструменты */} -
- -
-
- -
-
- -
-
-
+ )} + {success && ( +
+ +
{success}
+
+ )} - {/* Список серверов с 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="Очистить" + > + + + )} +
+
+
+ + +
- - {/* Контент (сворачиваемый) */} - {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' && ( +
+
+ +

Провайдер прокси

+

Раздел в разработке

+
+
+ )}
- )} +
); }