From 1f7bd4f5f37571b8a29a50a70f56404537ad47f9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 2 Feb 2026 10:38:48 +0300 Subject: [PATCH] refactor: move squad viewing to separate page and add RemnaWave icon - Create AdminRemnawaveSquadDetail page for viewing squad details - Remove squad detail modal from AdminRemnawave - Add RemnawaveIcon from official RemnaWave branding - Add GlobeIcon, PlayIcon, StopIcon, ArrowPathIcon to centralized icons - Update RefreshIcon to support spinning prop - Add route /admin/remnawave/squads/:uuid --- src/App.tsx | 11 + src/components/icons/index.tsx | 87 ++++++- src/pages/AdminRemnawave.tsx | 285 +++-------------------- src/pages/AdminRemnawaveSquadDetail.tsx | 289 ++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 256 deletions(-) create mode 100644 src/pages/AdminRemnawaveSquadDetail.tsx diff --git a/src/App.tsx b/src/App.tsx index b64f4a9..3ce9034 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,6 +58,7 @@ const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')); const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit')); const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend')); const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); +const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); @@ -570,6 +571,16 @@ function App() { } /> + + + + + + } + /> ( ); -export const RefreshIcon = ({ className }: IconProps) => ( +export const RefreshIcon = ({ + className, + spinning = false, +}: IconProps & { spinning?: boolean }) => ( ( ); +export const RemnawaveIcon = ({ className }: IconProps) => ( + + + +); + export const ChartIcon = ({ className }: IconProps) => ( ( /> ); + +export const GlobeIcon = ({ className }: IconProps) => ( + + + +); + +export const PlayIcon = ({ className }: IconProps) => ( + + + +); + +export const StopIcon = ({ className }: IconProps) => ( + + + +); + +export const ArrowPathIcon = ({ className }: IconProps) => ( + + + +); diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 9af981c..f60503d 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -1,4 +1,5 @@ import { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -9,140 +10,18 @@ import { AutoSyncStatus, } from '../api/adminRemnawave'; import { AdminBackButton } from '../components/admin'; - -// ============ Icons ============ - -const ServerIcon = ({ className = 'w-6 h-6' }: { className?: string }) => ( - - - -); - -const ChartIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const GlobeIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const UsersIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const RefreshIcon = ({ - className = 'w-4 h-4', - spinning = false, -}: { - className?: string; - spinning?: boolean; -}) => ( - - - -); - -const PlayIcon = ({ className = 'w-4 h-4' }: { className?: string }) => ( - - - -); - -const StopIcon = ({ className = 'w-4 h-4' }: { className?: string }) => ( - - - -); - -const ArrowPathIcon = ({ className = 'w-4 h-4' }: { className?: string }) => ( - - - -); +import { + ServerIcon, + ChartIcon, + GlobeIcon, + UsersIcon, + SyncIcon, + RefreshIcon, + PlayIcon, + StopIcon, + ArrowPathIcon, + RemnawaveIcon, +} from '../components/icons'; // ============ Helpers ============ @@ -320,15 +199,15 @@ function NodeCard({ node, onAction, isLoading }: NodeCardProps) { interface SquadCardProps { squad: SquadWithLocalInfo; - onSelect: (squad: SquadWithLocalInfo) => void; + onClick: () => void; } -function SquadCard({ squad, onSelect }: SquadCardProps) { +function SquadCard({ squad, onClick }: SquadCardProps) { const { t } = useTranslation(); return (
onSelect(squad)} + onClick={onClick} className="cursor-pointer rounded-xl border border-dark-700 bg-dark-800/50 p-4 transition-colors hover:border-dark-600" >
@@ -693,12 +572,19 @@ interface SquadsTabProps { squads: SquadWithLocalInfo[]; isLoading: boolean; onRefresh: () => void; - onSelect: (squad: SquadWithLocalInfo) => void; + onNavigate: (uuid: string) => void; onSync: () => void; isSyncing: boolean; } -function SquadsTab({ squads, isLoading, onRefresh, onSelect, onSync, isSyncing }: SquadsTabProps) { +function SquadsTab({ + squads, + isLoading, + onRefresh, + onNavigate, + onSync, + isSyncing, +}: SquadsTabProps) { const { t } = useTranslation(); const stats = useMemo(() => { @@ -763,7 +649,9 @@ function SquadsTab({ squads, isLoading, onRefresh, onSelect, onSync, isSyncing } {t('admin.remnawave.squads.noSquads', 'No squads found')}

) : ( - squads.map((squad) => ) + squads.map((squad) => ( + onNavigate(squad.uuid)} /> + )) )}
@@ -955,11 +843,11 @@ type TabType = 'overview' | 'nodes' | 'squads' | 'sync'; export default function AdminRemnawave() { const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); // State const [activeTab, setActiveTab] = useState('overview'); - const [selectedSquad, setSelectedSquad] = useState(null); const [syncResults, setSyncResults] = useState< Record >({}); @@ -1094,7 +982,7 @@ export default function AdminRemnawave() {
- +

@@ -1171,7 +1059,7 @@ export default function AdminRemnawave() { squads={squadsData?.items || []} isLoading={isLoadingSquads} onRefresh={() => refetchSquads()} - onSelect={setSelectedSquad} + onNavigate={(uuid) => navigate(`/admin/remnawave/squads/${uuid}`)} onSync={handleSyncServers} isSyncing={syncServersMutation.isPending} /> @@ -1195,117 +1083,6 @@ export default function AdminRemnawave() { loadingStates={loadingStates} /> )} - - {/* Squad Detail Modal */} - {selectedSquad && ( -
setSelectedSquad(null)} - > -
e.stopPropagation()} - > - {/* Header */} -
-
- {getCountryFlag(selectedSquad.country_code)} -

- {selectedSquad.display_name || selectedSquad.name} -

-
- -
- - {/* Content */} -
-
-
-

UUID

-

{selectedSquad.uuid}

-
-
-

Original Name

-

{selectedSquad.name}

-
-
-

Members

-

{selectedSquad.members_count}

-
-
-

Inbounds

-

{selectedSquad.inbounds_count}

-
- {selectedSquad.is_synced && ( - <> -
-

Price

-

- {((selectedSquad.price_kopeks ?? 0) / 100).toFixed(2)} ₽ -

-
-
-

Users

-

- {selectedSquad.current_users ?? 0} / {selectedSquad.max_users ?? '∞'} -

-
-
-

Available

-

- {selectedSquad.is_available ? 'Yes' : 'No'} -

-
-
-

Trial Eligible

-

- {selectedSquad.is_trial_eligible ? 'Yes' : 'No'} -

-
- - )} -
- - {/* Inbounds */} - {selectedSquad.inbounds.length > 0 && ( -
-

Inbounds

-
- {selectedSquad.inbounds.map((inbound: Record, idx) => ( -
- {String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)} -
- ))} -
-
- )} -
- - {/* Footer */} -
- -
-
-
- )}

); } diff --git a/src/pages/AdminRemnawaveSquadDetail.tsx b/src/pages/AdminRemnawaveSquadDetail.tsx new file mode 100644 index 0000000..10a72d3 --- /dev/null +++ b/src/pages/AdminRemnawaveSquadDetail.tsx @@ -0,0 +1,289 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { adminRemnawaveApi, SquadWithLocalInfo } from '../api/adminRemnawave'; +import { AdminBackButton } from '../components/admin'; +import { ServerIcon, UsersIcon, CheckIcon, XIcon } from '../components/icons'; + +// Country flags helper +const getCountryFlag = (code: string | null | undefined): string => { + if (!code) return '🌍'; + const codeMap: Record = { + RU: '🇷🇺', + US: '🇺🇸', + DE: '🇩🇪', + NL: '🇳🇱', + GB: '🇬🇧', + UK: '🇬🇧', + FR: '🇫🇷', + FI: '🇫🇮', + SE: '🇸🇪', + NO: '🇳🇴', + PL: '🇵🇱', + TR: '🇹🇷', + JP: '🇯🇵', + SG: '🇸🇬', + HK: '🇭🇰', + KR: '🇰🇷', + AU: '🇦🇺', + CA: '🇨🇦', + CH: '🇨🇭', + AT: '🇦🇹', + IT: '🇮🇹', + ES: '🇪🇸', + BR: '🇧🇷', + IN: '🇮🇳', + AE: '🇦🇪', + IL: '🇮🇱', + KZ: '🇰🇿', + UA: '🇺🇦', + CZ: '🇨🇿', + RO: '🇷🇴', + LV: '🇱🇻', + LT: '🇱🇹', + EE: '🇪🇪', + BG: '🇧🇬', + HU: '🇭🇺', + MD: '🇲🇩', + }; + return codeMap[code.toUpperCase()] || code; +}; + +export default function AdminRemnawaveSquadDetail() { + const { t } = useTranslation(); + const { uuid } = useParams<{ uuid: string }>(); + const navigate = useNavigate(); + + // Fetch all squads and find the one we need + const { + data: squadsData, + isLoading, + error, + } = useQuery({ + queryKey: ['admin-remnawave-squads'], + queryFn: adminRemnawaveApi.getSquads, + }); + + const squad: SquadWithLocalInfo | undefined = squadsData?.items?.find( + (s: SquadWithLocalInfo) => s.uuid === uuid, + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error || !squad) { + return ( +
+
+ +

+ {t('admin.remnawave.squads.detail', 'Squad Details')} +

+
+
+

+ {t('admin.remnawave.squads.loadError', 'Failed to load squad')} +

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ +
+ {getCountryFlag(squad.country_code)} +
+ +
+
+
+

+ {squad.display_name || squad.name} +

+

{squad.name}

+
+ {squad.is_synced ? ( + + {t('admin.remnawave.squads.synced', 'Synced')} + + ) : ( + + {t('admin.remnawave.squads.notSynced', 'Not synced')} + + )} +
+ + {/* Main Info */} +
+

+ {t('admin.remnawave.squads.info', 'Information')} +

+
+
+

UUID

+

{squad.uuid}

+
+
+

+ {t('admin.remnawave.squads.originalName', 'Original Name')} +

+

{squad.name}

+
+
+

+ {t('admin.remnawave.squads.countryCode', 'Country')} +

+

+ {getCountryFlag(squad.country_code)} {squad.country_code || '—'} +

+
+
+
+ + {/* Stats */} +
+

+ {t('admin.remnawave.squads.stats', 'Statistics')} +

+
+
+
+ + {t('admin.remnawave.squads.members', 'Members')} +
+

{squad.members_count}

+
+
+
+ + {t('admin.remnawave.squads.inbounds', 'Inbounds')} +
+

{squad.inbounds_count}

+
+ {squad.is_synced && ( + <> +
+
+ + {t('admin.remnawave.squads.users', 'Users')} +
+

+ {squad.current_users ?? 0} + + {' '} + / {squad.max_users ?? '∞'} + +

+
+
+
+ {t('admin.remnawave.squads.price', 'Price')} +
+

+ {((squad.price_kopeks ?? 0) / 100).toFixed(0)} ₽ +

+
+ + )} +
+
+ + {/* Local Settings (if synced) */} + {squad.is_synced && ( +
+

+ {t('admin.remnawave.squads.localSettings', 'Local Settings')} +

+
+
+
+ {squad.is_available ? : } +
+
+

+ {t('admin.remnawave.squads.available', 'Available')} +

+

+ {squad.is_available ? t('common.yes', 'Yes') : t('common.no', 'No')} +

+
+
+
+
+ {squad.is_trial_eligible ? : } +
+
+

+ {t('admin.remnawave.squads.trialEligible', 'Trial Eligible')} +

+

+ {squad.is_trial_eligible ? t('common.yes', 'Yes') : t('common.no', 'No')} +

+
+
+
+
+ )} + + {/* Inbounds */} + {squad.inbounds.length > 0 && ( +
+

+ {t('admin.remnawave.squads.inboundsList', 'Inbounds')} +

+
+ {squad.inbounds.map((inbound: Record, idx) => ( +
+ + {String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)} + + {typeof inbound.type === 'string' && ( + + {inbound.type} + + )} +
+ ))} +
+
+ )} + + {/* Footer */} +
+ +
+
+ ); +}