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

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;

View File

@@ -0,0 +1,69 @@
import { Crown, Shield, User, Star } from 'lucide-react';
import { TeamRole } from '../../api/types';
import { cn } from '../../utils/helpers';
import { TEAM_ROLE_LABELS } from '../../utils/constants';
interface TeamRoleSelectorProps {
value: TeamRole;
onChange: (role: TeamRole) => void;
disabled?: boolean;
className?: string;
}
const roles: { value: TeamRole; icon: React.ComponentType<{ className?: string }>; description: string }[] = [
{ value: 'member', icon: User, description: 'Участник команды' },
{ value: 'substitute', icon: Star, description: 'Запасной игрок' },
{ value: 'coach', icon: Shield, description: 'Тренер' },
{ value: 'co-captain', icon: Shield, description: 'Со-капитан' },
{ value: 'captain', icon: Crown, description: 'Капитан' },
];
export function TeamRoleSelector({
value,
onChange,
disabled = false,
className,
}: TeamRoleSelectorProps) {
return (
<div className={cn('space-y-2', className)}>
<label className="block text-sm font-medium">Роль в команде</label>
<div className="grid gap-2 sm:grid-cols-2">
{roles.map((role) => {
const Icon = role.icon;
const isSelected = value === role.value;
return (
<button
key={role.value}
type="button"
onClick={() => onChange(role.value)}
disabled={disabled}
className={cn(
'flex items-center gap-3 rounded-lg border p-4 text-left transition-colors',
'disabled:cursor-not-allowed disabled:opacity-50',
isSelected
? 'border-primary bg-primary/10'
: 'border-border hover:bg-accent'
)}
>
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
isSelected ? 'bg-primary text-primary-foreground' : 'bg-muted'
)}
>
<Icon className="h-5 w-5" />
</div>
<div>
<p className="font-medium">{TEAM_ROLE_LABELS[role.value]}</p>
<p className="text-xs text-muted-foreground">{role.description}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
export default TeamRoleSelector;

View File

@@ -0,0 +1,5 @@
// Export all team components
export { TeamMemberCard } from './TeamMemberCard';
export { TeamInviteForm } from './TeamInviteForm';
export { TeamRoleSelector } from './TeamRoleSelector';
export { TeamMatchHistory } from './TeamMatchHistory';

View File

@@ -0,0 +1,111 @@
import { Link } from 'react-router-dom';
import { CheckCircle, Circle, Lock, Award, Clock } from 'lucide-react';
import { Exercise } from '../../api/types';
import { cn } from '../../utils/helpers';
import { DIFFICULTY_LABELS } from '../../utils/constants';
import { DIFFICULTY_COLORS } from '../../utils/colors';
interface ExerciseCardProps {
exercise: Exercise;
isCompleted?: boolean;
isLocked?: boolean;
className?: string;
}
export function ExerciseCard({
exercise,
isCompleted = false,
isLocked = false,
className,
}: ExerciseCardProps) {
const difficultyColor = DIFFICULTY_COLORS[exercise.difficulty as keyof typeof DIFFICULTY_COLORS];
return (
<div
className={cn(
'group rounded-xl border border-border bg-card p-6 transition-all',
isLocked && 'opacity-60',
isCompleted && 'border-green-500/50 bg-green-500/5',
!isLocked && 'hover:border-primary/50 hover:shadow-lg',
className
)}
>
{/* Header */}
<div className="mb-4 flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-lg',
isCompleted
? 'bg-green-500/20 text-green-400'
: isLocked
? 'bg-muted text-muted-foreground'
: 'bg-primary/20 text-primary'
)}
>
{isCompleted ? (
<CheckCircle className="h-6 w-6" />
) : isLocked ? (
<Lock className="h-6 w-6" />
) : (
<Circle className="h-6 w-6" />
)}
</div>
<div>
<h3 className={cn('font-semibold', isLocked && 'text-muted-foreground')}>
{exercise.title}
</h3>
<p className="text-sm text-muted-foreground">
Упражнение #{exercise.order}
</p>
</div>
</div>
{exercise.points > 0 && (
<div className="flex items-center gap-1 text-sm font-medium text-yellow-400">
<Award className="h-4 w-4" />
<span>{exercise.points}</span>
</div>
)}
</div>
{/* Description */}
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
{exercise.description}
</p>
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-4 w-4" />
<span>~{exercise.estimatedMinutes} мин</span>
</div>
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${difficultyColor}20`, color: difficultyColor }}
>
{DIFFICULTY_LABELS[exercise.difficulty]}
</span>
<span className="rounded bg-muted px-2 py-0.5 text-xs capitalize">
{exercise.type}
</span>
</div>
{/* Action */}
{!isLocked && (
<Link
to={`/training/${exercise.trackId}/exercises/${exercise.id}`}
className={cn(
'mt-4 flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors',
isCompleted
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
{isCompleted ? 'Повторить' : 'Начать'}
</Link>
)}
</div>
);
}
export default ExerciseCard;

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { ChevronDown, Lightbulb, Lock, Check } from 'lucide-react';
import { Hint } from '../../api/types';
import { cn } from '../../utils/helpers';
interface HintAccordionProps {
hints: Hint[];
usedHintIds?: string[];
onUseHint?: (hintId: string) => void;
className?: string;
}
export function HintAccordion({
hints,
usedHintIds = [],
onUseHint,
className,
}: HintAccordionProps) {
const [expandedHint, setExpandedHint] = useState<string | null>(null);
const sortedHints = [...hints].sort((a, b) => a.order - b.order);
return (
<div className={cn('space-y-2', className)}>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Lightbulb className="h-4 w-4" />
Подсказки ({usedHintIds.length}/{hints.length})
</div>
{sortedHints.map((hint, index) => {
const isUsed = usedHintIds.includes(hint.id);
const isExpanded = expandedHint === hint.id;
const isLocked = index > 0 && !usedHintIds.includes(sortedHints[index - 1].id);
return (
<div
key={hint.id}
className={cn(
'overflow-hidden rounded-lg border transition-all',
isUsed && 'border-green-500/50 bg-green-500/5',
isLocked && 'border-muted bg-muted/30',
!isLocked && !isUsed && 'border-border hover:border-primary/50'
)}
>
{/* Header */}
<button
onClick={() => {
if (!isLocked) {
setExpandedHint(isExpanded ? null : hint.id);
if (!isUsed && onUseHint) {
onUseHint(hint.id);
}
}
}}
disabled={isLocked}
className={cn(
'flex w-full items-center justify-between px-4 py-3 text-left',
isLocked && 'cursor-not-allowed opacity-60'
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full',
isUsed
? 'bg-green-500/20 text-green-400'
: isLocked
? 'bg-muted text-muted-foreground'
: 'bg-yellow-500/20 text-yellow-400'
)}
>
{isUsed ? (
<Check className="h-4 w-4" />
) : isLocked ? (
<Lock className="h-4 w-4" />
) : (
<Lightbulb className="h-4 w-4" />
)}
</div>
<div>
<p className={cn('font-medium', isLocked && 'text-muted-foreground')}>
{hint.title}
</p>
{!isLocked && !isUsed && (
<p className="text-xs text-yellow-400">
-{hint.costPercentage}% к очкам
</p>
)}
</div>
</div>
<ChevronDown
className={cn(
'h-5 w-5 text-muted-foreground transition-transform',
isExpanded && 'rotate-180'
)}
/>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-border px-4 py-3 text-sm">
<p className="text-foreground">{hint.content}</p>
</div>
)}
</div>
);
})}
</div>
);
}
export default HintAccordion;

View File

@@ -0,0 +1,57 @@
import { cn } from '../../utils/helpers';
interface ProgressBarProps {
value: number;
max?: number;
size?: 'sm' | 'md' | 'lg';
showValue?: boolean;
color?: string;
className?: string;
label?: string;
}
const sizeClasses = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
};
export function ProgressBar({
value,
max = 100,
size = 'md',
showValue = false,
color = 'bg-primary',
className,
label,
}: ProgressBarProps) {
const percentage = Math.min(100, Math.max(0, (value / max) * 100));
return (
<div className={cn('w-full', className)}>
{(label || showValue) && (
<div className="mb-2 flex items-center justify-between text-sm">
{label && <span className="text-muted-foreground">{label}</span>}
{showValue && (
<span className="font-medium text-primary">
{percentage.toFixed(0)}%
</span>
)}
</div>
)}
<div
className={cn(
'overflow-hidden rounded-full bg-muted',
sizeClasses[size]
)}
>
<div
className={cn('h-full transition-all', color)}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
}
export default ProgressBar;

View File

@@ -0,0 +1,35 @@
import { cn } from '../../utils/helpers';
import { TRACK_ROLE_LABELS } from '../../utils/constants';
import { ROLE_COLORS } from '../../utils/colors';
interface RoleBadgeProps {
role: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-1.5 text-base',
};
export function RoleBadge({ role, size = 'md', className }: RoleBadgeProps) {
const color = ROLE_COLORS[role] || '#666';
const label = TRACK_ROLE_LABELS[role] || role;
return (
<span
className={cn(
'inline-flex items-center rounded-full font-medium',
sizeClasses[size],
className
)}
style={{ backgroundColor: `${color}20`, color }}
>
{label}
</span>
);
}
export default RoleBadge;

View File

@@ -0,0 +1,174 @@
import { useMemo } from 'react';
import { SkillLevel } from '../../api/types';
import { cn } from '../../utils/helpers';
interface SkillRadarChartProps {
skills: SkillLevel[];
size?: number;
className?: string;
}
const skillColors: Record<string, string> = {
offensive: '#ef4444',
defensive: '#3b82f6',
analysis: '#8b5cf6',
reverse_engineering: '#f59e0b',
cryptography: '#14b8a6',
networking: '#06b6d4',
programming: '#22c55e',
infrastructure: '#f97316',
};
export function SkillRadarChart({
skills,
size = 300,
className,
}: SkillRadarChartProps) {
const centerX = size / 2;
const centerY = size / 2;
const radius = (size / 2) - 40;
// Group skills by category and get max level
const skillsByCategory = useMemo(() => {
const grouped: Record<string, { current: number; max: number }> = {};
skills.forEach((skill) => {
if (!grouped[skill.category]) {
grouped[skill.category] = { current: 0, max: skill.maxLevel };
}
grouped[skill.category].current += skill.level;
});
return Object.entries(grouped).map(([category, data]) => ({
category,
level: data.current,
maxLevel: data.max * Math.ceil(skills.filter((s) => s.category === category).length / 2),
}));
}, [skills]);
// Generate polygon points
const points = useMemo(() => {
const angleStep = (Math.PI * 2) / skillsByCategory.length;
return skillsByCategory.map((skill, index) => {
const angle = index * angleStep - Math.PI / 2;
const normalizedLevel = skill.level / skill.maxLevel;
const r = radius * normalizedLevel;
return {
x: centerX + r * Math.cos(angle),
y: centerY + r * Math.sin(angle),
category: skill.category,
level: skill.level,
maxLevel: skill.maxLevel,
};
});
}, [skillsByCategory, centerX, centerY, radius]);
const pathD = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`
).join(' ') + ' Z';
// Background circles
const circles = [0.25, 0.5, 0.75, 1].map((ratio, i) => (
<circle
key={i}
cx={centerX}
cy={centerY}
r={radius * ratio}
fill="none"
stroke="hsl(var(--border))"
strokeWidth="1"
strokeDasharray="2,2"
/>
));
// Category labels
const labels = points.map((point, i) => {
const angle = i * ((Math.PI * 2) / points.length) - Math.PI / 2;
const labelRadius = radius + 20;
return (
<text
key={point.category}
x={centerX + labelRadius * Math.cos(angle)}
y={centerY + labelRadius * Math.sin(angle)}
textAnchor="middle"
dominantBaseline="middle"
className="fill-muted-foreground text-xs"
style={{ fontSize: '10px' }}
>
{point.category === 'offensive' && 'Атака'}
{point.category === 'defensive' && 'Защита'}
{point.category === 'analysis' && 'Анализ'}
{point.category === 'reverse_engineering' && 'Реверс'}
{point.category === 'cryptography' && 'Крипто'}
{point.category === 'networking' && 'Сети'}
{point.category === 'programming' && 'Кодинг'}
{point.category === 'infrastructure' && 'Инфра'}
</text>
);
});
return (
<div className={cn('flex flex-col items-center', className)}>
<svg width={size} height={size} className="overflow-visible">
{/* Background circles */}
{circles}
{/* Axis lines */}
{points.map((point, i) => (
<line
key={i}
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos(i * ((Math.PI * 2) / points.length) - Math.PI / 2)}
y2={centerY + radius * Math.sin(i * ((Math.PI * 2) / points.length) - Math.PI / 2)}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
))}
{/* Skill polygon */}
<path
d={pathD}
fill="hsl(var(--primary) / 0.2)"
stroke="hsl(var(--primary))"
strokeWidth="2"
/>
{/* Points */}
{points.map((point, i) => (
<circle
key={i}
cx={point.x}
cy={point.y}
r="4"
fill="hsl(var(--primary))"
className="transition-all hover:r-6"
/>
))}
{/* Labels */}
{labels}
</svg>
{/* Legend */}
<div className="mt-4 flex flex-wrap justify-center gap-4">
{skillsByCategory.map((skill) => (
<div key={skill.category} className="flex items-center gap-2 text-xs">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: skillColors[skill.category] || '#666' }}
/>
<span className="text-muted-foreground">
{skill.level}/{skill.maxLevel}
</span>
</div>
))}
</div>
</div>
);
}
export default SkillRadarChart;

View File

@@ -0,0 +1,110 @@
import { Link } from 'react-router-dom';
import { BookOpen, Clock, Award, Users } from 'lucide-react';
import { Track } from '../../api/types';
import { cn } from '../../utils/helpers';
import { DIFFICULTY_LABELS, TRACK_ROLE_LABELS } from '../../utils/constants';
import { DIFFICULTY_COLORS, ROLE_COLORS } from '../../utils/colors';
interface TrackCardProps {
track: Track;
className?: string;
showProgress?: boolean;
progress?: number;
}
export function TrackCard({ track, className, showProgress = false, progress = 0 }: TrackCardProps) {
const difficultyColor = DIFFICULTY_COLORS[track.difficulty as keyof typeof DIFFICULTY_COLORS];
const roleColor = ROLE_COLORS[track.role] || '#666';
return (
<Link
to={`/training/${track.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="flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-xl text-white"
style={{ backgroundColor: roleColor }}
>
<BookOpen className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold group-hover:text-primary">
{track.name}
</h3>
<p className="text-sm text-muted-foreground">
{TRACK_ROLE_LABELS[track.role]}
</p>
</div>
</div>
{track.isFeatured && (
<span className="rounded bg-yellow-400/20 px-2 py-1 text-xs font-medium text-yellow-400">
Популярный
</span>
)}
</div>
{/* Description */}
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
{track.description}
</p>
{/* Tags */}
<div className="mb-4 flex flex-wrap gap-2">
<span
className="rounded px-2 py-0.5 text-xs font-medium"
style={{ backgroundColor: `${difficultyColor}20`, color: difficultyColor }}
>
{DIFFICULTY_LABELS[track.difficulty]}
</span>
{track.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{tag}
</span>
))}
</div>
{/* Stats */}
<div className="mb-4 grid grid-cols-3 gap-4 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" />
<span>{track.estimatedHours}ч</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Award className="h-4 w-4" />
<span>{track.totalExercises} упр.</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>{track.enrolledCount}</span>
</div>
</div>
{/* Progress */}
{showProgress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Прогресс</span>
<span className="font-medium text-primary">{progress.toFixed(0)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</Link>
);
}
export default TrackCard;

View File

@@ -0,0 +1,7 @@
// Export all training components
export { TrackCard } from './TrackCard';
export { ExerciseCard } from './ExerciseCard';
export { ProgressBar } from './ProgressBar';
export { SkillRadarChart } from './SkillRadarChart';
export { RoleBadge } from './RoleBadge';
export { HintAccordion } from './HintAccordion';