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