upd
This commit is contained in:
119
src/components/analytics/AIRecommendationCard.tsx
Normal file
119
src/components/analytics/AIRecommendationCard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Lightbulb, TrendingUp, Target, BookOpen } from 'lucide-react';
|
||||
import { AIRecommendation } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface AIRecommendationCardProps {
|
||||
recommendation: AIRecommendation;
|
||||
className?: string;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
low: 'bg-blue-500/20 text-blue-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-orange-500/20 text-orange-400',
|
||||
critical: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
const typeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
skill_improvement: BookOpen,
|
||||
strategy_change: TrendingUp,
|
||||
service_focus: Target,
|
||||
team_coordination: Lightbulb,
|
||||
training_suggestion: BookOpen,
|
||||
role_adjustment: Target,
|
||||
tool_recommendation: Lightbulb,
|
||||
resource_allocation: TrendingUp,
|
||||
};
|
||||
|
||||
export function AIRecommendationCard({
|
||||
recommendation,
|
||||
className,
|
||||
onDismiss,
|
||||
}: AIRecommendationCardProps) {
|
||||
const Icon = typeIcons[recommendation.type] || Lightbulb;
|
||||
const priorityColor = priorityColors[recommendation.priority];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('rounded-lg p-2', priorityColor)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{recommendation.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{recommendation.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rationale */}
|
||||
{recommendation.rationale && (
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
{recommendation.rationale}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action items */}
|
||||
{recommendation.actionItems.length > 0 && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="text-sm font-medium">Действия:</div>
|
||||
{recommendation.actionItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2 rounded-lg bg-muted/50 p-2 text-sm"
|
||||
>
|
||||
<div className="mt-0.5 h-2 w-2 rounded-full bg-primary" />
|
||||
<span>{item.action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="font-medium">Влияние:</span>{' '}
|
||||
{(recommendation.estimatedImpact * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Усилия:</span>{' '}
|
||||
{(recommendation.estimatedEffort * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Уверенность:</span>{' '}
|
||||
{(recommendation.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn('rounded-full px-3 py-1 text-xs font-medium', priorityColor)}
|
||||
>
|
||||
{recommendation.priority === 'low' && 'Низкий'}
|
||||
{recommendation.priority === 'medium' && 'Средний'}
|
||||
{recommendation.priority === 'high' && 'Высокий'}
|
||||
{recommendation.priority === 'critical' && 'Критичный'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AIRecommendationCard;
|
||||
57
src/components/analytics/AnalyticsCard.tsx
Normal file
57
src/components/analytics/AnalyticsCard.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface AnalyticsCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
change?: number;
|
||||
icon?: LucideIcon;
|
||||
color?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnalyticsCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
icon: Icon,
|
||||
color = 'text-primary',
|
||||
description,
|
||||
className,
|
||||
}: AnalyticsCardProps) {
|
||||
const isPositive = change && change > 0;
|
||||
const isNegative = change && change < 0;
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-border bg-card p-6', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="mt-2 text-3xl font-bold">{value}</p>
|
||||
{change !== undefined && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-sm font-medium',
|
||||
isPositive && 'text-green-400',
|
||||
isNegative && 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||
</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<div className={cn('rounded-lg p-3', color.replace('text-', 'bg-').replace('400', '500/20'))}>
|
||||
<Icon className={cn('h-6 w-6', color)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalyticsCard;
|
||||
97
src/components/analytics/AttackHeatmap.tsx
Normal file
97
src/components/analytics/AttackHeatmap.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface HeatmapCell {
|
||||
round: number;
|
||||
serviceId: string;
|
||||
attacks: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
interface AttackHeatmapProps {
|
||||
data: HeatmapCell[];
|
||||
services: { id: string; name: string }[];
|
||||
rounds: number[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AttackHeatmap({
|
||||
data,
|
||||
services,
|
||||
rounds,
|
||||
className,
|
||||
}: AttackHeatmapProps) {
|
||||
const maxValue = Math.max(...data.map((d) => d.attacks), 1);
|
||||
|
||||
const getColor = (attacks: number, successRate: number) => {
|
||||
const intensity = attacks / maxValue;
|
||||
const hue = 142 - (intensity * 100); // Green to Red
|
||||
const saturation = 70 + (successRate * 30);
|
||||
const lightness = 30 + (intensity * 30);
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
const getCellValue = (round: number, serviceId: string) => {
|
||||
return data.find((d) => d.round === round && d.serviceId === serviceId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-x-auto', className)}>
|
||||
<div className="inline-block min-w-full">
|
||||
{/* Header */}
|
||||
<div className="grid" style={{ gridTemplateColumns: `120px repeat(${rounds.length}, 1fr)` }}>
|
||||
<div className="p-2 text-sm font-medium text-muted-foreground">Сервис</div>
|
||||
{rounds.map((round) => (
|
||||
<div
|
||||
key={round}
|
||||
className="p-2 text-center text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
R{round}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `120px repeat(${rounds.length}, 1fr)` }}
|
||||
>
|
||||
<div className="flex items-center p-2 text-sm">{service.name}</div>
|
||||
{rounds.map((round) => {
|
||||
const cell = getCellValue(round, service.id);
|
||||
return (
|
||||
<div
|
||||
key={`${service.id}-${round}`}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-2 text-xs font-medium transition-colors hover:opacity-80',
|
||||
!cell && 'bg-muted/30'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: cell ? getColor(cell.attacks, cell.successRate) : undefined,
|
||||
}}
|
||||
title={
|
||||
cell
|
||||
? `Атак: ${cell.attacks}, Успех: ${(cell.successRate * 100).toFixed(0)}%`
|
||||
: 'Нет данных'
|
||||
}
|
||||
>
|
||||
{cell ? cell.attacks : '-'}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Меньше</span>
|
||||
<div className="h-3 w-8 rounded bg-gradient-to-r from-green-500 to-red-500" />
|
||||
<span>Больше</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttackHeatmap;
|
||||
112
src/components/analytics/PerformanceTrend.tsx
Normal file
112
src/components/analytics/PerformanceTrend.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface TrendPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface PerformanceTrendProps {
|
||||
data: TrendPoint[];
|
||||
title?: string;
|
||||
value?: number;
|
||||
change?: number;
|
||||
height?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PerformanceTrend({
|
||||
data,
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
height = 60,
|
||||
color = 'hsl(var(--primary))',
|
||||
className,
|
||||
}: PerformanceTrendProps) {
|
||||
const trend = useMemo(() => {
|
||||
if (data.length < 2) return 'stable';
|
||||
const first = data[0].value;
|
||||
const last = data[data.length - 1].value;
|
||||
if (last > first) return 'up';
|
||||
if (last < first) return 'down';
|
||||
return 'stable';
|
||||
}, [data]);
|
||||
|
||||
const points = useMemo(() => {
|
||||
if (data.length === 0) return '';
|
||||
const maxValue = Math.max(...data.map((d) => d.value));
|
||||
const minValue = Math.min(...data.map((d) => d.value));
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
return data
|
||||
.map((d, i) => {
|
||||
const x = (i / (data.length - 1 || 1)) * 100;
|
||||
const y = 100 - ((d.value - minValue) / range) * 100;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
}, [data]);
|
||||
|
||||
const areaPath = points ? `M 0,100 L ${points} L 100,100 Z` : '';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{(title || value !== undefined) && (
|
||||
<div className="flex items-center justify-between">
|
||||
{title && <span className="text-sm text-muted-foreground">{title}</span>}
|
||||
{value !== undefined && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold">{value}</span>
|
||||
{change !== undefined && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center text-sm font-medium',
|
||||
change > 0 && 'text-green-400',
|
||||
change < 0 && 'text-red-400',
|
||||
change === 0 && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{change > 0 ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : change < 0 ? (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
) : (
|
||||
<Minus className="h-4 w-4" />
|
||||
)}
|
||||
{change > 0 && '+'}
|
||||
{change.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sparkline */}
|
||||
<div className="relative overflow-hidden rounded-lg bg-card">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
<path d={areaPath} fill={`${color}20`} />
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PerformanceTrend;
|
||||
160
src/components/analytics/SLAChart.tsx
Normal file
160
src/components/analytics/SLAChart.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface SLADataPoint {
|
||||
round: number;
|
||||
sla: number;
|
||||
serviceName?: string;
|
||||
}
|
||||
|
||||
interface SLAChartProps {
|
||||
data: SLADataPoint[];
|
||||
height?: number;
|
||||
showGrid?: boolean;
|
||||
showLabels?: boolean;
|
||||
className?: string;
|
||||
targetSLA?: number;
|
||||
}
|
||||
|
||||
export function SLAChart({
|
||||
data,
|
||||
height = 200,
|
||||
showGrid = true,
|
||||
showLabels = true,
|
||||
className,
|
||||
targetSLA = 1.0,
|
||||
}: SLAChartProps) {
|
||||
const rounds = useMemo(() => data.map((d) => d.round), [data]);
|
||||
const maxSLA = Math.max(...data.map((d) => d.sla), 1);
|
||||
|
||||
const points = data
|
||||
.map((d, i) => {
|
||||
const x = (i / (data.length - 1 || 1)) * 100;
|
||||
const y = 100 - (d.sla / maxSLA) * 100;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
const areaPath = `M 0,100 L ${points} L 100,100 Z`;
|
||||
|
||||
// Target line
|
||||
const targetY = 100 - (targetSLA / maxSLA) * 100;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Chart */}
|
||||
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<>
|
||||
{[0, 25, 50, 75, 100].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="0"
|
||||
y1={y}
|
||||
x2="100"
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="0.2"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Target line */}
|
||||
<line
|
||||
x1="0"
|
||||
y1={targetY}
|
||||
x2="100"
|
||||
y2={targetY}
|
||||
stroke="hsl(0, 84.2%, 60.2%)"
|
||||
strokeWidth="0.5"
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
|
||||
{/* Area */}
|
||||
<path d={areaPath} fill="hsl(142, 71%, 45%, 0.2)" />
|
||||
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="hsl(142, 71%, 45%)"
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Points */}
|
||||
{data.map((d, i) => {
|
||||
const x = (i / (data.length - 1 || 1)) * 100;
|
||||
const y = 100 - (d.sla / maxSLA) * 100;
|
||||
const isBelowTarget = d.sla < targetSLA;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="1.5"
|
||||
fill={isBelowTarget ? 'hsl(0, 84.2%, 60.2%)' : 'hsl(142, 71%, 45%)'}
|
||||
className="transition-all hover:r-3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{showLabels && (
|
||||
<div className="absolute left-0 top-0 flex h-full flex-col justify-between py-2 text-[10px] text-muted-foreground">
|
||||
<span>100%</span>
|
||||
<span>50%</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* X-axis labels */}
|
||||
{showLabels && (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
|
||||
{rounds.slice(0, 5).map((round) => (
|
||||
<span key={round}>R{round}</span>
|
||||
))}
|
||||
{rounds.length > 5 && <span>...</span>}
|
||||
{rounds.length > 1 && <span>R{rounds[rounds.length - 1]}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Средний SLA</div>
|
||||
<div className="text-lg font-bold">
|
||||
{((data.reduce((acc, d) => acc + d.sla, 0) / data.length) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Минимум</div>
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{(Math.min(...data.map((d) => d.sla)) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Максимум</div>
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
{(Math.max(...data.map((d) => d.sla)) * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SLAChart;
|
||||
98
src/components/analytics/TeamComparisonChart.tsx
Normal file
98
src/components/analytics/TeamComparisonChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface ComparisonMetric {
|
||||
name: string;
|
||||
ourValue: number;
|
||||
theirValue: number;
|
||||
difference: number;
|
||||
differencePercent: number;
|
||||
winner: 'us' | 'them' | 'tie';
|
||||
}
|
||||
|
||||
interface TeamComparisonChartProps {
|
||||
metrics: ComparisonMetric[];
|
||||
ourTeamName?: string;
|
||||
theirTeamName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TeamComparisonChart({
|
||||
metrics,
|
||||
ourTeamName = 'Наша команда',
|
||||
theirTeamName = 'Соперник',
|
||||
className,
|
||||
}: TeamComparisonChartProps) {
|
||||
const maxValue = useMemo(() => {
|
||||
return Math.max(
|
||||
...metrics.map((m) => Math.max(m.ourValue, m.theirValue))
|
||||
);
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm font-medium">
|
||||
<div className="text-right text-primary">{ourTeamName}</div>
|
||||
<div className="text-center text-muted-foreground">Метрика</div>
|
||||
<div className="text-left text-blue-400">{theirTeamName}</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{metrics.map((metric) => {
|
||||
const ourPercent = (metric.ourValue / maxValue) * 100;
|
||||
const theirPercent = (metric.theirValue / maxValue) * 100;
|
||||
|
||||
return (
|
||||
<div key={metric.name} className="space-y-2">
|
||||
<div className="text-center text-sm font-medium">{metric.name}</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Our bar */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{metric.ourValue}
|
||||
</span>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${ourPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric name */}
|
||||
<div className="flex items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
metric.winner === 'us' && 'text-green-400',
|
||||
metric.winner === 'them' && 'text-red-400',
|
||||
metric.winner === 'tie' && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{metric.differencePercent > 0 && '+'}
|
||||
{metric.differencePercent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Their bar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all"
|
||||
style={{ width: `${theirPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-blue-400">
|
||||
{metric.theirValue}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamComparisonChart;
|
||||
7
src/components/analytics/index.ts
Normal file
7
src/components/analytics/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export all analytics components
|
||||
export { AnalyticsCard } from './AnalyticsCard';
|
||||
export { TeamComparisonChart } from './TeamComparisonChart';
|
||||
export { AttackHeatmap } from './AttackHeatmap';
|
||||
export { SLAChart } from './SLAChart';
|
||||
export { AIRecommendationCard } from './AIRecommendationCard';
|
||||
export { PerformanceTrend } from './PerformanceTrend';
|
||||
139
src/components/auth/LoginForm.tsx
Normal file
139
src/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { loginSchema, LoginFormData } from '../../utils/validators';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoginForm({ onSuccess, className }: LoginFormProps) {
|
||||
const { login, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await login(data);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-6', className)}>
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
{...register('email')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.email ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.password ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember me */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rememberMe"
|
||||
{...register('rememberMe')}
|
||||
className="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="rememberMe" className="text-sm text-muted-foreground">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
'Войти'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginForm;
|
||||
39
src/components/auth/ProtectedRoute.tsx
Normal file
39
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredRoles?: string[];
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, isInitialized, isLoading, user } = useAuthStore();
|
||||
|
||||
// Show loading while checking auth
|
||||
if (!isInitialized || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<LoadingSpinner size="xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Check role requirements
|
||||
if (requiredRoles && user) {
|
||||
const hasRequiredRole = requiredRoles.includes(user.role);
|
||||
if (!hasRequiredRole) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default ProtectedRoute;
|
||||
251
src/components/auth/RegisterForm.tsx
Normal file
251
src/components/auth/RegisterForm.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Eye, EyeOff, Loader2, Check, X } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { registerSchema, RegisterFormData } from '../../utils/validators';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RegisterForm({ onSuccess, className }: RegisterFormProps) {
|
||||
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
displayName: '',
|
||||
acceptTerms: false,
|
||||
},
|
||||
});
|
||||
|
||||
const password = watch('password');
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: 'Минимум 8 символов', met: password?.length >= 8 },
|
||||
{ label: 'Заглавная буква', met: /[A-Z]/.test(password || '') },
|
||||
{ label: 'Строчная буква', met: /[a-z]/.test(password || '') },
|
||||
{ label: 'Цифра', met: /[0-9]/.test(password || '') },
|
||||
{ label: 'Специальный символ', met: /[^A-Za-z0-9]/.test(password || '') },
|
||||
];
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
clearError();
|
||||
await registerUser(data);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-6', className)}>
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="username" className="mb-2 block text-sm font-medium">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
{...register('username')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.username ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="username"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-2 block text-sm font-medium">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
{...register('email')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.email ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Display Name (optional) */}
|
||||
<div>
|
||||
<label htmlFor="displayName" className="mb-2 block text-sm font-medium">
|
||||
Отображаемое имя <span className="text-muted-foreground">(опционально)</span>
|
||||
</label>
|
||||
<input
|
||||
id="displayName"
|
||||
type="text"
|
||||
{...register('displayName')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.displayName ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="Ваше имя"
|
||||
/>
|
||||
{errors.displayName && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.displayName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
{...register('password')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.password ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Password requirements */}
|
||||
{password && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => (
|
||||
<div
|
||||
key={req.label}
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-xs',
|
||||
req.met ? 'text-green-500' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{req.met ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
|
||||
{req.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="mb-2 block text-sm font-medium">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
{...register('confirmPassword')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.confirmPassword ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accept Terms */}
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="acceptTerms"
|
||||
{...register('acceptTerms')}
|
||||
className="mt-1 h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="text-sm text-muted-foreground">
|
||||
Я принимаю{' '}
|
||||
<a href="/terms" className="text-primary hover:underline">
|
||||
условия использования
|
||||
</a>{' '}
|
||||
и{' '}
|
||||
<a href="/privacy" className="text-primary hover:underline">
|
||||
политику конфиденциальности
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-sm text-destructive">{errors.acceptTerms.message}</p>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Регистрация...
|
||||
</>
|
||||
) : (
|
||||
'Создать аккаунт'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterForm;
|
||||
79
src/components/auth/RoleGuard.tsx
Normal file
79
src/components/auth/RoleGuard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { UserRole } from '../../api/types';
|
||||
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||
|
||||
interface RoleGuardProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles: UserRole[];
|
||||
fallback?: React.ReactNode;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export function RoleGuard({
|
||||
children,
|
||||
allowedRoles,
|
||||
fallback,
|
||||
redirectTo = '/dashboard',
|
||||
}: RoleGuardProps) {
|
||||
const { user, isLoading, isInitialized } = useAuthStore();
|
||||
|
||||
// Show loading while checking auth
|
||||
if (!isInitialized || isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[200px] items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has required role
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only guard
|
||||
*/
|
||||
export function AdminGuard({
|
||||
children,
|
||||
fallback,
|
||||
redirectTo,
|
||||
}: Omit<RoleGuardProps, 'allowedRoles'>) {
|
||||
return (
|
||||
<RoleGuard
|
||||
allowedRoles={['admin', 'superadmin']}
|
||||
fallback={fallback}
|
||||
redirectTo={redirectTo}
|
||||
>
|
||||
{children}
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organizer or admin guard
|
||||
*/
|
||||
export function OrganizerGuard({
|
||||
children,
|
||||
fallback,
|
||||
redirectTo,
|
||||
}: Omit<RoleGuardProps, 'allowedRoles'>) {
|
||||
return (
|
||||
<RoleGuard
|
||||
allowedRoles={['organizer', 'admin', 'superadmin']}
|
||||
fallback={fallback}
|
||||
redirectTo={redirectTo}
|
||||
>
|
||||
{children}
|
||||
</RoleGuard>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleGuard;
|
||||
5
src/components/auth/index.ts
Normal file
5
src/components/auth/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Export all auth components
|
||||
export { ProtectedRoute } from './ProtectedRoute';
|
||||
export { LoginForm } from './LoginForm';
|
||||
export { RegisterForm } from './RegisterForm';
|
||||
export { RoleGuard, AdminGuard, OrganizerGuard } from './RoleGuard';
|
||||
115
src/components/common/Breadcrumbs.tsx
Normal file
115
src/components/common/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbsProps {
|
||||
items?: BreadcrumbItem[];
|
||||
showHome?: boolean;
|
||||
className?: string;
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Default route labels
|
||||
const routeLabels: Record<string, string> = {
|
||||
dashboard: 'Обзор',
|
||||
matches: 'Матчи',
|
||||
scoreboard: 'Рейтинг',
|
||||
services: 'Сервисы',
|
||||
teams: 'Команды',
|
||||
training: 'Обучение',
|
||||
analytics: 'Аналитика',
|
||||
logs: 'Логи',
|
||||
profile: 'Профиль',
|
||||
settings: 'Настройки',
|
||||
admin: 'Администрирование',
|
||||
seasons: 'Сезоны',
|
||||
live: 'Прямой эфир',
|
||||
lobby: 'Лобби',
|
||||
results: 'Результаты',
|
||||
exercises: 'Упражнения',
|
||||
team: 'Команда',
|
||||
player: 'Игрок',
|
||||
users: 'Пользователи',
|
||||
system: 'Система',
|
||||
};
|
||||
|
||||
export function Breadcrumbs({
|
||||
items,
|
||||
showHome = true,
|
||||
className,
|
||||
separator,
|
||||
}: BreadcrumbsProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// Generate breadcrumbs from current path if items not provided
|
||||
const breadcrumbItems: BreadcrumbItem[] = items || (() => {
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
return pathSegments.map((segment, index) => {
|
||||
const href = '/' + pathSegments.slice(0, index + 1).join('/');
|
||||
const label = routeLabels[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
return { label, href };
|
||||
});
|
||||
})();
|
||||
|
||||
const defaultSeparator = (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={cn('flex items-center text-sm', className)}
|
||||
>
|
||||
<ol className="flex items-center gap-2">
|
||||
{showHome && (
|
||||
<>
|
||||
<li>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
</li>
|
||||
{breadcrumbItems.length > 0 && (
|
||||
<li className="flex items-center">
|
||||
{separator || defaultSeparator}
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
|
||||
return (
|
||||
<li key={item.href || item.label} className="flex items-center gap-2">
|
||||
{isLast || !item.href ? (
|
||||
<span className={cn(
|
||||
isLast ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to={item.href}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
{!isLast && (separator || defaultSeparator)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Breadcrumbs;
|
||||
150
src/components/common/ConfirmDialog.tsx
Normal file
150
src/components/common/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useCallback, createContext, useContext, ReactNode } from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'default' | 'destructive';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmText = 'Подтвердить',
|
||||
cancelText = 'Отмена',
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: ConfirmDialogProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onClose();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [onConfirm, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Icon */}
|
||||
{variant === 'destructive' && (
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading || loading}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading || loading}
|
||||
className={cn(
|
||||
'rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50',
|
||||
variant === 'destructive'
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{isLoading || loading ? 'Загрузка...' : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Context for confirm dialogs
|
||||
interface ConfirmContextType {
|
||||
confirm: (options: Omit<ConfirmDialogProps, 'open' | 'onClose' | 'onConfirm'> & {
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextType | null>(null);
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const [dialogProps, setDialogProps] = useState<ConfirmDialogProps | null>(null);
|
||||
|
||||
const confirm = useCallback((
|
||||
options: Omit<ConfirmDialogProps, 'open' | 'onClose' | 'onConfirm'> & {
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}
|
||||
): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setDialogProps({
|
||||
...options,
|
||||
open: true,
|
||||
onClose: () => {
|
||||
setDialogProps(null);
|
||||
resolve(false);
|
||||
},
|
||||
onConfirm: async () => {
|
||||
await options.onConfirm();
|
||||
setDialogProps(null);
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
{dialogProps && <ConfirmDialog {...dialogProps} />}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
const context = useContext(ConfirmContext);
|
||||
if (!context) {
|
||||
throw new Error('useConfirm must be used within ConfirmProvider');
|
||||
}
|
||||
return context.confirm;
|
||||
}
|
||||
|
||||
export default ConfirmDialog;
|
||||
85
src/components/common/CopyButton.tsx
Normal file
85
src/components/common/CopyButton.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { copyToClipboard } from '../../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface CopyButtonProps {
|
||||
value: string;
|
||||
className?: string;
|
||||
successMessage?: string;
|
||||
showToast?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
variant?: 'default' | 'ghost' | 'outline';
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-7 w-7',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
};
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
className,
|
||||
successMessage = 'Скопировано!',
|
||||
showToast = true,
|
||||
size = 'md',
|
||||
variant = 'ghost',
|
||||
children,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const success = await copyToClipboard(value);
|
||||
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
if (showToast) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
if (showToast) {
|
||||
toast.error('Не удалось скопировать');
|
||||
}
|
||||
}
|
||||
}, [value, successMessage, showToast]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg transition-colors',
|
||||
!children && sizeClasses[size],
|
||||
children && 'px-3 py-1.5',
|
||||
variantClasses[variant],
|
||||
className
|
||||
)}
|
||||
title="Копировать"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className={cn(iconSizes[size], 'text-green-500')} />
|
||||
) : (
|
||||
<Copy className={iconSizes[size]} />
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyButton;
|
||||
133
src/components/common/Countdown.tsx
Normal file
133
src/components/common/Countdown.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useMatchTimer } from '../../hooks/useMatchTimer';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface CountdownProps {
|
||||
/** Duration in seconds */
|
||||
duration: number;
|
||||
/** Auto-start the countdown */
|
||||
autoStart?: boolean;
|
||||
/** Callback when countdown reaches zero */
|
||||
onComplete?: () => void;
|
||||
/** Show hours even if zero */
|
||||
showHours?: boolean;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
/** Style variant */
|
||||
variant?: 'default' | 'danger' | 'warning' | 'success';
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
/** Show progress bar */
|
||||
showProgress?: boolean;
|
||||
/** Separator between time parts */
|
||||
separator?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-2xl',
|
||||
lg: 'text-4xl',
|
||||
xl: 'text-6xl',
|
||||
};
|
||||
|
||||
const variantClasses = {
|
||||
default: 'text-foreground',
|
||||
danger: 'text-destructive',
|
||||
warning: 'text-yellow-500',
|
||||
success: 'text-green-500',
|
||||
};
|
||||
|
||||
export function Countdown({
|
||||
duration,
|
||||
autoStart = true,
|
||||
onComplete,
|
||||
showHours = false,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
className,
|
||||
showProgress = false,
|
||||
separator = ':',
|
||||
}: CountdownProps) {
|
||||
const { remaining, progress, parts, isCompleted } = useMatchTimer({
|
||||
duration,
|
||||
autoStart,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
// Determine variant based on remaining time
|
||||
const dynamicVariant = (() => {
|
||||
if (variant !== 'default') return variant;
|
||||
if (remaining <= 10) return 'danger';
|
||||
if (remaining <= 60) return 'warning';
|
||||
return 'default';
|
||||
})();
|
||||
|
||||
const formatPart = (value: number) => value.toString().padStart(2, '0');
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex flex-col items-center', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono font-bold tabular-nums tracking-wider',
|
||||
sizeClasses[size],
|
||||
variantClasses[dynamicVariant],
|
||||
isCompleted && 'animate-pulse'
|
||||
)}
|
||||
>
|
||||
{(showHours || parts.hours > 0) && (
|
||||
<>
|
||||
<span>{formatPart(parts.hours)}</span>
|
||||
<span className="mx-1 opacity-50">{separator}</span>
|
||||
</>
|
||||
)}
|
||||
<span>{formatPart(parts.minutes)}</span>
|
||||
<span className="mx-1 opacity-50">{separator}</span>
|
||||
<span>{formatPart(parts.seconds)}</span>
|
||||
</div>
|
||||
|
||||
{showProgress && (
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-1000',
|
||||
dynamicVariant === 'danger' && 'bg-destructive',
|
||||
dynamicVariant === 'warning' && 'bg-yellow-500',
|
||||
dynamicVariant === 'success' && 'bg-green-500',
|
||||
dynamicVariant === 'default' && 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${100 - progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline countdown for text
|
||||
*/
|
||||
export function InlineCountdown({
|
||||
duration,
|
||||
autoStart = true,
|
||||
className
|
||||
}: {
|
||||
duration: number;
|
||||
autoStart?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { formatted, isCompleted } = useMatchTimer({
|
||||
duration,
|
||||
autoStart,
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
'font-mono tabular-nums',
|
||||
isCompleted && 'text-destructive',
|
||||
className
|
||||
)}>
|
||||
{formatted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default Countdown;
|
||||
267
src/components/common/DataTable.tsx
Normal file
267
src/components/common/DataTable.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { Skeleton } from './LoadingSkeleton';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
// Simplified column definition
|
||||
export interface ColumnDef<T> {
|
||||
id: string;
|
||||
header: string;
|
||||
accessorKey?: keyof T;
|
||||
accessorFn?: (row: T) => unknown;
|
||||
cell?: (info: { row: T; getValue: () => unknown }) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: ColumnDef<T>[];
|
||||
data: T[];
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
pageSize?: number;
|
||||
showPagination?: boolean;
|
||||
enableSorting?: boolean;
|
||||
className?: string;
|
||||
striped?: boolean;
|
||||
compact?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
getRowId?: (row: T) => string;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
interface SortState {
|
||||
columnId: string | null;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
columns,
|
||||
data,
|
||||
isLoading = false,
|
||||
emptyMessage = 'Нет данных',
|
||||
emptyDescription,
|
||||
pageSize = 10,
|
||||
showPagination = true,
|
||||
enableSorting = true,
|
||||
className,
|
||||
striped = false,
|
||||
compact = false,
|
||||
onRowClick,
|
||||
getRowId,
|
||||
}: DataTableProps<T>) {
|
||||
const [sort, setSort] = useState<SortState>({ columnId: null, direction: null });
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
|
||||
// Get value from row
|
||||
const getValue = (row: T, column: ColumnDef<T>): unknown => {
|
||||
if (column.accessorFn) return column.accessorFn(row);
|
||||
if (column.accessorKey) return row[column.accessorKey];
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sort data
|
||||
const sortedData = [...data].sort((a, b) => {
|
||||
if (!sort.columnId || !sort.direction) return 0;
|
||||
|
||||
const column = columns.find((c) => c.id === sort.columnId);
|
||||
if (!column) return 0;
|
||||
|
||||
const aVal = getValue(a, column);
|
||||
const bVal = getValue(b, column);
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1;
|
||||
return sort.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
// Paginate data
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||
const paginatedData = showPagination
|
||||
? sortedData.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
|
||||
: sortedData;
|
||||
|
||||
// Toggle sort
|
||||
const toggleSort = (columnId: string) => {
|
||||
if (!enableSorting) return;
|
||||
|
||||
setSort((prev) => {
|
||||
if (prev.columnId !== columnId) {
|
||||
return { columnId, direction: 'asc' };
|
||||
}
|
||||
if (prev.direction === 'asc') {
|
||||
return { columnId, direction: 'desc' };
|
||||
}
|
||||
return { columnId: null, direction: null };
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th key={col.id} className={cn('px-4 text-left', compact ? 'py-2' : 'py-3')}>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<td key={col.id} className={cn('px-4', compact ? 'py-2' : 'py-3')}>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyMessage}
|
||||
description={emptyDescription}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.id}
|
||||
style={{ width: column.width }}
|
||||
className={cn(
|
||||
'text-left text-sm font-medium text-muted-foreground',
|
||||
compact ? 'px-3 py-2' : 'px-4 py-3'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2',
|
||||
column.sortable !== false && enableSorting && 'cursor-pointer select-none hover:text-foreground'
|
||||
)}
|
||||
onClick={() => column.sortable !== false && toggleSort(column.id)}
|
||||
>
|
||||
{column.header}
|
||||
{column.sortable !== false && enableSorting && (
|
||||
<span className="text-muted-foreground">
|
||||
{sort.columnId === column.id && sort.direction === 'asc' ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : sort.columnId === column.id && sort.direction === 'desc' ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedData.map((row, index) => (
|
||||
<tr
|
||||
key={getRowId ? getRowId(row) : index}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors',
|
||||
striped && index % 2 === 1 && 'bg-muted/30',
|
||||
'hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const value = getValue(row, column);
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(compact ? 'px-3 py-2' : 'px-4 py-3')}
|
||||
>
|
||||
{column.cell
|
||||
? column.cell({ row, getValue: () => value })
|
||||
: String(value ?? '')}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{showPagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
Страница {currentPage + 1} из {totalPages}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
|
||||
disabled={currentPage === 0}
|
||||
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
let pageNum: number;
|
||||
if (totalPages <= 5) {
|
||||
pageNum = i;
|
||||
} else if (currentPage < 3) {
|
||||
pageNum = i;
|
||||
} else if (currentPage > totalPages - 4) {
|
||||
pageNum = totalPages - 5 + i;
|
||||
} else {
|
||||
pageNum = currentPage - 2 + i;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded border transition-colors',
|
||||
currentPage === pageNum
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{pageNum + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
className="flex h-8 w-8 items-center justify-center rounded border border-border transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
42
src/components/common/EmptyState.tsx
Normal file
42
src/components/common/EmptyState.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center py-12 text-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon && (
|
||||
<div className="mb-4 rounded-full bg-muted p-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && <div className="mt-6">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmptyState;
|
||||
69
src/components/common/ErrorBoundary.tsx
Normal file
69
src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center p-8 text-center">
|
||||
<div className="mb-4 rounded-full bg-destructive/10 p-4">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Что-то пошло не так</h2>
|
||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Произошла непредвиденная ошибка. Попробуйте обновить страницу.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<pre className="mt-4 max-w-lg overflow-auto rounded-lg bg-muted p-4 text-left text-xs">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Попробовать снова
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
120
src/components/common/LoadingSkeleton.tsx
Normal file
120
src/components/common/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-muted',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Common skeleton patterns
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-6">
|
||||
<Skeleton className="h-4 w-1/3 mb-4" />
|
||||
<Skeleton className="h-8 w-1/2 mb-2" />
|
||||
<Skeleton className="h-4 w-full mb-2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRowSkeleton({ columns = 5 }: { columns?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-border">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5, columns = 5 }: { rows?: number; columns?: number }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<th key={i} className="px-4 py-3 text-left">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRowSkeleton key={i} columns={columns} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSkeleton({ items = 5 }: { items?: number }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: items }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-lg border border-border p-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-4 w-1/3 mb-2" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<Skeleton className="h-24 w-24 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="h-6 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-32 mb-4" />
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-card p-6">
|
||||
<Skeleton className="h-8 w-8 mb-4" />
|
||||
<Skeleton className="h-8 w-20 mb-2" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton as LoadingSkeleton };
|
||||
export default Skeleton;
|
||||
44
src/components/common/LoadingSpinner.tsx
Normal file
44
src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4 border-2',
|
||||
md: 'h-8 w-8 border-2',
|
||||
lg: 'h-12 w-12 border-3',
|
||||
xl: 'h-16 w-16 border-4',
|
||||
};
|
||||
|
||||
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-t-transparent border-primary',
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ message }: LoadingOverlayProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoadingSpinner size="xl" />
|
||||
{message && (
|
||||
<p className="text-lg text-muted-foreground">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingSpinner;
|
||||
126
src/components/common/MarkdownRenderer.tsx
Normal file
126
src/components/common/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Simple markdown parser (for basic formatting)
|
||||
// For production, consider using react-markdown or similar library
|
||||
function parseMarkdown(text: string): string {
|
||||
let html = text;
|
||||
|
||||
// Escape HTML
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
// Headers
|
||||
html = html.replace(/^### (.*$)/gim, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2 class="text-xl font-semibold mt-6 mb-3">$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1 class="text-2xl font-bold mt-6 mb-4">$1</h1>');
|
||||
|
||||
// Bold and italic
|
||||
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/___(.*?)___/g, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||
|
||||
// Inline code
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="px-1.5 py-0.5 rounded bg-muted font-mono text-sm">$1</code>');
|
||||
|
||||
// Code blocks
|
||||
html = html.replace(
|
||||
/```(\w+)?\n([\s\S]*?)```/g,
|
||||
'<pre class="p-4 rounded-lg bg-muted overflow-x-auto my-4"><code class="font-mono text-sm">$2</code></pre>'
|
||||
);
|
||||
|
||||
// Links
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" class="text-primary hover:underline" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||
);
|
||||
|
||||
// Images
|
||||
html = html.replace(
|
||||
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
||||
'<img src="$2" alt="$1" class="max-w-full rounded-lg my-4" />'
|
||||
);
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^\s*[-*+]\s+(.*)$/gim, '<li class="ml-4">$1</li>');
|
||||
html = html.replace(/(<li.*<\/li>\n?)+/g, '<ul class="list-disc my-2 space-y-1">$&</ul>');
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/^\s*\d+\.\s+(.*)$/gim, '<li class="ml-4">$1</li>');
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(
|
||||
/^>\s*(.*)$/gim,
|
||||
'<blockquote class="border-l-4 border-primary pl-4 italic text-muted-foreground my-4">$1</blockquote>'
|
||||
);
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/^---+$/gim, '<hr class="my-6 border-border" />');
|
||||
html = html.replace(/^\*\*\*+$/gim, '<hr class="my-6 border-border" />');
|
||||
|
||||
// Line breaks (two spaces at end of line)
|
||||
html = html.replace(/ $/gim, '<br />');
|
||||
|
||||
// Paragraphs
|
||||
html = html.replace(/\n\n/g, '</p><p class="my-4">');
|
||||
html = '<p class="my-4">' + html + '</p>';
|
||||
|
||||
// Clean up empty paragraphs
|
||||
html = html.replace(/<p class="my-4">\s*<\/p>/g, '');
|
||||
html = html.replace(/<p class="my-4">(<h[1-6])/g, '$1');
|
||||
html = html.replace(/(<\/h[1-6]>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p class="my-4">(<pre)/g, '$1');
|
||||
html = html.replace(/(<\/pre>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p class="my-4">(<ul)/g, '$1');
|
||||
html = html.replace(/(<\/ul>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p class="my-4">(<blockquote)/g, '$1');
|
||||
html = html.replace(/(<\/blockquote>)<\/p>/g, '$1');
|
||||
html = html.replace(/<p class="my-4">(<hr)/g, '$1');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
const html = useMemo(() => parseMarkdown(content), [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('prose prose-invert max-w-none', className)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple text renderer (no HTML, just clean text)
|
||||
export function MarkdownToText({ content }: { content: string }): string {
|
||||
return content
|
||||
.replace(/\*\*\*(.*?)\*\*\*/g, '$1')
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.*?)\*/g, '$1')
|
||||
.replace(/___(.*?)___/g, '$1')
|
||||
.replace(/__(.*?)__/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
||||
.replace(/^#+\s*/gm, '')
|
||||
.replace(/^[-*+]\s+/gm, '• ')
|
||||
.replace(/^\d+\.\s+/gm, '')
|
||||
.replace(/^>\s*/gm, '')
|
||||
.replace(/---+/g, '')
|
||||
.replace(/\*\*\*+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export default MarkdownRenderer;
|
||||
119
src/components/common/SearchInput.tsx
Normal file
119
src/components/common/SearchInput.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
||||
interface SearchInputProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onSearch?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
showClearButton?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-8 text-sm pl-8 pr-8',
|
||||
md: 'h-10 text-sm pl-10 pr-10',
|
||||
lg: 'h-12 text-base pl-12 pr-12',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
const iconPositions = {
|
||||
sm: 'left-2',
|
||||
md: 'left-3',
|
||||
lg: 'left-4',
|
||||
};
|
||||
|
||||
export function SearchInput({
|
||||
value: externalValue,
|
||||
onChange,
|
||||
onSearch,
|
||||
placeholder = 'Поиск...',
|
||||
debounceMs = 300,
|
||||
className,
|
||||
autoFocus = false,
|
||||
showClearButton = true,
|
||||
size = 'md',
|
||||
}: SearchInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(externalValue || '');
|
||||
const debouncedValue = useDebounce(internalValue, debounceMs);
|
||||
|
||||
// Sync with external value
|
||||
useEffect(() => {
|
||||
if (externalValue !== undefined) {
|
||||
setInternalValue(externalValue);
|
||||
}
|
||||
}, [externalValue]);
|
||||
|
||||
// Call onSearch when debounced value changes
|
||||
useEffect(() => {
|
||||
onSearch?.(debouncedValue);
|
||||
}, [debouncedValue, onSearch]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
onChange?.(newValue);
|
||||
}, [onChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInternalValue('');
|
||||
onChange?.('');
|
||||
onSearch?.('');
|
||||
}, [onChange, onSearch]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClear();
|
||||
}
|
||||
}, [handleClear]);
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Search
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 text-muted-foreground',
|
||||
iconSizes[size],
|
||||
iconPositions[size]
|
||||
)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-border bg-background outline-none transition-colors',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
{showClearButton && internalValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground',
|
||||
size === 'sm' ? 'right-2' : size === 'lg' ? 'right-4' : 'right-3'
|
||||
)}
|
||||
>
|
||||
<X className={iconSizes[size]} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchInput;
|
||||
152
src/components/common/StatusBadge.tsx
Normal file
152
src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
import {
|
||||
MATCH_STATUS_LABELS,
|
||||
CHECKER_RESULT_LABELS,
|
||||
DIFFICULTY_LABELS,
|
||||
} from '../../utils/constants';
|
||||
// Colors are defined inline to avoid unused imports
|
||||
|
||||
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
variant?: BadgeVariant;
|
||||
type?: 'match' | 'checker' | 'difficulty' | 'custom';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses: Record<BadgeVariant, string> = {
|
||||
default: 'bg-primary/20 text-primary border-primary/30',
|
||||
success: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
error: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
neutral: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-1.5 py-0.5 text-xs',
|
||||
md: 'px-2 py-1 text-xs',
|
||||
lg: 'px-3 py-1.5 text-sm',
|
||||
};
|
||||
|
||||
const getVariantFromMatchStatus = (status: string): BadgeVariant => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success';
|
||||
case 'lobby':
|
||||
case 'starting':
|
||||
return 'info';
|
||||
case 'paused':
|
||||
return 'warning';
|
||||
case 'finished':
|
||||
return 'neutral';
|
||||
case 'cancelled':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantFromCheckerResult = (result: string): BadgeVariant => {
|
||||
switch (result) {
|
||||
case 'ok':
|
||||
return 'success';
|
||||
case 'corrupt':
|
||||
case 'mumble':
|
||||
return 'warning';
|
||||
case 'down':
|
||||
case 'error':
|
||||
case 'timeout':
|
||||
return 'error';
|
||||
default:
|
||||
return 'neutral';
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantFromDifficulty = (difficulty: string): BadgeVariant => {
|
||||
switch (difficulty) {
|
||||
case 'beginner':
|
||||
case 'easy':
|
||||
return 'success';
|
||||
case 'medium':
|
||||
return 'warning';
|
||||
case 'hard':
|
||||
case 'expert':
|
||||
return 'error';
|
||||
case 'insane':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
variant,
|
||||
type = 'custom',
|
||||
size = 'md',
|
||||
pulse = false,
|
||||
className,
|
||||
}: StatusBadgeProps) {
|
||||
let label = status;
|
||||
let computedVariant = variant || 'default';
|
||||
|
||||
if (type === 'match') {
|
||||
label = MATCH_STATUS_LABELS[status] || status;
|
||||
if (!variant) {
|
||||
computedVariant = getVariantFromMatchStatus(status);
|
||||
}
|
||||
} else if (type === 'checker') {
|
||||
label = CHECKER_RESULT_LABELS[status] || status;
|
||||
if (!variant) {
|
||||
computedVariant = getVariantFromCheckerResult(status);
|
||||
}
|
||||
} else if (type === 'difficulty') {
|
||||
label = DIFFICULTY_LABELS[status] || status;
|
||||
if (!variant) {
|
||||
computedVariant = getVariantFromDifficulty(status);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border font-medium',
|
||||
variantClasses[computedVariant],
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{pulse && (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
computedVariant === 'success' && 'bg-green-400',
|
||||
computedVariant === 'warning' && 'bg-yellow-400',
|
||||
computedVariant === 'error' && 'bg-red-400',
|
||||
computedVariant === 'info' && 'bg-blue-400',
|
||||
computedVariant === 'default' && 'bg-primary'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2 w-2 rounded-full',
|
||||
computedVariant === 'success' && 'bg-green-400',
|
||||
computedVariant === 'warning' && 'bg-yellow-400',
|
||||
computedVariant === 'error' && 'bg-red-400',
|
||||
computedVariant === 'info' && 'bg-blue-400',
|
||||
computedVariant === 'default' && 'bg-primary'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatusBadge;
|
||||
43
src/components/common/ThemeToggle.tsx
Normal file
43
src/components/common/ThemeToggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import { useUIStore } from '../../store';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className, showLabel = false }: ThemeToggleProps) {
|
||||
const { theme, setTheme } = useUIStore();
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', icon: Sun, label: 'Светлая' },
|
||||
{ value: 'dark', icon: Moon, label: 'Тёмная' },
|
||||
{ value: 'system', icon: Monitor, label: 'Системная' },
|
||||
] as const;
|
||||
|
||||
const cycleTheme = () => {
|
||||
const currentIndex = themes.findIndex((t) => t.value === theme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex].value);
|
||||
};
|
||||
|
||||
const currentTheme = themes.find((t) => t.value === theme)!;
|
||||
const Icon = currentTheme.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={cycleTheme}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
className
|
||||
)}
|
||||
title={`Текущая тема: ${currentTheme.label}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{showLabel && <span className="text-sm">{currentTheme.label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeToggle;
|
||||
23
src/components/common/index.ts
Normal file
23
src/components/common/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Export all common components
|
||||
export { LoadingSpinner, LoadingOverlay } from './LoadingSpinner';
|
||||
export { StatusBadge } from './StatusBadge';
|
||||
export { ThemeToggle } from './ThemeToggle';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { ErrorBoundary } from './ErrorBoundary';
|
||||
export {
|
||||
Skeleton,
|
||||
LoadingSkeleton,
|
||||
CardSkeleton,
|
||||
TableSkeleton,
|
||||
TableRowSkeleton,
|
||||
ListSkeleton,
|
||||
ProfileSkeleton,
|
||||
StatsSkeleton,
|
||||
} from './LoadingSkeleton';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { CopyButton } from './CopyButton';
|
||||
export { ConfirmDialog, ConfirmProvider, useConfirm } from './ConfirmDialog';
|
||||
export { Breadcrumbs } from './Breadcrumbs';
|
||||
export { Countdown, InlineCountdown } from './Countdown';
|
||||
export { DataTable } from './DataTable';
|
||||
export { MarkdownRenderer, MarkdownToText } from './MarkdownRenderer';
|
||||
59
src/components/layout/AppLayout.tsx
Normal file
59
src/components/layout/AppLayout.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Header } from './Header';
|
||||
import { useUIStore } from '../../store';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
export function AppLayout() {
|
||||
const { sidebarCollapsed, sidebarMobileOpen, setSidebarMobileOpen } = useUIStore();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Mobile overlay */}
|
||||
{sidebarMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-30 bg-black/50 lg:hidden"
|
||||
onClick={() => setSidebarMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={cn(
|
||||
'lg:block',
|
||||
sidebarMobileOpen ? 'block' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-screen flex-col transition-all duration-300',
|
||||
sidebarCollapsed ? 'lg:ml-16' : 'lg:ml-64'
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 p-6 pt-20">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border px-6 py-4">
|
||||
<div className="flex flex-col items-center justify-between gap-2 text-sm text-muted-foreground md:flex-row">
|
||||
<p>© 2024 Врата ADA. Все права защищены.</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="#" className="hover:text-foreground">Документация</a>
|
||||
<a href="#" className="hover:text-foreground">Поддержка</a>
|
||||
<a href="#" className="hover:text-foreground">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
175
src/components/layout/Footer.tsx
Normal file
175
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageCircle, Mail, Globe } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface FooterProps {
|
||||
className?: string;
|
||||
minimal?: boolean;
|
||||
}
|
||||
|
||||
export function Footer({ className, minimal = false }: FooterProps) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<footer className={cn('border-t border-border px-6 py-4', className)}>
|
||||
<div className="flex flex-col items-center justify-between gap-2 text-sm text-muted-foreground md:flex-row">
|
||||
<p>© {currentYear} Врата ADA. Все права защищены.</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/docs" className="hover:text-foreground">Документация</Link>
|
||||
<Link to="/support" className="hover:text-foreground">Поддержка</Link>
|
||||
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="hover:text-foreground">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className={cn('border-t border-border bg-card', className)}>
|
||||
<div className="container mx-auto px-6 py-12">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-primary">Врата ADA</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Платформа для тренировок и обучения CTF в формате Attack-Defence.
|
||||
Развивайте навыки кибербезопасности вместе с нами.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="GitHub"
|
||||
>
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Discord"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:support@ada-ctf.com"
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Email"
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform */}
|
||||
<div>
|
||||
<h4 className="font-semibold">Платформа</h4>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/matches" className="text-muted-foreground hover:text-foreground">
|
||||
Матчи
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/training" className="text-muted-foreground hover:text-foreground">
|
||||
Обучение
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/services" className="text-muted-foreground hover:text-foreground">
|
||||
Сервисы
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/scoreboard" className="text-muted-foreground hover:text-foreground">
|
||||
Рейтинг
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/seasons" className="text-muted-foreground hover:text-foreground">
|
||||
Сезоны
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
<div>
|
||||
<h4 className="font-semibold">Ресурсы</h4>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/docs" className="text-muted-foreground hover:text-foreground">
|
||||
Документация
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/docs/api" className="text-muted-foreground hover:text-foreground">
|
||||
API
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
|
||||
Блог
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Legal */}
|
||||
<div>
|
||||
<h4 className="font-semibold">Правовая информация</h4>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li>
|
||||
<Link to="/terms" className="text-muted-foreground hover:text-foreground">
|
||||
Условия использования
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/privacy" className="text-muted-foreground hover:text-foreground">
|
||||
Политика конфиденциальности
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/rules" className="text-muted-foreground hover:text-foreground">
|
||||
Правила платформы
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/support" className="text-muted-foreground hover:text-foreground">
|
||||
Поддержка
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 border-t border-border pt-6">
|
||||
<div className="flex flex-col items-center justify-between gap-4 text-sm text-muted-foreground md:flex-row">
|
||||
<p>© {currentYear} Врата ADA. Все права защищены.</p>
|
||||
<p>
|
||||
Сделано с ❤️ для CTF-сообщества
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
110
src/components/layout/Header.tsx
Normal file
110
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Bell,
|
||||
Search,
|
||||
Menu,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { useUIStore, useAuthStore, useNotificationStore, useMatchStore } from '../../store';
|
||||
import { ThemeToggle } from '../common/ThemeToggle';
|
||||
import { formatCountdown } from '../../utils/formatters';
|
||||
|
||||
export function Header() {
|
||||
const { setSidebarMobileOpen, sidebarCollapsed, toggleCommandPalette } = useUIStore();
|
||||
const { user } = useAuthStore();
|
||||
const { unreadCount } = useNotificationStore();
|
||||
const { currentMatch, roundTimeRemaining } = useMatchStore();
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed right-0 top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-card/95 px-4 backdrop-blur transition-all',
|
||||
sidebarCollapsed ? 'left-16' : 'left-64'
|
||||
)}
|
||||
>
|
||||
{/* Left side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setSidebarMobileOpen(true)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<button
|
||||
onClick={() => toggleCommandPalette()}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="hidden md:inline">Поиск...</span>
|
||||
<kbd className="hidden rounded bg-muted px-1.5 py-0.5 text-xs font-medium md:inline">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Center - Match timer */}
|
||||
{currentMatch && currentMatch.status === 'running' && (
|
||||
<div className="flex items-center gap-3 rounded-lg bg-primary/10 px-4 py-2">
|
||||
<Timer className="h-5 w-5 text-primary" />
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Раунд {currentMatch.currentRound}/{currentMatch.totalRounds}
|
||||
</p>
|
||||
<p className="font-mono text-lg font-bold text-primary">
|
||||
{formatCountdown(roundTimeRemaining)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/matches/${currentMatch.id}/live`}
|
||||
className="rounded bg-primary px-2 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
Смотреть
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications */}
|
||||
<Link
|
||||
to="/notifications"
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-destructive px-1 text-xs font-bold text-destructive-foreground">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* User menu */}
|
||||
{user && (
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<p className="text-sm font-medium">{user.displayName || user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{user.teamId ? 'В команде' : 'Без команды'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
109
src/components/layout/MobileNav.tsx
Normal file
109
src/components/layout/MobileNav.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { X, LayoutDashboard, Swords, Trophy, Box, Users, GraduationCap, BarChart3, Settings, Shield } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { useUIStore, useAuthStore } from '../../store';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Обзор', icon: LayoutDashboard },
|
||||
{ path: '/matches', label: 'Матчи', icon: Swords },
|
||||
{ path: '/scoreboard', label: 'Рейтинг', icon: Trophy },
|
||||
{ path: '/services', label: 'Сервисы', icon: Box },
|
||||
{ path: '/teams', label: 'Команды', icon: Users },
|
||||
{ path: '/training', label: 'Обучение', icon: GraduationCap },
|
||||
{ path: '/analytics/team', label: 'Аналитика', icon: BarChart3 },
|
||||
{ path: '/settings', label: 'Настройки', icon: Settings },
|
||||
{ path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true },
|
||||
];
|
||||
|
||||
export function MobileNav() {
|
||||
const location = useLocation();
|
||||
const { sidebarMobileOpen, setSidebarMobileOpen } = useUIStore();
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
|
||||
|
||||
if (!sidebarMobileOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setSidebarMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-card shadow-xl lg:hidden">
|
||||
{/* Header */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Shield className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary">Врата ADA</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarMobileOpen(false)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
if (item.adminOnly && !isAdmin) return null;
|
||||
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
{user && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
|
||||
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.displayName || user.username}</p>
|
||||
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
199
src/components/layout/Sidebar.tsx
Normal file
199
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Swords,
|
||||
Trophy,
|
||||
Box,
|
||||
Users,
|
||||
GraduationCap,
|
||||
BarChart3,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
User,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { useUIStore, useAuthStore } from '../../store';
|
||||
|
||||
interface NavItem {
|
||||
path: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
badge?: number;
|
||||
adminOnly?: boolean;
|
||||
organizerOnly?: boolean;
|
||||
}
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{ path: '/dashboard', label: 'Обзор', icon: LayoutDashboard },
|
||||
{ path: '/matches', label: 'Матчи', icon: Swords },
|
||||
{ path: '/scoreboard', label: 'Рейтинг', icon: Trophy },
|
||||
{ path: '/services', label: 'Сервисы', icon: Box },
|
||||
{ path: '/teams', label: 'Команды', icon: Users },
|
||||
{ path: '/training', label: 'Обучение', icon: GraduationCap },
|
||||
{ path: '/seasons', label: 'Сезоны', icon: Calendar },
|
||||
];
|
||||
|
||||
const analyticsNavItems: NavItem[] = [
|
||||
{ path: '/analytics/team', label: 'Аналитика', icon: BarChart3 },
|
||||
{ path: '/logs', label: 'Логи', icon: ScrollText },
|
||||
];
|
||||
|
||||
const adminNavItems: NavItem[] = [
|
||||
{ path: '/admin', label: 'Админ-панель', icon: Shield, adminOnly: true },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { sidebarCollapsed, toggleSidebar, setSidebarMobileOpen } = useUIStore();
|
||||
const { user, logout } = useAuthStore();
|
||||
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin';
|
||||
const isOrganizer = user?.role === 'organizer' || isAdmin;
|
||||
|
||||
const renderNavItem = (item: NavItem) => {
|
||||
if (item.adminOnly && !isAdmin) return null;
|
||||
if (item.organizerOnly && !isOrganizer) return null;
|
||||
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname.startsWith(item.path);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setSidebarMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.badge && item.badge > 0 && (
|
||||
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary px-1.5 text-xs text-primary-foreground">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-border px-4">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary">
|
||||
<Shield className="h-5 w-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="text-lg font-bold text-primary">Врата ADA</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-2">
|
||||
{/* Main */}
|
||||
<div className="space-y-1">
|
||||
{!sidebarCollapsed && (
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Навигация
|
||||
</p>
|
||||
)}
|
||||
{mainNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
|
||||
{/* Analytics */}
|
||||
<div className="space-y-1 pt-4">
|
||||
{!sidebarCollapsed && (
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Аналитика
|
||||
</p>
|
||||
)}
|
||||
{analyticsNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
|
||||
{/* Admin */}
|
||||
{isAdmin && (
|
||||
<div className="space-y-1 pt-4">
|
||||
{!sidebarCollapsed && (
|
||||
<p className="mb-2 px-3 text-xs font-semibold uppercase text-muted-foreground">
|
||||
Управление
|
||||
</p>
|
||||
)}
|
||||
{adminNavItems.map(renderNavItem)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<div className="border-t border-border p-2">
|
||||
<NavLink
|
||||
to="/profile"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
location.pathname === '/profile'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<User className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>Профиль</span>}
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
location.pathname === '/settings'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>Настройки</span>}
|
||||
</NavLink>
|
||||
|
||||
<button
|
||||
onClick={() => logout()}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-all hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<LogOut className="h-5 w-5 shrink-0" />
|
||||
{!sidebarCollapsed && <span>Выйти</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
6
src/components/layout/index.ts
Normal file
6
src/components/layout/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all layout components
|
||||
export { AppLayout } from './AppLayout';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { Header } from './Header';
|
||||
export { Footer } from './Footer';
|
||||
export { MobileNav } from './MobileNav';
|
||||
75
src/components/logs/EventTimeline.tsx
Normal file
75
src/components/logs/EventTimeline.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { LogTimelineEvent } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
interface EventTimelineProps {
|
||||
events: LogTimelineEvent[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const eventColors: Record<string, string> = {
|
||||
round_start: 'bg-blue-500',
|
||||
round_end: 'bg-green-500',
|
||||
flag_captured: 'bg-red-500',
|
||||
flag_generated: 'bg-yellow-500',
|
||||
service_down: 'bg-orange-500',
|
||||
service_up: 'bg-green-500',
|
||||
attack_detected: 'bg-purple-500',
|
||||
score_update: 'bg-blue-500',
|
||||
};
|
||||
|
||||
export function EventTimeline({ events, className }: EventTimelineProps) {
|
||||
return (
|
||||
<div className={cn('relative space-y-4', className)}>
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-4 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
{/* Events */}
|
||||
{events.map((event, index) => {
|
||||
const color = eventColors[event.type] || 'bg-muted-foreground';
|
||||
|
||||
return (
|
||||
<div key={event.id} className="relative flex gap-4 pl-10">
|
||||
{/* Dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2.5 h-3 w-3 -translate-x-1/2 rounded-full ring-4 ring-background',
|
||||
color
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(event.timestamp)}
|
||||
</span>
|
||||
{event.importance === 'high' || event.importance === 'critical' && (
|
||||
<span className="rounded bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
||||
Важно
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
{event.teams.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.teams.map((team) => (
|
||||
<span
|
||||
key={team.teamId}
|
||||
className="rounded bg-muted px-2 py-0.5 text-xs"
|
||||
>
|
||||
{team.teamName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventTimeline;
|
||||
69
src/components/logs/LogEntry.tsx
Normal file
69
src/components/logs/LogEntry.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
import { LOG_LEVEL_COLORS, LOG_LEVEL_LABELS } from '../../utils/constants';
|
||||
import { LogEntry as LogEntryType } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface LogEntryProps {
|
||||
log: LogEntryType;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
match_start: '🚀',
|
||||
match_end: '🏁',
|
||||
round_start: '🔄',
|
||||
round_end: '✅',
|
||||
flag_captured: '🚩',
|
||||
flag_generated: '🎯',
|
||||
service_down: '⚠️',
|
||||
service_up: '✅',
|
||||
attack_detected: '⚔️',
|
||||
score_update: '📊',
|
||||
};
|
||||
|
||||
export function LogEntry({ log, compact = false, className }: LogEntryProps) {
|
||||
const color = LOG_LEVEL_COLORS[log.level];
|
||||
const icon = iconMap[log.type] || '📝';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 transition-colors hover:bg-muted/50',
|
||||
compact ? 'text-xs' : 'text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Icon */}
|
||||
<span className="text-base">{icon}</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
{LOG_LEVEL_LABELS[log.level]}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{formatDateTime(log.timestamp)}
|
||||
</span>
|
||||
{log.teamName && (
|
||||
<span className="text-xs font-medium text-primary">{log.teamName}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={compact ? 'line-clamp-1' : ''}>{log.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Round badge */}
|
||||
{log.roundNumber && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
R{log.roundNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogEntry;
|
||||
151
src/components/logs/LogFilter.tsx
Normal file
151
src/components/logs/LogFilter.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { LogLevel, EventType } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { LOG_LEVEL_LABELS } from '../../utils/constants';
|
||||
|
||||
interface LogFilterProps {
|
||||
selectedLevels?: LogLevel[];
|
||||
selectedTypes?: EventType[];
|
||||
selectedTeamId?: string | null;
|
||||
selectedServiceId?: string | null;
|
||||
search?: string;
|
||||
onLevelsChange?: (levels: LogLevel[]) => void;
|
||||
onTypesChange?: (types: EventType[]) => void;
|
||||
onTeamChange?: (teamId: string | null) => void;
|
||||
onServiceChange?: (serviceId: string | null) => void;
|
||||
onSearchChange?: (search: string) => void;
|
||||
onClear?: () => void;
|
||||
teams?: { id: string; name: string }[];
|
||||
services?: { id: string; name: string }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const logLevels: LogLevel[] = ['debug', 'info', 'warning', 'error', 'critical'];
|
||||
|
||||
export function LogFilter({
|
||||
selectedLevels = [],
|
||||
selectedTypes = [],
|
||||
selectedTeamId,
|
||||
selectedServiceId,
|
||||
search = '',
|
||||
onLevelsChange,
|
||||
onTypesChange,
|
||||
onTeamChange,
|
||||
onServiceChange,
|
||||
onSearchChange,
|
||||
onClear,
|
||||
teams = [],
|
||||
services = [],
|
||||
className,
|
||||
}: LogFilterProps) {
|
||||
const hasActiveFilters =
|
||||
selectedLevels.length > 0 ||
|
||||
selectedTypes.length > 0 ||
|
||||
selectedTeamId ||
|
||||
selectedServiceId ||
|
||||
search;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">Фильтры логов</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
placeholder="Поиск по тексту..."
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Levels */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Уровни</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{logLevels.map((level) => {
|
||||
const isSelected = selectedLevels.includes(level);
|
||||
const color = LOG_LEVEL_COLORS[level];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
const newLevels = isSelected
|
||||
? selectedLevels.filter((l) => l !== level)
|
||||
: [...selectedLevels, level];
|
||||
onLevelsChange?.(newLevels);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{LOG_LEVEL_LABELS[level]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams */}
|
||||
{teams.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Команда</label>
|
||||
<select
|
||||
value={selectedTeamId || ''}
|
||||
onChange={(e) => onTeamChange?.(e.target.value || null)}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Все команды</option>
|
||||
{teams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services */}
|
||||
{services.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Сервис</label>
|
||||
<select
|
||||
value={selectedServiceId || ''}
|
||||
onChange={(e) => onServiceChange?.(e.target.value || null)}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Все сервисы</option>
|
||||
{services.map((service) => (
|
||||
<option key={service.id} value={service.id}>
|
||||
{service.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogFilter;
|
||||
77
src/components/logs/LogStream.tsx
Normal file
77
src/components/logs/LogStream.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { LogEntry } from '../../api/types';
|
||||
import { LogEntry as LogEntryComponent } from './LogEntry';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface LogStreamProps {
|
||||
logs: LogEntry[];
|
||||
isLoading?: boolean;
|
||||
isLive?: boolean;
|
||||
autoScroll?: boolean;
|
||||
className?: string;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function LogStream({
|
||||
logs,
|
||||
isLoading = false,
|
||||
isLive = false,
|
||||
autoScroll = true,
|
||||
className,
|
||||
maxHeight = '500px',
|
||||
}: LogStreamProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const prevLogsLength = useRef(logs.length);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logs.length > prevLogsLength.current) {
|
||||
scrollToBottom();
|
||||
}
|
||||
prevLogsLength.current = logs.length;
|
||||
}, [logs.length, autoScroll, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'overflow-y-auto rounded-lg border border-border bg-card font-mono text-sm',
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{isLoading && logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
Загрузка логов...
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8 text-muted-foreground">
|
||||
Нет логов для отображения
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{logs.map((log) => (
|
||||
<LogEntryComponent key={log.id} log={log} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLive && (
|
||||
<div className="sticky bottom-0 left-0 right-0 flex items-center justify-center gap-2 border-t border-border bg-card/95 p-2 text-xs text-green-400 backdrop-blur">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-400" />
|
||||
</span>
|
||||
Прямой эфир
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LogStream;
|
||||
123
src/components/logs/ReplayControls.tsx
Normal file
123
src/components/logs/ReplayControls.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Play, Pause, SkipBack, SkipForward, FastForward, Rewind } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatCountdown } from '../../utils/formatters';
|
||||
|
||||
interface ReplayControlsProps {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
speed: number;
|
||||
onPlay: () => void;
|
||||
onPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onSpeedChange: (speed: number) => void;
|
||||
onSkipBack?: () => void;
|
||||
onSkipForward?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const speedOptions = [0.25, 0.5, 1, 1.5, 2, 4, 8];
|
||||
|
||||
export function ReplayControls({
|
||||
isPlaying,
|
||||
currentTime,
|
||||
duration,
|
||||
speed,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onSpeedChange,
|
||||
onSkipBack,
|
||||
onSkipForward,
|
||||
className,
|
||||
}: ReplayControlsProps) {
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
||||
onSeek(newTime);
|
||||
};
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const currentIndex = speedOptions.indexOf(speed);
|
||||
const nextIndex = (currentIndex + 1) % speedOptions.length;
|
||||
onSpeedChange(speedOptions[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4 rounded-lg border border-border bg-card p-4', className)}>
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="w-16 text-xs font-mono text-muted-foreground">
|
||||
{formatCountdown(currentTime)}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-16 text-right text-xs font-mono text-muted-foreground">
|
||||
{formatCountdown(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{onSkipBack && (
|
||||
<button
|
||||
onClick={onSkipBack}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Назад 10с"
|
||||
>
|
||||
<Rewind className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onSkipBack}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Назад"
|
||||
>
|
||||
<SkipBack className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={isPlaying ? onPause : onPlay}
|
||||
className="rounded-full bg-primary p-3 text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-6 w-6" />
|
||||
) : (
|
||||
<Play className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Вперёд"
|
||||
>
|
||||
<SkipForward className="h-5 w-5" />
|
||||
</button>
|
||||
{onSkipForward && (
|
||||
<button
|
||||
onClick={onSkipForward}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Вперёд 10с"
|
||||
>
|
||||
<FastForward className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="rounded-lg px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Скорость"
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReplayControls;
|
||||
6
src/components/logs/index.ts
Normal file
6
src/components/logs/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Export all logs components
|
||||
export { LogStream } from './LogStream';
|
||||
export { LogEntry } from './LogEntry';
|
||||
export { LogFilter } from './LogFilter';
|
||||
export { EventTimeline } from './EventTimeline';
|
||||
export { ReplayControls } from './ReplayControls';
|
||||
157
src/components/match/FlagSubmitForm.tsx
Normal file
157
src/components/match/FlagSubmitForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Flag, Loader2, Check, X } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import toast from 'react-hot-toast';
|
||||
import { flagsApi } from '../../api/endpoints';
|
||||
|
||||
const flagSubmitSchema = z.object({
|
||||
flag: z.string().min(1, 'Флаг обязателен'),
|
||||
});
|
||||
|
||||
type FlagSubmitFormData = z.infer<typeof flagSubmitSchema>;
|
||||
|
||||
interface FlagSubmitFormProps {
|
||||
matchId: string;
|
||||
onSuccess?: (result: any) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FlagSubmitForm({
|
||||
matchId,
|
||||
onSuccess,
|
||||
className,
|
||||
disabled = false,
|
||||
}: FlagSubmitFormProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
points?: number;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FlagSubmitFormData>({
|
||||
resolver: zodResolver(flagSubmitSchema),
|
||||
defaultValues: {
|
||||
flag: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (data: FlagSubmitFormData) => {
|
||||
setIsSubmitting(true);
|
||||
setLastResult(null);
|
||||
|
||||
try {
|
||||
const result = await flagsApi.submitFlag({
|
||||
matchId,
|
||||
flag: data.flag,
|
||||
});
|
||||
|
||||
setLastResult({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
points: result.points,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Флаг принят! +${result.points} очков`);
|
||||
onSuccess?.(result);
|
||||
reset();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
setLastResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Ошибка отправки',
|
||||
});
|
||||
toast.error('Не удалось отправить флаг');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[matchId, onSuccess, reset]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={cn('space-y-4', className)}>
|
||||
{/* Flag input */}
|
||||
<div>
|
||||
<label htmlFor="flag" className="mb-2 block text-sm font-medium">
|
||||
Флаг
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="flag"
|
||||
type="text"
|
||||
{...register('flag')}
|
||||
disabled={disabled || isSubmitting}
|
||||
placeholder="ADA{...}"
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 pr-12 text-sm font-mono outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
'disabled:opacity-50',
|
||||
errors.flag ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
<Flag className="absolute right-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
||||
</div>
|
||||
{errors.flag && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.flag.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last result */}
|
||||
{lastResult && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg p-3 text-sm',
|
||||
lastResult.success
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{lastResult.success ? (
|
||||
<Check className="h-5 w-5" />
|
||||
) : (
|
||||
<X className="h-5 w-5" />
|
||||
)}
|
||||
<span>{lastResult.message}</span>
|
||||
{lastResult.points && (
|
||||
<span className="ml-auto font-bold">+{lastResult.points}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || isSubmitting}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Проверка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flag className="h-5 w-5" />
|
||||
Отправить флаг
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlagSubmitForm;
|
||||
109
src/components/match/MatchCard.tsx
Normal file
109
src/components/match/MatchCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Swords, Calendar, Users, Clock } from 'lucide-react';
|
||||
import { Match } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatDateTime, formatRelativeTime } from '../../utils/formatters';
|
||||
import { StatusBadge } from '../common/StatusBadge';
|
||||
|
||||
interface MatchCardProps {
|
||||
match: Match;
|
||||
className?: string;
|
||||
showDescription?: boolean;
|
||||
}
|
||||
|
||||
const modeLabels: Record<string, string> = {
|
||||
training: 'Тренировка',
|
||||
tournament: 'Турнир',
|
||||
scrim: 'Скрим',
|
||||
practice: 'Практика',
|
||||
};
|
||||
|
||||
const modeColors: Record<string, string> = {
|
||||
training: 'text-blue-400',
|
||||
tournament: 'text-yellow-400',
|
||||
scrim: 'text-purple-400',
|
||||
practice: 'text-green-400',
|
||||
};
|
||||
|
||||
export function MatchCard({ match, className, showDescription = true }: MatchCardProps) {
|
||||
const isRunning = match.status === 'running';
|
||||
const isStarting = match.status === 'starting' || match.status === 'lobby';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={isRunning ? `/matches/${match.id}/live` : `/matches/${match.id}`}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('text-xs font-medium uppercase', modeColors[match.mode])}>
|
||||
{modeLabels[match.mode]}
|
||||
</span>
|
||||
{match.isRanked && (
|
||||
<span className="rounded bg-yellow-400/20 px-2 py-0.5 text-xs font-medium text-yellow-400">
|
||||
Рейтинг
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary">
|
||||
{match.title}
|
||||
</h3>
|
||||
</div>
|
||||
<StatusBadge status={match.status} type="match" pulse={isRunning} />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{showDescription && match.description && (
|
||||
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
|
||||
{match.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{match.config.maxTeams} команд</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Swords className="h-4 w-4" />
|
||||
<span>{match.totalRounds} раундов</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{Math.floor(match.roundDuration / 60)} мин раунд</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{match.scheduledAt ? formatDateTime(match.scheduledAt) : 'TBD'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{match.startedAt ? (
|
||||
<>Начался {formatRelativeTime(match.startedAt)}</>
|
||||
) : match.scheduledAt ? (
|
||||
<>Начнётся {formatRelativeTime(match.scheduledAt)}</>
|
||||
) : (
|
||||
<>Создан {formatRelativeTime(match.createdAt)}</>
|
||||
)}
|
||||
</div>
|
||||
{(isRunning || isStarting) && (
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-primary">
|
||||
{isRunning ? 'Смотреть' : 'Войти'}
|
||||
<Swords className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchCard;
|
||||
329
src/components/match/MatchConfigForm.tsx
Normal file
329
src/components/match/MatchConfigForm.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { Service } from '../../api/types';
|
||||
|
||||
const matchConfigSchema = z.object({
|
||||
title: z.string().min(3, 'Минимум 3 символа').max(100, 'Максимум 100 символов'),
|
||||
description: z.string().max(1000, 'Максимум 1000 символов').optional(),
|
||||
mode: z.enum(['training', 'tournament', 'scrim', 'practice']),
|
||||
maxTeams: z.number().int().min(2).max(100),
|
||||
minTeams: z.number().int().min(2),
|
||||
roundDuration: z.number().int().min(30).max(3600),
|
||||
totalRounds: z.number().int().min(1).max(500),
|
||||
flagLifetime: z.number().int().min(10).max(600),
|
||||
scoringPolicy: z.enum(['classic', 'linear', 'exponential', 'dynamic']),
|
||||
serviceIds: z.array(z.string()).min(1, 'Выберите хотя бы один сервис'),
|
||||
scheduledAt: z.string().optional(),
|
||||
visibility: z.enum(['public', 'private', 'unlisted']),
|
||||
isRanked: z.boolean(),
|
||||
});
|
||||
|
||||
type MatchConfigFormData = z.infer<typeof matchConfigSchema>;
|
||||
|
||||
interface MatchConfigFormProps {
|
||||
services: Service[];
|
||||
onSubmit: (data: MatchConfigFormData) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const modeLabels: Record<string, string> = {
|
||||
training: 'Тренировка',
|
||||
tournament: 'Турнир',
|
||||
scrim: 'Скрим',
|
||||
practice: 'Практика',
|
||||
};
|
||||
|
||||
const scoringLabels: Record<string, string> = {
|
||||
classic: 'Классическое',
|
||||
linear: 'Линейное',
|
||||
exponential: 'Экспоненциальное',
|
||||
dynamic: 'Динамическое',
|
||||
};
|
||||
|
||||
const visibilityLabels: Record<string, string> = {
|
||||
public: 'Публичный',
|
||||
private: 'Приватный',
|
||||
unlisted: 'Скрытый',
|
||||
};
|
||||
|
||||
export function MatchConfigForm({
|
||||
services,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: MatchConfigFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(matchConfigSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
mode: 'training',
|
||||
maxTeams: 10,
|
||||
minTeams: 2,
|
||||
roundDuration: 300,
|
||||
totalRounds: 20,
|
||||
flagLifetime: 120,
|
||||
scoringPolicy: 'classic',
|
||||
serviceIds: [],
|
||||
visibility: 'public',
|
||||
isRanked: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selectedServiceIds = watch('serviceIds');
|
||||
|
||||
const toggleService = (serviceId: string) => {
|
||||
const current = selectedServiceIds || [];
|
||||
if (current.includes(serviceId)) {
|
||||
setValue(
|
||||
'serviceIds',
|
||||
current.filter((id) => id !== serviceId)
|
||||
);
|
||||
} else {
|
||||
setValue('serviceIds', [...current, serviceId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: any) => {
|
||||
try {
|
||||
await onSubmit(data as MatchConfigFormData);
|
||||
} catch (error) {
|
||||
// Error handled by parent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleSubmitForm)} className={cn('space-y-6', className)}>
|
||||
{/* Basic info */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 block text-sm font-medium">
|
||||
Название матча
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
{...register('title')}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.title ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="Например: Еженедельный турнир #42"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.title.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="mb-2 block text-sm font-medium">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-background px-4 py-3 text-sm outline-none transition-colors',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary',
|
||||
errors.description ? 'border-destructive' : 'border-border'
|
||||
)}
|
||||
placeholder="Описание матча (опционально)"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Режим</label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
{(Object.entries(modeLabels) as [string, string][]).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setValue('mode', value as any)}
|
||||
className={cn(
|
||||
'rounded-lg border px-4 py-3 text-sm font-medium transition-colors',
|
||||
watch('mode') === value
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.mode && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.mode.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Макс. команд</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('maxTeams', { valueAsNumber: true })}
|
||||
min={2}
|
||||
max={100}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Мин. команд</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('minTeams', { valueAsNumber: true })}
|
||||
min={2}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Раундов</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('totalRounds', { valueAsNumber: true })}
|
||||
min={1}
|
||||
max={500}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Длительность раунда (сек)</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('roundDuration', { valueAsNumber: true })}
|
||||
min={30}
|
||||
max={3600}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Время жизни флага (сек)</label>
|
||||
<input
|
||||
type="number"
|
||||
{...register('flagLifetime', { valueAsNumber: true })}
|
||||
min={10}
|
||||
max={600}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Политика очков</label>
|
||||
<select
|
||||
{...register('scoringPolicy')}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{(Object.entries(scoringLabels) as [string, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Сервисы</label>
|
||||
<div className="grid max-h-64 grid-cols-2 gap-2 overflow-y-auto rounded-lg border border-border p-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{services.map((service) => {
|
||||
const isSelected = selectedServiceIds?.includes(service.id);
|
||||
return (
|
||||
<button
|
||||
key={service.id}
|
||||
type="button"
|
||||
onClick={() => toggleService(service.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg border p-3 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-4 w-4 items-center justify-center rounded',
|
||||
isSelected ? 'bg-primary' : 'border border-border'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Plus className="h-3 w-3" />}
|
||||
</div>
|
||||
<span className="truncate">{service.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{errors.serviceIds && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.serviceIds.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visibility & Ranked */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Видимость</label>
|
||||
<select
|
||||
{...register('visibility')}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-3 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{(Object.entries(visibilityLabels) as [string, string][]).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('isRanked')}
|
||||
className="h-4 w-4 rounded border-border bg-background text-primary focus:ring-primary"
|
||||
/>
|
||||
<span className="text-sm font-medium">Рейтинговый матч</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
'Создать матч'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchConfigForm;
|
||||
28
src/components/match/MatchStatusBadge.tsx
Normal file
28
src/components/match/MatchStatusBadge.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StatusBadge } from '../common/StatusBadge';
|
||||
import { MatchStatus } from '../../api/types';
|
||||
|
||||
interface MatchStatusBadgeProps {
|
||||
status: MatchStatus;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MatchStatusBadge({
|
||||
status,
|
||||
size = 'md',
|
||||
pulse = status === 'running' || status === 'starting',
|
||||
className,
|
||||
}: MatchStatusBadgeProps) {
|
||||
return (
|
||||
<StatusBadge
|
||||
status={status}
|
||||
type="match"
|
||||
size={size}
|
||||
pulse={pulse}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchStatusBadge;
|
||||
105
src/components/match/MatchTimer.tsx
Normal file
105
src/components/match/MatchTimer.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Timer, AlertTriangle } from 'lucide-react';
|
||||
import { useMatchTimer } from '../../hooks/useMatchTimer';
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface MatchTimerProps {
|
||||
/** Duration in seconds */
|
||||
duration: number;
|
||||
/** Round number */
|
||||
round?: number;
|
||||
/** Total rounds */
|
||||
totalRounds?: number;
|
||||
/** Auto-start */
|
||||
autoStart?: boolean;
|
||||
/** Callback when timer ends */
|
||||
onComplete?: () => void;
|
||||
/** Size variant */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** Show warning when time is low */
|
||||
showWarning?: boolean;
|
||||
warningThreshold?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-lg',
|
||||
md: 'text-3xl',
|
||||
lg: 'text-5xl',
|
||||
};
|
||||
|
||||
export function MatchTimer({
|
||||
duration,
|
||||
round,
|
||||
totalRounds,
|
||||
autoStart = true,
|
||||
onComplete,
|
||||
size = 'md',
|
||||
showWarning = true,
|
||||
warningThreshold = 60,
|
||||
className,
|
||||
}: MatchTimerProps) {
|
||||
const { remaining, parts, isCompleted, progress } = useMatchTimer({
|
||||
duration,
|
||||
autoStart,
|
||||
onComplete,
|
||||
});
|
||||
|
||||
const isLow = remaining <= warningThreshold;
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex flex-col items-center', className)}>
|
||||
{/* Round indicator */}
|
||||
{(round || totalRounds) && (
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
Раунд {round || '?'} {totalRounds ? `/ ${totalRounds}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timer display */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative font-mono font-bold tabular-nums tracking-wider',
|
||||
sizeClasses[size],
|
||||
isCompleted && 'text-destructive animate-pulse',
|
||||
isLow && !isCompleted && showWarning && 'text-yellow-500'
|
||||
)}
|
||||
>
|
||||
{parts.hours > 0 && (
|
||||
<>
|
||||
<span>{parts.hours.toString().padStart(2, '0')}</span>
|
||||
<span className="opacity-50">:</span>
|
||||
</>
|
||||
)}
|
||||
<span>{parts.minutes.toString().padStart(2, '0')}</span>
|
||||
<span className="opacity-50">:</span>
|
||||
<span>{parts.seconds.toString().padStart(2, '0')}</span>
|
||||
|
||||
{/* Low time indicator */}
|
||||
{isLow && !isCompleted && showWarning && (
|
||||
<AlertTriangle className="absolute -right-6 top-1/2 h-5 w-5 -translate-y-1/2 animate-pulse text-yellow-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-1000',
|
||||
isCompleted && 'bg-destructive',
|
||||
isLow && !isCompleted && 'bg-yellow-500',
|
||||
!isLow && !isCompleted && 'bg-primary'
|
||||
)}
|
||||
style={{ width: `${100 - progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Timer className="h-3 w-3" />
|
||||
{isCompleted ? 'Раунд завершён' : 'До конца раунда'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatchTimer;
|
||||
73
src/components/match/RoundIndicator.tsx
Normal file
73
src/components/match/RoundIndicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
|
||||
interface RoundIndicatorProps {
|
||||
currentRound: number;
|
||||
totalRounds: number;
|
||||
completedRounds?: number;
|
||||
className?: string;
|
||||
showLabels?: boolean;
|
||||
}
|
||||
|
||||
export function RoundIndicator({
|
||||
currentRound,
|
||||
totalRounds,
|
||||
completedRounds = 0,
|
||||
className,
|
||||
showLabels = true,
|
||||
}: RoundIndicatorProps) {
|
||||
// Generate rounds array
|
||||
const rounds = Array.from({ length: totalRounds }, (_, i) => i + 1);
|
||||
|
||||
// Limit display for large number of rounds
|
||||
const displayRounds = totalRounds > 20 ? 20 : totalRounds;
|
||||
const displayedRounds = rounds.slice(0, displayRounds);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
{showLabels && (
|
||||
<div className="mb-2 text-sm text-muted-foreground">
|
||||
Раунд {currentRound} из {totalRounds}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{displayedRounds.map((round) => {
|
||||
const isCompleted = round <= completedRounds;
|
||||
const isCurrent = round === currentRound;
|
||||
const isUpcoming = round > currentRound;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={round}
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded text-xs font-medium transition-all',
|
||||
isCompleted && 'bg-green-500/20 text-green-400',
|
||||
isCurrent && 'bg-primary text-primary-foreground scale-110',
|
||||
isUpcoming && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
title={`Раунд ${round}`}
|
||||
>
|
||||
{round}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{totalRounds > displayRounds && (
|
||||
<div className="flex h-6 w-6 items-center justify-center text-xs text-muted-foreground">
|
||||
+{totalRounds - displayRounds}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all"
|
||||
style={{ width: `${(currentRound / totalRounds) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoundIndicator;
|
||||
94
src/components/match/ServiceStatusGrid.tsx
Normal file
94
src/components/match/ServiceStatusGrid.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { ServiceStatusInfo } from '../../api/types';
|
||||
import { CHECKER_RESULT_LABELS } from '../../utils/constants';
|
||||
import { CHECKER_COLORS } from '../../utils/colors';
|
||||
|
||||
interface ServiceStatusGridProps {
|
||||
services: ServiceStatusInfo[];
|
||||
teams: { id: string; name: string; color: string }[];
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const statusBgColors: Record<string, string> = {
|
||||
ok: 'bg-green-500/20',
|
||||
corrupt: 'bg-yellow-500/20',
|
||||
mumble: 'bg-orange-500/20',
|
||||
down: 'bg-red-500/20',
|
||||
error: 'bg-red-600/20',
|
||||
timeout: 'bg-purple-500/20',
|
||||
};
|
||||
|
||||
const statusBorderColors: Record<string, string> = {
|
||||
ok: 'border-green-500/50',
|
||||
corrupt: 'border-yellow-500/50',
|
||||
mumble: 'border-orange-500/50',
|
||||
down: 'border-red-500/50',
|
||||
error: 'border-red-600/50',
|
||||
timeout: 'border-purple-500/50',
|
||||
};
|
||||
|
||||
export function ServiceStatusGrid({
|
||||
services,
|
||||
teams,
|
||||
compact = false,
|
||||
className,
|
||||
}: ServiceStatusGridProps) {
|
||||
// Group services by service
|
||||
const servicesByService = services.reduce((acc, status) => {
|
||||
if (!acc[status.serviceId]) {
|
||||
acc[status.serviceId] = [];
|
||||
}
|
||||
acc[status.serviceId].push(status);
|
||||
return acc;
|
||||
}, {} as Record<string, ServiceStatusInfo[]>);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Object.entries(servicesByService).map(([serviceId, statuses]) => {
|
||||
const serviceName = (statuses[0] as any)?.serviceName || serviceId;
|
||||
|
||||
return (
|
||||
<div key={serviceId}>
|
||||
<div className="mb-2 text-sm font-medium">{serviceName}</div>
|
||||
<div className={cn('grid gap-2', compact ? 'grid-cols-8' : 'grid-cols-6')}>
|
||||
{statuses.map((status) => {
|
||||
const team = teams.find((t) => t.id === status.teamId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={status.teamId}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border p-3 transition-colors',
|
||||
statusBgColors[status.status] || 'bg-muted',
|
||||
statusBorderColors[status.status] || 'border-border'
|
||||
)}
|
||||
title={`${team?.name || 'Unknown'}: ${CHECKER_RESULT_LABELS[status.status] || status.status}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-3 w-3 rounded-full',
|
||||
compact ? 'mb-1' : 'mb-2'
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: CHECKER_COLORS[status.status as keyof typeof CHECKER_COLORS] || '#666',
|
||||
}}
|
||||
/>
|
||||
<span className={cn('text-xs font-medium', compact ? 'text-[10px]' : 'text-xs')}>
|
||||
{team?.name || '???'}
|
||||
</span>
|
||||
<span className={cn('text-[10px] text-muted-foreground', compact ? 'text-[9px]' : 'text-xs')}>
|
||||
{CHECKER_RESULT_LABELS[status.status] || status.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceStatusGrid;
|
||||
8
src/components/match/index.ts
Normal file
8
src/components/match/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Export all match components
|
||||
export { MatchCard } from './MatchCard';
|
||||
export { MatchTimer } from './MatchTimer';
|
||||
export { MatchStatusBadge } from './MatchStatusBadge';
|
||||
export { RoundIndicator } from './RoundIndicator';
|
||||
export { ServiceStatusGrid } from './ServiceStatusGrid';
|
||||
export { FlagSubmitForm } from './FlagSubmitForm';
|
||||
export { MatchConfigForm } from './MatchConfigForm';
|
||||
77
src/components/profile/AchievementBadge.tsx
Normal file
77
src/components/profile/AchievementBadge.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Trophy, Award, Star, Medal } from 'lucide-react';
|
||||
import { Achievement } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { RARITY_LABELS } from '../../utils/constants';
|
||||
import { RARITY_COLORS } from '../../utils/colors';
|
||||
|
||||
interface AchievementBadgeProps {
|
||||
achievement: Achievement;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showProgress?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const rarityIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
common: Star,
|
||||
uncommon: Award,
|
||||
rare: Medal,
|
||||
epic: Trophy,
|
||||
legendary: Trophy,
|
||||
};
|
||||
|
||||
export function AchievementBadge({
|
||||
achievement,
|
||||
size = 'md',
|
||||
showProgress = false,
|
||||
className,
|
||||
}: AchievementBadgeProps) {
|
||||
const Icon = rarityIcons[achievement.rarity] || Star;
|
||||
const color = RARITY_COLORS[achievement.rarity];
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-12 w-12',
|
||||
md: 'h-16 w-16',
|
||||
lg: 'h-24 w-24',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<Icon className={cn(iconSizes[size])} style={{ color }} />
|
||||
{achievement.unlockedAt && (
|
||||
<div className="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-green-500 text-white">
|
||||
✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-sm font-medium">{achievement.name}</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
style={{ color }}
|
||||
>
|
||||
{RARITY_LABELS[achievement.rarity]}
|
||||
</p>
|
||||
{showProgress && achievement.progress !== undefined && achievement.maxProgress && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{achievement.progress}/{achievement.maxProgress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AchievementBadge;
|
||||
48
src/components/profile/ActivityFeed.tsx
Normal file
48
src/components/profile/ActivityFeed.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { UserActivity } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatRelativeTime } from '../../utils/formatters';
|
||||
|
||||
interface ActivityFeedProps {
|
||||
activities: UserActivity[];
|
||||
className?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const activityIcons: Record<string, string> = {
|
||||
match_played: '⚔️',
|
||||
achievement_unlocked: '🏆',
|
||||
exercise_completed: '📚',
|
||||
team_joined: '👥',
|
||||
rank_change: '📈',
|
||||
};
|
||||
|
||||
export function ActivityFeed({ activities, className, limit }: ActivityFeedProps) {
|
||||
const displayedActivities = limit ? activities.slice(0, limit) : activities;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{displayedActivities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="flex items-start gap-4 rounded-lg border border-border p-4"
|
||||
>
|
||||
<div className="text-2xl">{activityIcons[activity.type] || '📝'}</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{activity.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{activity.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(activity.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{activities.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
Нет активности
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityFeed;
|
||||
76
src/components/profile/ProfileStats.tsx
Normal file
76
src/components/profile/ProfileStats.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { UserStats } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber } from '../../utils/formatters';
|
||||
import { Swords, Trophy, Target, TrendingUp, Clock, Award } from 'lucide-react';
|
||||
|
||||
interface ProfileStatsProps {
|
||||
stats: UserStats;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProfileStats({ stats, className }: ProfileStatsProps) {
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Матчей сыграно',
|
||||
value: stats.matchesPlayed,
|
||||
icon: Swords,
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
label: 'Побед',
|
||||
value: stats.matchesWon,
|
||||
icon: Trophy,
|
||||
color: 'text-yellow-400',
|
||||
},
|
||||
{
|
||||
label: 'Флагов захвачено',
|
||||
value: stats.flagsCaptured,
|
||||
icon: Target,
|
||||
color: 'text-green-400',
|
||||
},
|
||||
{
|
||||
label: 'Рейтинг',
|
||||
value: formatNumber(stats.rating),
|
||||
icon: TrendingUp,
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
{
|
||||
label: 'Часов в игре',
|
||||
value: stats.hoursPlayed,
|
||||
icon: Clock,
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
{
|
||||
label: 'Достижений',
|
||||
value: stats.achievements?.length || 0,
|
||||
icon: Award,
|
||||
color: 'text-pink-400',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3', className)}>
|
||||
{statCards.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-xl border border-border bg-card p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{stat.label}</p>
|
||||
<p className="mt-2 text-2xl font-bold">{stat.value}</p>
|
||||
</div>
|
||||
<div className={cn('rounded-lg p-3', stat.color.replace('text-', 'bg-').replace('400', '500/20'))}>
|
||||
<Icon className={cn('h-6 w-6', stat.color)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileStats;
|
||||
4
src/components/profile/index.ts
Normal file
4
src/components/profile/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Export all profile components
|
||||
export { AchievementBadge } from './AchievementBadge';
|
||||
export { ProfileStats } from './ProfileStats';
|
||||
export { ActivityFeed } from './ActivityFeed';
|
||||
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
109
src/components/scoreboard/ScoreBreakdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ScoreBreakdown as ScoreBreakdownType } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber } from '../../utils/formatters';
|
||||
|
||||
interface ScoreBreakdownProps {
|
||||
breakdown: ScoreBreakdownType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreBreakdown({ breakdown, className }: ScoreBreakdownProps) {
|
||||
const total = breakdown.totalAttack + breakdown.totalDefense + breakdown.totalSLA;
|
||||
|
||||
const attackPercent = total > 0 ? (breakdown.totalAttack / total) * 100 : 0;
|
||||
const defensePercent = total > 0 ? (breakdown.totalDefense / total) * 100 : 0;
|
||||
const slaPercent = total > 0 ? (breakdown.totalSLA / total) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Breakdown bars */}
|
||||
<div className="h-4 overflow-hidden rounded-full bg-muted">
|
||||
<div className="flex h-full">
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${attackPercent}%` }}
|
||||
title={`Атака: ${formatNumber(breakdown.totalAttack)}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-blue-500 transition-all"
|
||||
style={{ width: `${defensePercent}%` }}
|
||||
title={`Защита: ${formatNumber(breakdown.totalDefense)}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${slaPercent}%` }}
|
||||
title={`SLA: ${formatNumber(breakdown.totalSLA)}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Атака</div>
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{formatNumber(breakdown.totalAttack)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{attackPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Защита</div>
|
||||
<div className="text-lg font-bold text-blue-400">
|
||||
{formatNumber(breakdown.totalDefense)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{defensePercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">SLA</div>
|
||||
<div className="text-lg font-bold text-green-400">
|
||||
{formatNumber(breakdown.totalSLA)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{slaPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Penalties and bonuses */}
|
||||
{(breakdown.penalties !== 0 || breakdown.bonuses !== 0) && (
|
||||
<div className="flex justify-between border-t border-border pt-4">
|
||||
{breakdown.penalties !== 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Штрафы: </span>
|
||||
<span className="text-destructive">-{formatNumber(breakdown.penalties)}</span>
|
||||
</div>
|
||||
)}
|
||||
{breakdown.bonuses !== 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Бонусы: </span>
|
||||
<span className="text-green-400">+{formatNumber(breakdown.bonuses)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service breakdown */}
|
||||
{breakdown.services.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<div className="text-sm font-medium">По сервисам:</div>
|
||||
{breakdown.services.map((service) => (
|
||||
<div key={service.serviceId} className="flex items-center justify-between text-sm">
|
||||
<span>{service.serviceName}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-red-400">+{service.attackScore}</span>
|
||||
<span className="text-xs text-blue-400">+{service.defenseScore}</span>
|
||||
<span className="text-xs text-green-400">+{service.slaScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreBreakdown;
|
||||
150
src/components/scoreboard/ScoreChart.tsx
Normal file
150
src/components/scoreboard/ScoreChart.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ScoreHistory } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface ScoreChartProps {
|
||||
history: ScoreHistory[];
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreChart({
|
||||
history,
|
||||
height = 300,
|
||||
showLegend = true,
|
||||
showGrid = true,
|
||||
className,
|
||||
}: ScoreChartProps) {
|
||||
// Get all rounds
|
||||
const allRounds = useMemo(() => {
|
||||
const rounds = new Set<number>();
|
||||
history.forEach((h) => {
|
||||
h.history.forEach((point) => rounds.add(point.round));
|
||||
});
|
||||
return Array.from(rounds).sort((a, b) => a - b);
|
||||
}, [history]);
|
||||
|
||||
// Get max score for scaling
|
||||
const maxScore = useMemo(() => {
|
||||
let max = 0;
|
||||
history.forEach((h) => {
|
||||
h.history.forEach((point) => {
|
||||
if (point.score > max) max = point.score;
|
||||
});
|
||||
});
|
||||
return max || 100;
|
||||
}, [history]);
|
||||
|
||||
// Generate path for a team
|
||||
const generatePath = (teamHistory: ScoreHistory) => {
|
||||
if (allRounds.length === 0 || teamHistory.history.length === 0) return '';
|
||||
|
||||
const points = allRounds.map((round) => {
|
||||
const point = teamHistory.history.find((p) => p.round === round);
|
||||
const score = point?.score || 0;
|
||||
const x = (allRounds.indexOf(round) / (allRounds.length - 1 || 1)) * 100;
|
||||
const y = 100 - (score / maxScore) * 100;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return points.map((p, i) => (i === 0 ? `M ${p}` : `L ${p}`)).join(' ');
|
||||
};
|
||||
|
||||
if (history.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center', className)} style={{ height }}>
|
||||
<p className="text-muted-foreground">Нет данных для отображения</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Chart */}
|
||||
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<>
|
||||
{[0, 25, 50, 75, 100].map((y) => (
|
||||
<line
|
||||
key={y}
|
||||
x1="0"
|
||||
y1={y}
|
||||
x2="100"
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="0.2"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Lines */}
|
||||
{history.map((teamHistory, index) => (
|
||||
<g key={teamHistory.teamId}>
|
||||
<path
|
||||
d={generatePath(teamHistory)}
|
||||
fill="none"
|
||||
stroke={getTeamColor(index)}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Points */}
|
||||
{teamHistory.history.map((point, i) => {
|
||||
const x = (allRounds.indexOf(point.round) / (allRounds.length - 1 || 1)) * 100;
|
||||
const y = 100 - (point.score / maxScore) * 100;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="1"
|
||||
fill={getTeamColor(index)}
|
||||
className="opacity-0 hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* X-axis labels */}
|
||||
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
|
||||
{allRounds.slice(0, 10).map((round) => (
|
||||
<span key={round}>{round}</span>
|
||||
))}
|
||||
{allRounds.length > 10 && <span>...</span>}
|
||||
{allRounds.length > 1 && <span>{allRounds[allRounds.length - 1]}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{history.map((teamHistory, index) => (
|
||||
<div key={teamHistory.teamId} className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: getTeamColor(index) }}
|
||||
/>
|
||||
<span className="text-sm">{teamHistory.teamName}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreChart;
|
||||
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
169
src/components/scoreboard/ScoreboardTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Trophy, TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||
import { ScoreboardEntry } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber, formatPositionChange, formatSLA } from '../../utils/formatters';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface ScoreboardTableProps {
|
||||
entries: ScoreboardEntry[];
|
||||
highlightTeamId?: string;
|
||||
compact?: boolean;
|
||||
showBreakdown?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreboardTable({
|
||||
entries,
|
||||
highlightTeamId,
|
||||
compact = false,
|
||||
showBreakdown = false,
|
||||
className,
|
||||
}: ScoreboardTableProps) {
|
||||
// Sort by position
|
||||
const sortedEntries = useMemo(() => {
|
||||
return [...entries].sort((a, b) => a.position - b.position);
|
||||
}, [entries]);
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className={cn('px-4 py-3 text-left text-sm font-medium', compact ? 'w-12' : 'w-16')}>
|
||||
#
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">Команда</th>
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-24' : 'w-32')}>
|
||||
Очки
|
||||
</th>
|
||||
{showBreakdown && (
|
||||
<>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
Атака
|
||||
</th>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
Защита
|
||||
</th>
|
||||
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
|
||||
SLA
|
||||
</th>
|
||||
</>
|
||||
)}
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-20' : 'w-24')}>
|
||||
Флаги
|
||||
</th>
|
||||
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-16' : 'w-20')}>
|
||||
SLA
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEntries.map((entry, index) => {
|
||||
const isHighlighted = entry.teamId === highlightTeamId;
|
||||
const isTop3 = entry.position <= 3;
|
||||
const positionChange = entry.positionChange || 0;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={entry.teamId}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors',
|
||||
isHighlighted && 'bg-primary/5',
|
||||
!isHighlighted && 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{/* Position */}
|
||||
<td className={cn('px-4 py-3', compact ? 'text-sm' : 'text-base')}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTop3 ? (
|
||||
<Trophy
|
||||
className={cn(
|
||||
'h-5 w-5',
|
||||
entry.position === 1 && 'text-yellow-400',
|
||||
entry.position === 2 && 'text-gray-300',
|
||||
entry.position === 3 && 'text-orange-400'
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-medium">{entry.position}</span>
|
||||
)}
|
||||
{positionChange !== 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center text-xs',
|
||||
positionChange > 0 && 'text-green-400',
|
||||
positionChange < 0 && 'text-red-400'
|
||||
)}
|
||||
>
|
||||
{positionChange > 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Team */}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
|
||||
style={{ backgroundColor: getTeamColor(index) }}
|
||||
>
|
||||
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||
{entry.teamName}
|
||||
</p>
|
||||
{showBreakdown && entry.teamTag && (
|
||||
<p className="text-xs text-muted-foreground">{entry.teamTag}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Score */}
|
||||
<td className={cn('px-4 py-3 text-right font-bold', compact ? 'text-lg' : 'text-xl')}>
|
||||
{formatNumber(entry.score)}
|
||||
</td>
|
||||
|
||||
{/* Breakdown */}
|
||||
{showBreakdown && (
|
||||
<>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-red-400">+{formatNumber(entry.attackScore)}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-blue-400">+{formatNumber(entry.defenseScore)}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
|
||||
<span className="text-green-400">+{formatNumber(entry.slaScore)}</span>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Flags */}
|
||||
<td className={cn('px-4 py-3 text-right', compact ? 'text-sm' : 'text-base')}>
|
||||
<span className="text-green-400">{entry.flagsCaptured}</span>
|
||||
<span className="mx-1 text-muted-foreground">/</span>
|
||||
<span className="text-red-400">{entry.flagsLost}</span>
|
||||
</td>
|
||||
|
||||
{/* SLA */}
|
||||
<td className={cn('px-4 py-3 text-right font-medium', compact ? 'text-sm' : 'text-base')}>
|
||||
{formatSLA(entry.avgSLA)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreboardTable;
|
||||
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
124
src/components/scoreboard/TeamScoreCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Trophy, Target, Shield, Activity } from 'lucide-react';
|
||||
import { ScoreboardEntry } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatNumber, formatSLA } from '../../utils/formatters';
|
||||
import { getTeamColor } from '../../utils/colors';
|
||||
|
||||
interface TeamScoreCardProps {
|
||||
entry: ScoreboardEntry;
|
||||
rank?: number;
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function TeamScoreCard({
|
||||
entry,
|
||||
rank,
|
||||
compact = false,
|
||||
className,
|
||||
onClick,
|
||||
}: TeamScoreCardProps) {
|
||||
const isTop3 = (rank || entry.position) <= 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold text-white"
|
||||
style={{ backgroundColor: getTeamColor(entry.position - 1) }}
|
||||
>
|
||||
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{entry.teamName}</h3>
|
||||
{entry.teamTag && (
|
||||
<p className="text-sm text-muted-foreground">{entry.teamTag}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isTop3 && <Trophy className="h-6 w-6 text-yellow-400" />}
|
||||
<span className="text-2xl font-bold">#{entry.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main score */}
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-muted-foreground">Общий счёт</div>
|
||||
<div className="text-4xl font-bold text-primary">{formatNumber(entry.score)}</div>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Attack */}
|
||||
<div className="rounded-lg bg-red-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-red-400">
|
||||
<Target className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Атака</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-red-400">
|
||||
+{formatNumber(entry.attackScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.flagsCaptured} флагов
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense */}
|
||||
<div className="rounded-lg bg-blue-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-blue-400">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Защита</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-400">
|
||||
+{formatNumber(entry.defenseScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{entry.flagsLost} потеряно
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div className="rounded-lg bg-green-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-green-400">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">SLA</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
+{formatNumber(entry.slaScore)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSLA(entry.avgSLA)} uptime
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend */}
|
||||
<div className="rounded-lg bg-purple-500/10 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-purple-400">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Тренд</span>
|
||||
</div>
|
||||
<div className={cn('text-xl font-bold', entry.trend === 'up' && 'text-green-400', entry.trend === 'down' && 'text-red-400')}>
|
||||
{entry.positionChange && entry.positionChange > 0 ? '+' : ''}
|
||||
{entry.positionChange || 0}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
позиций
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamScoreCard;
|
||||
5
src/components/scoreboard/index.ts
Normal file
5
src/components/scoreboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Export all scoreboard components
|
||||
export { ScoreboardTable } from './ScoreboardTable';
|
||||
export { ScoreChart } from './ScoreChart';
|
||||
export { TeamScoreCard } from './TeamScoreCard';
|
||||
export { ScoreBreakdown } from './ScoreBreakdown';
|
||||
105
src/components/services/ServiceCard.tsx
Normal file
105
src/components/services/ServiceCard.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Star, Download, Clock, Users } from 'lucide-react';
|
||||
import { Service } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { DIFFICULTY_LABELS, SERVICE_CATEGORY_LABELS } from '../../utils/constants';
|
||||
import { DIFFICULTY_COLORS, CATEGORY_COLORS } from '../../utils/colors';
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: Service;
|
||||
className?: string;
|
||||
showAuthor?: boolean;
|
||||
}
|
||||
|
||||
export function ServiceCard({ service, className, showAuthor = true }: ServiceCardProps) {
|
||||
const difficultyColor = DIFFICULTY_COLORS[service.difficulty as keyof typeof DIFFICULTY_COLORS];
|
||||
const categoryColor = CATEGORY_COLORS[service.category] || '#666';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/services/${service.id}`}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${categoryColor}20`, color: categoryColor }}
|
||||
>
|
||||
{SERVICE_CATEGORY_LABELS[service.category]}
|
||||
</span>
|
||||
<span
|
||||
className="rounded px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${difficultyColor}20`, color: difficultyColor }}
|
||||
>
|
||||
{DIFFICULTY_LABELS[service.difficulty]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold group-hover:text-primary">
|
||||
{service.name}
|
||||
</h3>
|
||||
</div>
|
||||
{service.isFeatured && (
|
||||
<span className="rounded bg-yellow-400/20 px-2 py-1 text-xs font-medium text-yellow-400">
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="mb-4 line-clamp-2 text-sm text-muted-foreground">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{service.tags.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{service.tags.slice(0, 4).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-muted px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Star className="h-4 w-4" />
|
||||
<span>{service.rating.average.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{service.stats.timesUsed}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{service.stats.uniqueTeams}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{showAuthor && (
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Автор: {service.author?.displayName || service.author?.username}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(service.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceCard;
|
||||
204
src/components/services/ServiceFilter.tsx
Normal file
204
src/components/services/ServiceFilter.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { ServiceCategory, ServiceDifficulty } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { SERVICE_CATEGORY_LABELS, DIFFICULTY_LABELS } from '../../utils/constants';
|
||||
import { CATEGORY_COLORS, DIFFICULTY_COLORS } from '../../utils/colors';
|
||||
|
||||
interface ServiceFilterProps {
|
||||
selectedCategory?: ServiceCategory | null;
|
||||
selectedDifficulty?: ServiceDifficulty | null;
|
||||
selectedStack?: string[];
|
||||
selectedTags?: string[];
|
||||
minRating?: number | null;
|
||||
onCategoryChange?: (category: ServiceCategory | null) => void;
|
||||
onDifficultyChange?: (difficulty: ServiceDifficulty | null) => void;
|
||||
onStackChange?: (stack: string[]) => void;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
onMinRatingChange?: (rating: number | null) => void;
|
||||
onClear?: () => void;
|
||||
availableStacks?: { stack: string; count: number }[];
|
||||
availableTags?: { tag: string; count: number }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const categories: ServiceCategory[] = ['web', 'crypto', 'pwn', 'reverse', 'forensics', 'network', 'misc', 'blockchain', 'hardware'];
|
||||
const difficulties: ServiceDifficulty[] = ['beginner', 'easy', 'medium', 'hard', 'expert', 'insane'];
|
||||
|
||||
export function ServiceFilter({
|
||||
selectedCategory,
|
||||
selectedDifficulty,
|
||||
selectedStack = [],
|
||||
selectedTags = [],
|
||||
minRating,
|
||||
onCategoryChange,
|
||||
onDifficultyChange,
|
||||
onStackChange,
|
||||
onTagsChange,
|
||||
onMinRatingChange,
|
||||
onClear,
|
||||
availableStacks = [],
|
||||
availableTags = [],
|
||||
className,
|
||||
}: ServiceFilterProps) {
|
||||
const hasActiveFilters = selectedCategory || selectedDifficulty || selectedStack.length > 0 || selectedTags.length > 0 || minRating;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="font-medium">Фильтры</span>
|
||||
</div>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Категория</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => {
|
||||
const isSelected = selectedCategory === category;
|
||||
const color = CATEGORY_COLORS[category] || '#666';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => onCategoryChange?.(isSelected ? null : category)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{SERVICE_CATEGORY_LABELS[category]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difficulty */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Сложность</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{difficulties.map((difficulty) => {
|
||||
const isSelected = selectedDifficulty === difficulty;
|
||||
const color = DIFFICULTY_COLORS[difficulty];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={difficulty}
|
||||
onClick={() => onDifficultyChange?.(isSelected ? null : difficulty)}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{DIFFICULTY_LABELS[difficulty]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stack */}
|
||||
{availableStacks.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Стек технологий</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableStacks.slice(0, 10).map(({ stack, count }) => {
|
||||
const isSelected = selectedStack.includes(stack);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={stack}
|
||||
onClick={() => {
|
||||
const newStack = isSelected
|
||||
? selectedStack.filter((s) => s !== stack)
|
||||
: [...selectedStack, stack];
|
||||
onStackChange?.(newStack);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{stack} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{availableTags.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Теги</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTags.slice(0, 15).map(({ tag, count }) => {
|
||||
const isSelected = selectedTags.includes(tag);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => {
|
||||
const newTags = isSelected
|
||||
? selectedTags.filter((t) => t !== tag)
|
||||
: [...selectedTags, tag];
|
||||
onTagsChange?.(newTags);
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-1.5 text-xs transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{tag} ({count})
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Минимальный рейтинг</label>
|
||||
<div className="flex gap-2">
|
||||
{[0, 1, 2, 3, 4].map((rating) => (
|
||||
<button
|
||||
key={rating}
|
||||
onClick={() => onMinRatingChange?.(minRating === rating ? null : rating)}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg border px-3 py-1.5 text-sm transition-colors',
|
||||
minRating === rating
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{rating}+ ★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceFilter;
|
||||
47
src/components/services/ServiceStatusIndicator.tsx
Normal file
47
src/components/services/ServiceStatusIndicator.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { CHECKER_RESULT_LABELS } from '../../utils/constants';
|
||||
import { CHECKER_COLORS } from '../../utils/colors';
|
||||
|
||||
interface ServiceStatusIndicatorProps {
|
||||
status: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabel?: boolean;
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-2 w-2',
|
||||
md: 'h-3 w-3',
|
||||
lg: 'h-4 w-4',
|
||||
};
|
||||
|
||||
export function ServiceStatusIndicator({
|
||||
status,
|
||||
size = 'md',
|
||||
showLabel = false,
|
||||
pulse = status === 'down' || status === 'error',
|
||||
className,
|
||||
}: ServiceStatusIndicatorProps) {
|
||||
const color = CHECKER_COLORS[status as keyof typeof CHECKER_COLORS] || '#666';
|
||||
const label = CHECKER_RESULT_LABELS[status] || status;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-full',
|
||||
sizeClasses[size],
|
||||
pulse && 'animate-pulse'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
title={label}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ServiceStatusIndicator;
|
||||
4
src/components/services/index.ts
Normal file
4
src/components/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Export all services components
|
||||
export { ServiceCard } from './ServiceCard';
|
||||
export { ServiceFilter } from './ServiceFilter';
|
||||
export { ServiceStatusIndicator } from './ServiceStatusIndicator';
|
||||
182
src/components/team/TeamInviteForm.tsx
Normal file
182
src/components/team/TeamInviteForm.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Loader2, UserPlus } from 'lucide-react';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { SearchInput } from '../common/SearchInput';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
|
||||
const inviteSchema = z.object({
|
||||
userId: z.string().min(1, 'Выберите пользователя'),
|
||||
role: z.enum(['captain', 'co-captain', 'member', 'substitute', 'coach']),
|
||||
message: z.string().max(200, 'Максимум 200 символов').optional(),
|
||||
});
|
||||
|
||||
type InviteFormData = z.infer<typeof inviteSchema>;
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface TeamInviteFormProps {
|
||||
onSubmit: (data: InviteFormData) => Promise<void>;
|
||||
searchUsers: (query: string) => Promise<User[]>;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TeamInviteForm({
|
||||
onSubmit,
|
||||
searchUsers,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: TeamInviteFormProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<User[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const debouncedSearch = useDebounce(searchQuery, 300);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<InviteFormData>({
|
||||
resolver: zodResolver(inviteSchema),
|
||||
defaultValues: {
|
||||
role: 'member',
|
||||
message: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedUserId = watch('userId');
|
||||
|
||||
// Search users
|
||||
useState(() => {
|
||||
if (debouncedSearch.length >= 2) {
|
||||
setIsSearching(true);
|
||||
searchUsers(debouncedSearch)
|
||||
.then(setSearchResults)
|
||||
.finally(() => setIsSearching(false));
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectUser = (user: User) => {
|
||||
setValue('userId', user.id);
|
||||
setSearchQuery(`${user.displayName || user.username} (@${user.username})`);
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const handleSubmitForm = async (data: InviteFormData) => {
|
||||
try {
|
||||
await onSubmit(data);
|
||||
setValue('userId', '');
|
||||
setSearchQuery('');
|
||||
} catch {
|
||||
// Error handled by parent
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleSubmitForm)} className={cn('space-y-4', className)}>
|
||||
{/* User search */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Пользователь</label>
|
||||
<div className="relative">
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={(value) => {
|
||||
setSearchQuery(value);
|
||||
if (!value) setValue('userId', '');
|
||||
}}
|
||||
placeholder="Поиск пользователя..."
|
||||
showClearButton
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full overflow-hidden rounded-lg border border-border bg-card shadow-lg">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left text-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/20 text-sm font-bold text-primary">
|
||||
{user.displayName?.[0]?.toUpperCase() || user.username[0].toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{user.displayName || user.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">@{user.username}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.userId && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.userId.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Роль</label>
|
||||
<select
|
||||
{...register('role')}
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="member">Участник</option>
|
||||
<option value="co-captain">Со-капитан</option>
|
||||
<option value="substitute">Запасной</option>
|
||||
<option value="coach">Тренер</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
Сообщение <span className="text-muted-foreground">(опционально)</span>
|
||||
</label>
|
||||
<textarea
|
||||
{...register('message')}
|
||||
rows={3}
|
||||
placeholder="Пригласительное сообщение..."
|
||||
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="mt-1 text-sm text-destructive">{errors.message.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !selectedUserId}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-3 font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Отправка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="h-5 w-5" />
|
||||
Пригласить
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamInviteForm;
|
||||
77
src/components/team/TeamMatchHistory.tsx
Normal file
77
src/components/team/TeamMatchHistory.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Swords, Trophy, Target, Activity } from 'lucide-react';
|
||||
import { TeamMatchHistory as TeamMatchHistoryType } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { formatDateTime, formatNumber } from '../../utils/formatters';
|
||||
import { StatusBadge } from '../common/StatusBadge';
|
||||
|
||||
interface TeamMatchHistoryProps {
|
||||
matches: TeamMatchHistoryType[];
|
||||
className?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function TeamMatchHistory({
|
||||
matches,
|
||||
className,
|
||||
limit,
|
||||
}: TeamMatchHistoryProps) {
|
||||
const displayedMatches = limit ? matches.slice(0, limit) : matches;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{displayedMatches.map((match) => (
|
||||
<Link
|
||||
key={match.matchId}
|
||||
to={`/matches/${match.matchId}`}
|
||||
className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-10 w-10 items-center justify-center rounded-full',
|
||||
match.position === 1 && 'bg-yellow-400/20 text-yellow-400',
|
||||
match.position === 2 && 'bg-gray-400/20 text-gray-400',
|
||||
match.position === 3 && 'bg-orange-400/20 text-orange-400',
|
||||
match.position > 3 && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{match.position === 1 ? (
|
||||
<Trophy className="h-5 w-5" />
|
||||
) : (
|
||||
<span className="font-bold">#{match.position}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{match.matchTitle}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDateTime(match.playedAt)}</span>
|
||||
<span>•</span>
|
||||
<span className="capitalize">{match.mode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatNumber(match.score)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{match.flagsCaptured}/{match.flagsLost} флаги
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={match.position <= 3 ? 'finished' : 'finished'} type="match" size="sm" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{matches.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<Swords className="mx-auto h-12 w-12 opacity-50" />
|
||||
<p className="mt-2">Нет сыгранных матчей</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamMatchHistory;
|
||||
91
src/components/team/TeamMemberCard.tsx
Normal file
91
src/components/team/TeamMemberCard.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { User, Crown, Shield, Star } from 'lucide-react';
|
||||
import { TeamMember } from '../../api/types';
|
||||
import { cn } from '../../utils/helpers';
|
||||
import { TEAM_ROLE_LABELS, SPECIALIZATION_LABELS } from '../../utils/constants';
|
||||
|
||||
interface TeamMemberCardProps {
|
||||
member: TeamMember;
|
||||
isCurrentUser?: boolean;
|
||||
canEdit?: boolean;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
onRoleChange?: (role: string) => void;
|
||||
}
|
||||
|
||||
const roleIcons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
captain: Crown,
|
||||
'co-captain': Shield,
|
||||
member: User,
|
||||
substitute: Star,
|
||||
coach: Shield,
|
||||
};
|
||||
|
||||
export function TeamMemberCard({
|
||||
member,
|
||||
isCurrentUser = false,
|
||||
canEdit = false,
|
||||
className,
|
||||
onRemove,
|
||||
onRoleChange,
|
||||
}: TeamMemberCardProps) {
|
||||
const RoleIcon = roleIcons[member.role] || User;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg border border-border p-4',
|
||||
!member.isActive && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Avatar */}
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/20 text-lg font-bold text-primary">
|
||||
{member.user.displayName?.[0]?.toUpperCase() ||
|
||||
member.user.username[0].toUpperCase()}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{member.user.displayName || member.user.username}
|
||||
</span>
|
||||
{isCurrentUser && (
|
||||
<span className="rounded bg-primary/20 px-2 py-0.5 text-xs text-primary">
|
||||
Вы
|
||||
</span>
|
||||
)}
|
||||
{!member.isActive && (
|
||||
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
Не активен
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
<span>{TEAM_ROLE_LABELS[member.role]}</span>
|
||||
{member.specialization && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span>{SPECIALIZATION_LABELS[member.specialization]}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canEdit && !isCurrentUser && onRemove && (
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamMemberCard;
|
||||
69
src/components/team/TeamRoleSelector.tsx
Normal file
69
src/components/team/TeamRoleSelector.tsx
Normal 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;
|
||||
5
src/components/team/index.ts
Normal file
5
src/components/team/index.ts
Normal 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';
|
||||
111
src/components/training/ExerciseCard.tsx
Normal file
111
src/components/training/ExerciseCard.tsx
Normal 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;
|
||||
112
src/components/training/HintAccordion.tsx
Normal file
112
src/components/training/HintAccordion.tsx
Normal 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;
|
||||
57
src/components/training/ProgressBar.tsx
Normal file
57
src/components/training/ProgressBar.tsx
Normal 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;
|
||||
35
src/components/training/RoleBadge.tsx
Normal file
35
src/components/training/RoleBadge.tsx
Normal 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;
|
||||
174
src/components/training/SkillRadarChart.tsx
Normal file
174
src/components/training/SkillRadarChart.tsx
Normal 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;
|
||||
110
src/components/training/TrackCard.tsx
Normal file
110
src/components/training/TrackCard.tsx
Normal 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;
|
||||
7
src/components/training/index.ts
Normal file
7
src/components/training/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user