This commit is contained in:
2026-08-12 19:30:17 +03:00
parent 40c324d7fe
commit b0b7196518
152 changed files with 24657 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

23
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;
}

View 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
View 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
}

View 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
View 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
View 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
View 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';
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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;
}

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
// 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(
/^&gt;\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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,4 @@
// Export all profile components
export { AchievementBadge } from './AchievementBadge';
export { ProfileStats } from './ProfileStats';
export { ActivityFeed } from './ActivityFeed';

View 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;

View 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;

View 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;

View 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;

View 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';

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,4 @@
// Export all services components
export { ServiceCard } from './ServiceCard';
export { ServiceFilter } from './ServiceFilter';
export { ServiceStatusIndicator } from './ServiceStatusIndicator';

View 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;

View 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;

View 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