mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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
This commit is contained in:
11
src/App.tsx
11
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() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/remnawave/squads/:uuid"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminRemnawaveSquadDetail />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates"
|
||||
element={
|
||||
|
||||
@@ -194,9 +194,12 @@ export const DownloadIcon = ({ className }: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RefreshIcon = ({ className }: IconProps) => (
|
||||
export const RefreshIcon = ({
|
||||
className,
|
||||
spinning = false,
|
||||
}: IconProps & { spinning?: boolean }) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
className={cn('h-4 w-4', spinning && 'animate-spin', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -575,6 +578,22 @@ export const CampaignIcon = ({ className }: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const RemnawaveIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-5 w-5', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M8 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-1.5 0V1.75A.75.75 0 0 1 8 1Zm6 2a.75.75 0 0 1 .75.75v8.5a.75.75 0 0 1-1.5 0v-8.5A.75.75 0 0 1 14 3ZM5 4a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 5 4Zm6 1a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 11 5ZM2 6a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 2 6Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ChartIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
@@ -590,3 +609,67 @@ export const ChartIcon = ({ className }: IconProps) => (
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const GlobeIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-5 w-5', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlayIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const StopIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ArrowPathIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -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 }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChartIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GlobeIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UsersIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SyncIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = ({
|
||||
className = 'w-4 h-4',
|
||||
spinning = false,
|
||||
}: {
|
||||
className?: string;
|
||||
spinning?: boolean;
|
||||
}) => (
|
||||
<svg
|
||||
className={`${className} ${spinning ? 'animate-spin' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlayIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const StopIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowPathIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
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 (
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -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')}
|
||||
</p>
|
||||
) : (
|
||||
squads.map((squad) => <SquadCard key={squad.uuid} squad={squad} onSelect={onSelect} />)
|
||||
squads.map((squad) => (
|
||||
<SquadCard key={squad.uuid} squad={squad} onClick={() => onNavigate(squad.uuid)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<TabType>('overview');
|
||||
const [selectedSquad, setSelectedSquad] = useState<SquadWithLocalInfo | null>(null);
|
||||
const [syncResults, setSyncResults] = useState<
|
||||
Record<string, { success: boolean; message?: string } | null>
|
||||
>({});
|
||||
@@ -1094,7 +982,7 @@ export default function AdminRemnawave() {
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||
<ServerIcon className="h-6 w-6 text-accent-400" />
|
||||
<RemnawaveIcon className="h-6 w-6 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
@@ -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 && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setSelectedSquad(null)}
|
||||
>
|
||||
<div
|
||||
className="max-h-[80vh] w-full max-w-lg overflow-auto rounded-xl bg-dark-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 flex items-center justify-between border-b border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{getCountryFlag(selectedSquad.country_code)}</span>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{selectedSquad.display_name || selectedSquad.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedSquad(null)}
|
||||
className="rounded-lg p-1 text-dark-400 hover:bg-dark-700"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-dark-500">UUID</p>
|
||||
<p className="break-all font-mono text-xs text-dark-200">{selectedSquad.uuid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Original Name</p>
|
||||
<p className="text-dark-200">{selectedSquad.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Members</p>
|
||||
<p className="text-dark-200">{selectedSquad.members_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Inbounds</p>
|
||||
<p className="text-dark-200">{selectedSquad.inbounds_count}</p>
|
||||
</div>
|
||||
{selectedSquad.is_synced && (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-dark-500">Price</p>
|
||||
<p className="text-dark-200">
|
||||
{((selectedSquad.price_kopeks ?? 0) / 100).toFixed(2)} ₽
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Users</p>
|
||||
<p className="text-dark-200">
|
||||
{selectedSquad.current_users ?? 0} / {selectedSquad.max_users ?? '∞'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Available</p>
|
||||
<p
|
||||
className={
|
||||
selectedSquad.is_available ? 'text-success-400' : 'text-error-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_available ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-dark-500">Trial Eligible</p>
|
||||
<p
|
||||
className={
|
||||
selectedSquad.is_trial_eligible ? 'text-success-400' : 'text-dark-400'
|
||||
}
|
||||
>
|
||||
{selectedSquad.is_trial_eligible ? 'Yes' : 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inbounds */}
|
||||
{selectedSquad.inbounds.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-dark-500">Inbounds</p>
|
||||
<div className="space-y-1">
|
||||
{selectedSquad.inbounds.map((inbound: Record<string, unknown>, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded bg-dark-700/50 px-2 py-1 text-xs text-dark-300"
|
||||
>
|
||||
{String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-dark-700 p-4">
|
||||
<button onClick={() => setSelectedSquad(null)} className="btn-secondary">
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
289
src/pages/AdminRemnawaveSquadDetail.tsx
Normal file
289
src/pages/AdminRemnawaveSquadDetail.tsx
Normal file
@@ -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<string, string> = {
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !squad) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/remnawave" />
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{t('admin.remnawave.squads.detail', 'Squad Details')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-6 text-center">
|
||||
<p className="text-error-400">
|
||||
{t('admin.remnawave.squads.loadError', 'Failed to load squad')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/admin/remnawave')}
|
||||
className="mt-4 text-sm text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/remnawave" />
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getCountryFlag(squad.country_code)}</span>
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<ServerIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{squad.display_name || squad.name}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">{squad.name}</p>
|
||||
</div>
|
||||
{squad.is_synced ? (
|
||||
<span className="rounded-full bg-success-500/20 px-3 py-1 text-xs text-success-400">
|
||||
{t('admin.remnawave.squads.synced', 'Synced')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-warning-500/20 px-3 py-1 text-xs text-warning-400">
|
||||
{t('admin.remnawave.squads.notSynced', 'Not synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Info */}
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.remnawave.squads.info', 'Information')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm text-dark-500">UUID</p>
|
||||
<p className="break-all font-mono text-xs text-dark-200">{squad.uuid}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-dark-500">
|
||||
{t('admin.remnawave.squads.originalName', 'Original Name')}
|
||||
</p>
|
||||
<p className="text-dark-200">{squad.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-dark-500">
|
||||
{t('admin.remnawave.squads.countryCode', 'Country')}
|
||||
</p>
|
||||
<p className="text-dark-200">
|
||||
{getCountryFlag(squad.country_code)} {squad.country_code || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.remnawave.squads.stats', 'Statistics')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{t('admin.remnawave.squads.members', 'Members')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.members_count}</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<ServerIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{t('admin.remnawave.squads.inbounds', 'Inbounds')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-dark-100">{squad.inbounds_count}</p>
|
||||
</div>
|
||||
{squad.is_synced && (
|
||||
<>
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{t('admin.remnawave.squads.users', 'Users')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-dark-100">
|
||||
{squad.current_users ?? 0}
|
||||
<span className="text-sm font-normal text-dark-400">
|
||||
{' '}
|
||||
/ {squad.max_users ?? '∞'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-2 text-dark-400">
|
||||
<span className="text-sm">{t('admin.remnawave.squads.price', 'Price')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-dark-100">
|
||||
{((squad.price_kopeks ?? 0) / 100).toFixed(0)} ₽
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Settings (if synced) */}
|
||||
{squad.is_synced && (
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.remnawave.squads.localSettings', 'Local Settings')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-dark-700/50 p-4">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
squad.is_available
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-error-500/20 text-error-400'
|
||||
}`}
|
||||
>
|
||||
{squad.is_available ? <CheckIcon /> : <XIcon />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.remnawave.squads.available', 'Available')}
|
||||
</p>
|
||||
<p className={squad.is_available ? 'text-success-400' : 'text-error-400'}>
|
||||
{squad.is_available ? t('common.yes', 'Yes') : t('common.no', 'No')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-dark-700/50 p-4">
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
squad.is_trial_eligible
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-600 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
{squad.is_trial_eligible ? <CheckIcon /> : <XIcon />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.remnawave.squads.trialEligible', 'Trial Eligible')}
|
||||
</p>
|
||||
<p className={squad.is_trial_eligible ? 'text-success-400' : 'text-dark-400'}>
|
||||
{squad.is_trial_eligible ? t('common.yes', 'Yes') : t('common.no', 'No')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inbounds */}
|
||||
{squad.inbounds.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.remnawave.squads.inboundsList', 'Inbounds')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{squad.inbounds.map((inbound: Record<string, unknown>, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between rounded-lg bg-dark-700/50 px-4 py-3"
|
||||
>
|
||||
<span className="text-sm text-dark-200">
|
||||
{String(inbound.tag || inbound.uuid || `Inbound ${idx + 1}`)}
|
||||
</span>
|
||||
{typeof inbound.type === 'string' && (
|
||||
<span className="rounded bg-dark-600 px-2 py-1 text-xs text-dark-400">
|
||||
{inbound.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end">
|
||||
<button onClick={() => navigate('/admin/remnawave')} className="btn-secondary">
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user