upd
This commit is contained in:
Binary file not shown.
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Врата ADA - платформа для CTF тренировок Attack-Defence" />
|
||||||
|
<meta name="theme-color" content="#0a0a0f" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
|
<title>Врата ADA - CTF Attack-Defence Platform</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: #0a0a0f;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4907
package-lock.json
generated
Normal file
4907
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "react-vite-tailwind",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.7.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.20",
|
||||||
|
"@radix-ui/react-avatar": "^1.2.6",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||||
|
"@radix-ui/react-label": "^2.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.23",
|
||||||
|
"@radix-ui/react-progress": "^1.1.16",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||||
|
"@radix-ui/react-select": "^2.3.7",
|
||||||
|
"@radix-ui/react-separator": "^1.1.15",
|
||||||
|
"@radix-ui/react-slot": "^1.3.3",
|
||||||
|
"@radix-ui/react-switch": "^1.3.7",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.21",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.16",
|
||||||
|
"@tanstack/react-table": "^9.1.2",
|
||||||
|
"axios": "^1.19.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.31.0",
|
||||||
|
"react": "19.2.6",
|
||||||
|
"react-dom": "19.2.6",
|
||||||
|
"react-hook-form": "^7.85.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"recharts": "^3.10.1",
|
||||||
|
"socket.io-client": "^4.8.3",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.1.17",
|
||||||
|
"@types/node": "22.19.17",
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
|
"tailwindcss": "4.1.17",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vite": "7.3.2",
|
||||||
|
"vite-plugin-singlefile": "2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/App.tsx
Normal file
52
src/App.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { router } from './router';
|
||||||
|
import { useAuthStore, useUIStore } from './store';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { initialize } = useAuthStore();
|
||||||
|
const { resolvedTheme } = useUIStore();
|
||||||
|
|
||||||
|
// Initialize auth on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
|
// Apply theme class to document
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(resolvedTheme);
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
className: '',
|
||||||
|
style: {
|
||||||
|
background: 'hsl(var(--card))',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'hsl(142 71% 45%)',
|
||||||
|
secondary: 'hsl(var(--card))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'hsl(0 84.2% 60.2%)',
|
||||||
|
secondary: 'hsl(var(--card))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
209
src/api/axios.ts
Normal file
209
src/api/axios.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { ApiError, TokenPair } from './types';
|
||||||
|
|
||||||
|
// Create axios instance with default config
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token storage utilities
|
||||||
|
const TOKEN_KEY = 'ada_access_token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'ada_refresh_token';
|
||||||
|
|
||||||
|
export const tokenStorage = {
|
||||||
|
getAccessToken: (): string | null => {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRefreshToken: (): string | null => {
|
||||||
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
setTokens: (tokens: TokenPair): void => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, tokens.accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTokens: (): void => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
},
|
||||||
|
|
||||||
|
hasTokens: (): boolean => {
|
||||||
|
return !!localStorage.getItem(TOKEN_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flag to prevent multiple refresh requests
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason?: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const processQueue = (error: Error | null, token: string | null = null): void => {
|
||||||
|
failedQueue.forEach((prom) => {
|
||||||
|
if (error) {
|
||||||
|
prom.reject(error);
|
||||||
|
} else {
|
||||||
|
prom.resolve(token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
apiClient.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error: AxiosError) => {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle errors and token refresh
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<ApiError>) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized - attempt token refresh
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
// Wait for the refresh to complete
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({ resolve, reject });
|
||||||
|
})
|
||||||
|
.then((token) => {
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken();
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
tokenStorage.clearTokens();
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<TokenPair>(
|
||||||
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTokens = response.data;
|
||||||
|
tokenStorage.setTokens(newTokens);
|
||||||
|
|
||||||
|
processQueue(null, newTokens.accessToken);
|
||||||
|
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newTokens.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiClient(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError as Error, null);
|
||||||
|
tokenStorage.clearTokens();
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
toast.error('Сессия истекла. Пожалуйста, войдите снова.');
|
||||||
|
window.location.href = '/login';
|
||||||
|
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other errors
|
||||||
|
const errorMessage = getErrorMessage(error);
|
||||||
|
|
||||||
|
// Show toast for client errors (except 401 which is handled above)
|
||||||
|
if (error.response?.status && error.response.status >= 400 && error.response.status !== 401) {
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle network errors
|
||||||
|
if (!error.response) {
|
||||||
|
toast.error('Ошибка сети. Проверьте подключение к интернету.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to extract error message
|
||||||
|
function getErrorMessage(error: AxiosError<ApiError>): string {
|
||||||
|
if (error.response?.data?.message) {
|
||||||
|
return error.response.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (error.response?.status) {
|
||||||
|
case 400:
|
||||||
|
return 'Неверный запрос';
|
||||||
|
case 403:
|
||||||
|
return 'Доступ запрещён';
|
||||||
|
case 404:
|
||||||
|
return 'Ресурс не найден';
|
||||||
|
case 409:
|
||||||
|
return 'Конфликт данных';
|
||||||
|
case 422:
|
||||||
|
return 'Ошибка валидации';
|
||||||
|
case 429:
|
||||||
|
return 'Слишком много запросов. Попробуйте позже.';
|
||||||
|
case 500:
|
||||||
|
return 'Внутренняя ошибка сервера';
|
||||||
|
case 502:
|
||||||
|
return 'Сервис временно недоступен';
|
||||||
|
case 503:
|
||||||
|
return 'Сервис на обслуживании';
|
||||||
|
default:
|
||||||
|
return 'Произошла ошибка';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export typed request helpers
|
||||||
|
export const api = {
|
||||||
|
get: <T>(url: string, config?: object) =>
|
||||||
|
apiClient.get<T>(url, config).then(res => res.data),
|
||||||
|
|
||||||
|
post: <T>(url: string, data?: object, config?: object) =>
|
||||||
|
apiClient.post<T>(url, data, config).then(res => res.data),
|
||||||
|
|
||||||
|
put: <T>(url: string, data?: object, config?: object) =>
|
||||||
|
apiClient.put<T>(url, data, config).then(res => res.data),
|
||||||
|
|
||||||
|
patch: <T>(url: string, data?: object, config?: object) =>
|
||||||
|
apiClient.patch<T>(url, data, config).then(res => res.data),
|
||||||
|
|
||||||
|
delete: <T>(url: string, config?: object) =>
|
||||||
|
apiClient.delete<T>(url, config).then(res => res.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
251
src/api/endpoints/admin.api.ts
Normal file
251
src/api/endpoints/admin.api.ts
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Match,
|
||||||
|
Service,
|
||||||
|
AdminDashboard,
|
||||||
|
AdminActivityLog,
|
||||||
|
SystemAlert,
|
||||||
|
SystemConfig,
|
||||||
|
SystemHealthCheck,
|
||||||
|
AdminUserListParams,
|
||||||
|
AdminUpdateUserRequest,
|
||||||
|
AdminCreateMatchRequest,
|
||||||
|
AdminMatchControl,
|
||||||
|
AdminServiceListParams,
|
||||||
|
AdminUpdateServiceRequest,
|
||||||
|
AdminServiceReview,
|
||||||
|
MaintenanceRequest,
|
||||||
|
UpdateSystemConfigRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: (): Promise<AdminDashboard> => {
|
||||||
|
return api.get<AdminDashboard>('/admin/dashboard');
|
||||||
|
},
|
||||||
|
|
||||||
|
// User management
|
||||||
|
getUsers: (
|
||||||
|
params?: AdminUserListParams
|
||||||
|
): Promise<PaginatedResponse<User>> => {
|
||||||
|
return api.get<PaginatedResponse<User>>('/admin/users', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getUser: (userId: string): Promise<User> => {
|
||||||
|
return api.get<User>(`/admin/users/${userId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUser: (userId: string, data: AdminUpdateUserRequest): Promise<User> => {
|
||||||
|
return api.patch<User>(`/admin/users/${userId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: (userId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/admin/users/${userId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
banUser: (
|
||||||
|
userId: string,
|
||||||
|
reason: string,
|
||||||
|
expiresAt?: string
|
||||||
|
): Promise<User> => {
|
||||||
|
return api.post<User>(`/admin/users/${userId}/ban`, { reason, expiresAt });
|
||||||
|
},
|
||||||
|
|
||||||
|
unbanUser: (userId: string): Promise<User> => {
|
||||||
|
return api.post<User>(`/admin/users/${userId}/unban`);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetUserPassword: (userId: string): Promise<{ temporaryPassword: string }> => {
|
||||||
|
return api.post(`/admin/users/${userId}/reset-password`);
|
||||||
|
},
|
||||||
|
|
||||||
|
impersonateUser: (userId: string): Promise<{ token: string; expiresAt: string }> => {
|
||||||
|
return api.post(`/admin/users/${userId}/impersonate`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Match management
|
||||||
|
getMatches: (
|
||||||
|
params?: PaginationParams & { status?: string; mode?: string }
|
||||||
|
): Promise<PaginatedResponse<Match>> => {
|
||||||
|
return api.get<PaginatedResponse<Match>>('/admin/matches', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
createMatch: (data: AdminCreateMatchRequest): Promise<Match> => {
|
||||||
|
return api.post<Match>('/admin/matches', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
controlMatch: (data: AdminMatchControl): Promise<Match> => {
|
||||||
|
const { matchId, ...body } = data;
|
||||||
|
return api.post<Match>(`/admin/matches/${matchId}/control`, body);
|
||||||
|
},
|
||||||
|
|
||||||
|
forceEndMatch: (matchId: string, reason: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/admin/matches/${matchId}/force-end`, { reason });
|
||||||
|
},
|
||||||
|
|
||||||
|
getMatchHealth: (matchId: string): Promise<{
|
||||||
|
status: string;
|
||||||
|
checkers: Array<{ serviceId: string; status: string; lastRun: string }>;
|
||||||
|
scorers: Array<{ status: string; queueLength: number }>;
|
||||||
|
network: { status: string; latency: number };
|
||||||
|
}> => {
|
||||||
|
return api.get(`/admin/matches/${matchId}/health`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Service management
|
||||||
|
getServices: (
|
||||||
|
params?: AdminServiceListParams
|
||||||
|
): Promise<PaginatedResponse<Service>> => {
|
||||||
|
return api.get<PaginatedResponse<Service>>('/admin/services', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getPendingServices: (): Promise<Service[]> => {
|
||||||
|
return api.get<Service[]>('/admin/services/pending');
|
||||||
|
},
|
||||||
|
|
||||||
|
reviewService: (data: AdminServiceReview): Promise<Service> => {
|
||||||
|
const { serviceId, ...body } = data;
|
||||||
|
return api.post<Service>(`/admin/services/${serviceId}/review`, body);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateService: (
|
||||||
|
serviceId: string,
|
||||||
|
data: AdminUpdateServiceRequest
|
||||||
|
): Promise<Service> => {
|
||||||
|
return api.patch<Service>(`/admin/services/${serviceId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteService: (serviceId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/admin/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// System management
|
||||||
|
getSystemConfig: (): Promise<SystemConfig> => {
|
||||||
|
return api.get<SystemConfig>('/admin/system/config');
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSystemConfig: (data: UpdateSystemConfigRequest): Promise<SystemConfig> => {
|
||||||
|
return api.patch<SystemConfig>('/admin/system/config', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSystemHealth: (): Promise<SystemHealthCheck> => {
|
||||||
|
return api.get<SystemHealthCheck>('/admin/system/health');
|
||||||
|
},
|
||||||
|
|
||||||
|
setMaintenanceMode: (data: MaintenanceRequest): Promise<{ success: boolean }> => {
|
||||||
|
return api.post('/admin/system/maintenance', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
restartService: (
|
||||||
|
serviceName: string
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
return api.post(`/admin/system/restart/${serviceName}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache: (
|
||||||
|
cacheType?: 'all' | 'scoreboard' | 'sessions' | 'api'
|
||||||
|
): Promise<{ cleared: string[] }> => {
|
||||||
|
return api.post('/admin/system/clear-cache', { cacheType });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Activity logs
|
||||||
|
getActivityLogs: (
|
||||||
|
params?: PaginationParams & {
|
||||||
|
adminId?: string;
|
||||||
|
action?: string;
|
||||||
|
targetType?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<AdminActivityLog>> => {
|
||||||
|
return api.get<PaginatedResponse<AdminActivityLog>>('/admin/activity-logs', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
getAlerts: (
|
||||||
|
params?: {
|
||||||
|
severity?: string;
|
||||||
|
isResolved?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
): Promise<SystemAlert[]> => {
|
||||||
|
return api.get<SystemAlert[]>('/admin/alerts', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveAlert: (alertId: string): Promise<SystemAlert> => {
|
||||||
|
return api.post<SystemAlert>(`/admin/alerts/${alertId}/resolve`);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissAlert: (alertId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/admin/alerts/${alertId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
getSystemStats: (): Promise<{
|
||||||
|
users: { total: number; active: number; new24h: number };
|
||||||
|
teams: { total: number; active: number };
|
||||||
|
matches: { total: number; active: number; today: number };
|
||||||
|
services: { total: number; pending: number };
|
||||||
|
exercises: { total: number; completions24h: number };
|
||||||
|
}> => {
|
||||||
|
return api.get('/admin/stats');
|
||||||
|
},
|
||||||
|
|
||||||
|
getUsageStats: (period: 'day' | 'week' | 'month'): Promise<{
|
||||||
|
apiRequests: Array<{ timestamp: string; count: number }>;
|
||||||
|
activeUsers: Array<{ timestamp: string; count: number }>;
|
||||||
|
matchesCreated: Array<{ timestamp: string; count: number }>;
|
||||||
|
flagsSubmitted: Array<{ timestamp: string; count: number }>;
|
||||||
|
}> => {
|
||||||
|
return api.get('/admin/stats/usage', { params: { period } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Broadcasts
|
||||||
|
sendBroadcast: (data: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
type: 'info' | 'warning' | 'maintenance';
|
||||||
|
targetRoles?: string[];
|
||||||
|
expiresAt?: string;
|
||||||
|
}): Promise<{ sent: number }> => {
|
||||||
|
return api.post('/admin/broadcast', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Backups
|
||||||
|
createBackup: (
|
||||||
|
type: 'full' | 'database' | 'files'
|
||||||
|
): Promise<{
|
||||||
|
backupId: string;
|
||||||
|
status: string;
|
||||||
|
estimatedTime: number;
|
||||||
|
}> => {
|
||||||
|
return api.post('/admin/backups', { type });
|
||||||
|
},
|
||||||
|
|
||||||
|
getBackups: (): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
createdAt: string;
|
||||||
|
status: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get('/admin/backups');
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadBackup: (backupId: string): Promise<Blob> => {
|
||||||
|
return api.get(`/admin/backups/${backupId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreBackup: (backupId: string): Promise<{ status: string }> => {
|
||||||
|
return api.post(`/admin/backups/${backupId}/restore`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default adminApi;
|
||||||
259
src/api/endpoints/analytics.api.ts
Normal file
259
src/api/endpoints/analytics.api.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
TeamAnalytics,
|
||||||
|
PlayerAnalytics,
|
||||||
|
MatchAnalytics,
|
||||||
|
AIRecommendation,
|
||||||
|
TeamAnalyticsParams,
|
||||||
|
PlayerAnalyticsParams,
|
||||||
|
MatchAnalyticsParams,
|
||||||
|
AnalyticsPeriod,
|
||||||
|
HeatmapData,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const analyticsApi = {
|
||||||
|
// Get team analytics
|
||||||
|
getTeamAnalytics: (params: TeamAnalyticsParams): Promise<TeamAnalytics> => {
|
||||||
|
const { teamId, ...queryParams } = params;
|
||||||
|
return api.get<TeamAnalytics>(`/analytics/teams/${teamId}`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get player analytics
|
||||||
|
getPlayerAnalytics: (params: PlayerAnalyticsParams): Promise<PlayerAnalytics> => {
|
||||||
|
const { userId, ...queryParams } = params;
|
||||||
|
return api.get<PlayerAnalytics>(`/analytics/players/${userId}`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match analytics
|
||||||
|
getMatchAnalytics: (params: MatchAnalyticsParams): Promise<MatchAnalytics> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<MatchAnalytics>(`/analytics/matches/${matchId}`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get AI recommendations for team
|
||||||
|
getTeamRecommendations: (
|
||||||
|
teamId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<AIRecommendation[]> => {
|
||||||
|
return api.get<AIRecommendation[]>(
|
||||||
|
`/analytics/teams/${teamId}/recommendations`,
|
||||||
|
{ params: { limit } }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get AI recommendations for player
|
||||||
|
getPlayerRecommendations: (
|
||||||
|
userId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<AIRecommendation[]> => {
|
||||||
|
return api.get<AIRecommendation[]>(
|
||||||
|
`/analytics/players/${userId}/recommendations`,
|
||||||
|
{ params: { limit } }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match AI insights
|
||||||
|
getMatchInsights: (matchId: string): Promise<AIRecommendation[]> => {
|
||||||
|
return api.get<AIRecommendation[]>(`/analytics/matches/${matchId}/insights`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get attack heatmap
|
||||||
|
getAttackHeatmap: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
round?: number;
|
||||||
|
}
|
||||||
|
): Promise<HeatmapData> => {
|
||||||
|
return api.get<HeatmapData>(`/analytics/matches/${matchId}/heatmap/attacks`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get SLA heatmap
|
||||||
|
getSLAHeatmap: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
|
): Promise<HeatmapData> => {
|
||||||
|
return api.get<HeatmapData>(`/analytics/matches/${matchId}/heatmap/sla`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team performance trends
|
||||||
|
getTeamTrends: (
|
||||||
|
teamId: string,
|
||||||
|
period: AnalyticsPeriod
|
||||||
|
): Promise<Array<{
|
||||||
|
metric: string;
|
||||||
|
values: Array<{ date: string; value: number }>;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
changePercent: number;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/analytics/teams/${teamId}/trends`, {
|
||||||
|
params: { ...period },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get player skill progression
|
||||||
|
getSkillProgression: (
|
||||||
|
userId: string,
|
||||||
|
skillId?: string
|
||||||
|
): Promise<Array<{
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
history: Array<{
|
||||||
|
date: string;
|
||||||
|
level: number;
|
||||||
|
experience: number;
|
||||||
|
}>;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/analytics/players/${userId}/skill-progression`, {
|
||||||
|
params: { skillId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compare teams
|
||||||
|
compareTeams: (
|
||||||
|
teamIds: string[],
|
||||||
|
period?: AnalyticsPeriod
|
||||||
|
): Promise<{
|
||||||
|
teams: Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
}>;
|
||||||
|
metrics: Array<{
|
||||||
|
name: string;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
return api.get('/analytics/compare/teams', {
|
||||||
|
params: { teamIds: teamIds.join(','), ...period },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Compare players
|
||||||
|
comparePlayers: (
|
||||||
|
userIds: string[],
|
||||||
|
period?: AnalyticsPeriod
|
||||||
|
): Promise<{
|
||||||
|
players: Array<{
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
}>;
|
||||||
|
metrics: Array<{
|
||||||
|
name: string;
|
||||||
|
values: Record<string, number>;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
return api.get('/analytics/compare/players', {
|
||||||
|
params: { userIds: userIds.join(','), ...period },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service analytics for match
|
||||||
|
getServiceAnalytics: (
|
||||||
|
matchId: string,
|
||||||
|
serviceId: string
|
||||||
|
): Promise<{
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
totalExploits: number;
|
||||||
|
uniqueExploiters: number;
|
||||||
|
avgExploitTime: number;
|
||||||
|
exploitsByRound: Array<{ round: number; count: number }>;
|
||||||
|
exploitsByTeam: Array<{ teamId: string; teamName: string; count: number }>;
|
||||||
|
slaByTeam: Array<{ teamId: string; teamName: string; sla: number }>;
|
||||||
|
firstBlood: {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
} | null;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/analytics/matches/${matchId}/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round-by-round analysis
|
||||||
|
getRoundAnalysis: (
|
||||||
|
matchId: string,
|
||||||
|
roundNumber: number
|
||||||
|
): Promise<{
|
||||||
|
round: number;
|
||||||
|
summary: string;
|
||||||
|
keyEvents: Array<{
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
impact: number;
|
||||||
|
}>;
|
||||||
|
teamPerformance: Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
score: number;
|
||||||
|
attackSuccess: number;
|
||||||
|
defenseSuccess: number;
|
||||||
|
sla: number;
|
||||||
|
}>;
|
||||||
|
mvp: {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
reason: string;
|
||||||
|
} | null;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/analytics/matches/${matchId}/rounds/${roundNumber}/analysis`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export analytics report
|
||||||
|
exportAnalytics: (
|
||||||
|
type: 'team' | 'player' | 'match',
|
||||||
|
id: string,
|
||||||
|
format: 'pdf' | 'json' | 'csv'
|
||||||
|
): Promise<Blob> => {
|
||||||
|
return api.get(`/analytics/${type}s/${id}/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get benchmark data (how team/player compares to others)
|
||||||
|
getBenchmark: (
|
||||||
|
type: 'team' | 'player',
|
||||||
|
id: string
|
||||||
|
): Promise<{
|
||||||
|
percentiles: Record<string, number>;
|
||||||
|
comparisons: Array<{
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
average: number;
|
||||||
|
top10Percent: number;
|
||||||
|
percentile: number;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/analytics/benchmark/${type}s/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get improvement suggestions
|
||||||
|
getImprovementSuggestions: (
|
||||||
|
type: 'team' | 'player',
|
||||||
|
id: string
|
||||||
|
): Promise<Array<{
|
||||||
|
area: string;
|
||||||
|
currentLevel: number;
|
||||||
|
targetLevel: number;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
suggestions: string[];
|
||||||
|
resources: Array<{ title: string; url: string }>;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/analytics/${type}s/${id}/suggestions`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default analyticsApi;
|
||||||
160
src/api/endpoints/auth.api.ts
Normal file
160
src/api/endpoints/auth.api.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { api, tokenStorage } from '../axios';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
AuthResponse,
|
||||||
|
TokenPair,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
ChangePasswordRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
ApiKey,
|
||||||
|
CreateApiKeyRequest,
|
||||||
|
CreateApiKeyResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
// Login user
|
||||||
|
login: async (data: LoginRequest): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/login', data);
|
||||||
|
tokenStorage.setTokens(response.tokens);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Register new user
|
||||||
|
register: async (data: RegisterRequest): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post<AuthResponse>('/auth/register', data);
|
||||||
|
tokenStorage.setTokens(response.tokens);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logout user
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout');
|
||||||
|
} finally {
|
||||||
|
tokenStorage.clearTokens();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
getMe: (): Promise<User> => {
|
||||||
|
return api.get<User>('/auth/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
refreshToken: async (refreshToken: string): Promise<TokenPair> => {
|
||||||
|
const response = await api.post<TokenPair>('/auth/refresh', { refreshToken });
|
||||||
|
tokenStorage.setTokens(response);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Send forgot password email
|
||||||
|
forgotPassword: (data: ForgotPasswordRequest): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/forgot-password', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset password with token
|
||||||
|
resetPassword: (data: ResetPasswordRequest): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/reset-password', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Change password (authenticated)
|
||||||
|
changePassword: (data: ChangePasswordRequest): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/change-password', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update profile
|
||||||
|
updateProfile: (data: UpdateProfileRequest): Promise<User> => {
|
||||||
|
return api.patch<User>('/auth/profile', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload avatar
|
||||||
|
uploadAvatar: async (file: File): Promise<{ avatarUrl: string }> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
return api.post<{ avatarUrl: string }>('/auth/avatar', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete avatar
|
||||||
|
deleteAvatar: (): Promise<void> => {
|
||||||
|
return api.delete<void>('/auth/avatar');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify email
|
||||||
|
verifyEmail: (token: string): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/verify-email', { token });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resend verification email
|
||||||
|
resendVerification: (): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/resend-verification');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enable two-factor authentication
|
||||||
|
enableTwoFactor: (): Promise<{ qrCode: string; secret: string }> => {
|
||||||
|
return api.post<{ qrCode: string; secret: string }>('/auth/2fa/enable');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Verify and complete 2FA setup
|
||||||
|
verifyTwoFactor: (code: string): Promise<{ backupCodes: string[] }> => {
|
||||||
|
return api.post<{ backupCodes: string[] }>('/auth/2fa/verify', { code });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disable two-factor authentication
|
||||||
|
disableTwoFactor: (password: string): Promise<{ message: string }> => {
|
||||||
|
return api.post<{ message: string }>('/auth/2fa/disable', { password });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get API keys
|
||||||
|
getApiKeys: (): Promise<ApiKey[]> => {
|
||||||
|
return api.get<ApiKey[]>('/auth/api-keys');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create API key
|
||||||
|
createApiKey: (data: CreateApiKeyRequest): Promise<CreateApiKeyResponse> => {
|
||||||
|
return api.post<CreateApiKeyResponse>('/auth/api-keys', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete API key
|
||||||
|
deleteApiKey: (keyId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/auth/api-keys/${keyId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if username is available
|
||||||
|
checkUsername: (username: string): Promise<{ available: boolean }> => {
|
||||||
|
return api.get<{ available: boolean }>(`/auth/check-username/${username}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if email is available
|
||||||
|
checkEmail: (email: string): Promise<{ available: boolean }> => {
|
||||||
|
return api.get<{ available: boolean }>(`/auth/check-email/${encodeURIComponent(email)}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get active sessions
|
||||||
|
getSessions: (): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
device: string;
|
||||||
|
ip: string;
|
||||||
|
location: string;
|
||||||
|
lastActive: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}>> => {
|
||||||
|
return api.get('/auth/sessions');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revoke session
|
||||||
|
revokeSession: (sessionId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/auth/sessions/${sessionId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Revoke all other sessions
|
||||||
|
revokeAllSessions: (): Promise<void> => {
|
||||||
|
return api.post<void>('/auth/sessions/revoke-all');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default authApi;
|
||||||
178
src/api/endpoints/flags.api.ts
Normal file
178
src/api/endpoints/flags.api.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Flag,
|
||||||
|
FlagSubmission,
|
||||||
|
FlagStats,
|
||||||
|
FlagSubmissionStats,
|
||||||
|
SubmitFlagRequest,
|
||||||
|
SubmitFlagResponse,
|
||||||
|
BulkSubmitFlagsRequest,
|
||||||
|
BulkSubmitFlagsResponse,
|
||||||
|
FlagHistoryParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const flagsApi = {
|
||||||
|
// Submit a flag
|
||||||
|
submitFlag: (data: SubmitFlagRequest): Promise<SubmitFlagResponse> => {
|
||||||
|
return api.post<SubmitFlagResponse>('/flags/submit', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bulk submit flags
|
||||||
|
bulkSubmitFlags: (data: BulkSubmitFlagsRequest): Promise<BulkSubmitFlagsResponse> => {
|
||||||
|
return api.post<BulkSubmitFlagsResponse>('/flags/submit/bulk', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag submission history for current team
|
||||||
|
getFlagHistory: (
|
||||||
|
params: FlagHistoryParams
|
||||||
|
): Promise<PaginatedResponse<FlagSubmission>> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<PaginatedResponse<FlagSubmission>>(
|
||||||
|
`/matches/${matchId}/flags/history`,
|
||||||
|
{ params: queryParams }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag stats for team
|
||||||
|
getFlagStats: (matchId: string, teamId: string): Promise<FlagStats> => {
|
||||||
|
return api.get<FlagStats>(`/matches/${matchId}/teams/${teamId}/flag-stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get submission stats
|
||||||
|
getSubmissionStats: (matchId: string, teamId: string): Promise<FlagSubmissionStats> => {
|
||||||
|
return api.get<FlagSubmissionStats>(
|
||||||
|
`/matches/${matchId}/teams/${teamId}/submission-stats`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get captured flags (attacks made by team)
|
||||||
|
getCapturedFlags: (
|
||||||
|
matchId: string,
|
||||||
|
teamId: string,
|
||||||
|
params?: {
|
||||||
|
serviceId?: string;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<Flag>> => {
|
||||||
|
return api.get<PaginatedResponse<Flag>>(
|
||||||
|
`/matches/${matchId}/teams/${teamId}/flags/captured`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get lost flags (flags stolen from team)
|
||||||
|
getLostFlags: (
|
||||||
|
matchId: string,
|
||||||
|
teamId: string,
|
||||||
|
params?: {
|
||||||
|
serviceId?: string;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<Flag>> => {
|
||||||
|
return api.get<PaginatedResponse<Flag>>(
|
||||||
|
`/matches/${matchId}/teams/${teamId}/flags/lost`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recent submissions (live feed)
|
||||||
|
getRecentSubmissions: (
|
||||||
|
matchId: string,
|
||||||
|
limit?: number
|
||||||
|
): Promise<FlagSubmission[]> => {
|
||||||
|
return api.get<FlagSubmission[]>(`/matches/${matchId}/flags/recent`, {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag distribution by service
|
||||||
|
getFlagDistributionByService: (
|
||||||
|
matchId: string
|
||||||
|
): Promise<Array<{
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
totalGenerated: number;
|
||||||
|
totalCaptured: number;
|
||||||
|
captureRate: number;
|
||||||
|
topAttacker: { teamId: string; teamName: string; count: number } | null;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/flags/distribution/by-service`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag distribution by team
|
||||||
|
getFlagDistributionByTeam: (
|
||||||
|
matchId: string
|
||||||
|
): Promise<Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
captured: number;
|
||||||
|
lost: number;
|
||||||
|
netFlags: number;
|
||||||
|
attackSuccess: number;
|
||||||
|
defenseSuccess: number;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/flags/distribution/by-team`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag timeline (for visualization)
|
||||||
|
getFlagTimeline: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
fromRound?: number;
|
||||||
|
toRound?: number;
|
||||||
|
}
|
||||||
|
): Promise<Array<{
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
captured: number;
|
||||||
|
lost: number;
|
||||||
|
cumulative: { captured: number; lost: number };
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/flags/timeline`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get first blood info
|
||||||
|
getFirstBlood: (matchId: string): Promise<{
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
attackerTeamId: string;
|
||||||
|
attackerTeamName: string;
|
||||||
|
defenderTeamId: string;
|
||||||
|
defenderTeamName: string;
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
timeFromStart: number; // seconds
|
||||||
|
} | null> => {
|
||||||
|
return api.get(`/matches/${matchId}/flags/first-blood`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validate flag format (client-side check before submission)
|
||||||
|
validateFlagFormat: (matchId: string, flag: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
message: string;
|
||||||
|
}> => {
|
||||||
|
return api.post(`/matches/${matchId}/flags/validate-format`, { flag });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get submission rate limits
|
||||||
|
getRateLimits: (matchId: string): Promise<{
|
||||||
|
perSecond: number;
|
||||||
|
perMinute: number;
|
||||||
|
currentUsage: {
|
||||||
|
lastSecond: number;
|
||||||
|
lastMinute: number;
|
||||||
|
};
|
||||||
|
resetAt: string;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/flags/rate-limits`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default flagsApi;
|
||||||
19
src/api/endpoints/index.ts
Normal file
19
src/api/endpoints/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// Export all API modules
|
||||||
|
export { authApi } from './auth.api';
|
||||||
|
export { usersApi } from './users.api';
|
||||||
|
export { teamsApi } from './teams.api';
|
||||||
|
export { matchesApi } from './matches.api';
|
||||||
|
export { roundsApi } from './rounds.api';
|
||||||
|
export { servicesApi } from './services.api';
|
||||||
|
export { scoreboardApi } from './scoreboard.api';
|
||||||
|
export { flagsApi } from './flags.api';
|
||||||
|
export { logsApi } from './logs.api';
|
||||||
|
export { trainingApi } from './training.api';
|
||||||
|
export { analyticsApi } from './analytics.api';
|
||||||
|
export { adminApi } from './admin.api';
|
||||||
|
export { notificationsApi } from './notifications.api';
|
||||||
|
export { seasonsApi } from './seasons.api';
|
||||||
|
|
||||||
|
// Re-export types for convenience
|
||||||
|
export type { UserProfile, UserActivity, LeaderboardUser } from './users.api';
|
||||||
|
export type { MatchSummary } from './matches.api';
|
||||||
220
src/api/endpoints/logs.api.ts
Normal file
220
src/api/endpoints/logs.api.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
LogEntry,
|
||||||
|
LogFilterParams,
|
||||||
|
ReplayData,
|
||||||
|
ReplayParams,
|
||||||
|
EventTimelineParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
EventType,
|
||||||
|
LogLevel,
|
||||||
|
} from '../types';
|
||||||
|
import type { LogTimelineEvent } from '../types';
|
||||||
|
|
||||||
|
export const logsApi = {
|
||||||
|
// Get logs with filtering
|
||||||
|
getLogs: (params: LogFilterParams): Promise<PaginatedResponse<LogEntry>> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<PaginatedResponse<LogEntry>>(`/matches/${matchId}/logs`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get event timeline
|
||||||
|
getEventTimeline: (
|
||||||
|
params: EventTimelineParams
|
||||||
|
): Promise<LogTimelineEvent[]> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<LogTimelineEvent[]>(`/matches/${matchId}/timeline`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get replay data
|
||||||
|
getReplay: (params: ReplayParams): Promise<ReplayData> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<ReplayData>(`/matches/${matchId}/replay`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get replay frame at specific time
|
||||||
|
getReplayFrame: (
|
||||||
|
matchId: string,
|
||||||
|
timestamp: number
|
||||||
|
): Promise<{
|
||||||
|
scoreboard: Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
position: number;
|
||||||
|
score: number;
|
||||||
|
}>;
|
||||||
|
events: LogTimelineEvent[];
|
||||||
|
serviceStatuses: Array<{
|
||||||
|
teamId: string;
|
||||||
|
serviceId: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/replay/frame`, {
|
||||||
|
params: { timestamp },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get key moments
|
||||||
|
getKeyMoments: (matchId: string): Promise<Array<{
|
||||||
|
timestamp: string;
|
||||||
|
relativeTime: number;
|
||||||
|
round: number;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/replay/key-moments`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stream logs (SSE)
|
||||||
|
streamLogs: (
|
||||||
|
matchId: string,
|
||||||
|
filters: {
|
||||||
|
levels?: LogLevel[];
|
||||||
|
types?: EventType[];
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
},
|
||||||
|
onMessage: (log: LogEntry) => void,
|
||||||
|
onError?: (error: Event) => void
|
||||||
|
): EventSource => {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters.levels) params.append('levels', filters.levels.join(','));
|
||||||
|
if (filters.types) params.append('types', filters.types.join(','));
|
||||||
|
if (filters.teamId) params.append('teamId', filters.teamId);
|
||||||
|
if (filters.serviceId) params.append('serviceId', filters.serviceId);
|
||||||
|
|
||||||
|
const url = `${baseUrl}/matches/${matchId}/logs/stream?${params.toString()}`;
|
||||||
|
const eventSource = new EventSource(url, { withCredentials: true });
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as LogEntry;
|
||||||
|
onMessage(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing log entry:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
eventSource.onerror = onError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get log statistics
|
||||||
|
getLogStats: (matchId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
byLevel: Record<LogLevel, number>;
|
||||||
|
byType: Record<EventType, number>;
|
||||||
|
byTeam: Record<string, number>;
|
||||||
|
byService: Record<string, number>;
|
||||||
|
perRound: Array<{ round: number; count: number }>;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/logs/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search logs
|
||||||
|
searchLogs: (
|
||||||
|
matchId: string,
|
||||||
|
query: string,
|
||||||
|
params?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<LogEntry>> => {
|
||||||
|
return api.get<PaginatedResponse<LogEntry>>(`/matches/${matchId}/logs/search`, {
|
||||||
|
params: { query, ...params },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export logs
|
||||||
|
exportLogs: (
|
||||||
|
matchId: string,
|
||||||
|
format: 'json' | 'csv' | 'txt',
|
||||||
|
filters?: Partial<LogFilterParams>
|
||||||
|
): Promise<Blob> => {
|
||||||
|
return api.get(`/matches/${matchId}/logs/export`, {
|
||||||
|
params: { format, ...filters },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get attack log (filtered for attack events)
|
||||||
|
getAttackLog: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
attackerTeamId?: string;
|
||||||
|
defenderTeamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<LogEntry>> => {
|
||||||
|
return api.get<PaginatedResponse<LogEntry>>(`/matches/${matchId}/logs/attacks`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service check log
|
||||||
|
getServiceCheckLog: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
result?: string;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<LogEntry>> => {
|
||||||
|
return api.get<PaginatedResponse<LogEntry>>(
|
||||||
|
`/matches/${matchId}/logs/service-checks`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get score change log
|
||||||
|
getScoreChangeLog: (
|
||||||
|
matchId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
): Promise<PaginatedResponse<LogEntry>> => {
|
||||||
|
return api.get<PaginatedResponse<LogEntry>>(
|
||||||
|
`/matches/${matchId}/logs/score-changes`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Bookmark log entry
|
||||||
|
bookmarkLogEntry: (logId: string, note?: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/logs/${logId}/bookmark`, { note });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get bookmarked logs
|
||||||
|
getBookmarkedLogs: (matchId: string): Promise<LogEntry[]> => {
|
||||||
|
return api.get<LogEntry[]>(`/matches/${matchId}/logs/bookmarked`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove bookmark
|
||||||
|
removeBookmark: (logId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/logs/${logId}/bookmark`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logsApi;
|
||||||
193
src/api/endpoints/matches.api.ts
Normal file
193
src/api/endpoints/matches.api.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Match,
|
||||||
|
MatchResult,
|
||||||
|
MatchTeam,
|
||||||
|
CreateMatchRequest,
|
||||||
|
UpdateMatchRequest,
|
||||||
|
MatchListParams,
|
||||||
|
JoinMatchRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export interface MatchSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
mode: string;
|
||||||
|
status: string;
|
||||||
|
teamsCount: number;
|
||||||
|
maxTeams: number;
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
startedAt: string | null;
|
||||||
|
isRanked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const matchesApi = {
|
||||||
|
// Create a new match
|
||||||
|
createMatch: (data: CreateMatchRequest): Promise<Match> => {
|
||||||
|
return api.post<Match>('/matches', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match by ID
|
||||||
|
getMatch: (matchId: string): Promise<Match> => {
|
||||||
|
return api.get<Match>(`/matches/${matchId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update match
|
||||||
|
updateMatch: (matchId: string, data: UpdateMatchRequest): Promise<Match> => {
|
||||||
|
return api.patch<Match>(`/matches/${matchId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete match
|
||||||
|
deleteMatch: (matchId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/matches/${matchId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// List matches
|
||||||
|
listMatches: (
|
||||||
|
params?: MatchListParams & PaginationParams
|
||||||
|
): Promise<PaginatedResponse<MatchSummary>> => {
|
||||||
|
return api.get<PaginatedResponse<MatchSummary>>('/matches', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get active matches
|
||||||
|
getActiveMatches: (): Promise<MatchSummary[]> => {
|
||||||
|
return api.get<MatchSummary[]>('/matches/active');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get upcoming matches
|
||||||
|
getUpcomingMatches: (
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<MatchSummary>> => {
|
||||||
|
return api.get<PaginatedResponse<MatchSummary>>('/matches/upcoming', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get past matches
|
||||||
|
getPastMatches: (
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<MatchSummary>> => {
|
||||||
|
return api.get<PaginatedResponse<MatchSummary>>('/matches/past', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Join match with team
|
||||||
|
joinMatch: (matchId: string, data: JoinMatchRequest): Promise<MatchTeam> => {
|
||||||
|
return api.post<MatchTeam>(`/matches/${matchId}/join`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Leave match
|
||||||
|
leaveMatch: (matchId: string, teamId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/matches/${matchId}/leave`, { teamId });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set team ready status
|
||||||
|
setTeamReady: (
|
||||||
|
matchId: string,
|
||||||
|
teamId: string,
|
||||||
|
isReady: boolean
|
||||||
|
): Promise<void> => {
|
||||||
|
return api.post<void>(`/matches/${matchId}/teams/${teamId}/ready`, {
|
||||||
|
isReady,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match teams
|
||||||
|
getMatchTeams: (matchId: string): Promise<MatchTeam[]> => {
|
||||||
|
return api.get<MatchTeam[]>(`/matches/${matchId}/teams`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start match (organizer/admin)
|
||||||
|
startMatch: (matchId: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/matches/${matchId}/start`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop match (organizer/admin)
|
||||||
|
stopMatch: (matchId: string, reason?: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/matches/${matchId}/stop`, { reason });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pause match
|
||||||
|
pauseMatch: (matchId: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/matches/${matchId}/pause`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Resume match
|
||||||
|
resumeMatch: (matchId: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/matches/${matchId}/resume`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match results
|
||||||
|
getMatchResults: (matchId: string): Promise<MatchResult> => {
|
||||||
|
return api.get<MatchResult>(`/matches/${matchId}/results`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match network config (for teams in match)
|
||||||
|
getNetworkConfig: (matchId: string, teamId: string): Promise<{
|
||||||
|
ip: string;
|
||||||
|
subnet: string;
|
||||||
|
vpnConfig: string | null;
|
||||||
|
instructions: string;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/teams/${teamId}/network`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Download VPN config
|
||||||
|
downloadVpnConfig: (matchId: string, teamId: string): Promise<Blob> => {
|
||||||
|
return api.get(`/matches/${matchId}/teams/${teamId}/vpn-config`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match services
|
||||||
|
getMatchServices: (matchId: string): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
difficulty: string;
|
||||||
|
port: number;
|
||||||
|
description: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/services`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get featured matches
|
||||||
|
getFeaturedMatches: (): Promise<MatchSummary[]> => {
|
||||||
|
return api.get<MatchSummary[]>('/matches/featured');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clone match (create new from existing config)
|
||||||
|
cloneMatch: (matchId: string): Promise<Match> => {
|
||||||
|
return api.post<Match>(`/matches/${matchId}/clone`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get match statistics overview
|
||||||
|
getMatchStats: (matchId: string): Promise<{
|
||||||
|
totalFlags: number;
|
||||||
|
totalAttacks: number;
|
||||||
|
successfulAttacks: number;
|
||||||
|
avgSLA: number;
|
||||||
|
duration: number;
|
||||||
|
roundsCompleted: number;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export match data
|
||||||
|
exportMatch: (
|
||||||
|
matchId: string,
|
||||||
|
format: 'json' | 'csv'
|
||||||
|
): Promise<Blob> => {
|
||||||
|
return api.get(`/matches/${matchId}/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default matchesApi;
|
||||||
125
src/api/endpoints/notifications.api.ts
Normal file
125
src/api/endpoints/notifications.api.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Notification,
|
||||||
|
NotificationGroup,
|
||||||
|
NotificationStats,
|
||||||
|
NotificationPreferences,
|
||||||
|
GetNotificationsParams,
|
||||||
|
MarkNotificationsRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
// Get notifications
|
||||||
|
getNotifications: (
|
||||||
|
params?: GetNotificationsParams
|
||||||
|
): Promise<PaginatedResponse<Notification>> => {
|
||||||
|
return api.get<PaginatedResponse<Notification>>('/notifications', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get notifications grouped by date
|
||||||
|
getNotificationsGrouped: (
|
||||||
|
params?: GetNotificationsParams
|
||||||
|
): Promise<NotificationGroup[]> => {
|
||||||
|
return api.get<NotificationGroup[]>('/notifications/grouped', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get single notification
|
||||||
|
getNotification: (notificationId: string): Promise<Notification> => {
|
||||||
|
return api.get<Notification>(`/notifications/${notificationId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get unread count
|
||||||
|
getUnreadCount: (): Promise<{ count: number }> => {
|
||||||
|
return api.get<{ count: number }>('/notifications/unread-count');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get notification stats
|
||||||
|
getStats: (): Promise<NotificationStats> => {
|
||||||
|
return api.get<NotificationStats>('/notifications/stats');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark notification as read
|
||||||
|
markAsRead: (notificationId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/notifications/${notificationId}/read`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark notification as unread
|
||||||
|
markAsUnread: (notificationId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/notifications/${notificationId}/unread`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark multiple notifications
|
||||||
|
markMultiple: (data: MarkNotificationsRequest): Promise<{ updated: number }> => {
|
||||||
|
return api.post<{ updated: number }>('/notifications/mark', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark all as read
|
||||||
|
markAllAsRead: (): Promise<{ updated: number }> => {
|
||||||
|
return api.post<{ updated: number }>('/notifications/mark-all-read');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Archive notification
|
||||||
|
archiveNotification: (notificationId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/notifications/${notificationId}/archive`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unarchive notification
|
||||||
|
unarchiveNotification: (notificationId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/notifications/${notificationId}/unarchive`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete notification
|
||||||
|
deleteNotification: (notificationId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/notifications/${notificationId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete multiple notifications
|
||||||
|
deleteMultiple: (notificationIds: string[]): Promise<{ deleted: number }> => {
|
||||||
|
return api.post<{ deleted: number }>('/notifications/delete', {
|
||||||
|
notificationIds,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clear all notifications
|
||||||
|
clearAll: (options?: { archived?: boolean }): Promise<{ deleted: number }> => {
|
||||||
|
return api.post<{ deleted: number }>('/notifications/clear', options);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get notification preferences
|
||||||
|
getPreferences: (): Promise<NotificationPreferences> => {
|
||||||
|
return api.get<NotificationPreferences>('/notifications/preferences');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update notification preferences
|
||||||
|
updatePreferences: (
|
||||||
|
data: Partial<NotificationPreferences>
|
||||||
|
): Promise<NotificationPreferences> => {
|
||||||
|
return api.patch<NotificationPreferences>('/notifications/preferences', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe to push notifications
|
||||||
|
subscribePush: (subscription: PushSubscriptionJSON): Promise<{ success: boolean }> => {
|
||||||
|
return api.post('/notifications/push/subscribe', { subscription });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unsubscribe from push notifications
|
||||||
|
unsubscribePush: (): Promise<void> => {
|
||||||
|
return api.post<void>('/notifications/push/unsubscribe');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Test notification
|
||||||
|
sendTestNotification: (): Promise<{ success: boolean }> => {
|
||||||
|
return api.post('/notifications/test');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get notification actions
|
||||||
|
performAction: (
|
||||||
|
notificationId: string,
|
||||||
|
action: string
|
||||||
|
): Promise<{ success: boolean; redirectUrl?: string }> => {
|
||||||
|
return api.post(`/notifications/${notificationId}/action`, { action });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default notificationsApi;
|
||||||
148
src/api/endpoints/rounds.api.ts
Normal file
148
src/api/endpoints/rounds.api.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Round,
|
||||||
|
RoundEvent,
|
||||||
|
RoundSummary,
|
||||||
|
RoundTimeline,
|
||||||
|
ServiceCheck,
|
||||||
|
RoundEventsParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const roundsApi = {
|
||||||
|
// Get round by ID
|
||||||
|
getRound: (roundId: string): Promise<Round> => {
|
||||||
|
return api.get<Round>(`/rounds/${roundId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round by match and number
|
||||||
|
getRoundByNumber: (matchId: string, roundNumber: number): Promise<Round> => {
|
||||||
|
return api.get<Round>(`/matches/${matchId}/rounds/${roundNumber}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// List rounds for a match
|
||||||
|
listRounds: (matchId: string): Promise<Round[]> => {
|
||||||
|
return api.get<Round[]>(`/matches/${matchId}/rounds`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current round for a match
|
||||||
|
getCurrentRound: (matchId: string): Promise<Round | null> => {
|
||||||
|
return api.get<Round | null>(`/matches/${matchId}/rounds/current`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round events
|
||||||
|
getRoundEvents: (
|
||||||
|
params: RoundEventsParams
|
||||||
|
): Promise<PaginatedResponse<RoundEvent>> => {
|
||||||
|
const { roundId, ...queryParams } = params;
|
||||||
|
return api.get<PaginatedResponse<RoundEvent>>(
|
||||||
|
`/rounds/${roundId}/events`,
|
||||||
|
{ params: queryParams }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round summary
|
||||||
|
getRoundSummary: (matchId: string, roundNumber: number): Promise<RoundSummary> => {
|
||||||
|
return api.get<RoundSummary>(`/matches/${matchId}/rounds/${roundNumber}/summary`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round timeline
|
||||||
|
getRoundTimeline: (matchId: string, roundNumber: number): Promise<RoundTimeline> => {
|
||||||
|
return api.get<RoundTimeline>(
|
||||||
|
`/matches/${matchId}/rounds/${roundNumber}/timeline`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service checks for a round
|
||||||
|
getServiceChecks: (
|
||||||
|
roundId: string,
|
||||||
|
params?: {
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
result?: string;
|
||||||
|
}
|
||||||
|
): Promise<ServiceCheck[]> => {
|
||||||
|
return api.get<ServiceCheck[]>(`/rounds/${roundId}/checks`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round statistics
|
||||||
|
getRoundStats: (roundId: string): Promise<{
|
||||||
|
flagsGenerated: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsExpired: number;
|
||||||
|
avgSLA: number;
|
||||||
|
totalChecks: number;
|
||||||
|
successfulChecks: number;
|
||||||
|
failedChecks: number;
|
||||||
|
topAttacker: { teamId: string; teamName: string; count: number } | null;
|
||||||
|
topDefender: { teamId: string; teamName: string; count: number } | null;
|
||||||
|
mostAttackedService: { serviceId: string; serviceName: string; count: number } | null;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/rounds/${roundId}/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get rounds comparison (for analytics)
|
||||||
|
getRoundsComparison: (
|
||||||
|
matchId: string,
|
||||||
|
rounds: number[]
|
||||||
|
): Promise<Array<{
|
||||||
|
round: number;
|
||||||
|
stats: {
|
||||||
|
flagsCaptured: number;
|
||||||
|
avgSLA: number;
|
||||||
|
topTeamId: string;
|
||||||
|
};
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/rounds/compare`, {
|
||||||
|
params: { rounds: rounds.join(',') },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get round events stream (for live view)
|
||||||
|
streamRoundEvents: (
|
||||||
|
matchId: string,
|
||||||
|
callback: (event: RoundEvent) => void
|
||||||
|
): EventSource => {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001/api/v1';
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
`${baseUrl}/matches/${matchId}/rounds/stream`,
|
||||||
|
{ withCredentials: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data) as RoundEvent;
|
||||||
|
callback(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing round event:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (error) => {
|
||||||
|
console.error('EventSource error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return eventSource;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get flag distribution for round
|
||||||
|
getFlagDistribution: (roundId: string): Promise<{
|
||||||
|
byService: Array<{
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
generated: number;
|
||||||
|
captured: number;
|
||||||
|
expired: number;
|
||||||
|
}>;
|
||||||
|
byTeam: Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
captured: number;
|
||||||
|
lost: number;
|
||||||
|
}>;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/rounds/${roundId}/flag-distribution`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default roundsApi;
|
||||||
182
src/api/endpoints/scoreboard.api.ts
Normal file
182
src/api/endpoints/scoreboard.api.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
ScoreboardData,
|
||||||
|
ScoreboardEntry,
|
||||||
|
ScoreHistory,
|
||||||
|
TeamScore,
|
||||||
|
ScoreBreakdown,
|
||||||
|
ScoreboardParams,
|
||||||
|
ScoreHistoryParams,
|
||||||
|
LeaderboardEntry,
|
||||||
|
SeasonLeaderboard,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const scoreboardApi = {
|
||||||
|
// Get full scoreboard for a match
|
||||||
|
getScoreboard: (params: ScoreboardParams): Promise<ScoreboardData> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<ScoreboardData>(`/matches/${matchId}/scoreboard`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get scoreboard entry for specific team
|
||||||
|
getTeamScore: (matchId: string, teamId: string): Promise<TeamScore> => {
|
||||||
|
return api.get<TeamScore>(`/matches/${matchId}/scoreboard/teams/${teamId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get score history for match
|
||||||
|
getScoreHistory: (params: ScoreHistoryParams): Promise<ScoreHistory[]> => {
|
||||||
|
const { matchId, ...queryParams } = params;
|
||||||
|
return api.get<ScoreHistory[]>(`/matches/${matchId}/scoreboard/history`, {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get score history for specific team
|
||||||
|
getTeamScoreHistory: (
|
||||||
|
matchId: string,
|
||||||
|
teamId: string,
|
||||||
|
params?: {
|
||||||
|
fromRound?: number;
|
||||||
|
toRound?: number;
|
||||||
|
}
|
||||||
|
): Promise<ScoreHistory> => {
|
||||||
|
return api.get<ScoreHistory>(
|
||||||
|
`/matches/${matchId}/scoreboard/teams/${teamId}/history`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get score breakdown for team
|
||||||
|
getScoreBreakdown: (matchId: string, teamId: string): Promise<ScoreBreakdown> => {
|
||||||
|
return api.get<ScoreBreakdown>(
|
||||||
|
`/matches/${matchId}/scoreboard/teams/${teamId}/breakdown`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get scoreboard for specific round
|
||||||
|
getRoundScoreboard: (
|
||||||
|
matchId: string,
|
||||||
|
roundNumber: number
|
||||||
|
): Promise<ScoreboardEntry[]> => {
|
||||||
|
return api.get<ScoreboardEntry[]>(
|
||||||
|
`/matches/${matchId}/rounds/${roundNumber}/scoreboard`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get frozen scoreboard (last public state before freeze)
|
||||||
|
getFrozenScoreboard: (matchId: string): Promise<ScoreboardData | null> => {
|
||||||
|
return api.get<ScoreboardData | null>(`/matches/${matchId}/scoreboard/frozen`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get final scoreboard (unfrozen, after match end)
|
||||||
|
getFinalScoreboard: (matchId: string): Promise<ScoreboardData> => {
|
||||||
|
return api.get<ScoreboardData>(`/matches/${matchId}/scoreboard/final`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get live scoreboard updates (polling fallback)
|
||||||
|
pollScoreboard: (
|
||||||
|
matchId: string,
|
||||||
|
lastUpdate?: string
|
||||||
|
): Promise<{
|
||||||
|
hasChanges: boolean;
|
||||||
|
scoreboard: ScoreboardData | null;
|
||||||
|
timestamp: string;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/scoreboard/poll`, {
|
||||||
|
params: { lastUpdate },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get position changes
|
||||||
|
getPositionChanges: (matchId: string): Promise<Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
changes: Array<{
|
||||||
|
round: number;
|
||||||
|
fromPosition: number;
|
||||||
|
toPosition: number;
|
||||||
|
}>;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/scoreboard/position-changes`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get score comparison between teams
|
||||||
|
getTeamsComparison: (
|
||||||
|
matchId: string,
|
||||||
|
teamIds: string[]
|
||||||
|
): Promise<Array<{
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
scores: Array<{
|
||||||
|
round: number;
|
||||||
|
score: number;
|
||||||
|
position: number;
|
||||||
|
}>;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/matches/${matchId}/scoreboard/compare`, {
|
||||||
|
params: { teamIds: teamIds.join(',') },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Global leaderboard
|
||||||
|
getGlobalLeaderboard: (params?: {
|
||||||
|
period?: 'week' | 'month' | 'season' | 'all_time';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<PaginatedResponse<LeaderboardEntry>> => {
|
||||||
|
return api.get<PaginatedResponse<LeaderboardEntry>>('/leaderboard', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season leaderboard
|
||||||
|
getSeasonLeaderboard: (seasonId: string): Promise<SeasonLeaderboard> => {
|
||||||
|
return api.get<SeasonLeaderboard>(`/seasons/${seasonId}/leaderboard`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get top teams
|
||||||
|
getTopTeams: (limit?: number): Promise<LeaderboardEntry[]> => {
|
||||||
|
return api.get<LeaderboardEntry[]>('/leaderboard/top', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team ranking
|
||||||
|
getTeamRanking: (teamId: string): Promise<{
|
||||||
|
globalRank: number;
|
||||||
|
seasonRank: number | null;
|
||||||
|
rating: number;
|
||||||
|
percentile: number;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/teams/${teamId}/ranking`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export scoreboard
|
||||||
|
exportScoreboard: (
|
||||||
|
matchId: string,
|
||||||
|
format: 'json' | 'csv' | 'pdf'
|
||||||
|
): Promise<Blob> => {
|
||||||
|
return api.get(`/matches/${matchId}/scoreboard/export`, {
|
||||||
|
params: { format },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service status matrix
|
||||||
|
getServiceStatusMatrix: (matchId: string): Promise<{
|
||||||
|
services: Array<{ id: string; name: string }>;
|
||||||
|
teams: Array<{ id: string; name: string }>;
|
||||||
|
matrix: Record<string, Record<string, {
|
||||||
|
status: string;
|
||||||
|
sla: number;
|
||||||
|
lastCheck: string;
|
||||||
|
}>>;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/matches/${matchId}/scoreboard/service-matrix`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default scoreboardApi;
|
||||||
159
src/api/endpoints/seasons.api.ts
Normal file
159
src/api/endpoints/seasons.api.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Season,
|
||||||
|
League,
|
||||||
|
SeasonSchedule,
|
||||||
|
SeasonStanding,
|
||||||
|
SeasonRegistration,
|
||||||
|
RegisterForSeasonRequest,
|
||||||
|
SeasonListParams,
|
||||||
|
LeagueListParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const seasonsApi = {
|
||||||
|
// Leagues
|
||||||
|
getLeagues: (params?: LeagueListParams): Promise<League[]> => {
|
||||||
|
return api.get<League[]>('/leagues', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getLeague: (leagueId: string): Promise<League> => {
|
||||||
|
return api.get<League>(`/leagues/${leagueId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getLeagueBySlug: (slug: string): Promise<League> => {
|
||||||
|
return api.get<League>(`/leagues/slug/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Seasons
|
||||||
|
getSeasons: (
|
||||||
|
params?: SeasonListParams & PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Season>> => {
|
||||||
|
return api.get<PaginatedResponse<Season>>('/seasons', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
getSeason: (seasonId: string): Promise<Season> => {
|
||||||
|
return api.get<Season>(`/seasons/${seasonId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSeasonBySlug: (slug: string): Promise<Season> => {
|
||||||
|
return api.get<Season>(`/seasons/slug/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCurrentSeason: (leagueId?: string): Promise<Season | null> => {
|
||||||
|
return api.get<Season | null>('/seasons/current', {
|
||||||
|
params: { leagueId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season schedule
|
||||||
|
getSeasonSchedule: (seasonId: string): Promise<SeasonSchedule> => {
|
||||||
|
return api.get<SeasonSchedule>(`/seasons/${seasonId}/schedule`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Standings
|
||||||
|
getSeasonStandings: (seasonId: string): Promise<SeasonStanding[]> => {
|
||||||
|
return api.get<SeasonStanding[]>(`/seasons/${seasonId}/standings`);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTeamStanding: (seasonId: string, teamId: string): Promise<SeasonStanding> => {
|
||||||
|
return api.get<SeasonStanding>(`/seasons/${seasonId}/standings/${teamId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Registration
|
||||||
|
registerForSeason: (data: RegisterForSeasonRequest): Promise<SeasonRegistration> => {
|
||||||
|
return api.post<SeasonRegistration>('/seasons/register', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
withdrawFromSeason: (seasonId: string, teamId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/seasons/${seasonId}/withdraw`, { teamId });
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyRegistrations: (): Promise<SeasonRegistration[]> => {
|
||||||
|
return api.get<SeasonRegistration[]>('/seasons/my-registrations');
|
||||||
|
},
|
||||||
|
|
||||||
|
getSeasonRegistrations: (seasonId: string): Promise<SeasonRegistration[]> => {
|
||||||
|
return api.get<SeasonRegistration[]>(`/seasons/${seasonId}/registrations`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season matches
|
||||||
|
getSeasonMatches: (
|
||||||
|
seasonId: string,
|
||||||
|
params?: { week?: number; teamId?: string }
|
||||||
|
): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
week: number;
|
||||||
|
round: string;
|
||||||
|
homeTeam: { id: string; name: string; tag: string };
|
||||||
|
awayTeam: { id: string; name: string; tag: string };
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
|
result: { homeScore: number; awayScore: number } | null;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/seasons/${seasonId}/matches`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season stats
|
||||||
|
getSeasonStats: (seasonId: string): Promise<{
|
||||||
|
teamsCount: number;
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesRemaining: number;
|
||||||
|
totalFlags: number;
|
||||||
|
avgScore: number;
|
||||||
|
topScorer: { teamId: string; teamName: string; score: number };
|
||||||
|
bestDefense: { teamId: string; teamName: string; sla: number };
|
||||||
|
}> => {
|
||||||
|
return api.get(`/seasons/${seasonId}/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season history for team
|
||||||
|
getTeamSeasonHistory: (
|
||||||
|
teamId: string
|
||||||
|
): Promise<Array<{
|
||||||
|
seasonId: string;
|
||||||
|
seasonName: string;
|
||||||
|
leagueName: string;
|
||||||
|
position: number;
|
||||||
|
totalTeams: number;
|
||||||
|
points: number;
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/teams/${teamId}/season-history`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upcoming season events
|
||||||
|
getUpcomingEvents: (
|
||||||
|
seasonId: string
|
||||||
|
): Promise<Array<{
|
||||||
|
type: 'match' | 'deadline' | 'announcement';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/seasons/${seasonId}/events`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season rules
|
||||||
|
getSeasonRules: (seasonId: string): Promise<string> => {
|
||||||
|
return api.get<string>(`/seasons/${seasonId}/rules`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Prizes
|
||||||
|
getSeasonPrizes: (seasonId: string): Promise<Array<{
|
||||||
|
position: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
value: number | null;
|
||||||
|
icon: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/seasons/${seasonId}/prizes`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default seasonsApi;
|
||||||
230
src/api/endpoints/services.api.ts
Normal file
230
src/api/endpoints/services.api.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Service,
|
||||||
|
ServiceReview,
|
||||||
|
ServiceVersion,
|
||||||
|
ServiceValidation,
|
||||||
|
ServiceSearchParams,
|
||||||
|
CreateReviewRequest,
|
||||||
|
UploadServiceRequest,
|
||||||
|
UpdateServiceRequest,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
ServiceCategory,
|
||||||
|
ServiceDifficulty,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const servicesApi = {
|
||||||
|
// List services
|
||||||
|
listServices: (
|
||||||
|
params?: ServiceSearchParams & PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Service>> => {
|
||||||
|
return api.get<PaginatedResponse<Service>>('/services', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service by ID
|
||||||
|
getService: (serviceId: string): Promise<Service> => {
|
||||||
|
return api.get<Service>(`/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service by slug
|
||||||
|
getServiceBySlug: (slug: string): Promise<Service> => {
|
||||||
|
return api.get<Service>(`/services/slug/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload new service
|
||||||
|
uploadService: (data: UploadServiceRequest): Promise<Service> => {
|
||||||
|
return api.post<Service>('/services', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update service
|
||||||
|
updateService: (serviceId: string, data: UpdateServiceRequest): Promise<Service> => {
|
||||||
|
return api.patch<Service>(`/services/${serviceId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete service
|
||||||
|
deleteService: (serviceId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/services/${serviceId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload service files (Docker images, checker, etc.)
|
||||||
|
uploadServiceFiles: async (
|
||||||
|
serviceId: string,
|
||||||
|
files: {
|
||||||
|
serviceImage?: File;
|
||||||
|
checkerImage?: File;
|
||||||
|
additionalFiles?: File[];
|
||||||
|
}
|
||||||
|
): Promise<{ status: string; urls: Record<string, string> }> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
if (files.serviceImage) {
|
||||||
|
formData.append('serviceImage', files.serviceImage);
|
||||||
|
}
|
||||||
|
if (files.checkerImage) {
|
||||||
|
formData.append('checkerImage', files.checkerImage);
|
||||||
|
}
|
||||||
|
if (files.additionalFiles) {
|
||||||
|
files.additionalFiles.forEach((file, index) => {
|
||||||
|
formData.append(`additionalFile_${index}`, file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.post(`/services/${serviceId}/files`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validate service
|
||||||
|
validateService: (serviceId: string): Promise<ServiceValidation> => {
|
||||||
|
return api.post<ServiceValidation>(`/services/${serviceId}/validate`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get validation status
|
||||||
|
getValidationStatus: (serviceId: string): Promise<ServiceValidation> => {
|
||||||
|
return api.get<ServiceValidation>(`/services/${serviceId}/validation`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get validation history
|
||||||
|
getValidationHistory: (serviceId: string): Promise<ServiceValidation[]> => {
|
||||||
|
return api.get<ServiceValidation[]>(`/services/${serviceId}/validation/history`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submit service for review
|
||||||
|
submitForReview: (serviceId: string): Promise<Service> => {
|
||||||
|
return api.post<Service>(`/services/${serviceId}/submit-review`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Publish service
|
||||||
|
publishService: (serviceId: string): Promise<Service> => {
|
||||||
|
return api.post<Service>(`/services/${serviceId}/publish`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deprecate service
|
||||||
|
deprecateService: (serviceId: string, reason: string): Promise<Service> => {
|
||||||
|
return api.post<Service>(`/services/${serviceId}/deprecate`, { reason });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service versions
|
||||||
|
getServiceVersions: (serviceId: string): Promise<ServiceVersion[]> => {
|
||||||
|
return api.get<ServiceVersion[]>(`/services/${serviceId}/versions`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create new version
|
||||||
|
createVersion: (
|
||||||
|
serviceId: string,
|
||||||
|
data: { version: string; changelog: string }
|
||||||
|
): Promise<ServiceVersion> => {
|
||||||
|
return api.post<ServiceVersion>(`/services/${serviceId}/versions`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service reviews
|
||||||
|
getServiceReviews: (
|
||||||
|
serviceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<ServiceReview>> => {
|
||||||
|
return api.get<PaginatedResponse<ServiceReview>>(
|
||||||
|
`/services/${serviceId}/reviews`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create review
|
||||||
|
createReview: (serviceId: string, data: CreateReviewRequest): Promise<ServiceReview> => {
|
||||||
|
return api.post<ServiceReview>(`/services/${serviceId}/reviews`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update review
|
||||||
|
updateReview: (
|
||||||
|
serviceId: string,
|
||||||
|
reviewId: string,
|
||||||
|
data: Partial<CreateReviewRequest>
|
||||||
|
): Promise<ServiceReview> => {
|
||||||
|
return api.patch<ServiceReview>(
|
||||||
|
`/services/${serviceId}/reviews/${reviewId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete review
|
||||||
|
deleteReview: (serviceId: string, reviewId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/services/${serviceId}/reviews/${reviewId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rate service (quick rating without full review)
|
||||||
|
rateService: (serviceId: string, rating: number): Promise<{ averageRating: number }> => {
|
||||||
|
return api.post<{ averageRating: number }>(`/services/${serviceId}/rate`, {
|
||||||
|
rating,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mark review as helpful
|
||||||
|
markReviewHelpful: (serviceId: string, reviewId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/services/${serviceId}/reviews/${reviewId}/helpful`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get featured services
|
||||||
|
getFeaturedServices: (): Promise<Service[]> => {
|
||||||
|
return api.get<Service[]>('/services/featured');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get popular services
|
||||||
|
getPopularServices: (limit?: number): Promise<Service[]> => {
|
||||||
|
return api.get<Service[]>('/services/popular', { params: { limit } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get services by category
|
||||||
|
getServicesByCategory: (
|
||||||
|
category: ServiceCategory,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Service>> => {
|
||||||
|
return api.get<PaginatedResponse<Service>>(`/services/category/${category}`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get services by difficulty
|
||||||
|
getServicesByDifficulty: (
|
||||||
|
difficulty: ServiceDifficulty,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Service>> => {
|
||||||
|
return api.get<PaginatedResponse<Service>>(`/services/difficulty/${difficulty}`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service categories with counts
|
||||||
|
getCategories: (): Promise<Array<{
|
||||||
|
category: ServiceCategory;
|
||||||
|
count: number;
|
||||||
|
}>> => {
|
||||||
|
return api.get('/services/categories');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service tags with counts
|
||||||
|
getTags: (): Promise<Array<{ tag: string; count: number }>> => {
|
||||||
|
return api.get('/services/tags');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get service tech stacks with counts
|
||||||
|
getStacks: (): Promise<Array<{ stack: string; count: number }>> => {
|
||||||
|
return api.get('/services/stacks');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Download service source (if allowed)
|
||||||
|
downloadSource: (serviceId: string, version?: string): Promise<Blob> => {
|
||||||
|
return api.get(`/services/${serviceId}/download`, {
|
||||||
|
params: { version },
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get my services (created by current user)
|
||||||
|
getMyServices: (
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Service>> => {
|
||||||
|
return api.get<PaginatedResponse<Service>>('/services/my', { params });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default servicesApi;
|
||||||
194
src/api/endpoints/teams.api.ts
Normal file
194
src/api/endpoints/teams.api.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Team,
|
||||||
|
TeamMember,
|
||||||
|
TeamInvite,
|
||||||
|
TeamJoinRequest,
|
||||||
|
TeamMatchHistory,
|
||||||
|
TeamStats,
|
||||||
|
CreateTeamRequest,
|
||||||
|
UpdateTeamRequest,
|
||||||
|
InviteToTeamRequest,
|
||||||
|
UpdateMemberRoleRequest,
|
||||||
|
TeamSearchParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const teamsApi = {
|
||||||
|
// Create a new team
|
||||||
|
createTeam: (data: CreateTeamRequest): Promise<Team> => {
|
||||||
|
return api.post<Team>('/teams', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team by ID
|
||||||
|
getTeam: (teamId: string): Promise<Team> => {
|
||||||
|
return api.get<Team>(`/teams/${teamId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team by tag
|
||||||
|
getTeamByTag: (tag: string): Promise<Team> => {
|
||||||
|
return api.get<Team>(`/teams/tag/${tag}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update team
|
||||||
|
updateTeam: (teamId: string, data: UpdateTeamRequest): Promise<Team> => {
|
||||||
|
return api.patch<Team>(`/teams/${teamId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Delete team
|
||||||
|
deleteTeam: (teamId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/teams/${teamId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team members
|
||||||
|
getTeamMembers: (teamId: string): Promise<TeamMember[]> => {
|
||||||
|
return api.get<TeamMember[]>(`/teams/${teamId}/members`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team stats
|
||||||
|
getTeamStats: (teamId: string): Promise<TeamStats> => {
|
||||||
|
return api.get<TeamStats>(`/teams/${teamId}/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team match history
|
||||||
|
getTeamMatchHistory: (
|
||||||
|
teamId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TeamMatchHistory>> => {
|
||||||
|
return api.get<PaginatedResponse<TeamMatchHistory>>(
|
||||||
|
`/teams/${teamId}/matches`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search teams
|
||||||
|
searchTeams: (
|
||||||
|
params: TeamSearchParams & PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Team>> => {
|
||||||
|
return api.get<PaginatedResponse<Team>>('/teams/search', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Join team (public team)
|
||||||
|
joinTeam: (teamId: string, message?: string): Promise<TeamJoinRequest> => {
|
||||||
|
return api.post<TeamJoinRequest>(`/teams/${teamId}/join`, { message });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Leave team
|
||||||
|
leaveTeam: (teamId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/${teamId}/leave`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Invite user to team
|
||||||
|
inviteToTeam: (teamId: string, data: InviteToTeamRequest): Promise<TeamInvite> => {
|
||||||
|
return api.post<TeamInvite>(`/teams/${teamId}/invites`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get team invites (sent by team)
|
||||||
|
getTeamInvites: (teamId: string): Promise<TeamInvite[]> => {
|
||||||
|
return api.get<TeamInvite[]>(`/teams/${teamId}/invites`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel team invite
|
||||||
|
cancelInvite: (teamId: string, inviteId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/teams/${teamId}/invites/${inviteId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get pending join requests (for team admins)
|
||||||
|
getJoinRequests: (teamId: string): Promise<TeamJoinRequest[]> => {
|
||||||
|
return api.get<TeamJoinRequest[]>(`/teams/${teamId}/join-requests`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Approve join request
|
||||||
|
approveJoinRequest: (teamId: string, requestId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/${teamId}/join-requests/${requestId}/approve`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reject join request
|
||||||
|
rejectJoinRequest: (teamId: string, requestId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/${teamId}/join-requests/${requestId}/reject`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get my invites (received by current user)
|
||||||
|
getMyInvites: (): Promise<TeamInvite[]> => {
|
||||||
|
return api.get<TeamInvite[]>('/teams/invites/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accept invite
|
||||||
|
acceptInvite: (inviteId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/invites/${inviteId}/accept`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Decline invite
|
||||||
|
declineInvite: (inviteId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/invites/${inviteId}/decline`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get my join requests (sent by current user)
|
||||||
|
getMyJoinRequests: (): Promise<TeamJoinRequest[]> => {
|
||||||
|
return api.get<TeamJoinRequest[]>('/teams/join-requests/me');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cancel my join request
|
||||||
|
cancelJoinRequest: (requestId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/teams/join-requests/${requestId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update member role
|
||||||
|
updateMemberRole: (
|
||||||
|
teamId: string,
|
||||||
|
memberId: string,
|
||||||
|
data: UpdateMemberRoleRequest
|
||||||
|
): Promise<TeamMember> => {
|
||||||
|
return api.patch<TeamMember>(`/teams/${teamId}/members/${memberId}`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove member from team
|
||||||
|
removeMember: (teamId: string, memberId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/teams/${teamId}/members/${memberId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transfer captaincy
|
||||||
|
transferCaptain: (teamId: string, newCaptainId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/teams/${teamId}/transfer-captain`, {
|
||||||
|
newCaptainId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload team avatar
|
||||||
|
uploadAvatar: async (teamId: string, file: File): Promise<{ avatarUrl: string }> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('avatar', file);
|
||||||
|
return api.post<{ avatarUrl: string }>(`/teams/${teamId}/avatar`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Upload team banner
|
||||||
|
uploadBanner: async (teamId: string, file: File): Promise<{ bannerUrl: string }> => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('banner', file);
|
||||||
|
return api.post<{ bannerUrl: string }>(`/teams/${teamId}/banner`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get featured teams
|
||||||
|
getFeaturedTeams: (): Promise<Team[]> => {
|
||||||
|
return api.get<Team[]>('/teams/featured');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recruiting teams
|
||||||
|
getRecruitingTeams: (
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Team>> => {
|
||||||
|
return api.get<PaginatedResponse<Team>>('/teams/recruiting', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check if team tag is available
|
||||||
|
checkTag: (tag: string): Promise<{ available: boolean }> => {
|
||||||
|
return api.get<{ available: boolean }>(`/teams/check-tag/${tag}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default teamsApi;
|
||||||
244
src/api/endpoints/training.api.ts
Normal file
244
src/api/endpoints/training.api.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
Track,
|
||||||
|
Exercise,
|
||||||
|
ExerciseResult,
|
||||||
|
Progress,
|
||||||
|
SkillMapData,
|
||||||
|
TrackProgress,
|
||||||
|
StartExerciseRequest,
|
||||||
|
StartExerciseResponse,
|
||||||
|
SubmitExerciseRequest,
|
||||||
|
SubmitExerciseResponse,
|
||||||
|
UseHintRequest,
|
||||||
|
UseHintResponse,
|
||||||
|
TrackSearchParams,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const trainingApi = {
|
||||||
|
// Get all tracks
|
||||||
|
getTracks: (
|
||||||
|
params?: TrackSearchParams & PaginationParams
|
||||||
|
): Promise<PaginatedResponse<Track>> => {
|
||||||
|
return api.get<PaginatedResponse<Track>>('/training/tracks', { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get track by ID
|
||||||
|
getTrack: (trackId: string): Promise<Track> => {
|
||||||
|
return api.get<Track>(`/training/tracks/${trackId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get track by slug
|
||||||
|
getTrackBySlug: (slug: string): Promise<Track> => {
|
||||||
|
return api.get<Track>(`/training/tracks/slug/${slug}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enroll in track
|
||||||
|
enrollInTrack: (trackId: string): Promise<TrackProgress> => {
|
||||||
|
return api.post<TrackProgress>(`/training/tracks/${trackId}/enroll`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unenroll from track
|
||||||
|
unenrollFromTrack: (trackId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/training/tracks/${trackId}/enroll`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get track progress
|
||||||
|
getTrackProgress: (trackId: string): Promise<TrackProgress> => {
|
||||||
|
return api.get<TrackProgress>(`/training/tracks/${trackId}/progress`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get exercise by ID
|
||||||
|
getExercise: (exerciseId: string): Promise<Exercise> => {
|
||||||
|
return api.get<Exercise>(`/training/exercises/${exerciseId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get exercise by slug
|
||||||
|
getExerciseBySlug: (trackSlug: string, exerciseSlug: string): Promise<Exercise> => {
|
||||||
|
return api.get<Exercise>(
|
||||||
|
`/training/tracks/slug/${trackSlug}/exercises/slug/${exerciseSlug}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start exercise
|
||||||
|
startExercise: (data: StartExerciseRequest): Promise<StartExerciseResponse> => {
|
||||||
|
return api.post<StartExerciseResponse>('/training/exercises/start', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submit exercise
|
||||||
|
submitExercise: (data: SubmitExerciseRequest): Promise<SubmitExerciseResponse> => {
|
||||||
|
return api.post<SubmitExerciseResponse>('/training/exercises/submit', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get exercise result
|
||||||
|
getExerciseResult: (exerciseId: string): Promise<ExerciseResult | null> => {
|
||||||
|
return api.get<ExerciseResult | null>(
|
||||||
|
`/training/exercises/${exerciseId}/result`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get available hints
|
||||||
|
getAvailableHints: (exerciseId: string): Promise<{
|
||||||
|
hints: Array<{
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
costPercentage: number;
|
||||||
|
}>;
|
||||||
|
usedHints: number;
|
||||||
|
totalHints: number;
|
||||||
|
}> => {
|
||||||
|
return api.get(`/training/exercises/${exerciseId}/hints`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use hint
|
||||||
|
useHint: (data: UseHintRequest): Promise<UseHintResponse> => {
|
||||||
|
return api.post<UseHintResponse>('/training/exercises/use-hint', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get overall progress
|
||||||
|
getProgress: (): Promise<Progress> => {
|
||||||
|
return api.get<Progress>('/training/progress');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get skill map
|
||||||
|
getSkillMap: (): Promise<SkillMapData> => {
|
||||||
|
return api.get<SkillMapData>('/training/skill-map');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recommended tracks
|
||||||
|
getRecommendedTracks: (limit?: number): Promise<Track[]> => {
|
||||||
|
return api.get<Track[]>('/training/recommendations/tracks', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get recommended exercises
|
||||||
|
getRecommendedExercises: (limit?: number): Promise<Exercise[]> => {
|
||||||
|
return api.get<Exercise[]>('/training/recommendations/exercises', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get featured tracks
|
||||||
|
getFeaturedTracks: (): Promise<Track[]> => {
|
||||||
|
return api.get<Track[]>('/training/tracks/featured');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get popular tracks
|
||||||
|
getPopularTracks: (limit?: number): Promise<Track[]> => {
|
||||||
|
return api.get<Track[]>('/training/tracks/popular', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get enrolled tracks
|
||||||
|
getEnrolledTracks: (): Promise<TrackProgress[]> => {
|
||||||
|
return api.get<TrackProgress[]>('/training/my-tracks');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get completed tracks
|
||||||
|
getCompletedTracks: (): Promise<TrackProgress[]> => {
|
||||||
|
return api.get<TrackProgress[]>('/training/completed-tracks');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get exercise history (all attempts)
|
||||||
|
getExerciseHistory: (
|
||||||
|
exerciseId: string
|
||||||
|
): Promise<Array<{
|
||||||
|
attemptNumber: number;
|
||||||
|
submittedAt: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
score: number;
|
||||||
|
timeSpent: number;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/training/exercises/${exerciseId}/history`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset exercise progress
|
||||||
|
resetExerciseProgress: (exerciseId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/training/exercises/${exerciseId}/reset`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get daily challenge
|
||||||
|
getDailyChallenge: (): Promise<Exercise | null> => {
|
||||||
|
return api.get<Exercise | null>('/training/daily-challenge');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get streak info
|
||||||
|
getStreakInfo: (): Promise<{
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
lastActivityDate: string;
|
||||||
|
streakFreezeAvailable: boolean;
|
||||||
|
}> => {
|
||||||
|
return api.get('/training/streak');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Use streak freeze
|
||||||
|
useStreakFreeze: (): Promise<{ success: boolean; freezesRemaining: number }> => {
|
||||||
|
return api.post('/training/streak/freeze');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get certificates
|
||||||
|
getCertificates: (): Promise<Array<{
|
||||||
|
id: string;
|
||||||
|
trackId: string;
|
||||||
|
trackName: string;
|
||||||
|
issuedAt: string;
|
||||||
|
certificateUrl: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get('/training/certificates');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generate certificate for completed track
|
||||||
|
generateCertificate: (trackId: string): Promise<{
|
||||||
|
certificateUrl: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}> => {
|
||||||
|
return api.post(`/training/tracks/${trackId}/certificate`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get leaderboard for track
|
||||||
|
getTrackLeaderboard: (
|
||||||
|
trackId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<{
|
||||||
|
position: number;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
avatar: string | null;
|
||||||
|
score: number;
|
||||||
|
completionTime: number;
|
||||||
|
completedAt: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/training/tracks/${trackId}/leaderboard`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rate track
|
||||||
|
rateTrack: (
|
||||||
|
trackId: string,
|
||||||
|
rating: number,
|
||||||
|
feedback?: string
|
||||||
|
): Promise<{ averageRating: number }> => {
|
||||||
|
return api.post(`/training/tracks/${trackId}/rate`, { rating, feedback });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Report issue with exercise
|
||||||
|
reportExerciseIssue: (
|
||||||
|
exerciseId: string,
|
||||||
|
type: 'bug' | 'unclear' | 'incorrect' | 'other',
|
||||||
|
description: string
|
||||||
|
): Promise<void> => {
|
||||||
|
return api.post<void>(`/training/exercises/${exerciseId}/report`, {
|
||||||
|
type,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default trainingApi;
|
||||||
173
src/api/endpoints/users.api.ts
Normal file
173
src/api/endpoints/users.api.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { api } from '../axios';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
UserStats,
|
||||||
|
Achievement,
|
||||||
|
PaginatedResponse,
|
||||||
|
PaginationParams,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export interface UserProfile extends User {
|
||||||
|
bio: string;
|
||||||
|
socialLinks: {
|
||||||
|
github?: string;
|
||||||
|
twitter?: string;
|
||||||
|
linkedin?: string;
|
||||||
|
website?: string;
|
||||||
|
ctftime?: string;
|
||||||
|
};
|
||||||
|
isFollowing?: boolean;
|
||||||
|
followersCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActivity {
|
||||||
|
id: string;
|
||||||
|
type: 'match_played' | 'achievement_unlocked' | 'exercise_completed' | 'team_joined' | 'rank_change';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardUser {
|
||||||
|
position: number;
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
avatar: string | null;
|
||||||
|
rating: number;
|
||||||
|
ratingChange: number;
|
||||||
|
matchesPlayed: number;
|
||||||
|
winRate: number;
|
||||||
|
teamId: string | null;
|
||||||
|
teamName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersApi = {
|
||||||
|
// Get user by ID
|
||||||
|
getUser: (userId: string): Promise<UserProfile> => {
|
||||||
|
return api.get<UserProfile>(`/users/${userId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user by username
|
||||||
|
getUserByUsername: (username: string): Promise<UserProfile> => {
|
||||||
|
return api.get<UserProfile>(`/users/username/${username}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user stats
|
||||||
|
getUserStats: (userId: string): Promise<UserStats> => {
|
||||||
|
return api.get<UserStats>(`/users/${userId}/stats`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user achievements
|
||||||
|
getUserAchievements: (userId: string): Promise<Achievement[]> => {
|
||||||
|
return api.get<Achievement[]>(`/users/${userId}/achievements`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user activity feed
|
||||||
|
getUserActivity: (
|
||||||
|
userId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<UserActivity>> => {
|
||||||
|
return api.get<PaginatedResponse<UserActivity>>(`/users/${userId}/activity`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Search users
|
||||||
|
searchUsers: (
|
||||||
|
query: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<User>> => {
|
||||||
|
return api.get<PaginatedResponse<User>>('/users/search', {
|
||||||
|
params: { query, ...params },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get global leaderboard
|
||||||
|
getLeaderboard: (params?: {
|
||||||
|
period?: 'week' | 'month' | 'season' | 'all_time';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<PaginatedResponse<LeaderboardUser>> => {
|
||||||
|
return api.get<PaginatedResponse<LeaderboardUser>>('/users/leaderboard', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Follow user
|
||||||
|
followUser: (userId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/users/${userId}/follow`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unfollow user
|
||||||
|
unfollowUser: (userId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/users/${userId}/follow`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get followers
|
||||||
|
getFollowers: (
|
||||||
|
userId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<User>> => {
|
||||||
|
return api.get<PaginatedResponse<User>>(`/users/${userId}/followers`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get following
|
||||||
|
getFollowing: (
|
||||||
|
userId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<User>> => {
|
||||||
|
return api.get<PaginatedResponse<User>>(`/users/${userId}/following`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user match history
|
||||||
|
getUserMatches: (
|
||||||
|
userId: string,
|
||||||
|
params?: PaginationParams & { status?: string; mode?: string }
|
||||||
|
): Promise<PaginatedResponse<{
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
mode: string;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
position: number;
|
||||||
|
totalTeams: number;
|
||||||
|
score: number;
|
||||||
|
playedAt: string;
|
||||||
|
}>> => {
|
||||||
|
return api.get(`/users/${userId}/matches`, { params });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get online users count
|
||||||
|
getOnlineCount: (): Promise<{ count: number }> => {
|
||||||
|
return api.get<{ count: number }>('/users/online-count');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Report user
|
||||||
|
reportUser: (userId: string, reason: string, details?: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/users/${userId}/report`, { reason, details });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Block user
|
||||||
|
blockUser: (userId: string): Promise<void> => {
|
||||||
|
return api.post<void>(`/users/${userId}/block`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Unblock user
|
||||||
|
unblockUser: (userId: string): Promise<void> => {
|
||||||
|
return api.delete<void>(`/users/${userId}/block`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get blocked users
|
||||||
|
getBlockedUsers: (): Promise<User[]> => {
|
||||||
|
return api.get<User[]>('/users/blocked');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usersApi;
|
||||||
312
src/api/types/admin.types.ts
Normal file
312
src/api/types/admin.types.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// Admin types
|
||||||
|
|
||||||
|
import { UserRole, UserStatus } from './auth.types';
|
||||||
|
import { MatchStatus } from './match.types';
|
||||||
|
import { ServiceStatus } from './service.types';
|
||||||
|
|
||||||
|
export interface AdminDashboard {
|
||||||
|
systemStats: SystemStats;
|
||||||
|
activeMatches: ActiveMatchInfo[];
|
||||||
|
recentActivity: AdminActivityLog[];
|
||||||
|
alerts: SystemAlert[];
|
||||||
|
queueStatus: QueueStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStats {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
newUsersToday: number;
|
||||||
|
newUsersWeek: number;
|
||||||
|
totalTeams: number;
|
||||||
|
activeTeams: number;
|
||||||
|
totalMatches: number;
|
||||||
|
activeMatches: number;
|
||||||
|
matchesToday: number;
|
||||||
|
totalServices: number;
|
||||||
|
pendingServices: number;
|
||||||
|
totalExercises: number;
|
||||||
|
systemUptime: number;
|
||||||
|
serverLoad: ServerLoad;
|
||||||
|
storageUsage: StorageUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerLoad {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
disk: number;
|
||||||
|
network: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageUsage {
|
||||||
|
total: number;
|
||||||
|
used: number;
|
||||||
|
available: number;
|
||||||
|
breakdown: StorageBreakdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageBreakdown {
|
||||||
|
services: number;
|
||||||
|
replays: number;
|
||||||
|
logs: number;
|
||||||
|
backups: number;
|
||||||
|
other: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveMatchInfo {
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
mode: string;
|
||||||
|
status: MatchStatus;
|
||||||
|
teamsCount: number;
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
startedAt: string;
|
||||||
|
healthStatus: HealthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HealthStatus = 'healthy' | 'degraded' | 'critical' | 'unknown';
|
||||||
|
|
||||||
|
export interface AdminActivityLog {
|
||||||
|
id: string;
|
||||||
|
adminId: string;
|
||||||
|
adminName: string;
|
||||||
|
action: AdminAction;
|
||||||
|
targetType: AdminTargetType;
|
||||||
|
targetId: string;
|
||||||
|
targetName: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminAction =
|
||||||
|
| 'create'
|
||||||
|
| 'update'
|
||||||
|
| 'delete'
|
||||||
|
| 'ban'
|
||||||
|
| 'unban'
|
||||||
|
| 'approve'
|
||||||
|
| 'reject'
|
||||||
|
| 'start'
|
||||||
|
| 'stop'
|
||||||
|
| 'pause'
|
||||||
|
| 'resume'
|
||||||
|
| 'config_change'
|
||||||
|
| 'role_change'
|
||||||
|
| 'system_restart';
|
||||||
|
|
||||||
|
export type AdminTargetType =
|
||||||
|
| 'user'
|
||||||
|
| 'team'
|
||||||
|
| 'match'
|
||||||
|
| 'service'
|
||||||
|
| 'exercise'
|
||||||
|
| 'system'
|
||||||
|
| 'config';
|
||||||
|
|
||||||
|
export interface SystemAlert {
|
||||||
|
id: string;
|
||||||
|
severity: AlertSeverity;
|
||||||
|
type: AlertType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
source: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
resolvedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AlertSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
|
||||||
|
export type AlertType =
|
||||||
|
| 'high_load'
|
||||||
|
| 'service_down'
|
||||||
|
| 'security_threat'
|
||||||
|
| 'disk_space'
|
||||||
|
| 'memory_usage'
|
||||||
|
| 'match_error'
|
||||||
|
| 'checker_timeout'
|
||||||
|
| 'database_issue'
|
||||||
|
| 'network_issue';
|
||||||
|
|
||||||
|
export interface QueueStatus {
|
||||||
|
checkerQueue: QueueInfo;
|
||||||
|
scorerQueue: QueueInfo;
|
||||||
|
validationQueue: QueueInfo;
|
||||||
|
notificationQueue: QueueInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueInfo {
|
||||||
|
pending: number;
|
||||||
|
processing: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
avgProcessingTime: number;
|
||||||
|
isHealthy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserListParams {
|
||||||
|
search?: string;
|
||||||
|
role?: UserRole;
|
||||||
|
status?: UserStatus;
|
||||||
|
teamId?: string;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
|
lastLoginFrom?: string;
|
||||||
|
lastLoginTo?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUpdateUserRequest {
|
||||||
|
role?: UserRole;
|
||||||
|
status?: UserStatus;
|
||||||
|
displayName?: string;
|
||||||
|
email?: string;
|
||||||
|
emailVerified?: boolean;
|
||||||
|
banReason?: string;
|
||||||
|
banExpires?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCreateMatchRequest {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
mode: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
scheduledAt?: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
serviceIds: string[];
|
||||||
|
isRanked?: boolean;
|
||||||
|
seasonId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMatchControl {
|
||||||
|
matchId: string;
|
||||||
|
action: 'start' | 'stop' | 'pause' | 'resume' | 'cancel' | 'restart';
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminServiceListParams {
|
||||||
|
search?: string;
|
||||||
|
status?: ServiceStatus;
|
||||||
|
category?: string;
|
||||||
|
authorId?: string;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
createdFrom?: string;
|
||||||
|
createdTo?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUpdateServiceRequest {
|
||||||
|
status?: ServiceStatus;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
rejectionReason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminServiceReview {
|
||||||
|
serviceId: string;
|
||||||
|
decision: 'approve' | 'reject' | 'needs_revision';
|
||||||
|
feedback: string;
|
||||||
|
suggestedChanges?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemConfig {
|
||||||
|
general: GeneralConfig;
|
||||||
|
matches: MatchesConfig;
|
||||||
|
security: SecurityConfig;
|
||||||
|
email: EmailConfig;
|
||||||
|
storage: StorageConfig;
|
||||||
|
integrations: IntegrationsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneralConfig {
|
||||||
|
siteName: string;
|
||||||
|
siteUrl: string;
|
||||||
|
maintenanceMode: boolean;
|
||||||
|
maintenanceMessage: string;
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
inviteOnly: boolean;
|
||||||
|
defaultUserRole: UserRole;
|
||||||
|
maxTeamSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchesConfig {
|
||||||
|
maxConcurrentMatches: number;
|
||||||
|
defaultRoundDuration: number;
|
||||||
|
defaultTotalRounds: number;
|
||||||
|
checkerTimeout: number;
|
||||||
|
checkerInterval: number;
|
||||||
|
flagLifetime: number;
|
||||||
|
scoringPolicy: string;
|
||||||
|
replayRetention: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecurityConfig {
|
||||||
|
sessionTimeout: number;
|
||||||
|
maxLoginAttempts: number;
|
||||||
|
lockoutDuration: number;
|
||||||
|
passwordMinLength: number;
|
||||||
|
requireEmailVerification: boolean;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
apiRateLimit: number;
|
||||||
|
flagSubmitRateLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailConfig {
|
||||||
|
provider: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
smtpHost?: string;
|
||||||
|
smtpPort?: number;
|
||||||
|
smtpSecure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageConfig {
|
||||||
|
provider: string;
|
||||||
|
bucket: string;
|
||||||
|
region: string;
|
||||||
|
maxFileSize: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntegrationsConfig {
|
||||||
|
discordWebhook?: string;
|
||||||
|
slackWebhook?: string;
|
||||||
|
ctftimeTeamId?: string;
|
||||||
|
googleAnalyticsId?: string;
|
||||||
|
sentryDsn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSystemConfigRequest {
|
||||||
|
section: keyof SystemConfig;
|
||||||
|
config: Partial<SystemConfig[keyof SystemConfig]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemHealthCheck {
|
||||||
|
overall: HealthStatus;
|
||||||
|
services: ServiceHealthCheck[];
|
||||||
|
lastCheck: string;
|
||||||
|
nextCheck: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceHealthCheck {
|
||||||
|
name: string;
|
||||||
|
status: HealthStatus;
|
||||||
|
latency: number;
|
||||||
|
message: string | null;
|
||||||
|
lastCheck: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceRequest {
|
||||||
|
enabled: boolean;
|
||||||
|
message: string;
|
||||||
|
estimatedDuration?: number;
|
||||||
|
allowAdminAccess?: boolean;
|
||||||
|
}
|
||||||
483
src/api/types/analytics.types.ts
Normal file
483
src/api/types/analytics.types.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
// Analytics types
|
||||||
|
|
||||||
|
import { ServiceCategory, ServiceDifficulty } from './service.types';
|
||||||
|
import { TrackRole, SkillCategory } from './training.types';
|
||||||
|
|
||||||
|
export interface TeamAnalytics {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
period: AnalyticsPeriod;
|
||||||
|
overview: TeamOverview;
|
||||||
|
performance: TeamPerformance;
|
||||||
|
strengths: StrengthWeakness[];
|
||||||
|
weaknesses: StrengthWeakness[];
|
||||||
|
trends: TeamTrend[];
|
||||||
|
comparison: TeamComparison | null;
|
||||||
|
recommendations: AIRecommendation[];
|
||||||
|
memberAnalytics: MemberAnalytics[];
|
||||||
|
serviceAnalytics: TeamServiceAnalytics[];
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPeriod {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
type: 'week' | 'month' | 'season' | 'all_time' | 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamOverview {
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
winRate: number;
|
||||||
|
avgPosition: number;
|
||||||
|
bestPosition: number;
|
||||||
|
worstPosition: number;
|
||||||
|
totalScore: number;
|
||||||
|
avgScore: number;
|
||||||
|
totalFlagsCaptured: number;
|
||||||
|
totalFlagsLost: number;
|
||||||
|
avgSLA: number;
|
||||||
|
rating: number;
|
||||||
|
ratingChange: number;
|
||||||
|
rank: number;
|
||||||
|
rankChange: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamPerformance {
|
||||||
|
attackEfficiency: number;
|
||||||
|
defenseEfficiency: number;
|
||||||
|
slaConsistency: number;
|
||||||
|
responseTime: number;
|
||||||
|
exploitSpeed: number;
|
||||||
|
patchSpeed: number;
|
||||||
|
coordination: number;
|
||||||
|
adaptability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrengthWeakness {
|
||||||
|
type: 'attack' | 'defense' | 'sla' | 'service' | 'skill';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
score: number;
|
||||||
|
trend: 'improving' | 'stable' | 'declining';
|
||||||
|
relatedServices?: string[];
|
||||||
|
relatedSkills?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamTrend {
|
||||||
|
metric: string;
|
||||||
|
values: TrendPoint[];
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
changePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamComparison {
|
||||||
|
compareToTeamId: string;
|
||||||
|
compareToTeamName: string;
|
||||||
|
metrics: ComparisonMetric[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonMetric {
|
||||||
|
name: string;
|
||||||
|
ourValue: number;
|
||||||
|
theirValue: number;
|
||||||
|
difference: number;
|
||||||
|
differencePercent: number;
|
||||||
|
winner: 'us' | 'them' | 'tie';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberAnalytics {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
role: TrackRole;
|
||||||
|
contribution: number;
|
||||||
|
attackContribution: number;
|
||||||
|
defenseContribution: number;
|
||||||
|
activityLevel: number;
|
||||||
|
skillGrowth: number;
|
||||||
|
strengths: string[];
|
||||||
|
areasToImprove: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamServiceAnalytics {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
attackSuccess: number;
|
||||||
|
defenseSuccess: number;
|
||||||
|
avgSLA: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsLost: number;
|
||||||
|
exploitTime: number;
|
||||||
|
patchTime: number;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerAnalytics {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
period: AnalyticsPeriod;
|
||||||
|
overview: PlayerOverview;
|
||||||
|
skillAnalysis: SkillAnalysis;
|
||||||
|
roleAnalysis: RoleAnalysis;
|
||||||
|
learningPath: LearningPath;
|
||||||
|
achievements: AchievementAnalysis;
|
||||||
|
recommendations: AIRecommendation[];
|
||||||
|
activityHeatmap: ActivityHeatmap;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerOverview {
|
||||||
|
matchesPlayed: number;
|
||||||
|
teamsPlayed: number;
|
||||||
|
totalScore: number;
|
||||||
|
avgScore: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsDefended: number;
|
||||||
|
trainingProgress: number;
|
||||||
|
exercisesCompleted: number;
|
||||||
|
hoursSpent: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
rating: number;
|
||||||
|
percentile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillAnalysis {
|
||||||
|
overallLevel: number;
|
||||||
|
skillLevels: SkillLevelAnalysis[];
|
||||||
|
skillGrowth: SkillGrowthData[];
|
||||||
|
strongestSkills: string[];
|
||||||
|
weakestSkills: string[];
|
||||||
|
recentlyImproved: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillLevelAnalysis {
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
category: SkillCategory;
|
||||||
|
level: number;
|
||||||
|
maxLevel: number;
|
||||||
|
percentile: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillGrowthData {
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
history: TrendPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleAnalysis {
|
||||||
|
primaryRole: TrackRole;
|
||||||
|
secondaryRole: TrackRole | null;
|
||||||
|
roleScores: RoleScore[];
|
||||||
|
roleRecommendation: string;
|
||||||
|
roleComparison: RoleComparison[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleScore {
|
||||||
|
role: TrackRole;
|
||||||
|
score: number;
|
||||||
|
percentile: number;
|
||||||
|
matchingSkills: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleComparison {
|
||||||
|
role: TrackRole;
|
||||||
|
avgScore: number;
|
||||||
|
yourScore: number;
|
||||||
|
gap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearningPath {
|
||||||
|
currentTrack: string | null;
|
||||||
|
completedTracks: string[];
|
||||||
|
suggestedTracks: SuggestedTrack[];
|
||||||
|
nextMilestone: Milestone;
|
||||||
|
longTermGoals: Goal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuggestedTrack {
|
||||||
|
trackId: string;
|
||||||
|
trackName: string;
|
||||||
|
reason: string;
|
||||||
|
matchScore: number;
|
||||||
|
estimatedTime: number;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Milestone {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
progress: number;
|
||||||
|
target: number;
|
||||||
|
estimatedCompletion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Goal {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
progress: number;
|
||||||
|
target: number;
|
||||||
|
deadline: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AchievementAnalysis {
|
||||||
|
totalUnlocked: number;
|
||||||
|
totalAvailable: number;
|
||||||
|
recentUnlocks: string[];
|
||||||
|
nearUnlocks: NearAchievement[];
|
||||||
|
rareAchievements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearAchievement {
|
||||||
|
achievementId: string;
|
||||||
|
name: string;
|
||||||
|
progress: number;
|
||||||
|
target: number;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityHeatmap {
|
||||||
|
data: HeatmapCell[];
|
||||||
|
mostActiveDay: string;
|
||||||
|
mostActiveHour: number;
|
||||||
|
totalActiveDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapCell {
|
||||||
|
date: string;
|
||||||
|
hour: number;
|
||||||
|
value: number;
|
||||||
|
activities: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchAnalytics {
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
overview: MatchAnalyticsOverview;
|
||||||
|
timeline: MatchTimeline;
|
||||||
|
teamAnalytics: MatchTeamAnalytics[];
|
||||||
|
serviceAnalytics: MatchServiceAnalytics[];
|
||||||
|
attackPatterns: AttackPattern[];
|
||||||
|
keyMoments: KeyMomentAnalysis[];
|
||||||
|
aiInsights: AIRecommendation[];
|
||||||
|
heatmaps: MatchHeatmaps;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchAnalyticsOverview {
|
||||||
|
duration: number;
|
||||||
|
totalRounds: number;
|
||||||
|
totalFlags: number;
|
||||||
|
totalAttacks: number;
|
||||||
|
successfulAttacks: number;
|
||||||
|
avgSLA: number;
|
||||||
|
competitiveness: number;
|
||||||
|
volatility: number;
|
||||||
|
dominantTeam: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchTimeline {
|
||||||
|
events: TimelineEventAnalysis[];
|
||||||
|
phases: MatchPhase[];
|
||||||
|
turningPoints: TurningPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEventAnalysis {
|
||||||
|
timestamp: string;
|
||||||
|
round: number;
|
||||||
|
type: string;
|
||||||
|
significance: number;
|
||||||
|
teams: string[];
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchPhase {
|
||||||
|
name: string;
|
||||||
|
fromRound: number;
|
||||||
|
toRound: number;
|
||||||
|
characteristics: string[];
|
||||||
|
dominantTeam: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TurningPoint {
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
description: string;
|
||||||
|
impact: number;
|
||||||
|
beforeLeader: string;
|
||||||
|
afterLeader: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchTeamAnalytics {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
finalPosition: number;
|
||||||
|
scoreBreakdown: ScoreBreakdownAnalysis;
|
||||||
|
performance: PerformanceMetrics;
|
||||||
|
strategy: StrategyAnalysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreBreakdownAnalysis {
|
||||||
|
attack: number;
|
||||||
|
defense: number;
|
||||||
|
sla: number;
|
||||||
|
penalties: number;
|
||||||
|
bonuses: number;
|
||||||
|
byRound: RoundScore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundScore {
|
||||||
|
round: number;
|
||||||
|
total: number;
|
||||||
|
attack: number;
|
||||||
|
defense: number;
|
||||||
|
sla: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceMetrics {
|
||||||
|
attackEfficiency: number;
|
||||||
|
defenseEfficiency: number;
|
||||||
|
exploitSpeed: number;
|
||||||
|
patchSpeed: number;
|
||||||
|
slaUptime: number;
|
||||||
|
consistency: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyAnalysis {
|
||||||
|
type: 'aggressive' | 'defensive' | 'balanced' | 'adaptive';
|
||||||
|
focusedServices: string[];
|
||||||
|
attackTargets: string[];
|
||||||
|
timeline: StrategyPhase[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrategyPhase {
|
||||||
|
fromRound: number;
|
||||||
|
toRound: number;
|
||||||
|
strategy: string;
|
||||||
|
effectiveness: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchServiceAnalytics {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
totalExploits: number;
|
||||||
|
uniqueExploiters: number;
|
||||||
|
firstBlood: FirstBloodAnalysis | null;
|
||||||
|
avgExploitTime: number;
|
||||||
|
patchRate: number;
|
||||||
|
avgSLA: number;
|
||||||
|
vulnerabilitiesExploited: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstBloodAnalysis {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
timeFromStart: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttackPattern {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
frequency: number;
|
||||||
|
successRate: number;
|
||||||
|
targetServices: string[];
|
||||||
|
attackerTeams: string[];
|
||||||
|
peakRounds: number[];
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyMomentAnalysis {
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
impact: number;
|
||||||
|
involvedTeams: string[];
|
||||||
|
scoreChanges: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchHeatmaps {
|
||||||
|
attackHeatmap: HeatmapData;
|
||||||
|
scoreHeatmap: HeatmapData;
|
||||||
|
slaHeatmap: HeatmapData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeatmapData {
|
||||||
|
xLabels: string[];
|
||||||
|
yLabels: string[];
|
||||||
|
values: number[][];
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIRecommendation {
|
||||||
|
id: string;
|
||||||
|
type: RecommendationType;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rationale: string;
|
||||||
|
actionItems: ActionItem[];
|
||||||
|
relatedSkills: string[];
|
||||||
|
relatedServices: string[];
|
||||||
|
estimatedImpact: number;
|
||||||
|
estimatedEffort: number;
|
||||||
|
confidence: number;
|
||||||
|
generatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecommendationType =
|
||||||
|
| 'skill_improvement'
|
||||||
|
| 'strategy_change'
|
||||||
|
| 'service_focus'
|
||||||
|
| 'team_coordination'
|
||||||
|
| 'training_suggestion'
|
||||||
|
| 'role_adjustment'
|
||||||
|
| 'tool_recommendation'
|
||||||
|
| 'resource_allocation';
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
priority: number;
|
||||||
|
estimatedTime: number;
|
||||||
|
resources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamAnalyticsParams {
|
||||||
|
teamId: string;
|
||||||
|
period?: AnalyticsPeriod;
|
||||||
|
compareToTeamId?: string;
|
||||||
|
includeMembers?: boolean;
|
||||||
|
includeRecommendations?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerAnalyticsParams {
|
||||||
|
userId: string;
|
||||||
|
period?: AnalyticsPeriod;
|
||||||
|
includeSkillAnalysis?: boolean;
|
||||||
|
includeRecommendations?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchAnalyticsParams {
|
||||||
|
matchId: string;
|
||||||
|
includeHeatmaps?: boolean;
|
||||||
|
includeAIInsights?: boolean;
|
||||||
|
teamId?: string;
|
||||||
|
}
|
||||||
135
src/api/types/auth.types.ts
Normal file
135
src/api/types/auth.types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Authentication types
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
avatar: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
teamId: string | null;
|
||||||
|
status: UserStatus;
|
||||||
|
stats: UserStats;
|
||||||
|
achievements: Achievement[];
|
||||||
|
createdAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
emailVerified: boolean;
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = 'user' | 'organizer' | 'admin' | 'superadmin';
|
||||||
|
|
||||||
|
export type UserStatus = 'active' | 'inactive' | 'banned' | 'pending';
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsDefended: number;
|
||||||
|
totalScore: number;
|
||||||
|
rank: number;
|
||||||
|
rating: number;
|
||||||
|
trainingProgress: number;
|
||||||
|
hoursPlayed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Achievement {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||||
|
unlockedAt: string;
|
||||||
|
progress?: number;
|
||||||
|
maxProgress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
|
twoFactorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
displayName?: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
user: User;
|
||||||
|
tokens: TokenPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenPair {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
refreshExpiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForgotPasswordRequest {
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
token: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordRequest {
|
||||||
|
currentPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
socialLinks?: SocialLinks;
|
||||||
|
notificationSettings?: NotificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocialLinks {
|
||||||
|
github?: string;
|
||||||
|
twitter?: string;
|
||||||
|
linkedin?: string;
|
||||||
|
website?: string;
|
||||||
|
ctftime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
matchReminders: boolean;
|
||||||
|
teamInvites: boolean;
|
||||||
|
achievementUnlocks: boolean;
|
||||||
|
systemAnnouncements: boolean;
|
||||||
|
weeklyDigest: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyRequest {
|
||||||
|
name: string;
|
||||||
|
permissions: string[];
|
||||||
|
expiresIn?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyResponse {
|
||||||
|
apiKey: ApiKey;
|
||||||
|
secret: string; // Only shown once
|
||||||
|
}
|
||||||
71
src/api/types/common.types.ts
Normal file
71
src/api/types/common.types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Common types used across the application
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
timestamp: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortOrder = 'asc' | 'desc';
|
||||||
|
|
||||||
|
export interface SortParams {
|
||||||
|
field: string;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterParams {
|
||||||
|
[key: string]: string | number | boolean | string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: SortParams;
|
||||||
|
filters?: FilterParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DateRange {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coordinates {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export interface AsyncState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
127
src/api/types/flag.types.ts
Normal file
127
src/api/types/flag.types.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Flag types
|
||||||
|
|
||||||
|
export interface Flag {
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
roundNumber: number;
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
value: string;
|
||||||
|
status: FlagStatus;
|
||||||
|
generatedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
capturedAt: string | null;
|
||||||
|
capturedBy: string | null;
|
||||||
|
capturedByTeam: string | null;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlagStatus =
|
||||||
|
| 'active'
|
||||||
|
| 'captured'
|
||||||
|
| 'expired'
|
||||||
|
| 'stolen'
|
||||||
|
| 'invalid';
|
||||||
|
|
||||||
|
export interface FlagSubmission {
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
flagValue: string;
|
||||||
|
result: FlagSubmissionResult;
|
||||||
|
message: string;
|
||||||
|
points: number;
|
||||||
|
targetTeamId: string | null;
|
||||||
|
targetTeamName: string | null;
|
||||||
|
serviceId: string | null;
|
||||||
|
serviceName: string | null;
|
||||||
|
roundNumber: number;
|
||||||
|
submittedAt: string;
|
||||||
|
latency: number; // ms
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlagSubmissionResult =
|
||||||
|
| 'accepted'
|
||||||
|
| 'duplicate'
|
||||||
|
| 'expired'
|
||||||
|
| 'own_flag'
|
||||||
|
| 'invalid'
|
||||||
|
| 'not_found'
|
||||||
|
| 'rate_limited'
|
||||||
|
| 'match_not_active';
|
||||||
|
|
||||||
|
export interface SubmitFlagRequest {
|
||||||
|
matchId: string;
|
||||||
|
flag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitFlagResponse {
|
||||||
|
success: boolean;
|
||||||
|
result: FlagSubmissionResult;
|
||||||
|
message: string;
|
||||||
|
points: number;
|
||||||
|
targetTeam: string | null;
|
||||||
|
service: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagHistoryParams {
|
||||||
|
matchId: string;
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
status?: FlagStatus;
|
||||||
|
round?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagStats {
|
||||||
|
matchId: string;
|
||||||
|
teamId: string;
|
||||||
|
totalCaptured: number;
|
||||||
|
totalLost: number;
|
||||||
|
totalPoints: number;
|
||||||
|
byService: FlagStatsByService[];
|
||||||
|
byRound: FlagStatsByRound[];
|
||||||
|
captureRate: number;
|
||||||
|
avgCaptureTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagStatsByService {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
captured: number;
|
||||||
|
lost: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagStatsByRound {
|
||||||
|
round: number;
|
||||||
|
captured: number;
|
||||||
|
lost: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlagSubmissionStats {
|
||||||
|
total: number;
|
||||||
|
accepted: number;
|
||||||
|
duplicate: number;
|
||||||
|
expired: number;
|
||||||
|
ownFlag: number;
|
||||||
|
invalid: number;
|
||||||
|
successRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkSubmitFlagsRequest {
|
||||||
|
matchId: string;
|
||||||
|
flags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BulkSubmitFlagsResponse {
|
||||||
|
results: SubmitFlagResponse[];
|
||||||
|
accepted: number;
|
||||||
|
rejected: number;
|
||||||
|
totalPoints: number;
|
||||||
|
}
|
||||||
38
src/api/types/index.ts
Normal file
38
src/api/types/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Export all types from a single entry point
|
||||||
|
|
||||||
|
export * from './common.types';
|
||||||
|
export * from './auth.types';
|
||||||
|
export * from './team.types';
|
||||||
|
export * from './match.types';
|
||||||
|
export * from './round.types';
|
||||||
|
export * from './service.types';
|
||||||
|
export * from './scoreboard.types';
|
||||||
|
export * from './flag.types';
|
||||||
|
export {
|
||||||
|
type LogEntry,
|
||||||
|
type LogLevel,
|
||||||
|
type EventType,
|
||||||
|
type LogSource,
|
||||||
|
type LogMetadata,
|
||||||
|
type TimelineEvent as LogTimelineEvent,
|
||||||
|
type TimelineTeamInfo,
|
||||||
|
type TimelineServiceInfo,
|
||||||
|
type EventImportance,
|
||||||
|
type ReplayFrame,
|
||||||
|
type ReplayScoreboardState,
|
||||||
|
type ReplayTeamScore,
|
||||||
|
type ReplayServiceStatus,
|
||||||
|
type ReplayData,
|
||||||
|
type ReplayTeamInfo,
|
||||||
|
type ReplayServiceInfo,
|
||||||
|
type KeyMoment,
|
||||||
|
type LogFilterParams,
|
||||||
|
type LogStreamConfig,
|
||||||
|
type EventTimelineParams,
|
||||||
|
type ReplayParams
|
||||||
|
} from './log.types';
|
||||||
|
export * from './training.types';
|
||||||
|
export * from './analytics.types';
|
||||||
|
export * from './notification.types';
|
||||||
|
export * from './admin.types';
|
||||||
|
export * from './season.types';
|
||||||
208
src/api/types/log.types.ts
Normal file
208
src/api/types/log.types.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
// Log types
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
roundNumber: number;
|
||||||
|
timestamp: string;
|
||||||
|
level: LogLevel;
|
||||||
|
type: EventType;
|
||||||
|
source: LogSource;
|
||||||
|
teamId: string | null;
|
||||||
|
teamName: string | null;
|
||||||
|
targetTeamId: string | null;
|
||||||
|
targetTeamName: string | null;
|
||||||
|
serviceId: string | null;
|
||||||
|
serviceName: string | null;
|
||||||
|
message: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
metadata: LogMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warning' | 'error' | 'critical';
|
||||||
|
|
||||||
|
export type EventType =
|
||||||
|
| 'match_start'
|
||||||
|
| 'match_end'
|
||||||
|
| 'match_pause'
|
||||||
|
| 'match_resume'
|
||||||
|
| 'round_start'
|
||||||
|
| 'round_end'
|
||||||
|
| 'flag_generated'
|
||||||
|
| 'flag_captured'
|
||||||
|
| 'flag_expired'
|
||||||
|
| 'flag_submitted'
|
||||||
|
| 'service_check'
|
||||||
|
| 'service_up'
|
||||||
|
| 'service_down'
|
||||||
|
| 'service_corrupt'
|
||||||
|
| 'attack_detected'
|
||||||
|
| 'exploit_attempt'
|
||||||
|
| 'score_update'
|
||||||
|
| 'penalty'
|
||||||
|
| 'bonus'
|
||||||
|
| 'team_join'
|
||||||
|
| 'team_leave'
|
||||||
|
| 'system_info'
|
||||||
|
| 'system_error'
|
||||||
|
| 'admin_action'
|
||||||
|
| 'network_event';
|
||||||
|
|
||||||
|
export type LogSource =
|
||||||
|
| 'system'
|
||||||
|
| 'checker'
|
||||||
|
| 'scorer'
|
||||||
|
| 'network'
|
||||||
|
| 'team'
|
||||||
|
| 'admin'
|
||||||
|
| 'api';
|
||||||
|
|
||||||
|
export interface LogMetadata {
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
requestId?: string;
|
||||||
|
traceId?: string;
|
||||||
|
spanId?: string;
|
||||||
|
duration?: number;
|
||||||
|
statusCode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
id: string;
|
||||||
|
type: EventType;
|
||||||
|
timestamp: string;
|
||||||
|
relativeTime: number;
|
||||||
|
round: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
teams: TimelineTeamInfo[];
|
||||||
|
service: TimelineServiceInfo | null;
|
||||||
|
score: number | null;
|
||||||
|
importance: EventImportance;
|
||||||
|
linkedEvents: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineTeamInfo {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamTag: string;
|
||||||
|
role: 'attacker' | 'defender' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineServiceInfo {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventImportance = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export interface ReplayFrame {
|
||||||
|
timestamp: string;
|
||||||
|
relativeTime: number;
|
||||||
|
round: number;
|
||||||
|
scoreboard: ReplayScoreboardState;
|
||||||
|
events: TimelineEvent[];
|
||||||
|
serviceStatuses: ReplayServiceStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayScoreboardState {
|
||||||
|
entries: ReplayTeamScore[];
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayTeamScore {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
position: number;
|
||||||
|
score: number;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayServiceStatus {
|
||||||
|
teamId: string;
|
||||||
|
serviceId: string;
|
||||||
|
status: string;
|
||||||
|
lastCheck: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayData {
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
duration: number;
|
||||||
|
totalRounds: number;
|
||||||
|
teams: ReplayTeamInfo[];
|
||||||
|
services: ReplayServiceInfo[];
|
||||||
|
frames: ReplayFrame[];
|
||||||
|
keyMoments: KeyMoment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayTeamInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
avatar: string | null;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayServiceInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyMoment {
|
||||||
|
timestamp: string;
|
||||||
|
relativeTime: number;
|
||||||
|
round: number;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogFilterParams {
|
||||||
|
matchId: string;
|
||||||
|
levels?: LogLevel[];
|
||||||
|
types?: EventType[];
|
||||||
|
sources?: LogSource[];
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
fromTimestamp?: string;
|
||||||
|
toTimestamp?: string;
|
||||||
|
fromRound?: number;
|
||||||
|
toRound?: number;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStreamConfig {
|
||||||
|
matchId: string;
|
||||||
|
filters?: {
|
||||||
|
levels?: LogLevel[];
|
||||||
|
types?: EventType[];
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
};
|
||||||
|
bufferSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventTimelineParams {
|
||||||
|
matchId: string;
|
||||||
|
fromRound?: number;
|
||||||
|
toRound?: number;
|
||||||
|
teamIds?: string[];
|
||||||
|
serviceIds?: string[];
|
||||||
|
importance?: EventImportance;
|
||||||
|
types?: EventType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayParams {
|
||||||
|
matchId: string;
|
||||||
|
fromTime?: number;
|
||||||
|
toTime?: number;
|
||||||
|
resolution?: 'low' | 'medium' | 'high';
|
||||||
|
}
|
||||||
196
src/api/types/match.types.ts
Normal file
196
src/api/types/match.types.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
// Match types
|
||||||
|
|
||||||
|
import { Team } from './team.types';
|
||||||
|
import { Service } from './service.types';
|
||||||
|
import { Round } from './round.types';
|
||||||
|
|
||||||
|
export interface Match {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
mode: MatchMode;
|
||||||
|
status: MatchStatus;
|
||||||
|
config: MatchConfig;
|
||||||
|
teams: MatchTeam[];
|
||||||
|
services: Service[];
|
||||||
|
rounds: Round[];
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
roundDuration: number; // seconds
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
scheduledAt: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
seasonId: string | null;
|
||||||
|
isRanked: boolean;
|
||||||
|
visibility: MatchVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatchMode = 'training' | 'tournament' | 'scrim' | 'practice';
|
||||||
|
|
||||||
|
export type MatchStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'lobby'
|
||||||
|
| 'starting'
|
||||||
|
| 'running'
|
||||||
|
| 'paused'
|
||||||
|
| 'finished'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
|
export type MatchVisibility = 'public' | 'private' | 'unlisted';
|
||||||
|
|
||||||
|
export interface MatchConfig {
|
||||||
|
maxTeams: number;
|
||||||
|
minTeams: number;
|
||||||
|
scoringPolicy: ScoringPolicy;
|
||||||
|
flagLifetime: number; // seconds
|
||||||
|
checkerTimeout: number; // seconds
|
||||||
|
checkerInterval: number; // seconds
|
||||||
|
enableHints: boolean;
|
||||||
|
enableReplay: boolean;
|
||||||
|
freezeTime: number; // minutes before end to freeze scoreboard
|
||||||
|
allowedServiceIds: string[];
|
||||||
|
networkConfig: NetworkConfig;
|
||||||
|
penaltyConfig: PenaltyConfig;
|
||||||
|
flagFormat: string; // regex pattern
|
||||||
|
autoStart: boolean;
|
||||||
|
warmupDuration: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScoringPolicy = 'classic' | 'linear' | 'exponential' | 'dynamic';
|
||||||
|
|
||||||
|
export interface NetworkConfig {
|
||||||
|
teamSubnet: string;
|
||||||
|
servicePort: number;
|
||||||
|
vpnEnabled: boolean;
|
||||||
|
bandwidthLimit: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PenaltyConfig {
|
||||||
|
slaDownPenalty: number;
|
||||||
|
slaCorruptPenalty: number;
|
||||||
|
flagMissPenalty: number;
|
||||||
|
maxPenaltyPerRound: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchTeam {
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
team: Team;
|
||||||
|
slot: number;
|
||||||
|
status: MatchTeamStatus;
|
||||||
|
isReady: boolean;
|
||||||
|
joinedAt: string;
|
||||||
|
networkInfo: TeamNetworkInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MatchTeamStatus = 'registered' | 'ready' | 'playing' | 'finished' | 'disqualified';
|
||||||
|
|
||||||
|
export interface TeamNetworkInfo {
|
||||||
|
ip: string;
|
||||||
|
subnet: string;
|
||||||
|
vpnConfig: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchResult {
|
||||||
|
matchId: string;
|
||||||
|
match: Match;
|
||||||
|
rankings: MatchRanking[];
|
||||||
|
duration: number; // seconds
|
||||||
|
totalRounds: number;
|
||||||
|
totalFlags: number;
|
||||||
|
stats: MatchStats;
|
||||||
|
highlights: MatchHighlight[];
|
||||||
|
completedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchRanking {
|
||||||
|
position: number;
|
||||||
|
team: Team;
|
||||||
|
score: number;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsLost: number;
|
||||||
|
avgSLA: number;
|
||||||
|
servicesUp: number;
|
||||||
|
servicesTotal: number;
|
||||||
|
trend: number; // position change from previous
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchStats {
|
||||||
|
totalFlags: number;
|
||||||
|
totalAttacks: number;
|
||||||
|
successfulAttacks: number;
|
||||||
|
avgSLA: number;
|
||||||
|
mostActiveTeam: string;
|
||||||
|
mostAttackedService: string;
|
||||||
|
longestDowntime: number;
|
||||||
|
firstBlood: FirstBloodInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstBloodInfo {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
timestamp: string;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchHighlight {
|
||||||
|
id: string;
|
||||||
|
type: HighlightType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
teamId: string;
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HighlightType =
|
||||||
|
| 'first_blood'
|
||||||
|
| 'comeback'
|
||||||
|
| 'dominant_round'
|
||||||
|
| 'perfect_defense'
|
||||||
|
| 'massive_attack'
|
||||||
|
| 'sla_recovery';
|
||||||
|
|
||||||
|
export interface CreateMatchRequest {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
mode: MatchMode;
|
||||||
|
config: Partial<MatchConfig>;
|
||||||
|
scheduledAt?: string;
|
||||||
|
visibility?: MatchVisibility;
|
||||||
|
isRanked?: boolean;
|
||||||
|
seasonId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMatchRequest {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
config?: Partial<MatchConfig>;
|
||||||
|
scheduledAt?: string;
|
||||||
|
visibility?: MatchVisibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchListParams {
|
||||||
|
mode?: MatchMode;
|
||||||
|
status?: MatchStatus;
|
||||||
|
teamId?: string;
|
||||||
|
seasonId?: string;
|
||||||
|
isRanked?: boolean;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinMatchRequest {
|
||||||
|
teamId: string;
|
||||||
|
slot?: number;
|
||||||
|
}
|
||||||
133
src/api/types/notification.types.ts
Normal file
133
src/api/types/notification.types.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Notification types
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
category: NotificationCategory;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
data: NotificationData;
|
||||||
|
isRead: boolean;
|
||||||
|
isArchived: boolean;
|
||||||
|
priority: NotificationPriority;
|
||||||
|
actionUrl: string | null;
|
||||||
|
actionText: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
expiresAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| 'match_invite'
|
||||||
|
| 'match_starting'
|
||||||
|
| 'match_ended'
|
||||||
|
| 'match_result'
|
||||||
|
| 'team_invite'
|
||||||
|
| 'team_join_request'
|
||||||
|
| 'team_member_joined'
|
||||||
|
| 'team_member_left'
|
||||||
|
| 'team_role_changed'
|
||||||
|
| 'flag_captured'
|
||||||
|
| 'service_down'
|
||||||
|
| 'achievement_unlocked'
|
||||||
|
| 'training_completed'
|
||||||
|
| 'new_exercise'
|
||||||
|
| 'streak_reminder'
|
||||||
|
| 'weekly_digest'
|
||||||
|
| 'system_announcement'
|
||||||
|
| 'maintenance'
|
||||||
|
| 'security_alert'
|
||||||
|
| 'rating_change'
|
||||||
|
| 'comment_reply'
|
||||||
|
| 'mention';
|
||||||
|
|
||||||
|
export type NotificationCategory =
|
||||||
|
| 'match'
|
||||||
|
| 'team'
|
||||||
|
| 'training'
|
||||||
|
| 'achievement'
|
||||||
|
| 'social'
|
||||||
|
| 'system'
|
||||||
|
| 'security';
|
||||||
|
|
||||||
|
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
matchId?: string;
|
||||||
|
matchTitle?: string;
|
||||||
|
teamId?: string;
|
||||||
|
teamName?: string;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
achievementId?: string;
|
||||||
|
achievementName?: string;
|
||||||
|
trackId?: string;
|
||||||
|
trackName?: string;
|
||||||
|
exerciseId?: string;
|
||||||
|
exerciseName?: string;
|
||||||
|
score?: number;
|
||||||
|
position?: number;
|
||||||
|
rating?: number;
|
||||||
|
ratingChange?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
email: NotificationChannelPrefs;
|
||||||
|
push: NotificationChannelPrefs;
|
||||||
|
inApp: NotificationChannelPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationChannelPrefs {
|
||||||
|
enabled: boolean;
|
||||||
|
categories: {
|
||||||
|
[K in NotificationCategory]: boolean;
|
||||||
|
};
|
||||||
|
quietHours: QuietHours | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuietHours {
|
||||||
|
enabled: boolean;
|
||||||
|
from: string; // HH:mm
|
||||||
|
to: string; // HH:mm
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationGroup {
|
||||||
|
date: string;
|
||||||
|
notifications: Notification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationStats {
|
||||||
|
total: number;
|
||||||
|
unread: number;
|
||||||
|
byCategory: Record<NotificationCategory, number>;
|
||||||
|
byType: Record<NotificationType, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetNotificationsParams {
|
||||||
|
category?: NotificationCategory;
|
||||||
|
type?: NotificationType;
|
||||||
|
isRead?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkNotificationsRequest {
|
||||||
|
notificationIds: string[];
|
||||||
|
isRead?: boolean;
|
||||||
|
isArchived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationEvent {
|
||||||
|
type: 'new' | 'read' | 'deleted';
|
||||||
|
notification?: Notification;
|
||||||
|
notificationId?: string;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
136
src/api/types/round.types.ts
Normal file
136
src/api/types/round.types.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Round types
|
||||||
|
|
||||||
|
export interface Round {
|
||||||
|
id: string;
|
||||||
|
matchId: string;
|
||||||
|
number: number;
|
||||||
|
status: RoundStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
duration: number; // actual duration in seconds
|
||||||
|
events: RoundEvent[];
|
||||||
|
serviceChecks: ServiceCheck[];
|
||||||
|
flagsGenerated: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
avgSLA: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundStatus = 'pending' | 'active' | 'finished' | 'skipped';
|
||||||
|
|
||||||
|
export interface RoundEvent {
|
||||||
|
id: string;
|
||||||
|
roundId: string;
|
||||||
|
type: RoundEventType;
|
||||||
|
teamId: string | null;
|
||||||
|
targetTeamId: string | null;
|
||||||
|
serviceId: string | null;
|
||||||
|
data: RoundEventData;
|
||||||
|
timestamp: string;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundEventType =
|
||||||
|
| 'round_start'
|
||||||
|
| 'round_end'
|
||||||
|
| 'flag_generated'
|
||||||
|
| 'flag_captured'
|
||||||
|
| 'flag_expired'
|
||||||
|
| 'service_check'
|
||||||
|
| 'service_up'
|
||||||
|
| 'service_down'
|
||||||
|
| 'service_corrupt'
|
||||||
|
| 'attack_detected'
|
||||||
|
| 'exploit_blocked'
|
||||||
|
| 'score_update'
|
||||||
|
| 'penalty_applied'
|
||||||
|
| 'first_blood';
|
||||||
|
|
||||||
|
export interface RoundEventData {
|
||||||
|
flagId?: string;
|
||||||
|
flagValue?: string;
|
||||||
|
score?: number;
|
||||||
|
attackerTeamId?: string;
|
||||||
|
defenderTeamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
checkResult?: CheckerResult;
|
||||||
|
penaltyReason?: string;
|
||||||
|
penaltyAmount?: number;
|
||||||
|
message?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceCheck {
|
||||||
|
id: string;
|
||||||
|
roundId: string;
|
||||||
|
serviceId: string;
|
||||||
|
teamId: string;
|
||||||
|
result: CheckerResult;
|
||||||
|
message: string | null;
|
||||||
|
latency: number; // ms
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckerResult =
|
||||||
|
| 'ok'
|
||||||
|
| 'corrupt'
|
||||||
|
| 'mumble'
|
||||||
|
| 'down'
|
||||||
|
| 'error'
|
||||||
|
| 'timeout';
|
||||||
|
|
||||||
|
export interface RoundSummary {
|
||||||
|
roundNumber: number;
|
||||||
|
teamScores: TeamRoundScore[];
|
||||||
|
topAttacker: string | null;
|
||||||
|
topDefender: string | null;
|
||||||
|
flagsCaptured: number;
|
||||||
|
avgSLA: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamRoundScore {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
score: number;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsLost: number;
|
||||||
|
servicesUp: number;
|
||||||
|
totalServices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundTimeline {
|
||||||
|
roundNumber: number;
|
||||||
|
events: TimelineEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineEvent {
|
||||||
|
id: string;
|
||||||
|
type: RoundEventType;
|
||||||
|
timestamp: string;
|
||||||
|
relativeTime: number; // seconds from round start
|
||||||
|
teams: string[];
|
||||||
|
service: string | null;
|
||||||
|
score: number | null;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundParams {
|
||||||
|
matchId: string;
|
||||||
|
roundNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoundEventsParams {
|
||||||
|
roundId: string;
|
||||||
|
types?: RoundEventType[];
|
||||||
|
teamId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
138
src/api/types/scoreboard.types.ts
Normal file
138
src/api/types/scoreboard.types.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// Scoreboard types
|
||||||
|
|
||||||
|
import { ServiceStatusInfo } from './service.types';
|
||||||
|
|
||||||
|
export interface ScoreboardEntry {
|
||||||
|
position: number;
|
||||||
|
previousPosition: number;
|
||||||
|
positionChange: number;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamTag: string;
|
||||||
|
teamAvatar: string | null;
|
||||||
|
score: number;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
breakdown: ScoreBreakdown;
|
||||||
|
serviceStatuses: ServiceStatusInfo[];
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsLost: number;
|
||||||
|
avgSLA: number;
|
||||||
|
isCurrentTeam: boolean;
|
||||||
|
trend: ScoreTrend;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreBreakdown {
|
||||||
|
services: ServiceScore[];
|
||||||
|
totalAttack: number;
|
||||||
|
totalDefense: number;
|
||||||
|
totalSLA: number;
|
||||||
|
penalties: number;
|
||||||
|
bonuses: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceScore {
|
||||||
|
serviceId: string;
|
||||||
|
serviceName: string;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsLost: number;
|
||||||
|
slaPercentage: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScoreTrend = 'up' | 'down' | 'stable';
|
||||||
|
|
||||||
|
export interface ScoreHistory {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
history: ScorePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScorePoint {
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
score: number;
|
||||||
|
attackScore: number;
|
||||||
|
defenseScore: number;
|
||||||
|
slaScore: number;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamScore {
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
score: number;
|
||||||
|
position: number;
|
||||||
|
breakdown: ScoreBreakdown;
|
||||||
|
history: ScorePoint[];
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreboardData {
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
roundEndsAt: string | null;
|
||||||
|
isFrozen: boolean;
|
||||||
|
frozenAt: string | null;
|
||||||
|
entries: ScoreboardEntry[];
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreboardUpdate {
|
||||||
|
type: 'full' | 'partial';
|
||||||
|
matchId: string;
|
||||||
|
round: number;
|
||||||
|
timestamp: string;
|
||||||
|
entries?: ScoreboardEntry[];
|
||||||
|
updates?: PartialScoreUpdate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartialScoreUpdate {
|
||||||
|
teamId: string;
|
||||||
|
field: string;
|
||||||
|
value: number;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreboardParams {
|
||||||
|
matchId: string;
|
||||||
|
round?: number;
|
||||||
|
includeHistory?: boolean;
|
||||||
|
includeBreakdown?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreHistoryParams {
|
||||||
|
matchId: string;
|
||||||
|
teamIds?: string[];
|
||||||
|
fromRound?: number;
|
||||||
|
toRound?: number;
|
||||||
|
granularity?: 'round' | 'minute' | 'second';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaderboardEntry {
|
||||||
|
position: number;
|
||||||
|
teamId: string;
|
||||||
|
teamName: string;
|
||||||
|
teamTag: string;
|
||||||
|
teamAvatar: string | null;
|
||||||
|
rating: number;
|
||||||
|
ratingChange: number;
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
winRate: number;
|
||||||
|
avgScore: number;
|
||||||
|
country: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonLeaderboard {
|
||||||
|
seasonId: string;
|
||||||
|
seasonName: string;
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
186
src/api/types/season.types.ts
Normal file
186
src/api/types/season.types.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// Season and league types
|
||||||
|
|
||||||
|
import { Team } from './team.types';
|
||||||
|
|
||||||
|
export interface Season {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
league: League;
|
||||||
|
status: SeasonStatus;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
registrationStart: string;
|
||||||
|
registrationEnd: string;
|
||||||
|
config: SeasonConfig;
|
||||||
|
matches: SeasonMatch[];
|
||||||
|
standings: SeasonStanding[];
|
||||||
|
prizes: Prize[];
|
||||||
|
rules: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeasonStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'registration'
|
||||||
|
| 'active'
|
||||||
|
| 'playoffs'
|
||||||
|
| 'finished'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
|
export interface League {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
tier: LeagueTier;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
seasons: Season[];
|
||||||
|
currentSeason: Season | null;
|
||||||
|
requirements: LeagueRequirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeagueTier = 'open' | 'amateur' | 'semi_pro' | 'professional' | 'elite';
|
||||||
|
|
||||||
|
export interface LeagueRequirements {
|
||||||
|
minRating: number;
|
||||||
|
minMatches: number;
|
||||||
|
minTeamSize: number;
|
||||||
|
maxTeamSize: number;
|
||||||
|
minTrainingProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonConfig {
|
||||||
|
format: SeasonFormat;
|
||||||
|
matchesPerWeek: number;
|
||||||
|
pointsForWin: number;
|
||||||
|
pointsForDraw: number;
|
||||||
|
pointsForLoss: number;
|
||||||
|
tiebreakers: string[];
|
||||||
|
playoffTeams: number;
|
||||||
|
playoffFormat: PlayoffFormat;
|
||||||
|
rankingAlgorithm: RankingAlgorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SeasonFormat = 'round_robin' | 'swiss' | 'elimination' | 'double_elimination' | 'hybrid';
|
||||||
|
|
||||||
|
export type PlayoffFormat = 'single_elimination' | 'double_elimination' | 'best_of_3' | 'best_of_5';
|
||||||
|
|
||||||
|
export type RankingAlgorithm = 'elo' | 'glicko2' | 'trueskill' | 'points';
|
||||||
|
|
||||||
|
export interface SeasonMatch {
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
matchId: string;
|
||||||
|
week: number;
|
||||||
|
round: string;
|
||||||
|
homeTeamId: string;
|
||||||
|
homeTeam: Team;
|
||||||
|
awayTeamId: string;
|
||||||
|
awayTeam: Team;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
|
result: SeasonMatchResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonMatchResult {
|
||||||
|
homeScore: number;
|
||||||
|
awayScore: number;
|
||||||
|
homePoints: number;
|
||||||
|
awayPoints: number;
|
||||||
|
winnerId: string | null;
|
||||||
|
isDraw: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonStanding {
|
||||||
|
position: number;
|
||||||
|
team: Team;
|
||||||
|
played: number;
|
||||||
|
won: number;
|
||||||
|
drawn: number;
|
||||||
|
lost: number;
|
||||||
|
pointsFor: number;
|
||||||
|
pointsAgainst: number;
|
||||||
|
pointsDiff: number;
|
||||||
|
seasonPoints: number;
|
||||||
|
form: string[]; // Last 5 results: W, L, D
|
||||||
|
streak: number;
|
||||||
|
streakType: 'win' | 'loss' | 'draw' | null;
|
||||||
|
qualifiedForPlayoffs: boolean;
|
||||||
|
eliminated: boolean;
|
||||||
|
rating: number;
|
||||||
|
ratingChange: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Prize {
|
||||||
|
position: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
type: PrizeType;
|
||||||
|
value: number | null;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PrizeType = 'cash' | 'points' | 'badge' | 'qualification' | 'merchandise' | 'other';
|
||||||
|
|
||||||
|
export interface SeasonSchedule {
|
||||||
|
seasonId: string;
|
||||||
|
weeks: ScheduleWeek[];
|
||||||
|
playoffs: PlayoffBracket | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleWeek {
|
||||||
|
week: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
matches: SeasonMatch[];
|
||||||
|
isCurrentWeek: boolean;
|
||||||
|
isCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayoffBracket {
|
||||||
|
rounds: PlayoffRound[];
|
||||||
|
final: SeasonMatch | null;
|
||||||
|
thirdPlace: SeasonMatch | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayoffRound {
|
||||||
|
name: string;
|
||||||
|
matches: SeasonMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonRegistration {
|
||||||
|
id: string;
|
||||||
|
seasonId: string;
|
||||||
|
teamId: string;
|
||||||
|
team: Team;
|
||||||
|
status: RegistrationStatus;
|
||||||
|
registeredAt: string;
|
||||||
|
processedAt: string | null;
|
||||||
|
processedBy: string | null;
|
||||||
|
rejectionReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegistrationStatus = 'pending' | 'approved' | 'rejected' | 'waitlisted' | 'withdrawn';
|
||||||
|
|
||||||
|
export interface RegisterForSeasonRequest {
|
||||||
|
seasonId: string;
|
||||||
|
teamId: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonListParams {
|
||||||
|
leagueId?: string;
|
||||||
|
status?: SeasonStatus;
|
||||||
|
isActive?: boolean;
|
||||||
|
year?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeagueListParams {
|
||||||
|
tier?: LeagueTier;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
240
src/api/types/service.types.ts
Normal file
240
src/api/types/service.types.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
// Service types
|
||||||
|
|
||||||
|
import { User } from './auth.types';
|
||||||
|
import { CheckerResult } from './round.types';
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
fullDescription: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
stack: string[];
|
||||||
|
tags: string[];
|
||||||
|
version: string;
|
||||||
|
author: User;
|
||||||
|
maintainers: User[];
|
||||||
|
status: ServiceStatus;
|
||||||
|
rating: ServiceRating;
|
||||||
|
stats: ServiceStats;
|
||||||
|
config: ServiceConfig;
|
||||||
|
vulnerabilities: VulnerabilityInfo[];
|
||||||
|
screenshots: string[];
|
||||||
|
repository: string | null;
|
||||||
|
documentation: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
isPublic: boolean;
|
||||||
|
isFeatured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceCategory =
|
||||||
|
| 'web'
|
||||||
|
| 'crypto'
|
||||||
|
| 'pwn'
|
||||||
|
| 'reverse'
|
||||||
|
| 'forensics'
|
||||||
|
| 'network'
|
||||||
|
| 'misc'
|
||||||
|
| 'blockchain'
|
||||||
|
| 'hardware';
|
||||||
|
|
||||||
|
export type ServiceDifficulty = 'beginner' | 'easy' | 'medium' | 'hard' | 'expert' | 'insane';
|
||||||
|
|
||||||
|
export type ServiceStatus =
|
||||||
|
| 'draft'
|
||||||
|
| 'pending_review'
|
||||||
|
| 'under_review'
|
||||||
|
| 'needs_revision'
|
||||||
|
| 'approved'
|
||||||
|
| 'published'
|
||||||
|
| 'deprecated'
|
||||||
|
| 'archived';
|
||||||
|
|
||||||
|
export interface ServiceRating {
|
||||||
|
average: number;
|
||||||
|
count: number;
|
||||||
|
distribution: RatingDistribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingDistribution {
|
||||||
|
1: number;
|
||||||
|
2: number;
|
||||||
|
3: number;
|
||||||
|
4: number;
|
||||||
|
5: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStats {
|
||||||
|
timesUsed: number;
|
||||||
|
uniqueTeams: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
avgExploitTime: number; // seconds
|
||||||
|
successRate: number;
|
||||||
|
avgSLA: number;
|
||||||
|
downloadsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceConfig {
|
||||||
|
port: number;
|
||||||
|
checkerImage: string;
|
||||||
|
serviceImage: string;
|
||||||
|
flagRegex: string;
|
||||||
|
flagPlacementMethods: string[];
|
||||||
|
healthCheckEndpoint: string | null;
|
||||||
|
requiredResources: ResourceRequirements;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
volumes: VolumeMount[];
|
||||||
|
networks: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceRequirements {
|
||||||
|
cpu: string;
|
||||||
|
memory: string;
|
||||||
|
disk: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeMount {
|
||||||
|
name: string;
|
||||||
|
mountPath: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VulnerabilityInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: VulnerabilityType;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
description: string;
|
||||||
|
hint: string | null;
|
||||||
|
points: number;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VulnerabilityType =
|
||||||
|
| 'sqli'
|
||||||
|
| 'xss'
|
||||||
|
| 'rce'
|
||||||
|
| 'lfi'
|
||||||
|
| 'rfi'
|
||||||
|
| 'ssrf'
|
||||||
|
| 'xxe'
|
||||||
|
| 'deserialization'
|
||||||
|
| 'buffer_overflow'
|
||||||
|
| 'format_string'
|
||||||
|
| 'race_condition'
|
||||||
|
| 'logic_flaw'
|
||||||
|
| 'crypto_weakness'
|
||||||
|
| 'auth_bypass'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export interface ServiceReview {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
pros: string[];
|
||||||
|
cons: string[];
|
||||||
|
isVerified: boolean; // User actually played with this service
|
||||||
|
helpfulCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceVersion {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
version: string;
|
||||||
|
changelog: string;
|
||||||
|
isLatest: boolean;
|
||||||
|
isBroken: boolean;
|
||||||
|
publishedAt: string;
|
||||||
|
publishedBy: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceValidation {
|
||||||
|
id: string;
|
||||||
|
serviceId: string;
|
||||||
|
status: ValidationStatus;
|
||||||
|
checks: ValidationCheck[];
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
logs: string[];
|
||||||
|
errorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'passed'
|
||||||
|
| 'failed'
|
||||||
|
| 'cancelled';
|
||||||
|
|
||||||
|
export interface ValidationCheck {
|
||||||
|
name: string;
|
||||||
|
status: 'pending' | 'running' | 'passed' | 'failed' | 'skipped';
|
||||||
|
message: string | null;
|
||||||
|
duration: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadServiceRequest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
fullDescription?: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
stack: string[];
|
||||||
|
tags?: string[];
|
||||||
|
config: Partial<ServiceConfig>;
|
||||||
|
isPublic?: boolean;
|
||||||
|
repository?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateServiceRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
fullDescription?: string;
|
||||||
|
category?: ServiceCategory;
|
||||||
|
difficulty?: ServiceDifficulty;
|
||||||
|
stack?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
config?: Partial<ServiceConfig>;
|
||||||
|
isPublic?: boolean;
|
||||||
|
repository?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceSearchParams {
|
||||||
|
query?: string;
|
||||||
|
category?: ServiceCategory;
|
||||||
|
difficulty?: ServiceDifficulty;
|
||||||
|
stack?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
status?: ServiceStatus;
|
||||||
|
minRating?: number;
|
||||||
|
authorId?: string;
|
||||||
|
isFeatured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReviewRequest {
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
pros?: string[];
|
||||||
|
cons?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatusInfo {
|
||||||
|
serviceId: string;
|
||||||
|
teamId: string;
|
||||||
|
status: CheckerResult;
|
||||||
|
lastCheck: string;
|
||||||
|
uptime: number; // percentage
|
||||||
|
consecutiveFailures: number;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
168
src/api/types/team.types.ts
Normal file
168
src/api/types/team.types.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
// Team types
|
||||||
|
|
||||||
|
import { User } from './auth.types';
|
||||||
|
|
||||||
|
export interface Team {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tag: string; // Short team tag (3-5 chars)
|
||||||
|
description: string;
|
||||||
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
captain: TeamMember;
|
||||||
|
members: TeamMember[];
|
||||||
|
maxMembers: number;
|
||||||
|
isPublic: boolean;
|
||||||
|
isRecruiting: boolean;
|
||||||
|
stats: TeamStats;
|
||||||
|
rank: number;
|
||||||
|
rating: number;
|
||||||
|
country: string | null;
|
||||||
|
socialLinks: TeamSocialLinks;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMember {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
role: TeamRole;
|
||||||
|
specialization: TeamSpecialization | null;
|
||||||
|
joinedAt: string;
|
||||||
|
contribution: TeamContribution;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TeamRole = 'captain' | 'co-captain' | 'member' | 'substitute' | 'coach';
|
||||||
|
|
||||||
|
export type TeamSpecialization =
|
||||||
|
| 'attacker'
|
||||||
|
| 'defender'
|
||||||
|
| 'analyst'
|
||||||
|
| 'reverse_engineer'
|
||||||
|
| 'exploit_developer'
|
||||||
|
| 'infrastructure'
|
||||||
|
| 'generalist';
|
||||||
|
|
||||||
|
export interface TeamContribution {
|
||||||
|
matchesPlayed: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsDefended: number;
|
||||||
|
scoreContributed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamStats {
|
||||||
|
matchesPlayed: number;
|
||||||
|
matchesWon: number;
|
||||||
|
matchesLost: number;
|
||||||
|
winRate: number;
|
||||||
|
totalScore: number;
|
||||||
|
avgScore: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsDefended: number;
|
||||||
|
avgSLA: number;
|
||||||
|
bestRank: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSocialLinks {
|
||||||
|
website?: string;
|
||||||
|
discord?: string;
|
||||||
|
twitter?: string;
|
||||||
|
ctftime?: string;
|
||||||
|
github?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamInvite {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
team: Team;
|
||||||
|
invitedUserId: string;
|
||||||
|
invitedUser: User;
|
||||||
|
invitedByUserId: string;
|
||||||
|
invitedBy: User;
|
||||||
|
role: TeamRole;
|
||||||
|
message: string | null;
|
||||||
|
status: InviteStatus;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteStatus = 'pending' | 'accepted' | 'declined' | 'expired' | 'cancelled';
|
||||||
|
|
||||||
|
export interface TeamJoinRequest {
|
||||||
|
id: string;
|
||||||
|
teamId: string;
|
||||||
|
team: Team;
|
||||||
|
userId: string;
|
||||||
|
user: User;
|
||||||
|
message: string | null;
|
||||||
|
status: JoinRequestStatus;
|
||||||
|
createdAt: string;
|
||||||
|
processedAt: string | null;
|
||||||
|
processedBy: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JoinRequestStatus = 'pending' | 'approved' | 'rejected' | 'cancelled';
|
||||||
|
|
||||||
|
export interface CreateTeamRequest {
|
||||||
|
name: string;
|
||||||
|
tag: string;
|
||||||
|
description?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
isRecruiting?: boolean;
|
||||||
|
country?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTeamRequest {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
avatar?: string;
|
||||||
|
banner?: string;
|
||||||
|
isPublic?: boolean;
|
||||||
|
isRecruiting?: boolean;
|
||||||
|
maxMembers?: number;
|
||||||
|
socialLinks?: TeamSocialLinks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteToTeamRequest {
|
||||||
|
userId: string;
|
||||||
|
role?: TeamRole;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinTeamRequest {
|
||||||
|
teamId: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMemberRoleRequest {
|
||||||
|
memberId: string;
|
||||||
|
role: TeamRole;
|
||||||
|
specialization?: TeamSpecialization;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamSearchParams {
|
||||||
|
query?: string;
|
||||||
|
isRecruiting?: boolean;
|
||||||
|
country?: string;
|
||||||
|
minRating?: number;
|
||||||
|
maxRating?: number;
|
||||||
|
minMembers?: number;
|
||||||
|
maxMembers?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMatchHistory {
|
||||||
|
matchId: string;
|
||||||
|
matchTitle: string;
|
||||||
|
mode: string;
|
||||||
|
position: number;
|
||||||
|
totalTeams: number;
|
||||||
|
score: number;
|
||||||
|
flagsCaptured: number;
|
||||||
|
flagsDefended: number;
|
||||||
|
sla: number;
|
||||||
|
playedAt: string;
|
||||||
|
}
|
||||||
326
src/api/types/training.types.ts
Normal file
326
src/api/types/training.types.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
// Training types
|
||||||
|
|
||||||
|
import { User } from './auth.types';
|
||||||
|
import { ServiceCategory, ServiceDifficulty } from './service.types';
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
fullDescription: string;
|
||||||
|
role: TrackRole;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
skills: Skill[];
|
||||||
|
exercises: Exercise[];
|
||||||
|
totalExercises: number;
|
||||||
|
estimatedHours: number;
|
||||||
|
prerequisites: string[];
|
||||||
|
learningOutcomes: string[];
|
||||||
|
author: User;
|
||||||
|
rating: TrackRating;
|
||||||
|
enrolledCount: number;
|
||||||
|
completedCount: number;
|
||||||
|
tags: string[];
|
||||||
|
isPublished: boolean;
|
||||||
|
isFeatured: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackRole =
|
||||||
|
| 'attacker'
|
||||||
|
| 'defender'
|
||||||
|
| 'analyst'
|
||||||
|
| 'reverse_engineer'
|
||||||
|
| 'exploit_developer'
|
||||||
|
| 'infrastructure'
|
||||||
|
| 'generalist'
|
||||||
|
| 'team_lead';
|
||||||
|
|
||||||
|
export interface TrackRating {
|
||||||
|
average: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
category: SkillCategory;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
maxLevel: number;
|
||||||
|
relatedSkills: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SkillCategory =
|
||||||
|
| 'offensive'
|
||||||
|
| 'defensive'
|
||||||
|
| 'analysis'
|
||||||
|
| 'reverse_engineering'
|
||||||
|
| 'cryptography'
|
||||||
|
| 'networking'
|
||||||
|
| 'programming'
|
||||||
|
| 'infrastructure'
|
||||||
|
| 'soft_skills';
|
||||||
|
|
||||||
|
export interface Exercise {
|
||||||
|
id: string;
|
||||||
|
trackId: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
content: string; // Markdown
|
||||||
|
type: ExerciseType;
|
||||||
|
difficulty: ServiceDifficulty;
|
||||||
|
category: ServiceCategory;
|
||||||
|
skills: Skill[];
|
||||||
|
order: number;
|
||||||
|
estimatedMinutes: number;
|
||||||
|
points: number;
|
||||||
|
hints: Hint[];
|
||||||
|
resources: Resource[];
|
||||||
|
prerequisites: string[];
|
||||||
|
successCriteria: SuccessCriteria;
|
||||||
|
environment: ExerciseEnvironment | null;
|
||||||
|
isLocked: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExerciseType =
|
||||||
|
| 'theory'
|
||||||
|
| 'quiz'
|
||||||
|
| 'practical'
|
||||||
|
| 'challenge'
|
||||||
|
| 'ctf'
|
||||||
|
| 'project'
|
||||||
|
| 'assessment';
|
||||||
|
|
||||||
|
export interface Hint {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
costPercentage: number; // Score reduction for using hint
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Resource {
|
||||||
|
id: string;
|
||||||
|
type: ResourceType;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResourceType =
|
||||||
|
| 'article'
|
||||||
|
| 'video'
|
||||||
|
| 'documentation'
|
||||||
|
| 'tool'
|
||||||
|
| 'writeup'
|
||||||
|
| 'book'
|
||||||
|
| 'course';
|
||||||
|
|
||||||
|
export interface SuccessCriteria {
|
||||||
|
type: 'flag' | 'quiz' | 'code' | 'manual' | 'auto';
|
||||||
|
flagFormat?: string;
|
||||||
|
quizQuestions?: QuizQuestion[];
|
||||||
|
codeTests?: CodeTest[];
|
||||||
|
passingScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
options: string[];
|
||||||
|
correctIndex: number;
|
||||||
|
explanation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeTest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: string;
|
||||||
|
expectedOutput: string;
|
||||||
|
timeLimit: number;
|
||||||
|
memoryLimit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseEnvironment {
|
||||||
|
type: 'docker' | 'vm' | 'browser' | 'ssh';
|
||||||
|
image: string;
|
||||||
|
ports: number[];
|
||||||
|
credentials?: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
files: EnvironmentFile[];
|
||||||
|
timeLimit: number; // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnvironmentFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
isEditable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExerciseResult {
|
||||||
|
id: string;
|
||||||
|
exerciseId: string;
|
||||||
|
userId: string;
|
||||||
|
status: ExerciseStatus;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
hintsUsed: number;
|
||||||
|
totalHints: number;
|
||||||
|
attempts: number;
|
||||||
|
timeSpent: number; // seconds
|
||||||
|
startedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
submission: ExerciseSubmission | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExerciseStatus =
|
||||||
|
| 'not_started'
|
||||||
|
| 'in_progress'
|
||||||
|
| 'completed'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped';
|
||||||
|
|
||||||
|
export interface ExerciseSubmission {
|
||||||
|
id: string;
|
||||||
|
type: 'flag' | 'code' | 'quiz' | 'file';
|
||||||
|
content: string;
|
||||||
|
isCorrect: boolean;
|
||||||
|
feedback: string;
|
||||||
|
submittedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Progress {
|
||||||
|
userId: string;
|
||||||
|
trackId: string | null;
|
||||||
|
overallProgress: number;
|
||||||
|
totalPoints: number;
|
||||||
|
totalExercises: number;
|
||||||
|
completedExercises: number;
|
||||||
|
currentStreak: number;
|
||||||
|
longestStreak: number;
|
||||||
|
lastActivityAt: string;
|
||||||
|
trackProgress: TrackProgress[];
|
||||||
|
skillLevels: SkillLevel[];
|
||||||
|
achievements: string[];
|
||||||
|
weeklyActivity: WeeklyActivity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackProgress {
|
||||||
|
trackId: string;
|
||||||
|
trackName: string;
|
||||||
|
progress: number;
|
||||||
|
completedExercises: number;
|
||||||
|
totalExercises: number;
|
||||||
|
pointsEarned: number;
|
||||||
|
maxPoints: number;
|
||||||
|
startedAt: string;
|
||||||
|
lastActivityAt: string;
|
||||||
|
isCompleted: boolean;
|
||||||
|
certificate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillLevel {
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
category: SkillCategory;
|
||||||
|
level: number;
|
||||||
|
maxLevel: number;
|
||||||
|
experience: number;
|
||||||
|
nextLevelExperience: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyActivity {
|
||||||
|
date: string;
|
||||||
|
exercisesCompleted: number;
|
||||||
|
pointsEarned: number;
|
||||||
|
minutesSpent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartExerciseRequest {
|
||||||
|
exerciseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartExerciseResponse {
|
||||||
|
exerciseId: string;
|
||||||
|
sessionId: string;
|
||||||
|
environment: ExerciseEnvironment | null;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitExerciseRequest {
|
||||||
|
exerciseId: string;
|
||||||
|
type: 'flag' | 'code' | 'quiz' | 'file';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubmitExerciseResponse {
|
||||||
|
success: boolean;
|
||||||
|
isCorrect: boolean;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
feedback: string;
|
||||||
|
nextExerciseId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHintRequest {
|
||||||
|
exerciseId: string;
|
||||||
|
hintId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHintResponse {
|
||||||
|
hint: Hint;
|
||||||
|
scorePenalty: number;
|
||||||
|
remainingHints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackSearchParams {
|
||||||
|
query?: string;
|
||||||
|
role?: TrackRole;
|
||||||
|
difficulty?: ServiceDifficulty;
|
||||||
|
skills?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
isFeatured?: boolean;
|
||||||
|
minRating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillMapData {
|
||||||
|
userId: string;
|
||||||
|
skills: SkillMapNode[];
|
||||||
|
connections: SkillConnection[];
|
||||||
|
recommendations: SkillRecommendation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillMapNode {
|
||||||
|
skill: Skill;
|
||||||
|
level: number;
|
||||||
|
experience: number;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillConnection {
|
||||||
|
fromSkillId: string;
|
||||||
|
toSkillId: string;
|
||||||
|
isUnlocked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillRecommendation {
|
||||||
|
skillId: string;
|
||||||
|
reason: string;
|
||||||
|
priority: number;
|
||||||
|
suggestedExercises: string[];
|
||||||
|
}
|
||||||
396
src/api/websocket.ts
Normal file
396
src/api/websocket.ts
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import { tokenStorage } from './axios';
|
||||||
|
import {
|
||||||
|
ScoreboardUpdate,
|
||||||
|
RoundEvent,
|
||||||
|
LogEntry,
|
||||||
|
NotificationEvent
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
|
// Channel types
|
||||||
|
export type ChannelType =
|
||||||
|
| 'scoreboard'
|
||||||
|
| 'rounds'
|
||||||
|
| 'logs'
|
||||||
|
| 'notifications'
|
||||||
|
| 'match_status';
|
||||||
|
|
||||||
|
// Event handlers type
|
||||||
|
export interface WebSocketEvents {
|
||||||
|
// Scoreboard events
|
||||||
|
'scoreboard:update': (data: ScoreboardUpdate) => void;
|
||||||
|
'scoreboard:freeze': (data: { matchId: string; frozenAt: string }) => void;
|
||||||
|
'scoreboard:unfreeze': (data: { matchId: string }) => void;
|
||||||
|
|
||||||
|
// Round events
|
||||||
|
'round:start': (data: RoundEvent) => void;
|
||||||
|
'round:end': (data: RoundEvent) => void;
|
||||||
|
'round:event': (data: RoundEvent) => void;
|
||||||
|
|
||||||
|
// Log events
|
||||||
|
'log:entry': (data: LogEntry) => void;
|
||||||
|
'log:batch': (data: LogEntry[]) => void;
|
||||||
|
|
||||||
|
// Match events
|
||||||
|
'match:status': (data: { matchId: string; status: string; message?: string }) => void;
|
||||||
|
'match:start': (data: { matchId: string; startedAt: string }) => void;
|
||||||
|
'match:end': (data: { matchId: string; finishedAt: string }) => void;
|
||||||
|
'match:pause': (data: { matchId: string }) => void;
|
||||||
|
'match:resume': (data: { matchId: string }) => void;
|
||||||
|
|
||||||
|
// Service events
|
||||||
|
'service:status': (data: { matchId: string; teamId: string; serviceId: string; status: string }) => void;
|
||||||
|
'service:check': (data: { matchId: string; teamId: string; serviceId: string; result: string }) => void;
|
||||||
|
|
||||||
|
// Flag events
|
||||||
|
'flag:captured': (data: { matchId: string; attackerId: string; defenderId: string; serviceId: string; points: number }) => void;
|
||||||
|
'flag:generated': (data: { matchId: string; round: number; count: number }) => void;
|
||||||
|
|
||||||
|
// Notification events
|
||||||
|
'notification:new': (data: NotificationEvent) => void;
|
||||||
|
'notification:read': (data: { notificationId: string }) => void;
|
||||||
|
|
||||||
|
// Connection events
|
||||||
|
'connect': () => void;
|
||||||
|
'disconnect': (reason: string) => void;
|
||||||
|
'connect_error': (error: Error) => void;
|
||||||
|
'reconnect': (attemptNumber: number) => void;
|
||||||
|
'reconnect_attempt': (attemptNumber: number) => void;
|
||||||
|
'reconnect_error': (error: Error) => void;
|
||||||
|
'reconnect_failed': () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventName = keyof WebSocketEvents;
|
||||||
|
type EventHandler<E extends EventName> = WebSocketEvents[E];
|
||||||
|
|
||||||
|
class WebSocketClient {
|
||||||
|
private socket: Socket | null = null;
|
||||||
|
private subscriptions: Map<string, Set<string>> = new Map();
|
||||||
|
private eventHandlers: Map<string, Set<Function>> = new Map();
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 10;
|
||||||
|
private reconnectDelay = 1000;
|
||||||
|
private isConnecting = false;
|
||||||
|
|
||||||
|
// Connect to WebSocket server
|
||||||
|
connect(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isConnecting) {
|
||||||
|
// Wait for existing connection attempt
|
||||||
|
const checkConnection = setInterval(() => {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
clearInterval(checkConnection);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true;
|
||||||
|
const token = tokenStorage.getAccessToken();
|
||||||
|
|
||||||
|
this.socket = io(WS_URL, {
|
||||||
|
auth: { token },
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionAttempts: this.maxReconnectAttempts,
|
||||||
|
reconnectionDelay: this.reconnectDelay,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
timeout: 20000,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[WS] Connected');
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.resubscribeAll();
|
||||||
|
this.emit('connect');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
console.log('[WS] Disconnected:', reason);
|
||||||
|
this.emit('disconnect', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
console.error('[WS] Connection error:', error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.emit('connect_error', error);
|
||||||
|
if (this.reconnectAttempts === 0) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('reconnect', (attemptNumber) => {
|
||||||
|
console.log('[WS] Reconnected after', attemptNumber, 'attempts');
|
||||||
|
this.emit('reconnect', attemptNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('reconnect_attempt', (attemptNumber) => {
|
||||||
|
this.reconnectAttempts = attemptNumber;
|
||||||
|
this.emit('reconnect_attempt', attemptNumber);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('reconnect_error', (error) => {
|
||||||
|
console.error('[WS] Reconnect error:', error);
|
||||||
|
this.emit('reconnect_error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('reconnect_failed', () => {
|
||||||
|
console.error('[WS] Reconnection failed');
|
||||||
|
this.emit('reconnect_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup all event listeners
|
||||||
|
this.setupEventListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from WebSocket server
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
this.subscriptions.clear();
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if connected
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.socket?.connected ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to a channel
|
||||||
|
subscribe(channel: ChannelType, id: string): void {
|
||||||
|
const channelKey = `${channel}:${id}`;
|
||||||
|
|
||||||
|
if (!this.subscriptions.has(channel)) {
|
||||||
|
this.subscriptions.set(channel, new Set());
|
||||||
|
}
|
||||||
|
this.subscriptions.get(channel)!.add(id);
|
||||||
|
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit('subscribe', { channel, id });
|
||||||
|
console.log('[WS] Subscribed to', channelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe from a channel
|
||||||
|
unsubscribe(channel: ChannelType, id: string): void {
|
||||||
|
const channelKey = `${channel}:${id}`;
|
||||||
|
|
||||||
|
this.subscriptions.get(channel)?.delete(id);
|
||||||
|
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit('unsubscribe', { channel, id });
|
||||||
|
console.log('[WS] Unsubscribed from', channelKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resubscribe to all channels after reconnection
|
||||||
|
private resubscribeAll(): void {
|
||||||
|
this.subscriptions.forEach((ids, channel) => {
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit('subscribe', { channel, id });
|
||||||
|
console.log('[WS] Resubscribed to', `${channel}:${id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event handler
|
||||||
|
on<E extends EventName>(event: E, handler: EventHandler<E>): void {
|
||||||
|
if (!this.eventHandlers.has(event)) {
|
||||||
|
this.eventHandlers.set(event, new Set());
|
||||||
|
}
|
||||||
|
this.eventHandlers.get(event)!.add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event handler
|
||||||
|
off<E extends EventName>(event: E, handler: EventHandler<E>): void {
|
||||||
|
this.eventHandlers.get(event)?.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit to handlers
|
||||||
|
private emit(event: string, ...args: unknown[]): void {
|
||||||
|
this.eventHandlers.get(event)?.forEach((handler) => {
|
||||||
|
try {
|
||||||
|
handler(...args);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[WS] Error in handler for ${event}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup all socket event listeners
|
||||||
|
private setupEventListeners(): void {
|
||||||
|
if (!this.socket) return;
|
||||||
|
|
||||||
|
// Scoreboard events
|
||||||
|
this.socket.on('scoreboard:update', (data: ScoreboardUpdate) => {
|
||||||
|
this.emit('scoreboard:update', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scoreboard:freeze', (data) => {
|
||||||
|
this.emit('scoreboard:freeze', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('scoreboard:unfreeze', (data) => {
|
||||||
|
this.emit('scoreboard:unfreeze', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round events
|
||||||
|
this.socket.on('round:start', (data: RoundEvent) => {
|
||||||
|
this.emit('round:start', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('round:end', (data: RoundEvent) => {
|
||||||
|
this.emit('round:end', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('round:event', (data: RoundEvent) => {
|
||||||
|
this.emit('round:event', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log events
|
||||||
|
this.socket.on('log:entry', (data: LogEntry) => {
|
||||||
|
this.emit('log:entry', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('log:batch', (data: LogEntry[]) => {
|
||||||
|
this.emit('log:batch', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match events
|
||||||
|
this.socket.on('match:status', (data) => {
|
||||||
|
this.emit('match:status', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('match:start', (data) => {
|
||||||
|
this.emit('match:start', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('match:end', (data) => {
|
||||||
|
this.emit('match:end', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('match:pause', (data) => {
|
||||||
|
this.emit('match:pause', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('match:resume', (data) => {
|
||||||
|
this.emit('match:resume', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service events
|
||||||
|
this.socket.on('service:status', (data) => {
|
||||||
|
this.emit('service:status', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('service:check', (data) => {
|
||||||
|
this.emit('service:check', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flag events
|
||||||
|
this.socket.on('flag:captured', (data) => {
|
||||||
|
this.emit('flag:captured', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('flag:generated', (data) => {
|
||||||
|
this.emit('flag:generated', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notification events
|
||||||
|
this.socket.on('notification:new', (data: NotificationEvent) => {
|
||||||
|
this.emit('notification:new', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('notification:read', (data) => {
|
||||||
|
this.emit('notification:read', data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message through socket
|
||||||
|
send(event: string, data: unknown): void {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit(event, data);
|
||||||
|
} else {
|
||||||
|
console.warn('[WS] Cannot send message, not connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get socket instance (for advanced usage)
|
||||||
|
getSocket(): Socket | null {
|
||||||
|
return this.socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const wsClient = new WebSocketClient();
|
||||||
|
|
||||||
|
// Export helper functions for common operations
|
||||||
|
export const wsHelpers = {
|
||||||
|
// Subscribe to match scoreboard updates
|
||||||
|
subscribeToScoreboard: (matchId: string) => {
|
||||||
|
wsClient.subscribe('scoreboard', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeFromScoreboard: (matchId: string) => {
|
||||||
|
wsClient.unsubscribe('scoreboard', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe to match round events
|
||||||
|
subscribeToRounds: (matchId: string) => {
|
||||||
|
wsClient.subscribe('rounds', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeFromRounds: (matchId: string) => {
|
||||||
|
wsClient.unsubscribe('rounds', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe to match logs
|
||||||
|
subscribeToLogs: (matchId: string) => {
|
||||||
|
wsClient.subscribe('logs', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeFromLogs: (matchId: string) => {
|
||||||
|
wsClient.unsubscribe('logs', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe to user notifications
|
||||||
|
subscribeToNotifications: (userId: string) => {
|
||||||
|
wsClient.subscribe('notifications', userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeFromNotifications: (userId: string) => {
|
||||||
|
wsClient.unsubscribe('notifications', userId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Subscribe to match status changes
|
||||||
|
subscribeToMatchStatus: (matchId: string) => {
|
||||||
|
wsClient.subscribe('match_status', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribeFromMatchStatus: (matchId: string) => {
|
||||||
|
wsClient.unsubscribe('match_status', matchId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Connect and authenticate
|
||||||
|
connectWithAuth: async () => {
|
||||||
|
if (tokenStorage.hasTokens()) {
|
||||||
|
await wsClient.connect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default wsClient;
|
||||||
119
src/components/analytics/AIRecommendationCard.tsx
Normal file
119
src/components/analytics/AIRecommendationCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Lightbulb, TrendingUp, Target, BookOpen } from 'lucide-react';
|
||||||
|
import { AIRecommendation } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface AIRecommendationCardProps {
|
||||||
|
recommendation: AIRecommendation;
|
||||||
|
className?: string;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors = {
|
||||||
|
low: 'bg-blue-500/20 text-blue-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
high: 'bg-orange-500/20 text-orange-400',
|
||||||
|
critical: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
skill_improvement: BookOpen,
|
||||||
|
strategy_change: TrendingUp,
|
||||||
|
service_focus: Target,
|
||||||
|
team_coordination: Lightbulb,
|
||||||
|
training_suggestion: BookOpen,
|
||||||
|
role_adjustment: Target,
|
||||||
|
tool_recommendation: Lightbulb,
|
||||||
|
resource_allocation: TrendingUp,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AIRecommendationCard({
|
||||||
|
recommendation,
|
||||||
|
className,
|
||||||
|
onDismiss,
|
||||||
|
}: AIRecommendationCardProps) {
|
||||||
|
const Icon = typeIcons[recommendation.type] || Lightbulb;
|
||||||
|
const priorityColor = priorityColors[recommendation.priority];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn('rounded-lg p-2', priorityColor)}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{recommendation.title}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{recommendation.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rationale */}
|
||||||
|
{recommendation.rationale && (
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{recommendation.rationale}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action items */}
|
||||||
|
{recommendation.actionItems.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
<div className="text-sm font-medium">Действия:</div>
|
||||||
|
{recommendation.actionItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-start gap-2 rounded-lg bg-muted/50 p-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<span>{item.action}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Влияние:</span>{' '}
|
||||||
|
{(recommendation.estimatedImpact * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Усилия:</span>{' '}
|
||||||
|
{(recommendation.estimatedEffort * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Уверенность:</span>{' '}
|
||||||
|
{(recommendation.confidence * 100).toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn('rounded-full px-3 py-1 text-xs font-medium', priorityColor)}
|
||||||
|
>
|
||||||
|
{recommendation.priority === 'low' && 'Низкий'}
|
||||||
|
{recommendation.priority === 'medium' && 'Средний'}
|
||||||
|
{recommendation.priority === 'high' && 'Высокий'}
|
||||||
|
{recommendation.priority === 'critical' && 'Критичный'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AIRecommendationCard;
|
||||||
57
src/components/analytics/AnalyticsCard.tsx
Normal file
57
src/components/analytics/AnalyticsCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface AnalyticsCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
change?: number;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
icon: Icon,
|
||||||
|
color = 'text-primary',
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: AnalyticsCardProps) {
|
||||||
|
const isPositive = change && change > 0;
|
||||||
|
const isNegative = change && change < 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-xl border border-border bg-card p-6', className)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{title}</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold">{value}</p>
|
||||||
|
{change !== undefined && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'mt-1 text-sm font-medium',
|
||||||
|
isPositive && 'text-green-400',
|
||||||
|
isNegative && 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Icon && (
|
||||||
|
<div className={cn('rounded-lg p-3', color.replace('text-', 'bg-').replace('400', '500/20'))}>
|
||||||
|
<Icon className={cn('h-6 w-6', color)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnalyticsCard;
|
||||||
97
src/components/analytics/AttackHeatmap.tsx
Normal file
97
src/components/analytics/AttackHeatmap.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface HeatmapCell {
|
||||||
|
round: number;
|
||||||
|
serviceId: string;
|
||||||
|
attacks: number;
|
||||||
|
successRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttackHeatmapProps {
|
||||||
|
data: HeatmapCell[];
|
||||||
|
services: { id: string; name: string }[];
|
||||||
|
rounds: number[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttackHeatmap({
|
||||||
|
data,
|
||||||
|
services,
|
||||||
|
rounds,
|
||||||
|
className,
|
||||||
|
}: AttackHeatmapProps) {
|
||||||
|
const maxValue = Math.max(...data.map((d) => d.attacks), 1);
|
||||||
|
|
||||||
|
const getColor = (attacks: number, successRate: number) => {
|
||||||
|
const intensity = attacks / maxValue;
|
||||||
|
const hue = 142 - (intensity * 100); // Green to Red
|
||||||
|
const saturation = 70 + (successRate * 30);
|
||||||
|
const lightness = 30 + (intensity * 30);
|
||||||
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCellValue = (round: number, serviceId: string) => {
|
||||||
|
return data.find((d) => d.round === round && d.serviceId === serviceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-x-auto', className)}>
|
||||||
|
<div className="inline-block min-w-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: `120px repeat(${rounds.length}, 1fr)` }}>
|
||||||
|
<div className="p-2 text-sm font-medium text-muted-foreground">Сервис</div>
|
||||||
|
{rounds.map((round) => (
|
||||||
|
<div
|
||||||
|
key={round}
|
||||||
|
className="p-2 text-center text-sm font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
R{round}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
{services.map((service) => (
|
||||||
|
<div
|
||||||
|
key={service.id}
|
||||||
|
className="grid"
|
||||||
|
style={{ gridTemplateColumns: `120px repeat(${rounds.length}, 1fr)` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center p-2 text-sm">{service.name}</div>
|
||||||
|
{rounds.map((round) => {
|
||||||
|
const cell = getCellValue(round, service.id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${service.id}-${round}`}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center p-2 text-xs font-medium transition-colors hover:opacity-80',
|
||||||
|
!cell && 'bg-muted/30'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: cell ? getColor(cell.attacks, cell.successRate) : undefined,
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
cell
|
||||||
|
? `Атак: ${cell.attacks}, Успех: ${(cell.successRate * 100).toFixed(0)}%`
|
||||||
|
: 'Нет данных'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cell ? cell.attacks : '-'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Меньше</span>
|
||||||
|
<div className="h-3 w-8 rounded bg-gradient-to-r from-green-500 to-red-500" />
|
||||||
|
<span>Больше</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AttackHeatmap;
|
||||||
112
src/components/analytics/PerformanceTrend.tsx
Normal file
112
src/components/analytics/PerformanceTrend.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface TrendPoint {
|
||||||
|
timestamp: string;
|
||||||
|
value: number;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PerformanceTrendProps {
|
||||||
|
data: TrendPoint[];
|
||||||
|
title?: string;
|
||||||
|
value?: number;
|
||||||
|
change?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PerformanceTrend({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
change,
|
||||||
|
height = 60,
|
||||||
|
color = 'hsl(var(--primary))',
|
||||||
|
className,
|
||||||
|
}: PerformanceTrendProps) {
|
||||||
|
const trend = useMemo(() => {
|
||||||
|
if (data.length < 2) return 'stable';
|
||||||
|
const first = data[0].value;
|
||||||
|
const last = data[data.length - 1].value;
|
||||||
|
if (last > first) return 'up';
|
||||||
|
if (last < first) return 'down';
|
||||||
|
return 'stable';
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const points = useMemo(() => {
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const maxValue = Math.max(...data.map((d) => d.value));
|
||||||
|
const minValue = Math.min(...data.map((d) => d.value));
|
||||||
|
const range = maxValue - minValue || 1;
|
||||||
|
|
||||||
|
return data
|
||||||
|
.map((d, i) => {
|
||||||
|
const x = (i / (data.length - 1 || 1)) * 100;
|
||||||
|
const y = 100 - ((d.value - minValue) / range) * 100;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const areaPath = points ? `M 0,100 L ${points} L 100,100 Z` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-2', className)}>
|
||||||
|
{(title || value !== undefined) && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{title && <span className="text-sm text-muted-foreground">{title}</span>}
|
||||||
|
{value !== undefined && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold">{value}</span>
|
||||||
|
{change !== undefined && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center text-sm font-medium',
|
||||||
|
change > 0 && 'text-green-400',
|
||||||
|
change < 0 && 'text-red-400',
|
||||||
|
change === 0 && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{change > 0 ? (
|
||||||
|
<TrendingUp className="h-4 w-4" />
|
||||||
|
) : change < 0 ? (
|
||||||
|
<TrendingDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{change > 0 && '+'}
|
||||||
|
{change.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sparkline */}
|
||||||
|
<div className="relative overflow-hidden rounded-lg bg-card">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<path d={areaPath} fill={`${color}20`} />
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PerformanceTrend;
|
||||||
160
src/components/analytics/SLAChart.tsx
Normal file
160
src/components/analytics/SLAChart.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface SLADataPoint {
|
||||||
|
round: number;
|
||||||
|
sla: number;
|
||||||
|
serviceName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SLAChartProps {
|
||||||
|
data: SLADataPoint[];
|
||||||
|
height?: number;
|
||||||
|
showGrid?: boolean;
|
||||||
|
showLabels?: boolean;
|
||||||
|
className?: string;
|
||||||
|
targetSLA?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SLAChart({
|
||||||
|
data,
|
||||||
|
height = 200,
|
||||||
|
showGrid = true,
|
||||||
|
showLabels = true,
|
||||||
|
className,
|
||||||
|
targetSLA = 1.0,
|
||||||
|
}: SLAChartProps) {
|
||||||
|
const rounds = useMemo(() => data.map((d) => d.round), [data]);
|
||||||
|
const maxSLA = Math.max(...data.map((d) => d.sla), 1);
|
||||||
|
|
||||||
|
const points = data
|
||||||
|
.map((d, i) => {
|
||||||
|
const x = (i / (data.length - 1 || 1)) * 100;
|
||||||
|
const y = 100 - (d.sla / maxSLA) * 100;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
const areaPath = `M 0,100 L ${points} L 100,100 Z`;
|
||||||
|
|
||||||
|
// Target line
|
||||||
|
const targetY = 100 - (targetSLA / maxSLA) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{/* Grid */}
|
||||||
|
{showGrid && (
|
||||||
|
<>
|
||||||
|
{[0, 25, 50, 75, 100].map((y) => (
|
||||||
|
<line
|
||||||
|
key={y}
|
||||||
|
x1="0"
|
||||||
|
y1={y}
|
||||||
|
x2="100"
|
||||||
|
y2={y}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeDasharray="2,2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target line */}
|
||||||
|
<line
|
||||||
|
x1="0"
|
||||||
|
y1={targetY}
|
||||||
|
x2="100"
|
||||||
|
y2={targetY}
|
||||||
|
stroke="hsl(0, 84.2%, 60.2%)"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeDasharray="4,2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Area */}
|
||||||
|
<path d={areaPath} fill="hsl(142, 71%, 45%, 0.2)" />
|
||||||
|
|
||||||
|
{/* Line */}
|
||||||
|
<polyline
|
||||||
|
points={points}
|
||||||
|
fill="none"
|
||||||
|
stroke="hsl(142, 71%, 45%)"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Points */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const x = (i / (data.length - 1 || 1)) * 100;
|
||||||
|
const y = 100 - (d.sla / maxSLA) * 100;
|
||||||
|
const isBelowTarget = d.sla < targetSLA;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r="1.5"
|
||||||
|
fill={isBelowTarget ? 'hsl(0, 84.2%, 60.2%)' : 'hsl(142, 71%, 45%)'}
|
||||||
|
className="transition-all hover:r-3"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Y-axis labels */}
|
||||||
|
{showLabels && (
|
||||||
|
<div className="absolute left-0 top-0 flex h-full flex-col justify-between py-2 text-[10px] text-muted-foreground">
|
||||||
|
<span>100%</span>
|
||||||
|
<span>50%</span>
|
||||||
|
<span>0%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{showLabels && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
|
||||||
|
{rounds.slice(0, 5).map((round) => (
|
||||||
|
<span key={round}>R{round}</span>
|
||||||
|
))}
|
||||||
|
{rounds.length > 5 && <span>...</span>}
|
||||||
|
{rounds.length > 1 && <span>R{rounds[rounds.length - 1]}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Средний SLA</div>
|
||||||
|
<div className="text-lg font-bold">
|
||||||
|
{((data.reduce((acc, d) => acc + d.sla, 0) / data.length) * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Минимум</div>
|
||||||
|
<div className="text-lg font-bold text-red-400">
|
||||||
|
{(Math.min(...data.map((d) => d.sla)) * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Максимум</div>
|
||||||
|
<div className="text-lg font-bold text-green-400">
|
||||||
|
{(Math.max(...data.map((d) => d.sla)) * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SLAChart;
|
||||||
98
src/components/analytics/TeamComparisonChart.tsx
Normal file
98
src/components/analytics/TeamComparisonChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface ComparisonMetric {
|
||||||
|
name: string;
|
||||||
|
ourValue: number;
|
||||||
|
theirValue: number;
|
||||||
|
difference: number;
|
||||||
|
differencePercent: number;
|
||||||
|
winner: 'us' | 'them' | 'tie';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamComparisonChartProps {
|
||||||
|
metrics: ComparisonMetric[];
|
||||||
|
ourTeamName?: string;
|
||||||
|
theirTeamName?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamComparisonChart({
|
||||||
|
metrics,
|
||||||
|
ourTeamName = 'Наша команда',
|
||||||
|
theirTeamName = 'Соперник',
|
||||||
|
className,
|
||||||
|
}: TeamComparisonChartProps) {
|
||||||
|
const maxValue = useMemo(() => {
|
||||||
|
return Math.max(
|
||||||
|
...metrics.map((m) => Math.max(m.ourValue, m.theirValue))
|
||||||
|
);
|
||||||
|
}, [metrics]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm font-medium">
|
||||||
|
<div className="text-right text-primary">{ourTeamName}</div>
|
||||||
|
<div className="text-center text-muted-foreground">Метрика</div>
|
||||||
|
<div className="text-left text-blue-400">{theirTeamName}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
{metrics.map((metric) => {
|
||||||
|
const ourPercent = (metric.ourValue / maxValue) * 100;
|
||||||
|
const theirPercent = (metric.theirValue / maxValue) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={metric.name} className="space-y-2">
|
||||||
|
<div className="text-center text-sm font-medium">{metric.name}</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Our bar */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<span className="text-sm font-bold text-primary">
|
||||||
|
{metric.ourValue}
|
||||||
|
</span>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{ width: `${ourPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metric name */}
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-xs font-medium',
|
||||||
|
metric.winner === 'us' && 'text-green-400',
|
||||||
|
metric.winner === 'them' && 'text-red-400',
|
||||||
|
metric.winner === 'tie' && 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{metric.differencePercent > 0 && '+'}
|
||||||
|
{metric.differencePercent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Their bar */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${theirPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-blue-400">
|
||||||
|
{metric.theirValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamComparisonChart;
|
||||||
7
src/components/analytics/index.ts
Normal file
7
src/components/analytics/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Export all analytics components
|
||||||
|
export { AnalyticsCard } from './AnalyticsCard';
|
||||||
|
export { TeamComparisonChart } from './TeamComparisonChart';
|
||||||
|
export { AttackHeatmap } from './AttackHeatmap';
|
||||||
|
export { SLAChart } from './SLAChart';
|
||||||
|
export { AIRecommendationCard } from './AIRecommendationCard';
|
||||||
|
export { PerformanceTrend } from './PerformanceTrend';
|
||||||
139
src/components/auth/LoginForm.tsx
Normal file
139
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store';
|
||||||
|
import { loginSchema, LoginFormData } from '../../utils/validators';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface LoginFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ onSuccess, className }: LoginFormProps) {
|
||||||
|
const { login, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
rememberMe: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
try {
|
||||||
|
clearError();
|
||||||
|
await login(data);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-6', className)}>
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-2 block text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.email ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-2 block text-sm font-medium">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember me */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="rememberMe"
|
||||||
|
{...register('rememberMe')}
|
||||||
|
className="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rememberMe" className="text-sm text-muted-foreground">
|
||||||
|
Запомнить меня
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
Вход...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Войти'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
39
src/components/auth/ProtectedRoute.tsx
Normal file
39
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../store';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requiredRoles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
const { isAuthenticated, isInitialized, isLoading, user } = useAuthStore();
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (!isInitialized || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
|
<LoadingSpinner size="xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role requirements
|
||||||
|
if (requiredRoles && user) {
|
||||||
|
const hasRequiredRole = requiredRoles.includes(user.role);
|
||||||
|
if (!hasRequiredRole) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
251
src/components/auth/RegisterForm.tsx
Normal file
251
src/components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { Eye, EyeOff, Loader2, Check, X } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '../../store';
|
||||||
|
import { registerSchema, RegisterFormData } from '../../utils/validators';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface RegisterFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ onSuccess, className }: RegisterFormProps) {
|
||||||
|
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RegisterFormData>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
displayName: '',
|
||||||
|
acceptTerms: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch('password');
|
||||||
|
|
||||||
|
const passwordRequirements = [
|
||||||
|
{ label: 'Минимум 8 символов', met: password?.length >= 8 },
|
||||||
|
{ label: 'Заглавная буква', met: /[A-Z]/.test(password || '') },
|
||||||
|
{ label: 'Строчная буква', met: /[a-z]/.test(password || '') },
|
||||||
|
{ label: 'Цифра', met: /[0-9]/.test(password || '') },
|
||||||
|
{ label: 'Специальный символ', met: /[^A-Za-z0-9]/.test(password || '') },
|
||||||
|
];
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterFormData) => {
|
||||||
|
try {
|
||||||
|
clearError();
|
||||||
|
await registerUser(data);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch {
|
||||||
|
// Error handled by store
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-6', className)}>
|
||||||
|
{/* Error message */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Username */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="mb-2 block text-sm font-medium">
|
||||||
|
Имя пользователя
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
{...register('username')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.username ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="username"
|
||||||
|
/>
|
||||||
|
{errors.username && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.username.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-2 block text-sm font-medium">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
{...register('email')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.email ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name (optional) */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="displayName" className="mb-2 block text-sm font-medium">
|
||||||
|
Отображаемое имя <span className="text-muted-foreground">(опционально)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="displayName"
|
||||||
|
type="text"
|
||||||
|
{...register('displayName')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.displayName ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="Ваше имя"
|
||||||
|
/>
|
||||||
|
{errors.displayName && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.displayName.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-2 block text-sm font-medium">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...register('password')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.password ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Password requirements */}
|
||||||
|
{password && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{passwordRequirements.map((req) => (
|
||||||
|
<div
|
||||||
|
key={req.label}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-xs',
|
||||||
|
req.met ? 'text-green-500' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{req.met ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
|
||||||
|
{req.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="mb-2 block text-sm font-medium">
|
||||||
|
Подтверждение пароля
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.confirmPassword ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accept Terms */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="acceptTerms"
|
||||||
|
{...register('acceptTerms')}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<label htmlFor="acceptTerms" className="text-sm text-muted-foreground">
|
||||||
|
Я принимаю{' '}
|
||||||
|
<a href="/terms" className="text-primary hover:underline">
|
||||||
|
условия использования
|
||||||
|
</a>{' '}
|
||||||
|
и{' '}
|
||||||
|
<a href="/privacy" className="text-primary hover:underline">
|
||||||
|
политику конфиденциальности
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.acceptTerms && (
|
||||||
|
<p className="text-sm text-destructive">{errors.acceptTerms.message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
Регистрация...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Создать аккаунт'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterForm;
|
||||||
79
src/components/auth/RoleGuard.tsx
Normal file
79
src/components/auth/RoleGuard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { Navigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../store';
|
||||||
|
import { UserRole } from '../../api/types';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
|
||||||
|
interface RoleGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles: UserRole[];
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGuard({
|
||||||
|
children,
|
||||||
|
allowedRoles,
|
||||||
|
fallback,
|
||||||
|
redirectTo = '/dashboard',
|
||||||
|
}: RoleGuardProps) {
|
||||||
|
const { user, isLoading, isInitialized } = useAuthStore();
|
||||||
|
|
||||||
|
// Show loading while checking auth
|
||||||
|
if (!isInitialized || isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[200px] items-center justify-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has required role
|
||||||
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
|
if (fallback) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
return <Navigate to={redirectTo} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only guard
|
||||||
|
*/
|
||||||
|
export function AdminGuard({
|
||||||
|
children,
|
||||||
|
fallback,
|
||||||
|
redirectTo,
|
||||||
|
}: Omit<RoleGuardProps, 'allowedRoles'>) {
|
||||||
|
return (
|
||||||
|
<RoleGuard
|
||||||
|
allowedRoles={['admin', 'superadmin']}
|
||||||
|
fallback={fallback}
|
||||||
|
redirectTo={redirectTo}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organizer or admin guard
|
||||||
|
*/
|
||||||
|
export function OrganizerGuard({
|
||||||
|
children,
|
||||||
|
fallback,
|
||||||
|
redirectTo,
|
||||||
|
}: Omit<RoleGuardProps, 'allowedRoles'>) {
|
||||||
|
return (
|
||||||
|
<RoleGuard
|
||||||
|
allowedRoles={['organizer', 'admin', 'superadmin']}
|
||||||
|
fallback={fallback}
|
||||||
|
redirectTo={redirectTo}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</RoleGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoleGuard;
|
||||||
5
src/components/auth/index.ts
Normal file
5
src/components/auth/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Export all auth components
|
||||||
|
export { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
export { LoginForm } from './LoginForm';
|
||||||
|
export { RegisterForm } from './RegisterForm';
|
||||||
|
export { RoleGuard, AdminGuard, OrganizerGuard } from './RoleGuard';
|
||||||
115
src/components/common/Breadcrumbs.tsx
Normal file
115
src/components/common/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { ChevronRight, Home } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreadcrumbsProps {
|
||||||
|
items?: BreadcrumbItem[];
|
||||||
|
showHome?: boolean;
|
||||||
|
className?: string;
|
||||||
|
separator?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default route labels
|
||||||
|
const routeLabels: Record<string, string> = {
|
||||||
|
dashboard: 'Обзор',
|
||||||
|
matches: 'Матчи',
|
||||||
|
scoreboard: 'Рейтинг',
|
||||||
|
services: 'Сервисы',
|
||||||
|
teams: 'Команды',
|
||||||
|
training: 'Обучение',
|
||||||
|
analytics: 'Аналитика',
|
||||||
|
logs: 'Логи',
|
||||||
|
profile: 'Профиль',
|
||||||
|
settings: 'Настройки',
|
||||||
|
admin: 'Администрирование',
|
||||||
|
seasons: 'Сезоны',
|
||||||
|
live: 'Прямой эфир',
|
||||||
|
lobby: 'Лобби',
|
||||||
|
results: 'Результаты',
|
||||||
|
exercises: 'Упражнения',
|
||||||
|
team: 'Команда',
|
||||||
|
player: 'Игрок',
|
||||||
|
users: 'Пользователи',
|
||||||
|
system: 'Система',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Breadcrumbs({
|
||||||
|
items,
|
||||||
|
showHome = true,
|
||||||
|
className,
|
||||||
|
separator,
|
||||||
|
}: BreadcrumbsProps) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Generate breadcrumbs from current path if items not provided
|
||||||
|
const breadcrumbItems: BreadcrumbItem[] = items || (() => {
|
||||||
|
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||||
|
return pathSegments.map((segment, index) => {
|
||||||
|
const href = '/' + pathSegments.slice(0, index + 1).join('/');
|
||||||
|
const label = routeLabels[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||||
|
return { label, href };
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const defaultSeparator = (
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className={cn('flex items-center text-sm', className)}
|
||||||
|
>
|
||||||
|
<ol className="flex items-center gap-2">
|
||||||
|
{showHome && (
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Home className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
{breadcrumbItems.length > 0 && (
|
||||||
|
<li className="flex items-center">
|
||||||
|
{separator || defaultSeparator}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{breadcrumbItems.map((item, index) => {
|
||||||
|
const isLast = index === breadcrumbItems.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href || item.label} className="flex items-center gap-2">
|
||||||
|
{isLast || !item.href ? (
|
||||||
|
<span className={cn(
|
||||||
|
isLast ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{!isLast && (separator || defaultSeparator)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Breadcrumbs;
|
||||||
150
src/components/common/ConfirmDialog.tsx
Normal file
150
src/components/common/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState, useCallback, createContext, useContext, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
variant?: 'default' | 'destructive';
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmText = 'Подтвердить',
|
||||||
|
cancelText = 'Отмена',
|
||||||
|
variant = 'default',
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [onConfirm, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="relative w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{variant === 'destructive' && (
|
||||||
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading || loading}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={isLoading || loading}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||||
|
variant === 'destructive'
|
||||||
|
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||||
|
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading || loading ? 'Загрузка...' : confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for confirm dialogs
|
||||||
|
interface ConfirmContextType {
|
||||||
|
confirm: (options: Omit<ConfirmDialogProps, 'open' | 'onClose' | 'onConfirm'> & {
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
}) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmContext = createContext<ConfirmContextType | null>(null);
|
||||||
|
|
||||||
|
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [dialogProps, setDialogProps] = useState<ConfirmDialogProps | null>(null);
|
||||||
|
|
||||||
|
const confirm = useCallback((
|
||||||
|
options: Omit<ConfirmDialogProps, 'open' | 'onClose' | 'onConfirm'> & {
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setDialogProps({
|
||||||
|
...options,
|
||||||
|
open: true,
|
||||||
|
onClose: () => {
|
||||||
|
setDialogProps(null);
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
await options.onConfirm();
|
||||||
|
setDialogProps(null);
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmContext.Provider value={{ confirm }}>
|
||||||
|
{children}
|
||||||
|
{dialogProps && <ConfirmDialog {...dialogProps} />}
|
||||||
|
</ConfirmContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfirm() {
|
||||||
|
const context = useContext(ConfirmContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useConfirm must be used within ConfirmProvider');
|
||||||
|
}
|
||||||
|
return context.confirm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
85
src/components/common/CopyButton.tsx
Normal file
85
src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Copy, Check } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { copyToClipboard } from '../../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface CopyButtonProps {
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
successMessage?: string;
|
||||||
|
showToast?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
variant?: 'default' | 'ghost' | 'outline';
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-7 w-7',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-10 w-10',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 'h-3 w-3',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CopyButton({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
successMessage = 'Скопировано!',
|
||||||
|
showToast = true,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'ghost',
|
||||||
|
children,
|
||||||
|
}: CopyButtonProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(async () => {
|
||||||
|
const success = await copyToClipboard(value);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setCopied(true);
|
||||||
|
if (showToast) {
|
||||||
|
toast.success(successMessage);
|
||||||
|
}
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
if (showToast) {
|
||||||
|
toast.error('Не удалось скопировать');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, successMessage, showToast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-lg transition-colors',
|
||||||
|
!children && sizeClasses[size],
|
||||||
|
children && 'px-3 py-1.5',
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title="Копировать"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className={cn(iconSizes[size], 'text-green-500')} />
|
||||||
|
) : (
|
||||||
|
<Copy className={iconSizes[size]} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyButton;
|
||||||
133
src/components/common/Countdown.tsx
Normal file
133
src/components/common/Countdown.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useMatchTimer } from '../../hooks/useMatchTimer';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface CountdownProps {
|
||||||
|
/** Duration in seconds */
|
||||||
|
duration: number;
|
||||||
|
/** Auto-start the countdown */
|
||||||
|
autoStart?: boolean;
|
||||||
|
/** Callback when countdown reaches zero */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Show hours even if zero */
|
||||||
|
showHours?: boolean;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
/** Style variant */
|
||||||
|
variant?: 'default' | 'danger' | 'warning' | 'success';
|
||||||
|
/** Additional className */
|
||||||
|
className?: string;
|
||||||
|
/** Show progress bar */
|
||||||
|
showProgress?: boolean;
|
||||||
|
/** Separator between time parts */
|
||||||
|
separator?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-lg',
|
||||||
|
md: 'text-2xl',
|
||||||
|
lg: 'text-4xl',
|
||||||
|
xl: 'text-6xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'text-foreground',
|
||||||
|
danger: 'text-destructive',
|
||||||
|
warning: 'text-yellow-500',
|
||||||
|
success: 'text-green-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Countdown({
|
||||||
|
duration,
|
||||||
|
autoStart = true,
|
||||||
|
onComplete,
|
||||||
|
showHours = false,
|
||||||
|
size = 'md',
|
||||||
|
variant = 'default',
|
||||||
|
className,
|
||||||
|
showProgress = false,
|
||||||
|
separator = ':',
|
||||||
|
}: CountdownProps) {
|
||||||
|
const { remaining, progress, parts, isCompleted } = useMatchTimer({
|
||||||
|
duration,
|
||||||
|
autoStart,
|
||||||
|
onComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine variant based on remaining time
|
||||||
|
const dynamicVariant = (() => {
|
||||||
|
if (variant !== 'default') return variant;
|
||||||
|
if (remaining <= 10) return 'danger';
|
||||||
|
if (remaining <= 60) return 'warning';
|
||||||
|
return 'default';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const formatPart = (value: number) => value.toString().padStart(2, '0');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('inline-flex flex-col items-center', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'font-mono font-bold tabular-nums tracking-wider',
|
||||||
|
sizeClasses[size],
|
||||||
|
variantClasses[dynamicVariant],
|
||||||
|
isCompleted && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(showHours || parts.hours > 0) && (
|
||||||
|
<>
|
||||||
|
<span>{formatPart(parts.hours)}</span>
|
||||||
|
<span className="mx-1 opacity-50">{separator}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>{formatPart(parts.minutes)}</span>
|
||||||
|
<span className="mx-1 opacity-50">{separator}</span>
|
||||||
|
<span>{formatPart(parts.seconds)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showProgress && (
|
||||||
|
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all duration-1000',
|
||||||
|
dynamicVariant === 'danger' && 'bg-destructive',
|
||||||
|
dynamicVariant === 'warning' && 'bg-yellow-500',
|
||||||
|
dynamicVariant === 'success' && 'bg-green-500',
|
||||||
|
dynamicVariant === 'default' && 'bg-primary'
|
||||||
|
)}
|
||||||
|
style={{ width: `${100 - progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline countdown for text
|
||||||
|
*/
|
||||||
|
export function InlineCountdown({
|
||||||
|
duration,
|
||||||
|
autoStart = true,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
duration: number;
|
||||||
|
autoStart?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { formatted, isCompleted } = useMatchTimer({
|
||||||
|
duration,
|
||||||
|
autoStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn(
|
||||||
|
'font-mono tabular-nums',
|
||||||
|
isCompleted && 'text-destructive',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{formatted}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Countdown;
|
||||||
267
src/components/common/DataTable.tsx
Normal file
267
src/components/common/DataTable.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { Skeleton } from './LoadingSkeleton';
|
||||||
|
import { EmptyState } from './EmptyState';
|
||||||
|
|
||||||
|
// Simplified column definition
|
||||||
|
export interface ColumnDef<T> {
|
||||||
|
id: string;
|
||||||
|
header: string;
|
||||||
|
accessorKey?: keyof T;
|
||||||
|
accessorFn?: (row: T) => unknown;
|
||||||
|
cell?: (info: { row: T; getValue: () => unknown }) => React.ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
width?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: ColumnDef<T>[];
|
||||||
|
data: T[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
pageSize?: number;
|
||||||
|
showPagination?: boolean;
|
||||||
|
enableSorting?: boolean;
|
||||||
|
className?: string;
|
||||||
|
striped?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
getRowId?: (row: T) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc' | null;
|
||||||
|
|
||||||
|
interface SortState {
|
||||||
|
columnId: string | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage = 'Нет данных',
|
||||||
|
emptyDescription,
|
||||||
|
pageSize = 10,
|
||||||
|
showPagination = true,
|
||||||
|
enableSorting = true,
|
||||||
|
className,
|
||||||
|
striped = false,
|
||||||
|
compact = false,
|
||||||
|
onRowClick,
|
||||||
|
getRowId,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const [sort, setSort] = useState<SortState>({ columnId: null, direction: null });
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
|
||||||
|
// Get value from row
|
||||||
|
const getValue = (row: T, column: ColumnDef<T>): unknown => {
|
||||||
|
if (column.accessorFn) return column.accessorFn(row);
|
||||||
|
if (column.accessorKey) return row[column.accessorKey];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort data
|
||||||
|
const sortedData = [...data].sort((a, b) => {
|
||||||
|
if (!sort.columnId || !sort.direction) return 0;
|
||||||
|
|
||||||
|
const column = columns.find((c) => c.id === sort.columnId);
|
||||||
|
if (!column) return 0;
|
||||||
|
|
||||||
|
const aVal = getValue(a, column);
|
||||||
|
const bVal = getValue(b, column);
|
||||||
|
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal === null || aVal === undefined) return 1;
|
||||||
|
if (bVal === null || bVal === undefined) return -1;
|
||||||
|
|
||||||
|
const comparison = aVal < bVal ? -1 : 1;
|
||||||
|
return sort.direction === 'asc' ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paginate data
|
||||||
|
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||||
|
const paginatedData = showPagination
|
||||||
|
? sortedData.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
||||||
|
: sortedData;
|
||||||
|
|
||||||
|
// Toggle sort
|
||||||
|
const toggleSort = (columnId: string) => {
|
||||||
|
if (!enableSorting) return;
|
||||||
|
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev.columnId !== columnId) {
|
||||||
|
return { columnId, direction: 'asc' };
|
||||||
|
}
|
||||||
|
if (prev.direction === 'asc') {
|
||||||
|
return { columnId, direction: 'desc' };
|
||||||
|
}
|
||||||
|
return { columnId: null, direction: null };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col.id} className={cn('px-4 text-left', compact ? 'py-2' : 'py-3')}>
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<tr key={i} className="border-b border-border">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.id} className={cn('px-4', compact ? 'py-2' : 'py-3')}>
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyMessage}
|
||||||
|
description={emptyDescription}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.id}
|
||||||
|
style={{ width: column.width }}
|
||||||
|
className={cn(
|
||||||
|
'text-left text-sm font-medium text-muted-foreground',
|
||||||
|
compact ? 'px-3 py-2' : 'px-4 py-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2',
|
||||||
|
column.sortable !== false && enableSorting && 'cursor-pointer select-none hover:text-foreground'
|
||||||
|
)}
|
||||||
|
onClick={() => column.sortable !== false && toggleSort(column.id)}
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
{column.sortable !== false && enableSorting && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{sort.columnId === column.id && sort.direction === 'asc' ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : sort.columnId === column.id && sort.direction === 'desc' ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paginatedData.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={getRowId ? getRowId(row) : index}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors',
|
||||||
|
striped && index % 2 === 1 && 'bg-muted/30',
|
||||||
|
'hover:bg-muted/50',
|
||||||
|
onRowClick && 'cursor-pointer'
|
||||||
|
)}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const value = getValue(row, column);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.id}
|
||||||
|
className={cn(compact ? 'px-3 py-2' : 'px-4 py-3')}
|
||||||
|
>
|
||||||
|
{column.cell
|
||||||
|
? column.cell({ row, getValue: () => value })
|
||||||
|
: String(value ?? '')}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPagination && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Страница {currentPage + 1} из {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={currentPage === 0}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum: number;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i;
|
||||||
|
} else if (currentPage < 3) {
|
||||||
|
pageNum = i;
|
||||||
|
} else if (currentPage > totalPages - 4) {
|
||||||
|
pageNum = totalPages - 5 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pageNum}
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-8 w-8 items-center justify-center rounded border transition-colors',
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pageNum + 1}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={currentPage === totalPages - 1}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
42
src/components/common/EmptyState.tsx
Normal file
42
src/components/common/EmptyState.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center py-12 text-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && (
|
||||||
|
<div className="mb-4 rounded-full bg-muted p-4">
|
||||||
|
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-6">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EmptyState;
|
||||||
69
src/components/common/ErrorBoundary.tsx
Normal file
69
src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
|
this.props.onError?.(error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[400px] flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div className="mb-4 rounded-full bg-destructive/10 p-4">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-semibold">Что-то пошло не так</h2>
|
||||||
|
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
Произошла непредвиденная ошибка. Попробуйте обновить страницу.
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<pre className="mt-4 max-w-lg overflow-auto rounded-lg bg-muted p-4 text-left text-xs">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
120
src/components/common/LoadingSkeleton.tsx
Normal file
120
src/components/common/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-pulse rounded-md bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common skeleton patterns
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<Skeleton className="h-4 w-1/3 mb-4" />
|
||||||
|
<Skeleton className="h-8 w-1/2 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-full mb-2" />
|
||||||
|
<Skeleton className="h-4 w-2/3" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRowSkeleton({ columns = 5 }: { columns?: number }) {
|
||||||
|
return (
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<td key={i} className="px-4 py-3">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableSkeleton({ rows = 5, columns = 5 }: { rows?: number; columns?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<th key={i} className="px-4 py-3 text-left">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<TableRowSkeleton key={i} columns={columns} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListSkeleton({ items = 5 }: { items?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: items }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 rounded-lg border border-border p-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-4 w-1/3 mb-2" />
|
||||||
|
<Skeleton className="h-3 w-1/2" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Skeleton className="h-24 w-24 rounded-full" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Skeleton className="h-6 w-48 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-32 mb-4" />
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="rounded-xl border border-border bg-card p-6">
|
||||||
|
<Skeleton className="h-8 w-8 mb-4" />
|
||||||
|
<Skeleton className="h-8 w-20 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton as LoadingSkeleton };
|
||||||
|
export default Skeleton;
|
||||||
44
src/components/common/LoadingSpinner.tsx
Normal file
44
src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4 border-2',
|
||||||
|
md: 'h-8 w-8 border-2',
|
||||||
|
lg: 'h-12 w-12 border-3',
|
||||||
|
xl: 'h-16 w-16 border-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-spin rounded-full border-t-transparent border-primary',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({ message }: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<LoadingSpinner size="xl" />
|
||||||
|
{message && (
|
||||||
|
<p className="text-lg text-muted-foreground">{message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
126
src/components/common/MarkdownRenderer.tsx
Normal file
126
src/components/common/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface MarkdownRendererProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple markdown parser (for basic formatting)
|
||||||
|
// For production, consider using react-markdown or similar library
|
||||||
|
function parseMarkdown(text: string): string {
|
||||||
|
let html = text;
|
||||||
|
|
||||||
|
// Escape HTML
|
||||||
|
html = html
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
html = html.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>');
|
||||||
|
html = html.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-6 mb-3">$1</h2>');
|
||||||
|
html = html.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-6 mb-4">$1</h1>');
|
||||||
|
|
||||||
|
// Bold and italic
|
||||||
|
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||||
|
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||||
|
html = html.replace(/___(.*?)___/g, '<strong><em>$1</em></strong>');
|
||||||
|
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||||
|
|
||||||
|
// Inline code
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="px-1.5 py-0.5 rounded bg-muted font-mono text-sm">$1</code>');
|
||||||
|
|
||||||
|
// Code blocks
|
||||||
|
html = html.replace(
|
||||||
|
/```(\w+)?\n([\s\S]*?)```/g,
|
||||||
|
'<pre class="p-4 rounded-lg bg-muted overflow-x-auto my-4"><code class="font-mono text-sm">$2</code></pre>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Links
|
||||||
|
html = html.replace(
|
||||||
|
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||||
|
'<a href="$2" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Images
|
||||||
|
html = html.replace(
|
||||||
|
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||||
|
'<img src="$2" alt="$1" class="max-w-full rounded-lg my-4" />'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^\s*[-*+]\s+(.*)$/gim, '<li class="ml-4">$1</li>');
|
||||||
|
html = html.replace(/(<li.*<\/li>\n?)+/g, '<ul class="list-disc my-2 space-y-1">$&</ul>');
|
||||||
|
|
||||||
|
// Ordered lists
|
||||||
|
html = html.replace(/^\s*\d+\.\s+(.*)$/gim, '<li class="ml-4">$1</li>');
|
||||||
|
|
||||||
|
// Blockquotes
|
||||||
|
html = html.replace(
|
||||||
|
/^>\s*(.*)$/gim,
|
||||||
|
'<blockquote class="border-l-4 border-primary pl-4 italic text-muted-foreground my-4">$1</blockquote>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/^---+$/gim, '<hr class="my-6 border-border" />');
|
||||||
|
html = html.replace(/^\*\*\*+$/gim, '<hr class="my-6 border-border" />');
|
||||||
|
|
||||||
|
// Line breaks (two spaces at end of line)
|
||||||
|
html = html.replace(/ $/gim, '<br />');
|
||||||
|
|
||||||
|
// Paragraphs
|
||||||
|
html = html.replace(/\n\n/g, '</p><p class="my-4">');
|
||||||
|
html = '<p class="my-4">' + html + '</p>';
|
||||||
|
|
||||||
|
// Clean up empty paragraphs
|
||||||
|
html = html.replace(/<p class="my-4">\s*<\/p>/g, '');
|
||||||
|
html = html.replace(/<p class="my-4">(<h[1-6])/g, '$1');
|
||||||
|
html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
|
||||||
|
html = html.replace(/<p class="my-4">(<pre)/g, '$1');
|
||||||
|
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
|
||||||
|
html = html.replace(/<p class="my-4">(<ul)/g, '$1');
|
||||||
|
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||||||
|
html = html.replace(/<p class="my-4">(<blockquote)/g, '$1');
|
||||||
|
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
|
||||||
|
html = html.replace(/<p class="my-4">(<hr)/g, '$1');
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||||
|
const html = useMemo(() => parseMarkdown(content), [content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('prose prose-invert max-w-none', className)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple text renderer (no HTML, just clean text)
|
||||||
|
export function MarkdownToText({ content }: { content: string }): string {
|
||||||
|
return content
|
||||||
|
.replace(/\*\*\*(.*?)\*\*\*/g, '$1')
|
||||||
|
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||||
|
.replace(/\*(.*?)\*/g, '$1')
|
||||||
|
.replace(/___(.*?)___/g, '$1')
|
||||||
|
.replace(/__(.*?)__/g, '$1')
|
||||||
|
.replace(/_(.*?)_/g, '$1')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||||
|
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
||||||
|
.replace(/^#+\s*/gm, '')
|
||||||
|
.replace(/^[-*+]\s+/gm, '• ')
|
||||||
|
.replace(/^\d+\.\s+/gm, '')
|
||||||
|
.replace(/^>\s*/gm, '')
|
||||||
|
.replace(/---+/g, '')
|
||||||
|
.replace(/\*\*\*+/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MarkdownRenderer;
|
||||||
119
src/components/common/SearchInput.tsx
Normal file
119
src/components/common/SearchInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
onSearch?: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
debounceMs?: number;
|
||||||
|
className?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
showClearButton?: boolean;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-8 text-sm pl-8 pr-8',
|
||||||
|
md: 'h-10 text-sm pl-10 pr-10',
|
||||||
|
lg: 'h-12 text-base pl-12 pr-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-4 w-4',
|
||||||
|
lg: 'h-5 w-5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconPositions = {
|
||||||
|
sm: 'left-2',
|
||||||
|
md: 'left-3',
|
||||||
|
lg: 'left-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchInput({
|
||||||
|
value: externalValue,
|
||||||
|
onChange,
|
||||||
|
onSearch,
|
||||||
|
placeholder = 'Поиск...',
|
||||||
|
debounceMs = 300,
|
||||||
|
className,
|
||||||
|
autoFocus = false,
|
||||||
|
showClearButton = true,
|
||||||
|
size = 'md',
|
||||||
|
}: SearchInputProps) {
|
||||||
|
const [internalValue, setInternalValue] = useState(externalValue || '');
|
||||||
|
const debouncedValue = useDebounce(internalValue, debounceMs);
|
||||||
|
|
||||||
|
// Sync with external value
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalValue !== undefined) {
|
||||||
|
setInternalValue(externalValue);
|
||||||
|
}
|
||||||
|
}, [externalValue]);
|
||||||
|
|
||||||
|
// Call onSearch when debounced value changes
|
||||||
|
useEffect(() => {
|
||||||
|
onSearch?.(debouncedValue);
|
||||||
|
}, [debouncedValue, onSearch]);
|
||||||
|
|
||||||
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setInternalValue(newValue);
|
||||||
|
onChange?.(newValue);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setInternalValue('');
|
||||||
|
onChange?.('');
|
||||||
|
onSearch?.('');
|
||||||
|
}, [onChange, onSearch]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
handleClear();
|
||||||
|
}
|
||||||
|
}, [handleClear]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<Search
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-y-1/2 text-muted-foreground',
|
||||||
|
iconSizes[size],
|
||||||
|
iconPositions[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border border-border bg-background outline-none transition-colors',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{showClearButton && internalValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground',
|
||||||
|
size === 'sm' ? 'right-2' : size === 'lg' ? 'right-4' : 'right-3'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className={iconSizes[size]} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchInput;
|
||||||
152
src/components/common/StatusBadge.tsx
Normal file
152
src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import {
|
||||||
|
MATCH_STATUS_LABELS,
|
||||||
|
CHECKER_RESULT_LABELS,
|
||||||
|
DIFFICULTY_LABELS,
|
||||||
|
} from '../../utils/constants';
|
||||||
|
// Colors are defined inline to avoid unused imports
|
||||||
|
|
||||||
|
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string;
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
type?: 'match' | 'checker' | 'difficulty' | 'custom';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
pulse?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantClasses: Record<BadgeVariant, string> = {
|
||||||
|
default: 'bg-primary/20 text-primary border-primary/30',
|
||||||
|
success: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
|
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
error: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
|
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||||
|
neutral: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-1.5 py-0.5 text-xs',
|
||||||
|
md: 'px-2 py-1 text-xs',
|
||||||
|
lg: 'px-3 py-1.5 text-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariantFromMatchStatus = (status: string): BadgeVariant => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return 'success';
|
||||||
|
case 'lobby':
|
||||||
|
case 'starting':
|
||||||
|
return 'info';
|
||||||
|
case 'paused':
|
||||||
|
return 'warning';
|
||||||
|
case 'finished':
|
||||||
|
return 'neutral';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariantFromCheckerResult = (result: string): BadgeVariant => {
|
||||||
|
switch (result) {
|
||||||
|
case 'ok':
|
||||||
|
return 'success';
|
||||||
|
case 'corrupt':
|
||||||
|
case 'mumble':
|
||||||
|
return 'warning';
|
||||||
|
case 'down':
|
||||||
|
case 'error':
|
||||||
|
case 'timeout':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariantFromDifficulty = (difficulty: string): BadgeVariant => {
|
||||||
|
switch (difficulty) {
|
||||||
|
case 'beginner':
|
||||||
|
case 'easy':
|
||||||
|
return 'success';
|
||||||
|
case 'medium':
|
||||||
|
return 'warning';
|
||||||
|
case 'hard':
|
||||||
|
case 'expert':
|
||||||
|
return 'error';
|
||||||
|
case 'insane':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
status,
|
||||||
|
variant,
|
||||||
|
type = 'custom',
|
||||||
|
size = 'md',
|
||||||
|
pulse = false,
|
||||||
|
className,
|
||||||
|
}: StatusBadgeProps) {
|
||||||
|
let label = status;
|
||||||
|
let computedVariant = variant || 'default';
|
||||||
|
|
||||||
|
if (type === 'match') {
|
||||||
|
label = MATCH_STATUS_LABELS[status] || status;
|
||||||
|
if (!variant) {
|
||||||
|
computedVariant = getVariantFromMatchStatus(status);
|
||||||
|
}
|
||||||
|
} else if (type === 'checker') {
|
||||||
|
label = CHECKER_RESULT_LABELS[status] || status;
|
||||||
|
if (!variant) {
|
||||||
|
computedVariant = getVariantFromCheckerResult(status);
|
||||||
|
}
|
||||||
|
} else if (type === 'difficulty') {
|
||||||
|
label = DIFFICULTY_LABELS[status] || status;
|
||||||
|
if (!variant) {
|
||||||
|
computedVariant = getVariantFromDifficulty(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border font-medium',
|
||||||
|
variantClasses[computedVariant],
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pulse && (
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||||
|
computedVariant === 'success' && 'bg-green-400',
|
||||||
|
computedVariant === 'warning' && 'bg-yellow-400',
|
||||||
|
computedVariant === 'error' && 'bg-red-400',
|
||||||
|
computedVariant === 'info' && 'bg-blue-400',
|
||||||
|
computedVariant === 'default' && 'bg-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex h-2 w-2 rounded-full',
|
||||||
|
computedVariant === 'success' && 'bg-green-400',
|
||||||
|
computedVariant === 'warning' && 'bg-yellow-400',
|
||||||
|
computedVariant === 'error' && 'bg-red-400',
|
||||||
|
computedVariant === 'info' && 'bg-blue-400',
|
||||||
|
computedVariant === 'default' && 'bg-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StatusBadge;
|
||||||
43
src/components/common/ThemeToggle.tsx
Normal file
43
src/components/common/ThemeToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||||
|
import { useUIStore } from '../../store';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
className?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ className, showLabel = false }: ThemeToggleProps) {
|
||||||
|
const { theme, setTheme } = useUIStore();
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ value: 'light', icon: Sun, label: 'Светлая' },
|
||||||
|
{ value: 'dark', icon: Moon, label: 'Тёмная' },
|
||||||
|
{ value: 'system', icon: Monitor, label: 'Системная' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
const currentIndex = themes.findIndex((t) => t.value === theme);
|
||||||
|
const nextIndex = (currentIndex + 1) % themes.length;
|
||||||
|
setTheme(themes[nextIndex].value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentTheme = themes.find((t) => t.value === theme)!;
|
||||||
|
const Icon = currentTheme.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={cycleTheme}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title={`Текущая тема: ${currentTheme.label}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{showLabel && <span className="text-sm">{currentTheme.label}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
||||||
23
src/components/common/index.ts
Normal file
23
src/components/common/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Export all common components
|
||||||
|
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
|
||||||
|
export { StatusBadge } from './StatusBadge';
|
||||||
|
export { ThemeToggle } from './ThemeToggle';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
LoadingSkeleton,
|
||||||
|
CardSkeleton,
|
||||||
|
TableSkeleton,
|
||||||
|
TableRowSkeleton,
|
||||||
|
ListSkeleton,
|
||||||
|
ProfileSkeleton,
|
||||||
|
StatsSkeleton,
|
||||||
|
} from './LoadingSkeleton';
|
||||||
|
export { SearchInput } from './SearchInput';
|
||||||
|
export { CopyButton } from './CopyButton';
|
||||||
|
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog';
|
||||||
|
export { Breadcrumbs } from './Breadcrumbs';
|
||||||
|
export { Countdown, InlineCountdown } from './Countdown';
|
||||||
|
export { DataTable } from './DataTable';
|
||||||
|
export { MarkdownRenderer, MarkdownToText } from './MarkdownRenderer';
|
||||||
59
src/components/layout/AppLayout.tsx
Normal file
59
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { useUIStore } from '../../store';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
export function AppLayout() {
|
||||||
|
const { sidebarCollapsed, sidebarMobileOpen, setSidebarMobileOpen } = useUIStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{sidebarMobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||||
|
onClick={() => setSidebarMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'lg:block',
|
||||||
|
sidebarMobileOpen ? 'block' : 'hidden'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-screen flex-col transition-all duration-300',
|
||||||
|
sidebarCollapsed ? 'lg:ml-16' : 'lg:ml-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="flex-1 p-6 pt-20">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-border px-6 py-4">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-2 text-sm text-muted-foreground md:flex-row">
|
||||||
|
<p>© 2024 Врата ADA. Все права защищены.</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a href="#" className="hover:text-foreground">Документация</a>
|
||||||
|
<a href="#" className="hover:text-foreground">Поддержка</a>
|
||||||
|
<a href="#" className="hover:text-foreground">GitHub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout;
|
||||||
175
src/components/layout/Footer.tsx
Normal file
175
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { MessageCircle, Mail, Globe } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
className?: string;
|
||||||
|
minimal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ className, minimal = false }: FooterProps) {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
if (minimal) {
|
||||||
|
return (
|
||||||
|
<footer className={cn('border-t border-border px-6 py-4', className)}>
|
||||||
|
<div className="flex flex-col items-center justify-between gap-2 text-sm text-muted-foreground md:flex-row">
|
||||||
|
<p>© {currentYear} Врата ADA. Все права защищены.</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link to="/docs" className="hover:text-foreground">Документация</Link>
|
||||||
|
<Link to="/support" className="hover:text-foreground">Поддержка</Link>
|
||||||
|
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={cn('border-t border-border bg-card', className)}>
|
||||||
|
<div className="container mx-auto px-6 py-12">
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{/* Brand */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-primary">Врата ADA</h3>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
Платформа для тренировок и обучения CTF в формате Attack-Defence.
|
||||||
|
Развивайте навыки кибербезопасности вместе с нами.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex gap-4">
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
title="GitHub"
|
||||||
|
>
|
||||||
|
<Globe className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
title="Discord"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="mailto:support@ada-ctf.com"
|
||||||
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
title="Email"
|
||||||
|
>
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">Платформа</h4>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link to="/matches" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Матчи
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/training" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Обучение
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/services" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Сервисы
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/scoreboard" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Рейтинг
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/seasons" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Сезоны
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">Ресурсы</h4>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link to="/docs" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Документация
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/docs/api" className="text-muted-foreground hover:text-foreground">
|
||||||
|
API
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Блог
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legal */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold">Правовая информация</h4>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link to="/terms" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Условия использования
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/privacy" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Политика конфиденциальности
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/rules" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Правила платформы
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
||||||
|
Поддержка
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 border-t border-border pt-6">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground md:flex-row">
|
||||||
|
<p>© {currentYear} Врата ADA. Все права защищены.</p>
|
||||||
|
<p>
|
||||||
|
Сделано с ❤️ для CTF-сообщества
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
110
src/components/layout/Header.tsx
Normal file
110
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Search,
|
||||||
|
Menu,
|
||||||
|
Timer,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { useUIStore, useAuthStore, useNotificationStore, useMatchStore } from '../../store';
|
||||||
|
import { ThemeToggle } from '../common/ThemeToggle';
|
||||||
|
import { formatCountdown } from '../../utils/formatters';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { setSidebarMobileOpen, sidebarCollapsed, toggleCommandPalette } = useUIStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { unreadCount } = useNotificationStore();
|
||||||
|
const { currentMatch, roundTimeRemaining } = useMatchStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'fixed right-0 top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-card/95 px-4 backdrop-blur transition-all',
|
||||||
|
sidebarCollapsed ? 'left-16' : 'left-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Left side */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarMobileOpen(true)}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground lg:hidden"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<button
|
||||||
|
onClick={() => toggleCommandPalette()}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
<span className="hidden md:inline">Поиск...</span>
|
||||||
|
<kbd className="hidden rounded bg-muted px-1.5 py-0.5 text-xs font-medium md:inline">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center - Match timer */}
|
||||||
|
{currentMatch && currentMatch.status === 'running' && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg bg-primary/10 px-4 py-2">
|
||||||
|
<Timer className="h-5 w-5 text-primary" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Раунд {currentMatch.currentRound}/{currentMatch.totalRounds}
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-lg font-bold text-primary">
|
||||||
|
{formatCountdown(roundTimeRemaining)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to={`/matches/${currentMatch.id}/live`}
|
||||||
|
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Смотреть
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Link
|
||||||
|
to="/notifications"
|
||||||
|
className="relative flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1 text-xs font-bold text-destructive-foreground">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* User menu */}
|
||||||
|
{user && (
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||||
|
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<p className="text-sm font-medium">{user.displayName || user.username}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.teamId ? 'В команде' : 'Без команды'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
109
src/components/layout/MobileNav.tsx
Normal file
109
src/components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import { X, LayoutDashboard, Swords, Trophy, Box, Users, GraduationCap, BarChart3, Settings, Shield } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { useUIStore, useAuthStore } from '../../store';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/dashboard', label: 'Обзор', icon: LayoutDashboard },
|
||||||
|
{ path: '/matches', label: 'Матчи', icon: Swords },
|
||||||
|
{ path: '/scoreboard', label: 'Рейтинг', icon: Trophy },
|
||||||
|
{ path: '/services', label: 'Сервисы', icon: Box },
|
||||||
|
{ path: '/teams', label: 'Команды', icon: Users },
|
||||||
|
{ path: '/training', label: 'Обучение', icon: GraduationCap },
|
||||||
|
{ path: '/analytics/team', label: 'Аналитика', icon: BarChart3 },
|
||||||
|
{ path: '/settings', label: 'Настройки', icon: Settings },
|
||||||
|
{ path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileNav() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { sidebarMobileOpen, setSidebarMobileOpen } = useUIStore();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
|
||||||
|
|
||||||
|
if (!sidebarMobileOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||||
|
onClick={() => setSidebarMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-card shadow-xl lg:hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||||
|
<Shield className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-primary">Врата ADA</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarMobileOpen(false)}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
if (item.adminOnly && !isAdmin) return null;
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname.startsWith(item.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setSidebarMobileOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
{user && (
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||||
|
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.displayName || user.username}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileNav;
|
||||||
199
src/components/layout/Sidebar.tsx
Normal file
199
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Swords,
|
||||||
|
Trophy,
|
||||||
|
Box,
|
||||||
|
Users,
|
||||||
|
GraduationCap,
|
||||||
|
BarChart3,
|
||||||
|
ScrollText,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { useUIStore, useAuthStore } from '../../store';
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
badge?: number;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
organizerOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNavItems: NavItem[] = [
|
||||||
|
{ path: '/dashboard', label: 'Обзор', icon: LayoutDashboard },
|
||||||
|
{ path: '/matches', label: 'Матчи', icon: Swords },
|
||||||
|
{ path: '/scoreboard', label: 'Рейтинг', icon: Trophy },
|
||||||
|
{ path: '/services', label: 'Сервисы', icon: Box },
|
||||||
|
{ path: '/teams', label: 'Команды', icon: Users },
|
||||||
|
{ path: '/training', label: 'Обучение', icon: GraduationCap },
|
||||||
|
{ path: '/seasons', label: 'Сезоны', icon: Calendar },
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyticsNavItems: NavItem[] = [
|
||||||
|
{ path: '/analytics/team', label: 'Аналитика', icon: BarChart3 },
|
||||||
|
{ path: '/logs', label: 'Логи', icon: ScrollText },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNavItems: NavItem[] = [
|
||||||
|
{ path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { sidebarCollapsed, toggleSidebar, setSidebarMobileOpen } = useUIStore();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
|
||||||
|
const isOrganizer = user?.role === 'organizer' || isAdmin;
|
||||||
|
|
||||||
|
const renderNavItem = (item: NavItem) => {
|
||||||
|
if (item.adminOnly && !isAdmin) return null;
|
||||||
|
if (item.organizerOnly && !isOrganizer) return null;
|
||||||
|
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname.startsWith(item.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setSidebarMobileOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 shrink-0" />
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<>
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
{item.badge && item.badge > 0 && (
|
||||||
|
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||||
|
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||||
|
<Shield className="h-5 w-5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-bold text-primary">Врата ADA</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
>
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||||
|
{/* Main */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Навигация
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mainNavItems.map(renderNavItem)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Analytics */}
|
||||||
|
<div className="space-y-1 pt-4">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Аналитика
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{analyticsNavItems.map(renderNavItem)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin */}
|
||||||
|
{isAdmin && (
|
||||||
|
<div className="space-y-1 pt-4">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||||
|
Управление
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{adminNavItems.map(renderNavItem)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User */}
|
||||||
|
<div className="border-t border-border p-2">
|
||||||
|
<NavLink
|
||||||
|
to="/profile"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
location.pathname === '/profile'
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="h-5 w-5 shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span>Профиль</span>}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
to="/settings"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
location.pathname === '/settings'
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-5 w-5 shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span>Настройки</span>}
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-all hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5 shrink-0" />
|
||||||
|
{!sidebarCollapsed && <span>Выйти</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
6
src/components/layout/index.ts
Normal file
6
src/components/layout/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Export all layout components
|
||||||
|
export { AppLayout } from './AppLayout';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { Header } from './Header';
|
||||||
|
export { Footer } from './Footer';
|
||||||
|
export { MobileNav } from './MobileNav';
|
||||||
75
src/components/logs/EventTimeline.tsx
Normal file
75
src/components/logs/EventTimeline.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { LogTimelineEvent } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatDateTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface EventTimelineProps {
|
||||||
|
events: LogTimelineEvent[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventColors: Record<string, string> = {
|
||||||
|
round_start: 'bg-blue-500',
|
||||||
|
round_end: 'bg-green-500',
|
||||||
|
flag_captured: 'bg-red-500',
|
||||||
|
flag_generated: 'bg-yellow-500',
|
||||||
|
service_down: 'bg-orange-500',
|
||||||
|
service_up: 'bg-green-500',
|
||||||
|
attack_detected: 'bg-purple-500',
|
||||||
|
score_update: 'bg-blue-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EventTimeline({ events, className }: EventTimelineProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('relative space-y-4', className)}>
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
{events.map((event, index) => {
|
||||||
|
const color = eventColors[event.type] || 'bg-muted-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={event.id} className="relative flex gap-4 pl-10">
|
||||||
|
{/* Dot */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-2.5 h-3 w-3 -translate-x-1/2 rounded-full ring-4 ring-background',
|
||||||
|
color
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">{event.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
{event.importance === 'high' || event.importance === 'critical' && (
|
||||||
|
<span className="rounded bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||||
|
Важно
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||||
|
{event.teams.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{event.teams.map((team) => (
|
||||||
|
<span
|
||||||
|
key={team.teamId}
|
||||||
|
className="rounded bg-muted px-2 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{team.teamName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EventTimeline;
|
||||||
69
src/components/logs/LogEntry.tsx
Normal file
69
src/components/logs/LogEntry.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { formatDateTime } from '../../utils/formatters';
|
||||||
|
import { LOG_LEVEL_COLORS, LOG_LEVEL_LABELS } from '../../utils/constants';
|
||||||
|
import { LogEntry as LogEntryType } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface LogEntryProps {
|
||||||
|
log: LogEntryType;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
match_start: '🚀',
|
||||||
|
match_end: '🏁',
|
||||||
|
round_start: '🔄',
|
||||||
|
round_end: '✅',
|
||||||
|
flag_captured: '🚩',
|
||||||
|
flag_generated: '🎯',
|
||||||
|
service_down: '⚠️',
|
||||||
|
service_up: '✅',
|
||||||
|
attack_detected: '⚔️',
|
||||||
|
score_update: '📊',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogEntry({ log, compact = false, className }: LogEntryProps) {
|
||||||
|
const color = LOG_LEVEL_COLORS[log.level];
|
||||||
|
const icon = iconMap[log.type] || '📝';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 p-3 transition-colors hover:bg-muted/50',
|
||||||
|
compact ? 'text-xs' : 'text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="text-base">{icon}</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase"
|
||||||
|
style={{ backgroundColor: `${color}20`, color }}
|
||||||
|
>
|
||||||
|
{LOG_LEVEL_LABELS[log.level]}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(log.timestamp)}
|
||||||
|
</span>
|
||||||
|
{log.teamName && (
|
||||||
|
<span className="text-xs font-medium text-primary">{log.teamName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={compact ? 'line-clamp-1' : ''}>{log.message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Round badge */}
|
||||||
|
{log.roundNumber && (
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
R{log.roundNumber}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogEntry;
|
||||||
151
src/components/logs/LogFilter.tsx
Normal file
151
src/components/logs/LogFilter.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Filter, X } from 'lucide-react';
|
||||||
|
import { LogLevel, EventType } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { LOG_LEVEL_LABELS } from '../../utils/constants';
|
||||||
|
|
||||||
|
interface LogFilterProps {
|
||||||
|
selectedLevels?: LogLevel[];
|
||||||
|
selectedTypes?: EventType[];
|
||||||
|
selectedTeamId?: string | null;
|
||||||
|
selectedServiceId?: string | null;
|
||||||
|
search?: string;
|
||||||
|
onLevelsChange?: (levels: LogLevel[]) => void;
|
||||||
|
onTypesChange?: (types: EventType[]) => void;
|
||||||
|
onTeamChange?: (teamId: string | null) => void;
|
||||||
|
onServiceChange?: (serviceId: string | null) => void;
|
||||||
|
onSearchChange?: (search: string) => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
teams?: { id: string; name: string }[];
|
||||||
|
services?: { id: string; name: string }[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logLevels: LogLevel[] = ['debug', 'info', 'warning', 'error', 'critical'];
|
||||||
|
|
||||||
|
export function LogFilter({
|
||||||
|
selectedLevels = [],
|
||||||
|
selectedTypes = [],
|
||||||
|
selectedTeamId,
|
||||||
|
selectedServiceId,
|
||||||
|
search = '',
|
||||||
|
onLevelsChange,
|
||||||
|
onTypesChange,
|
||||||
|
onTeamChange,
|
||||||
|
onServiceChange,
|
||||||
|
onSearchChange,
|
||||||
|
onClear,
|
||||||
|
teams = [],
|
||||||
|
services = [],
|
||||||
|
className,
|
||||||
|
}: LogFilterProps) {
|
||||||
|
const hasActiveFilters =
|
||||||
|
selectedLevels.length > 0 ||
|
||||||
|
selectedTypes.length > 0 ||
|
||||||
|
selectedTeamId ||
|
||||||
|
selectedServiceId ||
|
||||||
|
search;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">Фильтры логов</span>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Поиск</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||||
|
placeholder="Поиск по тексту..."
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Levels */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Уровни</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{logLevels.map((level) => {
|
||||||
|
const isSelected = selectedLevels.includes(level);
|
||||||
|
const color = LOG_LEVEL_COLORS[level];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={level}
|
||||||
|
onClick={() => {
|
||||||
|
const newLevels = isSelected
|
||||||
|
? selectedLevels.filter((l) => l !== level)
|
||||||
|
: [...selectedLevels, level];
|
||||||
|
onLevelsChange?.(newLevels);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{LOG_LEVEL_LABELS[level]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
{teams.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Команда</label>
|
||||||
|
<select
|
||||||
|
value={selectedTeamId || ''}
|
||||||
|
onChange={(e) => onTeamChange?.(e.target.value || null)}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">Все команды</option>
|
||||||
|
{teams.map((team) => (
|
||||||
|
<option key={team.id} value={team.id}>
|
||||||
|
{team.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
{services.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Сервис</label>
|
||||||
|
<select
|
||||||
|
value={selectedServiceId || ''}
|
||||||
|
onChange={(e) => onServiceChange?.(e.target.value || null)}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">Все сервисы</option>
|
||||||
|
{services.map((service) => (
|
||||||
|
<option key={service.id} value={service.id}>
|
||||||
|
{service.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogFilter;
|
||||||
77
src/components/logs/LogStream.tsx
Normal file
77
src/components/logs/LogStream.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { LogEntry } from '../../api/types';
|
||||||
|
import { LogEntry as LogEntryComponent } from './LogEntry';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface LogStreamProps {
|
||||||
|
logs: LogEntry[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
isLive?: boolean;
|
||||||
|
autoScroll?: boolean;
|
||||||
|
className?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogStream({
|
||||||
|
logs,
|
||||||
|
isLoading = false,
|
||||||
|
isLive = false,
|
||||||
|
autoScroll = true,
|
||||||
|
className,
|
||||||
|
maxHeight = '500px',
|
||||||
|
}: LogStreamProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const prevLogsLength = useRef(logs.length);
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback(() => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && logs.length > prevLogsLength.current) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
prevLogsLength.current = logs.length;
|
||||||
|
}, [logs.length, autoScroll, scrollToBottom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'overflow-y-auto rounded-lg border border-border bg-card font-mono text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
{isLoading && logs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||||
|
Загрузка логов...
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||||
|
Нет логов для отображения
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<LogEntryComponent key={log.id} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLive && (
|
||||||
|
<div className="sticky bottom-0 left-0 right-0 flex items-center justify-center gap-2 border-t border-border bg-card/95 p-2 text-xs text-green-400 backdrop-blur">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-400" />
|
||||||
|
</span>
|
||||||
|
Прямой эфир
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogStream;
|
||||||
123
src/components/logs/ReplayControls.tsx
Normal file
123
src/components/logs/ReplayControls.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Play, Pause, SkipBack, SkipForward, FastForward, Rewind } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatCountdown } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface ReplayControlsProps {
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
speed: number;
|
||||||
|
onPlay: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onSeek: (time: number) => void;
|
||||||
|
onSpeedChange: (speed: number) => void;
|
||||||
|
onSkipBack?: () => void;
|
||||||
|
onSkipForward?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedOptions = [0.25, 0.5, 1, 1.5, 2, 4, 8];
|
||||||
|
|
||||||
|
export function ReplayControls({
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
speed,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onSeek,
|
||||||
|
onSpeedChange,
|
||||||
|
onSkipBack,
|
||||||
|
onSkipForward,
|
||||||
|
className,
|
||||||
|
}: ReplayControlsProps) {
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
||||||
|
onSeek(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleSpeed = () => {
|
||||||
|
const currentIndex = speedOptions.indexOf(speed);
|
||||||
|
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||||
|
onSpeedChange(speedOptions[nextIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4 rounded-lg border border-border bg-card p-4', className)}>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="w-16 text-xs font-mono text-muted-foreground">
|
||||||
|
{formatCountdown(currentTime)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={progress}
|
||||||
|
onChange={handleSeek}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<span className="w-16 text-right text-xs font-mono text-muted-foreground">
|
||||||
|
{formatCountdown(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{onSkipBack && (
|
||||||
|
<button
|
||||||
|
onClick={onSkipBack}
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title="Назад 10с"
|
||||||
|
>
|
||||||
|
<Rewind className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onSkipBack}
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title="Назад"
|
||||||
|
>
|
||||||
|
<SkipBack className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={isPlaying ? onPause : onPlay}
|
||||||
|
className="rounded-full bg-primary p-3 text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Play className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSkipForward}
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title="Вперёд"
|
||||||
|
>
|
||||||
|
<SkipForward className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{onSkipForward && (
|
||||||
|
<button
|
||||||
|
onClick={onSkipForward}
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title="Вперёд 10с"
|
||||||
|
>
|
||||||
|
<FastForward className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={cycleSpeed}
|
||||||
|
className="rounded-lg px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
title="Скорость"
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReplayControls;
|
||||||
6
src/components/logs/index.ts
Normal file
6
src/components/logs/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Export all logs components
|
||||||
|
export { LogStream } from './LogStream';
|
||||||
|
export { LogEntry } from './LogEntry';
|
||||||
|
export { LogFilter } from './LogFilter';
|
||||||
|
export { EventTimeline } from './EventTimeline';
|
||||||
|
export { ReplayControls } from './ReplayControls';
|
||||||
157
src/components/match/FlagSubmitForm.tsx
Normal file
157
src/components/match/FlagSubmitForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Flag, Loader2, Check, X } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { flagsApi } from '../../api/endpoints';
|
||||||
|
|
||||||
|
const flagSubmitSchema = z.object({
|
||||||
|
flag: z.string().min(1, 'Флаг обязателен'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FlagSubmitFormData = z.infer<typeof flagSubmitSchema>;
|
||||||
|
|
||||||
|
interface FlagSubmitFormProps {
|
||||||
|
matchId: string;
|
||||||
|
onSuccess?: (result: any) => void;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlagSubmitForm({
|
||||||
|
matchId,
|
||||||
|
onSuccess,
|
||||||
|
className,
|
||||||
|
disabled = false,
|
||||||
|
}: FlagSubmitFormProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [lastResult, setLastResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
points?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FlagSubmitFormData>({
|
||||||
|
resolver: zodResolver(flagSubmitSchema),
|
||||||
|
defaultValues: {
|
||||||
|
flag: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = useCallback(
|
||||||
|
async (data: FlagSubmitFormData) => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setLastResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await flagsApi.submitFlag({
|
||||||
|
matchId,
|
||||||
|
flag: data.flag,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLastResult({
|
||||||
|
success: result.success,
|
||||||
|
message: result.message,
|
||||||
|
points: result.points,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Флаг принят! +${result.points} очков`);
|
||||||
|
onSuccess?.(result);
|
||||||
|
reset();
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setLastResult({
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Ошибка отправки',
|
||||||
|
});
|
||||||
|
toast.error('Не удалось отправить флаг');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[matchId, onSuccess, reset]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-4', className)}>
|
||||||
|
{/* Flag input */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="flag" className="mb-2 block text-sm font-medium">
|
||||||
|
Флаг
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="flag"
|
||||||
|
type="text"
|
||||||
|
{...register('flag')}
|
||||||
|
disabled={disabled || isSubmitting}
|
||||||
|
placeholder="ADA{...}"
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm font-mono outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
'disabled:opacity-50',
|
||||||
|
errors.flag ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Flag className="absolute right-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{errors.flag && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.flag.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last result */}
|
||||||
|
{lastResult && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg p-3 text-sm',
|
||||||
|
lastResult.success
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: 'bg-destructive/10 text-destructive'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{lastResult.success ? (
|
||||||
|
<Check className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
<span>{lastResult.message}</span>
|
||||||
|
{lastResult.points && (
|
||||||
|
<span className="ml-auto font-bold">+{lastResult.points}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={disabled || isSubmitting}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
Проверка...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Flag className="h-5 w-5" />
|
||||||
|
Отправить флаг
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlagSubmitForm;
|
||||||
109
src/components/match/MatchCard.tsx
Normal file
109
src/components/match/MatchCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Swords, Calendar, Users, Clock } from 'lucide-react';
|
||||||
|
import { Match } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatDateTime, formatRelativeTime } from '../../utils/formatters';
|
||||||
|
import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
|
||||||
|
interface MatchCardProps {
|
||||||
|
match: Match;
|
||||||
|
className?: string;
|
||||||
|
showDescription?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabels: Record<string, string> = {
|
||||||
|
training: 'Тренировка',
|
||||||
|
tournament: 'Турнир',
|
||||||
|
scrim: 'Скрим',
|
||||||
|
practice: 'Практика',
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeColors: Record<string, string> = {
|
||||||
|
training: 'text-blue-400',
|
||||||
|
tournament: 'text-yellow-400',
|
||||||
|
scrim: 'text-purple-400',
|
||||||
|
practice: 'text-green-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MatchCard({ match, className, showDescription = true }: MatchCardProps) {
|
||||||
|
const isRunning = match.status === 'running';
|
||||||
|
const isStarting = match.status === 'starting' || match.status === 'lobby';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={isRunning ? `/matches/${match.id}/live` : `/matches/${match.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('text-xs font-medium uppercase', modeColors[match.mode])}>
|
||||||
|
{modeLabels[match.mode]}
|
||||||
|
</span>
|
||||||
|
{match.isRanked && (
|
||||||
|
<span className="rounded bg-yellow-400/20 px-2 py-0.5 text-xs font-medium text-yellow-400">
|
||||||
|
Рейтинг
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold group-hover:text-primary">
|
||||||
|
{match.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={match.status} type="match" pulse={isRunning} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{showDescription && match.description && (
|
||||||
|
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{match.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{match.config.maxTeams} команд</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Swords className="h-4 w-4" />
|
||||||
|
<span>{match.totalRounds} раундов</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>{Math.floor(match.roundDuration / 60)} мин раунд</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{match.scheduledAt ? formatDateTime(match.scheduledAt) : 'TBD'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{match.startedAt ? (
|
||||||
|
<>Начался {formatRelativeTime(match.startedAt)}</>
|
||||||
|
) : match.scheduledAt ? (
|
||||||
|
<>Начнётся {formatRelativeTime(match.scheduledAt)}</>
|
||||||
|
) : (
|
||||||
|
<>Создан {formatRelativeTime(match.createdAt)}</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(isRunning || isStarting) && (
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium text-primary">
|
||||||
|
{isRunning ? 'Смотреть' : 'Войти'}
|
||||||
|
<Swords className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchCard;
|
||||||
329
src/components/match/MatchConfigForm.tsx
Normal file
329
src/components/match/MatchConfigForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Plus, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { Service } from '../../api/types';
|
||||||
|
|
||||||
|
const matchConfigSchema = z.object({
|
||||||
|
title: z.string().min(3, 'Минимум 3 символа').max(100, 'Максимум 100 символов'),
|
||||||
|
description: z.string().max(1000, 'Максимум 1000 символов').optional(),
|
||||||
|
mode: z.enum(['training', 'tournament', 'scrim', 'practice']),
|
||||||
|
maxTeams: z.number().int().min(2).max(100),
|
||||||
|
minTeams: z.number().int().min(2),
|
||||||
|
roundDuration: z.number().int().min(30).max(3600),
|
||||||
|
totalRounds: z.number().int().min(1).max(500),
|
||||||
|
flagLifetime: z.number().int().min(10).max(600),
|
||||||
|
scoringPolicy: z.enum(['classic', 'linear', 'exponential', 'dynamic']),
|
||||||
|
serviceIds: z.array(z.string()).min(1, 'Выберите хотя бы один сервис'),
|
||||||
|
scheduledAt: z.string().optional(),
|
||||||
|
visibility: z.enum(['public', 'private', 'unlisted']),
|
||||||
|
isRanked: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type MatchConfigFormData = z.infer<typeof matchConfigSchema>;
|
||||||
|
|
||||||
|
interface MatchConfigFormProps {
|
||||||
|
services: Service[];
|
||||||
|
onSubmit: (data: MatchConfigFormData) => Promise<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabels: Record<string, string> = {
|
||||||
|
training: 'Тренировка',
|
||||||
|
tournament: 'Турнир',
|
||||||
|
scrim: 'Скрим',
|
||||||
|
practice: 'Практика',
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoringLabels: Record<string, string> = {
|
||||||
|
classic: 'Классическое',
|
||||||
|
linear: 'Линейное',
|
||||||
|
exponential: 'Экспоненциальное',
|
||||||
|
dynamic: 'Динамическое',
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityLabels: Record<string, string> = {
|
||||||
|
public: 'Публичный',
|
||||||
|
private: 'Приватный',
|
||||||
|
unlisted: 'Скрытый',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MatchConfigForm({
|
||||||
|
services,
|
||||||
|
onSubmit,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
}: MatchConfigFormProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(matchConfigSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
mode: 'training',
|
||||||
|
maxTeams: 10,
|
||||||
|
minTeams: 2,
|
||||||
|
roundDuration: 300,
|
||||||
|
totalRounds: 20,
|
||||||
|
flagLifetime: 120,
|
||||||
|
scoringPolicy: 'classic',
|
||||||
|
serviceIds: [],
|
||||||
|
visibility: 'public',
|
||||||
|
isRanked: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedServiceIds = watch('serviceIds');
|
||||||
|
|
||||||
|
const toggleService = (serviceId: string) => {
|
||||||
|
const current = selectedServiceIds || [];
|
||||||
|
if (current.includes(serviceId)) {
|
||||||
|
setValue(
|
||||||
|
'serviceIds',
|
||||||
|
current.filter((id) => id !== serviceId)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setValue('serviceIds', [...current, serviceId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitForm = async (data: any) => {
|
||||||
|
try {
|
||||||
|
await onSubmit(data as MatchConfigFormData);
|
||||||
|
} catch (error) {
|
||||||
|
// Error handled by parent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleSubmitForm)} className={cn('space-y-6', className)}>
|
||||||
|
{/* Basic info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="title" className="mb-2 block text-sm font-medium">
|
||||||
|
Название матча
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
type="text"
|
||||||
|
{...register('title')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.title ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="Например: Еженедельный турнир #42"
|
||||||
|
/>
|
||||||
|
{errors.title && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.title.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="description" className="mb-2 block text-sm font-medium">
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
{...register('description')}
|
||||||
|
rows={3}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||||
|
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||||
|
errors.description ? 'border-destructive' : 'border-border'
|
||||||
|
)}
|
||||||
|
placeholder="Описание матча (опционально)"
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Режим</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{(Object.entries(modeLabels) as [string, string][]).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setValue('mode', value as any)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-4 py-3 text-sm font-medium transition-colors',
|
||||||
|
watch('mode') === value
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{errors.mode && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.mode.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Макс. команд</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('maxTeams', { valueAsNumber: true })}
|
||||||
|
min={2}
|
||||||
|
max={100}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Мин. команд</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('minTeams', { valueAsNumber: true })}
|
||||||
|
min={2}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Раундов</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('totalRounds', { valueAsNumber: true })}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Длительность раунда (сек)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('roundDuration', { valueAsNumber: true })}
|
||||||
|
min={30}
|
||||||
|
max={3600}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Время жизни флага (сек)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
{...register('flagLifetime', { valueAsNumber: true })}
|
||||||
|
min={10}
|
||||||
|
max={600}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Политика очков</label>
|
||||||
|
<select
|
||||||
|
{...register('scoringPolicy')}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{(Object.entries(scoringLabels) as [string, string][]).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Сервисы</label>
|
||||||
|
<div className="grid max-h-64 grid-cols-2 gap-2 overflow-y-auto rounded-lg border border-border p-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{services.map((service) => {
|
||||||
|
const isSelected = selectedServiceIds?.includes(service.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleService(service.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg border p-3 text-left text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-4 w-4 items-center justify-center rounded',
|
||||||
|
isSelected ? 'bg-primary' : 'border border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSelected && <Plus className="h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{service.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{errors.serviceIds && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.serviceIds.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visibility & Ranked */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Видимость</label>
|
||||||
|
<select
|
||||||
|
{...register('visibility')}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
{(Object.entries(visibilityLabels) as [string, string][]).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
{...register('isRanked')}
|
||||||
|
className="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">Рейтинговый матч</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
Создание...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Создать матч'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchConfigForm;
|
||||||
28
src/components/match/MatchStatusBadge.tsx
Normal file
28
src/components/match/MatchStatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
import { MatchStatus } from '../../api/types';
|
||||||
|
|
||||||
|
interface MatchStatusBadgeProps {
|
||||||
|
status: MatchStatus;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
pulse?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MatchStatusBadge({
|
||||||
|
status,
|
||||||
|
size = 'md',
|
||||||
|
pulse = status === 'running' || status === 'starting',
|
||||||
|
className,
|
||||||
|
}: MatchStatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<StatusBadge
|
||||||
|
status={status}
|
||||||
|
type="match"
|
||||||
|
size={size}
|
||||||
|
pulse={pulse}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchStatusBadge;
|
||||||
105
src/components/match/MatchTimer.tsx
Normal file
105
src/components/match/MatchTimer.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Timer, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useMatchTimer } from '../../hooks/useMatchTimer';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface MatchTimerProps {
|
||||||
|
/** Duration in seconds */
|
||||||
|
duration: number;
|
||||||
|
/** Round number */
|
||||||
|
round?: number;
|
||||||
|
/** Total rounds */
|
||||||
|
totalRounds?: number;
|
||||||
|
/** Auto-start */
|
||||||
|
autoStart?: boolean;
|
||||||
|
/** Callback when timer ends */
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
/** Show warning when time is low */
|
||||||
|
showWarning?: boolean;
|
||||||
|
warningThreshold?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-lg',
|
||||||
|
md: 'text-3xl',
|
||||||
|
lg: 'text-5xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MatchTimer({
|
||||||
|
duration,
|
||||||
|
round,
|
||||||
|
totalRounds,
|
||||||
|
autoStart = true,
|
||||||
|
onComplete,
|
||||||
|
size = 'md',
|
||||||
|
showWarning = true,
|
||||||
|
warningThreshold = 60,
|
||||||
|
className,
|
||||||
|
}: MatchTimerProps) {
|
||||||
|
const { remaining, parts, isCompleted, progress } = useMatchTimer({
|
||||||
|
duration,
|
||||||
|
autoStart,
|
||||||
|
onComplete,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLow = remaining <= warningThreshold;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('inline-flex flex-col items-center', className)}>
|
||||||
|
{/* Round indicator */}
|
||||||
|
{(round || totalRounds) && (
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
|
Раунд {round || '?'} {totalRounds ? `/ ${totalRounds}` : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timer display */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative font-mono font-bold tabular-nums tracking-wider',
|
||||||
|
sizeClasses[size],
|
||||||
|
isCompleted && 'text-destructive animate-pulse',
|
||||||
|
isLow && !isCompleted && showWarning && 'text-yellow-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{parts.hours > 0 && (
|
||||||
|
<>
|
||||||
|
<span>{parts.hours.toString().padStart(2, '0')}</span>
|
||||||
|
<span className="opacity-50">:</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span>{parts.minutes.toString().padStart(2, '0')}</span>
|
||||||
|
<span className="opacity-50">:</span>
|
||||||
|
<span>{parts.seconds.toString().padStart(2, '0')}</span>
|
||||||
|
|
||||||
|
{/* Low time indicator */}
|
||||||
|
{isLow && !isCompleted && showWarning && (
|
||||||
|
<AlertTriangle className="absolute -right-6 top-1/2 h-5 w-5 -translate-y-1/2 animate-pulse text-yellow-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all duration-1000',
|
||||||
|
isCompleted && 'bg-destructive',
|
||||||
|
isLow && !isCompleted && 'bg-yellow-500',
|
||||||
|
!isLow && !isCompleted && 'bg-primary'
|
||||||
|
)}
|
||||||
|
style={{ width: `${100 - progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status text */}
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Timer className="h-3 w-3" />
|
||||||
|
{isCompleted ? 'Раунд завершён' : 'До конца раунда'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MatchTimer;
|
||||||
73
src/components/match/RoundIndicator.tsx
Normal file
73
src/components/match/RoundIndicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
|
||||||
|
interface RoundIndicatorProps {
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
completedRounds?: number;
|
||||||
|
className?: string;
|
||||||
|
showLabels?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundIndicator({
|
||||||
|
currentRound,
|
||||||
|
totalRounds,
|
||||||
|
completedRounds = 0,
|
||||||
|
className,
|
||||||
|
showLabels = true,
|
||||||
|
}: RoundIndicatorProps) {
|
||||||
|
// Generate rounds array
|
||||||
|
const rounds = Array.from({ length: totalRounds }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
// Limit display for large number of rounds
|
||||||
|
const displayRounds = totalRounds > 20 ? 20 : totalRounds;
|
||||||
|
const displayedRounds = rounds.slice(0, displayRounds);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center', className)}>
|
||||||
|
{showLabels && (
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
|
Раунд {currentRound} из {totalRounds}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{displayedRounds.map((round) => {
|
||||||
|
const isCompleted = round <= completedRounds;
|
||||||
|
const isCurrent = round === currentRound;
|
||||||
|
const isUpcoming = round > currentRound;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={round}
|
||||||
|
className={cn(
|
||||||
|
'flex h-6 w-6 items-center justify-center rounded text-xs font-medium transition-all',
|
||||||
|
isCompleted && 'bg-green-500/20 text-green-400',
|
||||||
|
isCurrent && 'bg-primary text-primary-foreground scale-110',
|
||||||
|
isUpcoming && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={`Раунд ${round}`}
|
||||||
|
>
|
||||||
|
{round}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{totalRounds > displayRounds && (
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center text-xs text-muted-foreground">
|
||||||
|
+{totalRounds - displayRounds}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{ width: `${(currentRound / totalRounds) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoundIndicator;
|
||||||
94
src/components/match/ServiceStatusGrid.tsx
Normal file
94
src/components/match/ServiceStatusGrid.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { ServiceStatusInfo } from '../../api/types';
|
||||||
|
import { CHECKER_RESULT_LABELS } from '../../utils/constants';
|
||||||
|
import { CHECKER_COLORS } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ServiceStatusGridProps {
|
||||||
|
services: ServiceStatusInfo[];
|
||||||
|
teams: { id: string; name: string; color: string }[];
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBgColors: Record<string, string> = {
|
||||||
|
ok: 'bg-green-500/20',
|
||||||
|
corrupt: 'bg-yellow-500/20',
|
||||||
|
mumble: 'bg-orange-500/20',
|
||||||
|
down: 'bg-red-500/20',
|
||||||
|
error: 'bg-red-600/20',
|
||||||
|
timeout: 'bg-purple-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBorderColors: Record<string, string> = {
|
||||||
|
ok: 'border-green-500/50',
|
||||||
|
corrupt: 'border-yellow-500/50',
|
||||||
|
mumble: 'border-orange-500/50',
|
||||||
|
down: 'border-red-500/50',
|
||||||
|
error: 'border-red-600/50',
|
||||||
|
timeout: 'border-purple-500/50',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceStatusGrid({
|
||||||
|
services,
|
||||||
|
teams,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: ServiceStatusGridProps) {
|
||||||
|
// Group services by service
|
||||||
|
const servicesByService = services.reduce((acc, status) => {
|
||||||
|
if (!acc[status.serviceId]) {
|
||||||
|
acc[status.serviceId] = [];
|
||||||
|
}
|
||||||
|
acc[status.serviceId].push(status);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, ServiceStatusInfo[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{Object.entries(servicesByService).map(([serviceId, statuses]) => {
|
||||||
|
const serviceName = (statuses[0] as any)?.serviceName || serviceId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={serviceId}>
|
||||||
|
<div className="mb-2 text-sm font-medium">{serviceName}</div>
|
||||||
|
<div className={cn('grid gap-2', compact ? 'grid-cols-8' : 'grid-cols-6')}>
|
||||||
|
{statuses.map((status) => {
|
||||||
|
const team = teams.find((t) => t.id === status.teamId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={status.teamId}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center justify-center rounded-lg border p-3 transition-colors',
|
||||||
|
statusBgColors[status.status] || 'bg-muted',
|
||||||
|
statusBorderColors[status.status] || 'border-border'
|
||||||
|
)}
|
||||||
|
title={`${team?.name || 'Unknown'}: ${CHECKER_RESULT_LABELS[status.status] || status.status}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3 rounded-full',
|
||||||
|
compact ? 'mb-1' : 'mb-2'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: CHECKER_COLORS[status.status as keyof typeof CHECKER_COLORS] || '#666',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={cn('text-xs font-medium', compact ? 'text-[10px]' : 'text-xs')}>
|
||||||
|
{team?.name || '???'}
|
||||||
|
</span>
|
||||||
|
<span className={cn('text-[10px] text-muted-foreground', compact ? 'text-[9px]' : 'text-xs')}>
|
||||||
|
{CHECKER_RESULT_LABELS[status.status] || status.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceStatusGrid;
|
||||||
8
src/components/match/index.ts
Normal file
8
src/components/match/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// Export all match components
|
||||||
|
export { MatchCard } from './MatchCard';
|
||||||
|
export { MatchTimer } from './MatchTimer';
|
||||||
|
export { MatchStatusBadge } from './MatchStatusBadge';
|
||||||
|
export { RoundIndicator } from './RoundIndicator';
|
||||||
|
export { ServiceStatusGrid } from './ServiceStatusGrid';
|
||||||
|
export { FlagSubmitForm } from './FlagSubmitForm';
|
||||||
|
export { MatchConfigForm } from './MatchConfigForm';
|
||||||
77
src/components/profile/AchievementBadge.tsx
Normal file
77
src/components/profile/AchievementBadge.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Trophy, Award, Star, Medal } from 'lucide-react';
|
||||||
|
import { Achievement } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { RARITY_LABELS } from '../../utils/constants';
|
||||||
|
import { RARITY_COLORS } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface AchievementBadgeProps {
|
||||||
|
achievement: Achievement;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showProgress?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rarityIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
common: Star,
|
||||||
|
uncommon: Award,
|
||||||
|
rare: Medal,
|
||||||
|
epic: Trophy,
|
||||||
|
legendary: Trophy,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AchievementBadge({
|
||||||
|
achievement,
|
||||||
|
size = 'md',
|
||||||
|
showProgress = false,
|
||||||
|
className,
|
||||||
|
}: AchievementBadgeProps) {
|
||||||
|
const Icon = rarityIcons[achievement.rarity] || Star;
|
||||||
|
const color = RARITY_COLORS[achievement.rarity];
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-12 w-12',
|
||||||
|
md: 'h-16 w-16',
|
||||||
|
lg: 'h-24 w-24',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 'h-6 w-6',
|
||||||
|
md: 'h-8 w-8',
|
||||||
|
lg: 'h-12 w-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col items-center', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex items-center justify-center rounded-full',
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: `${color}20` }}
|
||||||
|
>
|
||||||
|
<Icon className={cn(iconSizes[size])} style={{ color }} />
|
||||||
|
{achievement.unlockedAt && (
|
||||||
|
<div className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white">
|
||||||
|
✓
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-center">
|
||||||
|
<p className="text-sm font-medium">{achievement.name}</p>
|
||||||
|
<p
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
{RARITY_LABELS[achievement.rarity]}
|
||||||
|
</p>
|
||||||
|
{showProgress && achievement.progress !== undefined && achievement.maxProgress && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{achievement.progress}/{achievement.maxProgress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AchievementBadge;
|
||||||
48
src/components/profile/ActivityFeed.tsx
Normal file
48
src/components/profile/ActivityFeed.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { UserActivity } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatRelativeTime } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface ActivityFeedProps {
|
||||||
|
activities: UserActivity[];
|
||||||
|
className?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityIcons: Record<string, string> = {
|
||||||
|
match_played: '⚔️',
|
||||||
|
achievement_unlocked: '🏆',
|
||||||
|
exercise_completed: '📚',
|
||||||
|
team_joined: '👥',
|
||||||
|
rank_change: '📈',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityFeed({ activities, className, limit }: ActivityFeedProps) {
|
||||||
|
const displayedActivities = limit ? activities.slice(0, limit) : activities;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{displayedActivities.map((activity) => (
|
||||||
|
<div
|
||||||
|
key={activity.id}
|
||||||
|
className="flex items-start gap-4 rounded-lg border border-border p-4"
|
||||||
|
>
|
||||||
|
<div className="text-2xl">{activityIcons[activity.type] || '📝'}</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{activity.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{activity.description}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{formatRelativeTime(activity.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{activities.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
Нет активности
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActivityFeed;
|
||||||
76
src/components/profile/ProfileStats.tsx
Normal file
76
src/components/profile/ProfileStats.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { UserStats } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatNumber } from '../../utils/formatters';
|
||||||
|
import { Swords, Trophy, Target, TrendingUp, Clock, Award } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ProfileStatsProps {
|
||||||
|
stats: UserStats;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileStats({ stats, className }: ProfileStatsProps) {
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: 'Матчей сыграно',
|
||||||
|
value: stats.matchesPlayed,
|
||||||
|
icon: Swords,
|
||||||
|
color: 'text-blue-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Побед',
|
||||||
|
value: stats.matchesWon,
|
||||||
|
icon: Trophy,
|
||||||
|
color: 'text-yellow-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Флагов захвачено',
|
||||||
|
value: stats.flagsCaptured,
|
||||||
|
icon: Target,
|
||||||
|
color: 'text-green-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Рейтинг',
|
||||||
|
value: formatNumber(stats.rating),
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: 'text-purple-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Часов в игре',
|
||||||
|
value: stats.hoursPlayed,
|
||||||
|
icon: Clock,
|
||||||
|
color: 'text-orange-400',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Достижений',
|
||||||
|
value: stats.achievements?.length || 0,
|
||||||
|
icon: Award,
|
||||||
|
color: 'text-pink-400',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3', className)}>
|
||||||
|
{statCards.map((stat) => {
|
||||||
|
const Icon = stat.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className="rounded-xl border border-border bg-card p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold">{stat.value}</p>
|
||||||
|
</div>
|
||||||
|
<div className={cn('rounded-lg p-3', stat.color.replace('text-', 'bg-').replace('400', '500/20'))}>
|
||||||
|
<Icon className={cn('h-6 w-6', stat.color)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProfileStats;
|
||||||
4
src/components/profile/index.ts
Normal file
4
src/components/profile/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Export all profile components
|
||||||
|
export { AchievementBadge } from './AchievementBadge';
|
||||||
|
export { ProfileStats } from './ProfileStats';
|
||||||
|
export { ActivityFeed } from './ActivityFeed';
|
||||||
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { ScoreBreakdown as ScoreBreakdownType } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatNumber } from '../../utils/formatters';
|
||||||
|
|
||||||
|
interface ScoreBreakdownProps {
|
||||||
|
breakdown: ScoreBreakdownType;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreBreakdown({ breakdown, className }: ScoreBreakdownProps) {
|
||||||
|
const total = breakdown.totalAttack + breakdown.totalDefense + breakdown.totalSLA;
|
||||||
|
|
||||||
|
const attackPercent = total > 0 ? (breakdown.totalAttack / total) * 100 : 0;
|
||||||
|
const defensePercent = total > 0 ? (breakdown.totalDefense / total) * 100 : 0;
|
||||||
|
const slaPercent = total > 0 ? (breakdown.totalSLA / total) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Breakdown bars */}
|
||||||
|
<div className="h-4 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div
|
||||||
|
className="bg-red-500 transition-all"
|
||||||
|
style={{ width: `${attackPercent}%` }}
|
||||||
|
title={`Атака: ${formatNumber(breakdown.totalAttack)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${defensePercent}%` }}
|
||||||
|
title={`Защита: ${formatNumber(breakdown.totalDefense)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="bg-green-500 transition-all"
|
||||||
|
style={{ width: `${slaPercent}%` }}
|
||||||
|
title={`SLA: ${formatNumber(breakdown.totalSLA)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Атака</div>
|
||||||
|
<div className="text-lg font-bold text-red-400">
|
||||||
|
{formatNumber(breakdown.totalAttack)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{attackPercent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Защита</div>
|
||||||
|
<div className="text-lg font-bold text-blue-400">
|
||||||
|
{formatNumber(breakdown.totalDefense)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{defensePercent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">SLA</div>
|
||||||
|
<div className="text-lg font-bold text-green-400">
|
||||||
|
{formatNumber(breakdown.totalSLA)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{slaPercent.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Penalties and bonuses */}
|
||||||
|
{(breakdown.penalties !== 0 || breakdown.bonuses !== 0) && (
|
||||||
|
<div className="flex justify-between border-t border-border pt-4">
|
||||||
|
{breakdown.penalties !== 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Штрафы: </span>
|
||||||
|
<span className="text-destructive">-{formatNumber(breakdown.penalties)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{breakdown.bonuses !== 0 && (
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="text-muted-foreground">Бонусы: </span>
|
||||||
|
<span className="text-green-400">+{formatNumber(breakdown.bonuses)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service breakdown */}
|
||||||
|
{breakdown.services.length > 0 && (
|
||||||
|
<div className="space-y-2 border-t border-border pt-4">
|
||||||
|
<div className="text-sm font-medium">По сервисам:</div>
|
||||||
|
{breakdown.services.map((service) => (
|
||||||
|
<div key={service.serviceId} className="flex items-center justify-between text-sm">
|
||||||
|
<span>{service.serviceName}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-red-400">+{service.attackScore}</span>
|
||||||
|
<span className="text-xs text-blue-400">+{service.defenseScore}</span>
|
||||||
|
<span className="text-xs text-green-400">+{service.slaScore}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScoreBreakdown;
|
||||||
150
src/components/scoreboard/ScoreChart.tsx
Normal file
150
src/components/scoreboard/ScoreChart.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { ScoreHistory } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { getTeamColor } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ScoreChartProps {
|
||||||
|
history: ScoreHistory[];
|
||||||
|
height?: number;
|
||||||
|
showLegend?: boolean;
|
||||||
|
showGrid?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreChart({
|
||||||
|
history,
|
||||||
|
height = 300,
|
||||||
|
showLegend = true,
|
||||||
|
showGrid = true,
|
||||||
|
className,
|
||||||
|
}: ScoreChartProps) {
|
||||||
|
// Get all rounds
|
||||||
|
const allRounds = useMemo(() => {
|
||||||
|
const rounds = new Set<number>();
|
||||||
|
history.forEach((h) => {
|
||||||
|
h.history.forEach((point) => rounds.add(point.round));
|
||||||
|
});
|
||||||
|
return Array.from(rounds).sort((a, b) => a - b);
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
// Get max score for scaling
|
||||||
|
const maxScore = useMemo(() => {
|
||||||
|
let max = 0;
|
||||||
|
history.forEach((h) => {
|
||||||
|
h.history.forEach((point) => {
|
||||||
|
if (point.score > max) max = point.score;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return max || 100;
|
||||||
|
}, [history]);
|
||||||
|
|
||||||
|
// Generate path for a team
|
||||||
|
const generatePath = (teamHistory: ScoreHistory) => {
|
||||||
|
if (allRounds.length === 0 || teamHistory.history.length === 0) return '';
|
||||||
|
|
||||||
|
const points = allRounds.map((round) => {
|
||||||
|
const point = teamHistory.history.find((p) => p.round === round);
|
||||||
|
const score = point?.score || 0;
|
||||||
|
const x = (allRounds.indexOf(round) / (allRounds.length - 1 || 1)) * 100;
|
||||||
|
const y = 100 - (score / maxScore) * 100;
|
||||||
|
return `${x},${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return points.map((p, i) => (i === 0 ? `M ${p}` : `L ${p}`)).join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center justify-center', className)} style={{ height }}>
|
||||||
|
<p className="text-muted-foreground">Нет данных для отображения</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Chart */}
|
||||||
|
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
{/* Grid */}
|
||||||
|
{showGrid && (
|
||||||
|
<>
|
||||||
|
{[0, 25, 50, 75, 100].map((y) => (
|
||||||
|
<line
|
||||||
|
key={y}
|
||||||
|
x1="0"
|
||||||
|
y1={y}
|
||||||
|
x2="100"
|
||||||
|
y2={y}
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
strokeWidth="0.2"
|
||||||
|
strokeDasharray="2,2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lines */}
|
||||||
|
{history.map((teamHistory, index) => (
|
||||||
|
<g key={teamHistory.teamId}>
|
||||||
|
<path
|
||||||
|
d={generatePath(teamHistory)}
|
||||||
|
fill="none"
|
||||||
|
stroke={getTeamColor(index)}
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Points */}
|
||||||
|
{teamHistory.history.map((point, i) => {
|
||||||
|
const x = (allRounds.indexOf(point.round) / (allRounds.length - 1 || 1)) * 100;
|
||||||
|
const y = 100 - (point.score / maxScore) * 100;
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={i}
|
||||||
|
cx={x}
|
||||||
|
cy={y}
|
||||||
|
r="1"
|
||||||
|
fill={getTeamColor(index)}
|
||||||
|
className="opacity-0 hover:opacity-100 transition-opacity"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* X-axis labels */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
|
||||||
|
{allRounds.slice(0, 10).map((round) => (
|
||||||
|
<span key={round}>{round}</span>
|
||||||
|
))}
|
||||||
|
{allRounds.length > 10 && <span>...</span>}
|
||||||
|
{allRounds.length > 1 && <span>{allRounds[allRounds.length - 1]}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
{showLegend && (
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
{history.map((teamHistory, index) => (
|
||||||
|
<div key={teamHistory.teamId} className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-3 w-3 rounded-full"
|
||||||
|
style={{ backgroundColor: getTeamColor(index) }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{teamHistory.teamName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScoreChart;
|
||||||
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Trophy, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { ScoreboardEntry } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatNumber, formatPositionChange, formatSLA } from '../../utils/formatters';
|
||||||
|
import { getTeamColor } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ScoreboardTableProps {
|
||||||
|
entries: ScoreboardEntry[];
|
||||||
|
highlightTeamId?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
showBreakdown?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreboardTable({
|
||||||
|
entries,
|
||||||
|
highlightTeamId,
|
||||||
|
compact = false,
|
||||||
|
showBreakdown = false,
|
||||||
|
className,
|
||||||
|
}: ScoreboardTableProps) {
|
||||||
|
// Sort by position
|
||||||
|
const sortedEntries = useMemo(() => {
|
||||||
|
return [...entries].sort((a, b) => a.position - b.position);
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className={cn('px-4 py-3 text-left text-sm font-medium', compact ? 'w-12' : 'w-16')}>
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium">Команда</th>
|
||||||
|
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-24' : 'w-32')}>
|
||||||
|
Очки
|
||||||
|
</th>
|
||||||
|
{showBreakdown && (
|
||||||
|
<>
|
||||||
|
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||||
|
Атака
|
||||||
|
</th>
|
||||||
|
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||||
|
Защита
|
||||||
|
</th>
|
||||||
|
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||||
|
SLA
|
||||||
|
</th>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-20' : 'w-24')}>
|
||||||
|
Флаги
|
||||||
|
</th>
|
||||||
|
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-16' : 'w-20')}>
|
||||||
|
SLA
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedEntries.map((entry, index) => {
|
||||||
|
const isHighlighted = entry.teamId === highlightTeamId;
|
||||||
|
const isTop3 = entry.position <= 3;
|
||||||
|
const positionChange = entry.positionChange || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={entry.teamId}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border transition-colors',
|
||||||
|
isHighlighted && 'bg-primary/5',
|
||||||
|
!isHighlighted && 'hover:bg-muted/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Position */}
|
||||||
|
<td className={cn('px-4 py-3', compact ? 'text-sm' : 'text-base')}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isTop3 ? (
|
||||||
|
<Trophy
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5',
|
||||||
|
entry.position === 1 && 'text-yellow-400',
|
||||||
|
entry.position === 2 && 'text-gray-300',
|
||||||
|
entry.position === 3 && 'text-orange-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono font-medium">{entry.position}</span>
|
||||||
|
)}
|
||||||
|
{positionChange !== 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex items-center text-xs',
|
||||||
|
positionChange > 0 && 'text-green-400',
|
||||||
|
positionChange < 0 && 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{positionChange > 0 ? (
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Team */}
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||||
|
style={{ backgroundColor: getTeamColor(index) }}
|
||||||
|
>
|
||||||
|
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||||
|
{entry.teamName}
|
||||||
|
</p>
|
||||||
|
{showBreakdown && entry.teamTag && (
|
||||||
|
<p className="text-xs text-muted-foreground">{entry.teamTag}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Score */}
|
||||||
|
<td className={cn('px-4 py-3 text-right font-bold', compact ? 'text-lg' : 'text-xl')}>
|
||||||
|
{formatNumber(entry.score)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Breakdown */}
|
||||||
|
{showBreakdown && (
|
||||||
|
<>
|
||||||
|
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||||
|
<span className="text-red-400">+{formatNumber(entry.attackScore)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||||
|
<span className="text-blue-400">+{formatNumber(entry.defenseScore)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||||
|
<span className="text-green-400">+{formatNumber(entry.slaScore)}</span>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Flags */}
|
||||||
|
<td className={cn('px-4 py-3 text-right', compact ? 'text-sm' : 'text-base')}>
|
||||||
|
<span className="text-green-400">{entry.flagsCaptured}</span>
|
||||||
|
<span className="mx-1 text-muted-foreground">/</span>
|
||||||
|
<span className="text-red-400">{entry.flagsLost}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* SLA */}
|
||||||
|
<td className={cn('px-4 py-3 text-right font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||||
|
{formatSLA(entry.avgSLA)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScoreboardTable;
|
||||||
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Trophy, Target, Shield, Activity } from 'lucide-react';
|
||||||
|
import { ScoreboardEntry } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatNumber, formatSLA } from '../../utils/formatters';
|
||||||
|
import { getTeamColor } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface TeamScoreCardProps {
|
||||||
|
entry: ScoreboardEntry;
|
||||||
|
rank?: number;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamScoreCard({
|
||||||
|
entry,
|
||||||
|
rank,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
}: TeamScoreCardProps) {
|
||||||
|
const isTop3 = (rank || entry.position) <= 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||||
|
style={{ backgroundColor: getTeamColor(entry.position - 1) }}
|
||||||
|
>
|
||||||
|
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{entry.teamName}</h3>
|
||||||
|
{entry.teamTag && (
|
||||||
|
<p className="text-sm text-muted-foreground">{entry.teamTag}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isTop3 && <Trophy className="h-6 w-6 text-yellow-400" />}
|
||||||
|
<span className="text-2xl font-bold">#{entry.position}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main score */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="text-sm text-muted-foreground">Общий счёт</div>
|
||||||
|
<div className="text-4xl font-bold text-primary">{formatNumber(entry.score)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Attack */}
|
||||||
|
<div className="rounded-lg bg-red-500/10 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-red-400">
|
||||||
|
<Target className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Атака</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-red-400">
|
||||||
|
+{formatNumber(entry.attackScore)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{entry.flagsCaptured} флагов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Defense */}
|
||||||
|
<div className="rounded-lg bg-blue-500/10 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-blue-400">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Защита</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-blue-400">
|
||||||
|
+{formatNumber(entry.defenseScore)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{entry.flagsLost} потеряно
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SLA */}
|
||||||
|
<div className="rounded-lg bg-green-500/10 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-green-400">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">SLA</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-green-400">
|
||||||
|
+{formatNumber(entry.slaScore)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatSLA(entry.avgSLA)} uptime
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend */}
|
||||||
|
<div className="rounded-lg bg-purple-500/10 p-3">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-purple-400">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
<span className="text-sm font-medium">Тренд</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn('text-xl font-bold', entry.trend === 'up' && 'text-green-400', entry.trend === 'down' && 'text-red-400')}>
|
||||||
|
{entry.positionChange && entry.positionChange > 0 ? '+' : ''}
|
||||||
|
{entry.positionChange || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
позиций
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamScoreCard;
|
||||||
5
src/components/scoreboard/index.ts
Normal file
5
src/components/scoreboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Export all scoreboard components
|
||||||
|
export { ScoreboardTable } from './ScoreboardTable';
|
||||||
|
export { ScoreChart } from './ScoreChart';
|
||||||
|
export { TeamScoreCard } from './TeamScoreCard';
|
||||||
|
export { ScoreBreakdown } from './ScoreBreakdown';
|
||||||
105
src/components/services/ServiceCard.tsx
Normal file
105
src/components/services/ServiceCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Star, Download, Clock, Users } from 'lucide-react';
|
||||||
|
import { Service } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { DIFFICULTY_LABELS, SERVICE_CATEGORY_LABELS } from '../../utils/constants';
|
||||||
|
import { DIFFICULTY_COLORS, CATEGORY_COLORS } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ServiceCardProps {
|
||||||
|
service: Service;
|
||||||
|
className?: string;
|
||||||
|
showAuthor?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceCard({ service, className, showAuthor = true }: ServiceCardProps) {
|
||||||
|
const difficultyColor = DIFFICULTY_COLORS[service.difficulty as keyof typeof DIFFICULTY_COLORS];
|
||||||
|
const categoryColor = CATEGORY_COLORS[service.category] || '#666';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/services/${service.id}`}
|
||||||
|
className={cn(
|
||||||
|
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="rounded px-2 py-0.5 text-xs font-medium"
|
||||||
|
style={{ backgroundColor: `${categoryColor}20`, color: categoryColor }}
|
||||||
|
>
|
||||||
|
{SERVICE_CATEGORY_LABELS[service.category]}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="rounded px-2 py-0.5 text-xs font-medium"
|
||||||
|
style={{ backgroundColor: `${difficultyColor}20`, color: difficultyColor }}
|
||||||
|
>
|
||||||
|
{DIFFICULTY_LABELS[service.difficulty]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold group-hover:text-primary">
|
||||||
|
{service.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{service.isFeatured && (
|
||||||
|
<span className="rounded bg-yellow-400/20 px-2 py-1 text-xs font-medium text-yellow-400">
|
||||||
|
Featured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{service.tags.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
{service.tags.slice(0, 4).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Star className="h-4 w-4" />
|
||||||
|
<span>{service.rating.average.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span>{service.stats.timesUsed}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{service.stats.uniqueTeams}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{showAuthor && (
|
||||||
|
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Автор: {service.author?.displayName || service.author?.username}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(service.updatedAt).toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceCard;
|
||||||
204
src/components/services/ServiceFilter.tsx
Normal file
204
src/components/services/ServiceFilter.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { Filter, X } from 'lucide-react';
|
||||||
|
import { ServiceCategory, ServiceDifficulty } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { SERVICE_CATEGORY_LABELS, DIFFICULTY_LABELS } from '../../utils/constants';
|
||||||
|
import { CATEGORY_COLORS, DIFFICULTY_COLORS } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ServiceFilterProps {
|
||||||
|
selectedCategory?: ServiceCategory | null;
|
||||||
|
selectedDifficulty?: ServiceDifficulty | null;
|
||||||
|
selectedStack?: string[];
|
||||||
|
selectedTags?: string[];
|
||||||
|
minRating?: number | null;
|
||||||
|
onCategoryChange?: (category: ServiceCategory | null) => void;
|
||||||
|
onDifficultyChange?: (difficulty: ServiceDifficulty | null) => void;
|
||||||
|
onStackChange?: (stack: string[]) => void;
|
||||||
|
onTagsChange?: (tags: string[]) => void;
|
||||||
|
onMinRatingChange?: (rating: number | null) => void;
|
||||||
|
onClear?: () => void;
|
||||||
|
availableStacks?: { stack: string; count: number }[];
|
||||||
|
availableTags?: { tag: string; count: number }[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: ServiceCategory[] = ['web', 'crypto', 'pwn', 'reverse', 'forensics', 'network', 'misc', 'blockchain', 'hardware'];
|
||||||
|
const difficulties: ServiceDifficulty[] = ['beginner', 'easy', 'medium', 'hard', 'expert', 'insane'];
|
||||||
|
|
||||||
|
export function ServiceFilter({
|
||||||
|
selectedCategory,
|
||||||
|
selectedDifficulty,
|
||||||
|
selectedStack = [],
|
||||||
|
selectedTags = [],
|
||||||
|
minRating,
|
||||||
|
onCategoryChange,
|
||||||
|
onDifficultyChange,
|
||||||
|
onStackChange,
|
||||||
|
onTagsChange,
|
||||||
|
onMinRatingChange,
|
||||||
|
onClear,
|
||||||
|
availableStacks = [],
|
||||||
|
availableTags = [],
|
||||||
|
className,
|
||||||
|
}: ServiceFilterProps) {
|
||||||
|
const hasActiveFilters = selectedCategory || selectedDifficulty || selectedStack.length > 0 || selectedTags.length > 0 || minRating;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">Фильтры</span>
|
||||||
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Категория</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const isSelected = selectedCategory === category;
|
||||||
|
const color = CATEGORY_COLORS[category] || '#666';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
onClick={() => onCategoryChange?.(isSelected ? null : category)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{SERVICE_CATEGORY_LABELS[category]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Difficulty */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Сложность</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{difficulties.map((difficulty) => {
|
||||||
|
const isSelected = selectedDifficulty === difficulty;
|
||||||
|
const color = DIFFICULTY_COLORS[difficulty];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={difficulty}
|
||||||
|
onClick={() => onDifficultyChange?.(isSelected ? null : difficulty)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{DIFFICULTY_LABELS[difficulty]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stack */}
|
||||||
|
{availableStacks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Стек технологий</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableStacks.slice(0, 10).map(({ stack, count }) => {
|
||||||
|
const isSelected = selectedStack.includes(stack);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={stack}
|
||||||
|
onClick={() => {
|
||||||
|
const newStack = isSelected
|
||||||
|
? selectedStack.filter((s) => s !== stack)
|
||||||
|
: [...selectedStack, stack];
|
||||||
|
onStackChange?.(newStack);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stack} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Теги</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{availableTags.slice(0, 15).map(({ tag, count }) => {
|
||||||
|
const isSelected = selectedTags.includes(tag);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
onClick={() => {
|
||||||
|
const newTags = isSelected
|
||||||
|
? selectedTags.filter((t) => t !== tag)
|
||||||
|
: [...selectedTags, tag];
|
||||||
|
onTagsChange?.(newTags);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-1.5 text-xs transition-colors',
|
||||||
|
isSelected
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tag} ({count})
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Минимальный рейтинг</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0, 1, 2, 3, 4].map((rating) => (
|
||||||
|
<button
|
||||||
|
key={rating}
|
||||||
|
onClick={() => onMinRatingChange?.(minRating === rating ? null : rating)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||||
|
minRating === rating
|
||||||
|
? 'border-primary bg-primary/10 text-primary'
|
||||||
|
: 'border-border hover:bg-accent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{rating}+ ★
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceFilter;
|
||||||
47
src/components/services/ServiceStatusIndicator.tsx
Normal file
47
src/components/services/ServiceStatusIndicator.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { CHECKER_RESULT_LABELS } from '../../utils/constants';
|
||||||
|
import { CHECKER_COLORS } from '../../utils/colors';
|
||||||
|
|
||||||
|
interface ServiceStatusIndicatorProps {
|
||||||
|
status: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showLabel?: boolean;
|
||||||
|
pulse?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-2 w-2',
|
||||||
|
md: 'h-3 w-3',
|
||||||
|
lg: 'h-4 w-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ServiceStatusIndicator({
|
||||||
|
status,
|
||||||
|
size = 'md',
|
||||||
|
showLabel = false,
|
||||||
|
pulse = status === 'down' || status === 'error',
|
||||||
|
className,
|
||||||
|
}: ServiceStatusIndicatorProps) {
|
||||||
|
const color = CHECKER_COLORS[status as keyof typeof CHECKER_COLORS] || '#666';
|
||||||
|
const label = CHECKER_RESULT_LABELS[status] || status;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-full',
|
||||||
|
sizeClasses[size],
|
||||||
|
pulse && 'animate-pulse'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
title={label}
|
||||||
|
/>
|
||||||
|
{showLabel && (
|
||||||
|
<span className="text-sm font-medium">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceStatusIndicator;
|
||||||
4
src/components/services/index.ts
Normal file
4
src/components/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Export all services components
|
||||||
|
export { ServiceCard } from './ServiceCard';
|
||||||
|
export { ServiceFilter } from './ServiceFilter';
|
||||||
|
export { ServiceStatusIndicator } from './ServiceStatusIndicator';
|
||||||
182
src/components/team/TeamInviteForm.tsx
Normal file
182
src/components/team/TeamInviteForm.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Loader2, UserPlus } from 'lucide-react';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { SearchInput } from '../common/SearchInput';
|
||||||
|
import { useDebounce } from '../../hooks/useDebounce';
|
||||||
|
|
||||||
|
const inviteSchema = z.object({
|
||||||
|
userId: z.string().min(1, 'Выберите пользователя'),
|
||||||
|
role: z.enum(['captain', 'co-captain', 'member', 'substitute', 'coach']),
|
||||||
|
message: z.string().max(200, 'Максимум 200 символов').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type InviteFormData = z.infer<typeof inviteSchema>;
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamInviteFormProps {
|
||||||
|
onSubmit: (data: InviteFormData) => Promise<void>;
|
||||||
|
searchUsers: (query: string) => Promise<User[]>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamInviteForm({
|
||||||
|
onSubmit,
|
||||||
|
searchUsers,
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
}: TeamInviteFormProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<User[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<InviteFormData>({
|
||||||
|
resolver: zodResolver(inviteSchema),
|
||||||
|
defaultValues: {
|
||||||
|
role: 'member',
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedUserId = watch('userId');
|
||||||
|
|
||||||
|
// Search users
|
||||||
|
useState(() => {
|
||||||
|
if (debouncedSearch.length >= 2) {
|
||||||
|
setIsSearching(true);
|
||||||
|
searchUsers(debouncedSearch)
|
||||||
|
.then(setSearchResults)
|
||||||
|
.finally(() => setIsSearching(false));
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectUser = (user: User) => {
|
||||||
|
setValue('userId', user.id);
|
||||||
|
setSearchQuery(`${user.displayName || user.username} (@${user.username})`);
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitForm = async (data: InviteFormData) => {
|
||||||
|
try {
|
||||||
|
await onSubmit(data);
|
||||||
|
setValue('userId', '');
|
||||||
|
setSearchQuery('');
|
||||||
|
} catch {
|
||||||
|
// Error handled by parent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleSubmitForm)} className={cn('space-y-4', className)}>
|
||||||
|
{/* User search */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Пользователь</label>
|
||||||
|
<div className="relative">
|
||||||
|
<SearchInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
if (!value) setValue('userId', '');
|
||||||
|
}}
|
||||||
|
placeholder="Поиск пользователя..."
|
||||||
|
showClearButton
|
||||||
|
/>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-border bg-card shadow-lg">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
className="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-sm font-bold text-primary">
|
||||||
|
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{user.displayName || user.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.userId && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.userId.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">Роль</label>
|
||||||
|
<select
|
||||||
|
{...register('role')}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="member">Участник</option>
|
||||||
|
<option value="co-captain">Со-капитан</option>
|
||||||
|
<option value="substitute">Запасной</option>
|
||||||
|
<option value="coach">Тренер</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">
|
||||||
|
Сообщение <span className="text-muted-foreground">(опционально)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
{...register('message')}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Пригласительное сообщение..."
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
{errors.message && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{errors.message.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !selectedUserId}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
Отправка...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="h-5 w-5" />
|
||||||
|
Пригласить
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamInviteForm;
|
||||||
77
src/components/team/TeamMatchHistory.tsx
Normal file
77
src/components/team/TeamMatchHistory.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Swords, Trophy, Target, Activity } from 'lucide-react';
|
||||||
|
import { TeamMatchHistory as TeamMatchHistoryType } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { formatDateTime, formatNumber } from '../../utils/formatters';
|
||||||
|
import { StatusBadge } from '../common/StatusBadge';
|
||||||
|
|
||||||
|
interface TeamMatchHistoryProps {
|
||||||
|
matches: TeamMatchHistoryType[];
|
||||||
|
className?: string;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamMatchHistory({
|
||||||
|
matches,
|
||||||
|
className,
|
||||||
|
limit,
|
||||||
|
}: TeamMatchHistoryProps) {
|
||||||
|
const displayedMatches = limit ? matches.slice(0, limit) : matches;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
{displayedMatches.map((match) => (
|
||||||
|
<Link
|
||||||
|
key={match.matchId}
|
||||||
|
to={`/matches/${match.matchId}`}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-10 items-center justify-center rounded-full',
|
||||||
|
match.position === 1 && 'bg-yellow-400/20 text-yellow-400',
|
||||||
|
match.position === 2 && 'bg-gray-400/20 text-gray-400',
|
||||||
|
match.position === 3 && 'bg-orange-400/20 text-orange-400',
|
||||||
|
match.position > 3 && 'bg-muted text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{match.position === 1 ? (
|
||||||
|
<Trophy className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<span className="font-bold">#{match.position}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{match.matchTitle}</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{formatDateTime(match.playedAt)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="capitalize">{match.mode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-primary">
|
||||||
|
{formatNumber(match.score)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{match.flagsCaptured}/{match.flagsLost} флаги
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={match.position <= 3 ? 'finished' : 'finished'} type="match" size="sm" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{matches.length === 0 && (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
<Swords className="mx-auto h-12 w-12 opacity-50" />
|
||||||
|
<p className="mt-2">Нет сыгранных матчей</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamMatchHistory;
|
||||||
91
src/components/team/TeamMemberCard.tsx
Normal file
91
src/components/team/TeamMemberCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { User, Crown, Shield, Star } from 'lucide-react';
|
||||||
|
import { TeamMember } from '../../api/types';
|
||||||
|
import { cn } from '../../utils/helpers';
|
||||||
|
import { TEAM_ROLE_LABELS, SPECIALIZATION_LABELS } from '../../utils/constants';
|
||||||
|
|
||||||
|
interface TeamMemberCardProps {
|
||||||
|
member: TeamMember;
|
||||||
|
isCurrentUser?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onRoleChange?: (role: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
captain: Crown,
|
||||||
|
'co-captain': Shield,
|
||||||
|
member: User,
|
||||||
|
substitute: Star,
|
||||||
|
coach: Shield,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TeamMemberCard({
|
||||||
|
member,
|
||||||
|
isCurrentUser = false,
|
||||||
|
canEdit = false,
|
||||||
|
className,
|
||||||
|
onRemove,
|
||||||
|
onRoleChange,
|
||||||
|
}: TeamMemberCardProps) {
|
||||||
|
const RoleIcon = roleIcons[member.role] || User;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between rounded-lg border border-border p-4',
|
||||||
|
!member.isActive && 'opacity-60',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/20 text-lg font-bold text-primary">
|
||||||
|
{member.user.displayName?.[0]?.toUpperCase() ||
|
||||||
|
member.user.username[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">
|
||||||
|
{member.user.displayName || member.user.username}
|
||||||
|
</span>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<span className="rounded bg-primary/20 px-2 py-0.5 text-xs text-primary">
|
||||||
|
Вы
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!member.isActive && (
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
|
Не активен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<RoleIcon className="h-4 w-4" />
|
||||||
|
<span>{TEAM_ROLE_LABELS[member.role]}</span>
|
||||||
|
{member.specialization && (
|
||||||
|
<>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{SPECIALIZATION_LABELS[member.specialization]}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{canEdit && !isCurrentUser && onRemove && (
|
||||||
|
<button
|
||||||
|
onClick={onRemove}
|
||||||
|
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TeamMemberCard;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user