- Implemented "click-outside-to-close" functionality for both the Challenge Modal and User Dropdown - Added protocol-specific action buttons for challenges: "Open in new tab" for HTTP and "Copy to clipboard" for NC - Enhanced Scoreboard rankings with significantly larger, consistent font sizes (text-2xl) for better readability - Rebranded "TEAM_IDENTIFIER" to "PLAYER" and "TOTAL_POINTS" to "POINTS" across the platform (Scoreboard, Matrix, User Menu) - Updated navigation: Renamed "SCOREBOARD" to "SCORES" in the nav bar and dynamic page titles - Modernized User Dropdown menu with a dedicated "PLAYER" header and "LOGOUT" action - Improved Score Matrix and Score Graph titles for consistency with the new "Player" terminology - Added CAPTCHA human verification (svg-captcha) to Login and Registration flows for enhanced security - Optimized frontend assets by migrating Tailwind and JetBrains Mono to local hosting - Refactored Admin panel: Renamed "Operators" to "Users" and improved layout alignment
297 lines
16 KiB
TypeScript
297 lines
16 KiB
TypeScript
|
|
import React, { useMemo, useState } from 'react';
|
|
import { Trophy, Table, CheckCircle2, Medal, SearchX, History, LineChart as LineChartIcon, List } from 'lucide-react';
|
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
|
import { useCTF } from './CTFContext';
|
|
import { calculateTeamTotalScore, calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
|
import { Button } from './UIComponents';
|
|
|
|
const COLORS = [
|
|
'#ff0000', // Red
|
|
'#00ff00', // Green
|
|
'#0000ff', // Blue
|
|
'#ffff00', // Yellow
|
|
'#ff00ff', // Magenta
|
|
'#00ffff', // Cyan
|
|
'#ffaa00', // Orange
|
|
'#bf00ff', // Purple
|
|
'#ff0080', // Pink
|
|
'#aaff00' // Lime
|
|
];
|
|
|
|
type ScoreboardView = 'ranking' | 'matrix' | 'log' | 'graph';
|
|
|
|
export const Scoreboard: React.FC = () => {
|
|
const { state } = useCTF();
|
|
const [view, setView] = useState<ScoreboardView>('ranking');
|
|
|
|
const rankings = useMemo(() => {
|
|
return state.teams
|
|
.filter(t => !t.isAdmin && !t.isDisabled)
|
|
.map(team => ({
|
|
...team,
|
|
score: calculateTeamTotalScore(team.id, state.challenges, state.solves),
|
|
solveCount: state.solves.filter(s => s.teamId === team.id).length
|
|
}))
|
|
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
|
|
}, [state]);
|
|
|
|
const top10Teams = useMemo(() => rankings.slice(0, 10), [rankings]);
|
|
|
|
const sortedSolves = useMemo(() => {
|
|
return [...state.solves].sort((a, b) => b.timestamp - a.timestamp);
|
|
}, [state.solves]);
|
|
|
|
const sortedChallenges = useMemo(() => {
|
|
return [...state.challenges].sort((a, b) => (b.solves?.length || 0) - (a.solves?.length || 0));
|
|
}, [state.challenges]);
|
|
|
|
const graphData = useMemo(() => {
|
|
if (state.solves.length === 0) return [];
|
|
|
|
const sortedSolvesAsc = [...state.solves].sort((a, b) => a.timestamp - b.timestamp);
|
|
const dataPoints: any[] = [];
|
|
|
|
const startTime = state.startTime || (sortedSolvesAsc.length > 0 ? sortedSolvesAsc[0].timestamp - 1000 : 0);
|
|
const initialPoint: any = { time: startTime, displayTime: new Date(startTime).toLocaleTimeString() };
|
|
top10Teams.forEach(t => initialPoint[t.name] = 0);
|
|
dataPoints.push(initialPoint);
|
|
|
|
sortedSolvesAsc.forEach((solve, idx) => {
|
|
const solvesUpToNow = sortedSolvesAsc.slice(0, idx + 1);
|
|
const point: any = { time: solve.timestamp, displayTime: new Date(solve.timestamp).toLocaleTimeString() };
|
|
|
|
top10Teams.forEach(team => {
|
|
let total = 0;
|
|
const teamSolves = solvesUpToNow.filter(s => s.teamId === team.id);
|
|
|
|
teamSolves.forEach(ts => {
|
|
const challenge = state.challenges.find(c => c.id === ts.challengeId);
|
|
if (!challenge) return;
|
|
|
|
const challengeSolvesUpToNow = solvesUpToNow.filter(s => s.challengeId === ts.challengeId);
|
|
|
|
const baseValue = calculateChallengeValue(
|
|
challenge.initialPoints,
|
|
challenge.minimumPoints || 0,
|
|
challenge.decaySolves || 1,
|
|
challengeSolvesUpToNow.length
|
|
);
|
|
|
|
const rank = challengeSolvesUpToNow.findIndex(s => s.teamId === team.id);
|
|
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
|
|
|
|
total += (baseValue + bonus);
|
|
});
|
|
|
|
point[team.name] = total;
|
|
});
|
|
|
|
dataPoints.push(point);
|
|
});
|
|
|
|
return dataPoints;
|
|
}, [state, top10Teams]);
|
|
|
|
const renderContent = () => {
|
|
switch (view) {
|
|
case 'ranking':
|
|
return (
|
|
<div className="hxp-border border-2 bg-black overflow-hidden max-w-[1000px] mx-auto">
|
|
<table className="w-full text-left">
|
|
<thead className="bg-[#333] font-black uppercase">
|
|
<tr>
|
|
<th className="p-4 text-2xl italic">RANK</th>
|
|
<th className="p-4 text-2xl italic">PLAYER</th>
|
|
<th className="p-4 text-center text-2xl italic">SOLVES</th>
|
|
<th className="p-4 text-right text-2xl italic">POINTS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/10">
|
|
{rankings.map((team, idx) => (
|
|
<tr key={team.id} className="hover:bg-white/5 group">
|
|
<td className="p-4 font-black">
|
|
<div className={`w-10 h-10 flex items-center justify-center font-black italic text-2xl ${idx === 0 ? 'bg-[#ffaa00] text-black rotate-[-12deg]' : idx === 1 ? 'bg-slate-400 text-black rotate-[6deg]' : idx === 2 ? 'bg-[#cd7f32] text-black rotate-[-6deg]' : 'text-slate-500'}`}>{idx + 1}</div>
|
|
</td>
|
|
<td className="p-4 font-black italic text-2xl text-white group-hover:text-[#bf00ff] transition-colors">{team.name}</td>
|
|
<td className="p-4 text-center font-black text-2xl text-[#bf00ff]">{team.solveCount}</td>
|
|
<td className="p-4 text-right text-2xl font-black text-white italic">{team.score}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
case 'matrix':
|
|
if (state.solves.length === 0) {
|
|
return (
|
|
<div className="text-center hxp-border border-4 p-16 bg-black">
|
|
<SearchX className="w-20 h-20 text-slate-700 mx-auto mb-6" />
|
|
<h2 className="text-4xl font-black italic mb-4 uppercase text-white tracking-tighter">No solves yet!</h2>
|
|
<p className="text-slate-500 font-bold tracking-widest uppercase mb-8">The matrix is currently empty. Be the first to solve a challenge!</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="w-full overflow-x-auto custom-scrollbar">
|
|
<div className="inline-block min-w-full bg-black hxp-border overflow-hidden">
|
|
<table className="border-collapse w-full">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 z-20 bg-black p-4 text-[10px] font-black uppercase text-slate-500 border-b-2 border-r-2 border-[#333] whitespace-nowrap min-w-[200px]">Player / Challenge</th>
|
|
{sortedChallenges.map(c => {
|
|
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
|
return (
|
|
<th key={c.id} className="p-0 border-b-2 border-r-2 border-[#333] min-w-[80px] align-bottom relative group/col">
|
|
<div className="flex flex-col items-center justify-end pb-12 pt-8">
|
|
<span className={`[writing-mode:vertical-rl] rotate-180 whitespace-nowrap text-[11px] font-black tracking-[0.2em] transition-colors uppercase ${diffColor} group-hover/col:brightness-125`}>
|
|
{c.title}
|
|
</span>
|
|
</div>
|
|
<div className="absolute bottom-1 left-0 right-0 text-[8px] text-slate-600 font-bold uppercase text-center py-1 border-t border-[#333] bg-black/50">
|
|
{(c.solves || []).length} SOLVES
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rankings.map(team => (
|
|
<tr key={team.id} className="hover:bg-white/5 group">
|
|
<td className="sticky left-0 z-10 bg-black p-3 font-black italic text-sm border-r-2 border-b-2 border-[#333] whitespace-nowrap group-hover:text-[#bf00ff]">
|
|
{team.name} <span className="text-white not-italic opacity-40 ml-1 text-[10px] tracking-widest">({team.score})</span>
|
|
</td>
|
|
{sortedChallenges.map(c => {
|
|
const solve = state.solves.find(s => s.challengeId === c.id && s.teamId === team.id);
|
|
if (!solve) {
|
|
return (
|
|
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
|
<div className="w-full h-12 flex items-center justify-center text-[#1a1a1a]">•</div>
|
|
</td>
|
|
);
|
|
}
|
|
const challengeSolves = state.solves
|
|
.filter(s => s.challengeId === c.id)
|
|
.sort((a, b) => a.timestamp - b.timestamp);
|
|
const rank = challengeSolves.findIndex(s => s.teamId === team.id);
|
|
const baseValue = calculateChallengeValue(
|
|
c.initialPoints,
|
|
c.minimumPoints || 0,
|
|
c.decaySolves || 1,
|
|
(c.solves || []).length
|
|
);
|
|
const bonus = Math.floor(c.initialPoints * getFirstBloodBonusFactor(rank));
|
|
const totalPoints = baseValue + bonus;
|
|
|
|
return (
|
|
<td key={c.id} className="p-0 border-r-2 border-b-2 border-[#333] text-center">
|
|
<div className={`w-full h-12 flex flex-col items-center justify-center ${rank === 0 ? 'bg-[#ffaa00]/10' : rank === 1 ? 'bg-slate-400/10' : rank === 2 ? 'bg-[#cd7f32]/10' : 'bg-[#00ff00]/5'}`}>
|
|
<div className="flex items-center justify-center">
|
|
{rank === 0 ? <Medal size={16} className="text-[#ffaa00]" /> : rank === 1 ? <Medal size={16} className="text-slate-400" /> : rank === 2 ? <Medal size={16} className="text-[#cd7f32]" /> : <CheckCircle2 size={14} className="text-[#00ff00]" />}
|
|
</div>
|
|
<span className="text-[9px] font-black text-white/70 mt-1 leading-none">{totalPoints}</span>
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'log':
|
|
return (
|
|
<div className="flex flex-col gap-2 max-w-[1000px] mx-auto">
|
|
{sortedSolves.length === 0 ? (
|
|
<div className="p-8 hxp-border border-[#333] text-center text-slate-500 font-bold uppercase tracking-widest bg-white/5">
|
|
No activities recorded yet.
|
|
</div>
|
|
) : (
|
|
sortedSolves.map((solve, idx) => {
|
|
const team = state.teams.find(t => t.id === solve.teamId);
|
|
const challenge = state.challenges.find(c => c.id === solve.challengeId);
|
|
if (!team || !challenge) return null;
|
|
const challengeSolves = state.solves.filter(s => s.challengeId === solve.challengeId).sort((a, b) => a.timestamp - b.timestamp);
|
|
const rank = challengeSolves.findIndex(s => s.teamId === solve.teamId);
|
|
const basePoints = calculateChallengeValue(challenge.initialPoints, challenge.minimumPoints || 0, challenge.decaySolves || 1, challengeSolves.length);
|
|
const bonus = Math.floor(challenge.initialPoints * getFirstBloodBonusFactor(rank));
|
|
const pointsGained = basePoints + bonus;
|
|
const difficultyColor = challenge.difficulty === 'Low' ? 'text-[#00ff00]' : challenge.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
|
|
|
return (
|
|
<div key={`${solve.teamId}-${solve.challengeId}-${idx}`} className="p-4 hxp-border border-[#333] bg-white/5 flex items-center gap-4 hover:border-[#bf00ff] transition-colors">
|
|
<div className="flex-shrink-0">
|
|
{rank === 0 ? <Medal size={18} className="text-[#ffaa00]" /> : rank === 1 ? <Medal size={18} className="text-[#c0c0c0]" /> : rank === 2 ? <Medal size={18} className="text-[#cd7f32]" /> : <CheckCircle2 size={18} className="text-[#00ff00]" />}
|
|
</div>
|
|
<div className="text-sm font-mono text-slate-300">
|
|
<span className="text-slate-500 mr-2">[{new Date(solve.timestamp).toLocaleString()}]</span>
|
|
<span className="font-bold text-white uppercase italic">{team.name}</span> solved <span className="text-[#bf00ff] uppercase">{challenge.category}</span> - <span className={`font-bold ${difficultyColor}`}>{challenge.title}</span> and gained <span className="text-white font-bold hxp-border-purple px-1 py-0.5 bg-[#bf00ff]/10">{pointsGained} points</span>.
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
case 'graph':
|
|
return (
|
|
<div className="w-full h-[750px] p-4 hxp-border border-[#333] bg-white/5 flex flex-col">
|
|
<h3 className="text-xl font-bold italic text-white mb-4 uppercase">Top 10 Players - Score Progression</h3>
|
|
{graphData.length === 0 ? (
|
|
<div className="flex-1 flex items-center justify-center text-slate-500 font-bold uppercase tracking-widest">No data to display.</div>
|
|
) : (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={graphData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
|
<XAxis dataKey="displayTime" stroke="#888" tick={{ fill: '#888' }} />
|
|
<YAxis stroke="#888" tick={{ fill: '#888' }} label={{ value: 'Points', angle: -90, position: 'insideLeft', fill: '#888' }} />
|
|
<Tooltip
|
|
contentStyle={{ backgroundColor: '#111', borderColor: '#333', color: '#fff' }}
|
|
itemSorter={(item: any) => -item.value}
|
|
/>
|
|
<Legend />
|
|
{top10Teams.map((team, idx) => (
|
|
<Line key={team.id} type="stepAfter" dataKey={team.name} stroke={COLORS[idx % COLORS.length]} strokeWidth={2} dot={false} activeDot={{ r: 8 }} />
|
|
))}
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-[1920px] mx-auto py-12 px-6">
|
|
<div className="mb-12 flex justify-center">
|
|
<div className="flex flex-wrap justify-center gap-4">
|
|
<button onClick={() => setView('ranking')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'ranking' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
|
|
<Trophy size={18} /> Rankings
|
|
</button>
|
|
<button onClick={() => setView('matrix')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'matrix' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
|
|
<Table size={18} /> Matrix
|
|
</button>
|
|
<button onClick={() => setView('log')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'log' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
|
|
<List size={18} /> Event Log
|
|
</button>
|
|
<button onClick={() => setView('graph')} className={`flex items-center gap-2 px-6 py-3 font-bold uppercase tracking-wider text-sm transition-colors ${view === 'graph' ? 'bg-[#ff0000] text-black shadow-[0_0_15px_rgba(255,0,0,0.3)]' : 'bg-[#333] text-slate-300 hover:bg-[#444]'}`}>
|
|
<LineChartIcon size={18} /> Score Graph
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
{renderContent()}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ScoreMatrix: React.FC = () => {
|
|
// Keeping this for compatibility but it's now integrated into Scoreboard main component
|
|
return <Scoreboard />;
|
|
};
|