- Redesigned the Admin panel with a new sidebar-based layout for streamlined management
- Organized Admin functionality into dedicated tabs: GENERAL, CHALLENGES, BLOG, PLAYERS, and TOOLS - Integrated "Backup & Restore" and "Danger Zone" into the new Admin TOOLS section - Fixed page title clipping by adding right-padding to the navigation bar's dynamic title - Updated Authentication UI: Renamed "New user?" to "NEW PLAYER?", capitalized registration labels, and added "PLAYER ALREADY?" navigation
This commit is contained in:
336
Admin.tsx
336
Admin.tsx
@@ -1,15 +1,17 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
import { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck, Medal, CheckCircle2 } from 'lucide-react';
|
import { X, Edit3, Trash2, Shield, ShieldCheck, ShieldAlert, Skull, Newspaper, Download, Upload, Database, Save, History, Plus, Globe, User, ShieldX, UserMinus, UserCheck, Medal, CheckCircle2, Settings, Flag, Wrench, LayoutGrid, RefreshCw } from 'lucide-react';
|
||||||
import { useCTF } from './CTFContext';
|
import { useCTF } from './CTFContext';
|
||||||
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
|
import { Challenge, Team, BlogPost, Difficulty, ChallengeFile } from './types';
|
||||||
import { Button, Countdown, CategoryIcon } from './UIComponents';
|
import { Button, Countdown, CategoryIcon } from './UIComponents';
|
||||||
import { CATEGORIES, DIFFICULTIES } from './constants';
|
import { CATEGORIES, DIFFICULTIES } from './constants';
|
||||||
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
import { calculateChallengeValue, getFirstBloodBonusFactor } from './services/scoring';
|
||||||
|
|
||||||
|
type AdminTab = 'GENERAL' | 'CHALLENGES' | 'BLOG' | 'PLAYERS' | 'TOOLS';
|
||||||
|
|
||||||
export const Admin: React.FC = () => {
|
export const Admin: React.FC = () => {
|
||||||
const { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF();
|
const { state, toggleCtf, resetScores, upsertChallenge, deleteChallenge, deleteAllChallenges, updateTeam, deleteTeam, deleteSolve, createBlogPost, updateBlogPost, deleteBlogPost, updateConfig, exportChallenges, importChallenges, backupDatabase, restoreDatabase, refreshState } = useCTF();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<AdminTab>('GENERAL');
|
||||||
const [editingChallenge, setEditingChallenge] = useState<Partial<Challenge> | null>(null);
|
const [editingChallenge, setEditingChallenge] = useState<Partial<Challenge> | null>(null);
|
||||||
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
|
const [editingTeam, setEditingTeam] = useState<Partial<Team> & { newPassword?: string } | null>(null);
|
||||||
const [editingBlogPost, setEditingBlogPost] = useState<Partial<BlogPost> | null>(null);
|
const [editingBlogPost, setEditingBlogPost] = useState<Partial<BlogPost> | null>(null);
|
||||||
@@ -105,24 +107,68 @@ export const Admin: React.FC = () => {
|
|||||||
await updateTeam(team.id, { ...team, isDisabled: !team.isDisabled });
|
await updateTeam(team.id, { ...team, isDisabled: !team.isDisabled });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const menuItems: { id: AdminTab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{ id: 'GENERAL', label: 'General', icon: <Settings size={18} /> },
|
||||||
|
{ id: 'CHALLENGES', label: 'Challenges', icon: <Flag size={18} /> },
|
||||||
|
{ id: 'BLOG', label: 'Blog', icon: <Newspaper size={18} /> },
|
||||||
|
{ id: 'PLAYERS', label: 'Players', icon: <User size={18} /> },
|
||||||
|
{ id: 'TOOLS', label: 'Tools', icon: <Wrench size={18} /> },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto py-12 px-6">
|
<div className="max-w-[1400px] mx-auto py-12 px-6">
|
||||||
<div className="flex flex-wrap gap-4 justify-end items-center mb-12 border-b-4 border-[#ff0000] pb-6">
|
<div className="flex flex-col lg:flex-row gap-12">
|
||||||
<div className="flex gap-4 items-center">
|
{/* Sidebar */}
|
||||||
|
<aside className="w-full lg:w-64 shrink-0">
|
||||||
|
<div className="sticky top-24 space-y-2">
|
||||||
|
<div className="mb-8 border-b-4 border-[#ff0000] pb-4">
|
||||||
|
<h2 className="text-3xl font-black italic text-white uppercase tracking-tighter">ADMIN</h2>
|
||||||
|
</div>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setActiveTab(item.id)}
|
||||||
|
className={`w-full flex items-center gap-4 px-6 py-4 font-black uppercase tracking-widest text-sm transition-all border-l-4 ${
|
||||||
|
activeTab === item.id
|
||||||
|
? 'bg-[#ff0000] text-black border-[#ff0000] shadow-[4px_0_15px_rgba(255,0,0,0.2)]'
|
||||||
|
: 'bg-white/5 text-slate-400 border-transparent hover:bg-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t border-white/10">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
{isBeforeStart && (
|
{isBeforeStart && (
|
||||||
<div className="px-4 py-2 hxp-border-purple bg-[#bf00ff]/10 hidden sm:flex items-center gap-3">
|
<div className="px-4 py-2 hxp-border-purple bg-[#bf00ff]/10 flex items-center gap-3">
|
||||||
<Countdown target={eventStartTime} label="STARTING IN" />
|
<Countdown target={eventStartTime} label="START" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button onClick={toggleCtf} variant={state.isStarted ? 'secondary' : 'primary'}>{state.isStarted ? 'PAUSE_BOARD' : 'RESUME_BOARD'}</Button>
|
<Button
|
||||||
|
onClick={toggleCtf}
|
||||||
|
variant={state.isStarted ? 'secondary' : 'primary'}
|
||||||
|
className="w-full py-3"
|
||||||
|
>
|
||||||
|
{state.isStarted ? 'PAUSE BOARD' : 'RESUME BOARD'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
</div>
|
||||||
<div className="space-y-12">
|
</aside>
|
||||||
<section className="space-y-6">
|
|
||||||
<h3 className="text-2xl font-black text-[#ffaa00] border-b-2 border-[#ffaa00] uppercase italic">GENERAL_CONFIG</h3>
|
{/* Main Content */}
|
||||||
<form onSubmit={handleConfigSubmit} className="hxp-border p-6 bg-white/5 space-y-6">
|
<main className="flex-1 min-w-0">
|
||||||
<div className="space-y-4">
|
{activeTab === 'GENERAL' && (
|
||||||
|
<section className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex items-center gap-4 border-b-2 border-[#ffaa00] pb-2">
|
||||||
|
<Settings className="text-[#ffaa00]" />
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase italic">GENERAL_CONFIG</h3>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleConfigSubmit} className="hxp-border p-8 bg-black space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Competition Name</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Competition Name</label>
|
||||||
<input placeholder="Ex: HIPCTF '26" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.conferenceName || ''} onChange={e => setLocalConf({...localConf, conferenceName: e.target.value})} />
|
<input placeholder="Ex: HIPCTF '26" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.conferenceName || ''} onChange={e => setLocalConf({...localConf, conferenceName: e.target.value})} />
|
||||||
@@ -141,18 +187,23 @@ export const Admin: React.FC = () => {
|
|||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Central Node IP</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Central Node IP</label>
|
||||||
<input placeholder="Ex: 10.0.0.5" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.dockerIp || ''} onChange={e => setLocalConf({...localConf, dockerIp: e.target.value})} />
|
<input placeholder="Ex: 10.0.0.5" className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.dockerIp || ''} onChange={e => setLocalConf({...localConf, dockerIp: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Landing Page Text</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Landing Page Text</label>
|
||||||
<textarea placeholder="Welcome message..." className="w-full bg-black hxp-border p-3 text-white font-black h-24" value={localConf.landingText || ''} onChange={e => setLocalConf({...localConf, landingText: e.target.value})} />
|
<textarea placeholder="Welcome message..." className="w-full bg-black hxp-border p-3 text-white font-black h-40" value={localConf.landingText || ''} onChange={e => setLocalConf({...localConf, landingText: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t-2 border-[#333] pt-8 grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h4 className="text-sm font-black text-[#bf00ff] italic uppercase tracking-tighter">Visual Branding</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Custom Logo</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Custom Logo</label>
|
||||||
<input type="file" name="logo" className="text-xs block w-full bg-black hxp-border p-2" />
|
<input type="file" name="logo" className="text-xs block w-full bg-black hxp-border p-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="border-t-2 border-[#333] pt-6 space-y-4">
|
|
||||||
<h4 className="text-sm font-black text-white italic uppercase tracking-tighter">Background Style</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Type</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Type</label>
|
||||||
<select className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.bgType || 'color'} onChange={e => setLocalConf({...localConf, bgType: e.target.value})}>
|
<select className="w-full bg-black hxp-border p-3 text-white font-black" value={localConf.bgType || 'color'} onChange={e => setLocalConf({...localConf, bgType: e.target.value})}>
|
||||||
@@ -160,20 +211,21 @@ export const Admin: React.FC = () => {
|
|||||||
<option value="image">IMAGE_DATA</option>
|
<option value="image">IMAGE_DATA</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h4 className="text-sm font-black text-transparent italic uppercase tracking-tighter">.</h4>
|
||||||
{localConf.bgType === 'color' ? (
|
{localConf.bgType === 'color' ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Color</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Color</label>
|
||||||
<input type="color" className="w-full bg-black hxp-border p-1 h-12" value={localConf.bgColor || '#000000'} onChange={e => setLocalConf({...localConf, bgColor: e.target.value})} />
|
<input type="color" className="w-full bg-black hxp-border p-1 h-12" value={localConf.bgColor || '#000000'} onChange={e => setLocalConf({...localConf, bgColor: e.target.value})} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Upload</label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest">Background Image</label>
|
||||||
<input type="file" name="bgImage" className="text-xs block w-full bg-black hxp-border p-2" />
|
<input type="file" name="bgImage" className="text-xs block w-full bg-black hxp-border p-2" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{localConf.bgType === 'image' && (
|
|
||||||
<div className="space-y-4 p-4 hxp-border-purple bg-white/5">
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest flex justify-between">Opacity <span>{localConf.bgOpacity}</span></label>
|
<label className="text-[10px] font-black uppercase text-slate-500 tracking-widest flex justify-between">Opacity <span>{localConf.bgOpacity}</span></label>
|
||||||
<input type="range" min="0" max="1" step="0.1" className="w-full accent-[#bf00ff]" value={localConf.bgOpacity || '0.5'} onChange={e => setLocalConf({...localConf, bgOpacity: e.target.value})} />
|
<input type="range" min="0" max="1" step="0.1" className="w-full accent-[#bf00ff]" value={localConf.bgOpacity || '0.5'} onChange={e => setLocalConf({...localConf, bgOpacity: e.target.value})} />
|
||||||
@@ -181,146 +233,212 @@ export const Admin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full py-3">Commit Configuration</Button>
|
</div>
|
||||||
|
<Button type="submit" className="w-full py-4 text-xl">SAVE ALL CHANGES</Button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-6">
|
)}
|
||||||
<div className="flex justify-between items-center border-b-2 border-[#bf00ff]"><h3 className="text-2xl font-black text-[#bf00ff] uppercase italic">CHALLENGES</h3><div className="flex gap-2"><Button onClick={() => { setEditingChallenge({ title: '', category: 'WEB', difficulty: 'Low', initialPoints: 100, minimumPoints: 50, decaySolves: 20, flag: '', description: '', files: [], port: 0, connectionType: 'nc', overrideIp: '' }); setCurrentFiles([]); setNewFiles([]); }} className="text-xs">NEW_CHAL</Button></div></div>
|
|
||||||
<div className="space-y-2 max-h-[500px] overflow-y-auto custom-scrollbar">
|
{activeTab === 'CHALLENGES' && (
|
||||||
|
<section className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex justify-between items-center border-b-2 border-[#bf00ff] pb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Flag className="text-[#bf00ff]" />
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase italic">CHALLENGES</h3>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setEditingChallenge({ title: '', category: 'WEB', difficulty: 'Low', initialPoints: 100, minimumPoints: 50, decaySolves: 20, flag: '', description: '', files: [], port: 0, connectionType: 'nc', overrideIp: '' }); setCurrentFiles([]); setNewFiles([]); }} className="py-2 px-6">ADD_CHALLENGE</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{sortedChallenges.map(c => {
|
{sortedChallenges.map(c => {
|
||||||
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
const diffColor = c.difficulty === 'Low' ? 'text-[#00ff00]' : c.difficulty === 'Medium' ? 'text-[#ffaa00]' : 'text-[#ff0000]';
|
||||||
return (
|
return (
|
||||||
<div key={c.id} className="hxp-border-purple p-3 flex justify-between items-center bg-white/5 hover:bg-white/10 transition-colors gap-4">
|
<div key={c.id} className="hxp-border-purple p-4 flex justify-between items-center bg-black hover:bg-white/5 transition-colors gap-4">
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<div className="shrink-0 opacity-70">
|
<div className="shrink-0 p-2 bg-white/5 hxp-border">
|
||||||
<CategoryIcon category={c.category} size={14} />
|
<CategoryIcon category={c.category} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="font-black text-white uppercase italic whitespace-normal leading-tight">{c.title}</span>
|
<span className="font-black text-white uppercase italic truncate">{c.title}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<span className={`text-[9px] font-black uppercase ${diffColor} px-1.5 border border-current opacity-80 h-4 flex items-center`}>{c.difficulty}</span>
|
<span className={`text-[9px] font-black uppercase ${diffColor}`}>{c.difficulty}</span>
|
||||||
<span className="text-[9px] font-black uppercase text-[#00ccff] px-1.5 border border-[#00ccff]/50 h-4 flex items-center bg-[#00ccff]/5">
|
<span className="text-[9px] font-black uppercase text-[#00ccff]">
|
||||||
{c.connectionType || 'nc'}{c.port && c.port > 0 ? `:${c.port}` : ''}
|
{c.connectionType || 'nc'}{c.port && c.port > 0 ? `:${c.port}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-4 shrink-0">
|
||||||
<button onClick={() => { setEditingChallenge(c); setCurrentFiles(c.files || []); setNewFiles([]); }} className="text-[#bf00ff] hover:text-white transition-colors" title="Edit Challenge"><Edit3 size={16}/></button>
|
<button onClick={() => { setEditingChallenge(c); setCurrentFiles(c.files || []); setNewFiles([]); }} className="text-[#bf00ff] hover:text-white transition-colors"><Edit3 size={18}/></button>
|
||||||
<button onClick={() => deleteChallenge(c.id)} className="text-red-500 hover:text-white transition-colors" title="Delete Challenge"><Trash2 size={16}/></button>
|
<button onClick={() => { if(window.confirm(`DELETE CHALLENGE "${c.title}"?`)) deleteChallenge(c.id); }} className="text-red-500 hover:text-white transition-colors"><Trash2 size={18}/></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
{sortedChallenges.length === 0 && (
|
{sortedChallenges.length === 0 && (
|
||||||
<div className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No challenges in database.</div>
|
<div className="py-20 text-center bg-black hxp-border border-dashed border-slate-800">
|
||||||
|
<Flag size={48} className="mx-auto text-slate-800 mb-4" />
|
||||||
|
<p className="text-slate-700 font-black italic uppercase tracking-widest text-sm">No active challenges detected.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'BLOG' && (
|
||||||
|
<section className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
|
<div className="flex justify-between items-center border-b-2 border-[#00ccff] pb-2">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Newspaper className="text-[#00ccff]" />
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase italic">BROADCASTS</h3>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setEditingBlogPost({ title: '', content: '' })} className="py-2 px-6">NEW_BROADCAST</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{state.blogs.map(post => (
|
||||||
|
<div key={post.id} className="hxp-border border-[#00ccff] p-6 bg-black flex justify-between items-center group">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h4 className="text-xl font-black text-white uppercase italic group-hover:text-[#00ccff] transition-colors">{post.title}</h4>
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">{new Date(post.timestamp).toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<button onClick={() => setEditingBlogPost(post)} className="text-[#00ccff] hover:text-white transition-colors"><Edit3 size={20}/></button>
|
||||||
|
<button onClick={() => deleteBlogPost(post.id)} className="text-red-500 hover:text-white transition-colors"><Trash2 size={20}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{state.blogs.length === 0 && (
|
||||||
|
<div className="py-20 text-center bg-black hxp-border border-dashed border-slate-800">
|
||||||
|
<Newspaper size={48} className="mx-auto text-slate-800 mb-4" />
|
||||||
|
<p className="text-slate-700 font-black italic uppercase tracking-widest text-sm">No broadcasts issued yet.</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-12">
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="flex justify-between items-center border-b-2 border-[#ff0000]"><h3 className="text-2xl font-black text-[#ff0000] uppercase italic">BLOGS</h3><Button onClick={() => setEditingBlogPost({ title: '', content: '' })} className="text-xs">NEW_POST</Button></div>
|
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto custom-scrollbar">
|
|
||||||
{state.blogs.map(post => (
|
|
||||||
<div key={post.id} className="hxp-border p-3 flex justify-between bg-white/5 hover:bg-white/10 transition-colors"><span className="truncate w-40 font-black uppercase italic">{post.title}</span><div className="flex gap-2"><button onClick={() => setEditingBlogPost(post)} className="text-[#bf00ff] hover:text-white"><Edit3 size={16}/></button><button onClick={() => deleteBlogPost(post.id)} className="text-red-500 hover:text-white"><Trash2 size={16}/></button></div></div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-6">
|
{activeTab === 'PLAYERS' && (
|
||||||
<h3 className="text-2xl font-black text-[#00ccff] border-b-2 border-[#00ccff] uppercase italic">BACKUP_RESTORE</h3>
|
<section className="space-y-8 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="flex items-center gap-4 border-b-2 border-[#bf00ff] pb-2">
|
||||||
<div className="space-y-2">
|
<User className="text-[#bf00ff]" />
|
||||||
<Button onClick={async () => {
|
<h3 className="text-2xl font-black text-white uppercase italic">PLAYER_MANAGEMENT</h3>
|
||||||
const data = await exportChallenges();
|
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a'); a.href = url; a.download = `challenges-${Date.now()}.json`; a.click();
|
|
||||||
}} className="w-full text-xs py-3">EXPORT_CHAL</Button>
|
|
||||||
<Button onClick={() => importChalRef.current?.click()} className="w-full text-xs py-3" variant="secondary">IMPORT_CHAL</Button>
|
|
||||||
<input type="file" ref={importChalRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0]) { await importChallenges(e.target.files[0]); alert('CHALLENGES_IMPORTED'); } }} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Button onClick={async () => {
|
|
||||||
const data = await backupDatabase();
|
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a'); a.href = url; a.download = `ctf_backup-${Date.now()}.json`; a.click();
|
|
||||||
}} className="w-full text-xs py-3 border-[#ffaa00] text-[#ffaa00] hover:bg-[#ffaa00] hover:text-black">BACKUP_DB</Button>
|
|
||||||
<Button onClick={() => restoreDbRef.current?.click()} className="w-full text-xs py-3 border-[#00ff00] text-[#00ff00] hover:bg-[#00ff00] hover:text-black" variant="secondary">RESTORE_DB</Button>
|
|
||||||
<input type="file" ref={restoreDbRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0] && window.confirm("CRITICAL_OVERWRITE: RESTORE WILL WIPE CURRENT DATA. PROCEED?")) { await restoreDatabase(e.target.files[0]); window.location.reload(); } }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-6">
|
|
||||||
<h3 className="text-2xl font-black text-[#bf00ff] border-b-2 border-[#bf00ff] uppercase italic">USERS</h3>
|
|
||||||
<div className="hxp-border border-2 overflow-hidden bg-black">
|
<div className="hxp-border border-2 overflow-hidden bg-black">
|
||||||
<table className="w-full text-[10px] font-black">
|
<table className="w-full text-xs font-black">
|
||||||
<thead className="bg-[#333] uppercase">
|
<thead className="bg-[#333] uppercase">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-3 text-left">USER_IDENTIFIER</th>
|
<th className="p-4 text-left">PLAYER_IDENTIFIER</th>
|
||||||
<th className="p-3 text-center">ROLE</th>
|
<th className="p-4 text-center">ROLE</th>
|
||||||
<th className="p-3 text-center">STATUS</th>
|
<th className="p-4 text-center">STATUS</th>
|
||||||
<th className="p-3 text-right">ACTIONS</th>
|
<th className="p-4 text-right">ACTIONS</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/10 italic">
|
<tbody className="divide-y divide-white/10 italic">
|
||||||
{sortedUsers.map(team => (
|
{sortedUsers.map(team => (
|
||||||
<tr key={team.id} className="hover:bg-white/5 transition-colors group">
|
<tr key={team.id} className="hover:bg-white/5 transition-colors group">
|
||||||
<td className="p-3 text-white text-sm">
|
<td className="p-4 text-white text-base">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<User size={14} className="text-slate-500" />
|
<div className="w-8 h-8 bg-[#bf00ff]/10 flex items-center justify-center hxp-border">
|
||||||
|
<User size={16} className="text-[#bf00ff]" />
|
||||||
|
</div>
|
||||||
{team.name}
|
{team.name}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center uppercase">
|
<td className="p-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => quickToggleAdmin(team)}
|
onClick={() => quickToggleAdmin(team)}
|
||||||
className={`flex items-center gap-1 mx-auto px-2 py-1 border transition-all ${team.isAdmin ? 'border-[#ffaa00] text-[#ffaa00] bg-[#ffaa00]/10' : 'border-slate-700 text-slate-500 hover:border-[#bf00ff] hover:text-[#bf00ff]'}`}
|
className={`flex items-center gap-2 mx-auto px-4 py-2 border-2 transition-all font-black text-[10px] uppercase ${team.isAdmin ? 'border-[#ffaa00] text-[#ffaa00] bg-[#ffaa00]/10' : 'border-slate-800 text-slate-500 hover:border-[#bf00ff] hover:text-[#bf00ff]'}`}
|
||||||
title={team.isAdmin ? "Revoke Admin Privileges" : "Grant Admin Privileges"}
|
|
||||||
>
|
>
|
||||||
{team.isAdmin ? <ShieldCheck size={12} /> : <Shield size={12} />}
|
{team.isAdmin ? <ShieldCheck size={14} /> : <Shield size={14} />}
|
||||||
<span>{team.isAdmin ? 'ADMIN' : 'USER'}</span>
|
<span>{team.isAdmin ? 'ADMIN' : 'USER'}</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center uppercase">
|
<td className="p-4 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => quickToggleStatus(team)}
|
onClick={() => quickToggleStatus(team)}
|
||||||
className={`flex items-center gap-1 mx-auto px-2 py-1 border transition-all ${team.isDisabled ? 'border-red-500 text-red-500 bg-red-500/10' : 'border-slate-700 text-[#00ff00] hover:bg-[#00ff00]/5'}`}
|
className={`flex items-center gap-2 mx-auto px-4 py-2 border-2 transition-all font-black text-[10px] uppercase ${team.isDisabled ? 'border-red-500 text-red-500 bg-red-500/10' : 'border-slate-800 text-[#00ff00] hover:bg-[#00ff00]/5'}`}
|
||||||
title={team.isDisabled ? "Enable Account" : "Disable Account"}
|
|
||||||
>
|
>
|
||||||
{team.isDisabled ? <UserMinus size={12} /> : <UserCheck size={12} />}
|
{team.isDisabled ? <UserMinus size={14} /> : <UserCheck size={14} />}
|
||||||
<span>{team.isDisabled ? 'BANNED' : 'ACTIVE'}</span>
|
<span>{team.isDisabled ? 'BANNED' : 'ACTIVE'}</span>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-right">
|
<td className="p-4 text-right">
|
||||||
<div className="flex justify-end gap-3 opacity-60 group-hover:opacity-100 transition-opacity">
|
<div className="flex justify-end gap-6 opacity-40 group-hover:opacity-100 transition-opacity">
|
||||||
<button onClick={() => setEditingTeam(team)} className="text-[#bf00ff] hover:text-white transition-colors" title="Full Edit"><Edit3 size={16} /></button>
|
<button onClick={() => setEditingTeam(team)} className="text-[#bf00ff] hover:text-white transition-colors" title="Full Edit"><Edit3 size={20} /></button>
|
||||||
<button onClick={() => deleteTeam(team.id)} className="text-red-500 hover:text-white transition-colors" title="Delete Identity"><Trash2 size={16} /></button>
|
<button onClick={() => { if(window.confirm(`WIPE IDENTITY "${team.name}"?`)) deleteTeam(team.id); }} className="text-red-500 hover:text-white transition-colors" title="Delete Identity"><Trash2 size={20} /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{sortedUsers.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="p-10 text-center text-slate-700 font-black italic uppercase tracking-widest text-xs">No registered users detected.</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{sortedUsers.length === 0 && (
|
||||||
|
<div className="py-20 text-center bg-black">
|
||||||
|
<User size={48} className="mx-auto text-slate-800 mb-4" />
|
||||||
|
<p className="text-slate-700 font-black italic uppercase tracking-widest text-sm">No player records found.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="space-y-6">
|
{activeTab === 'TOOLS' && (
|
||||||
<div className="flex items-center gap-3 border-b-2 border-[#ff0000]"><Skull className="text-[#ff0000]" size={24} /><h3 className="text-2xl font-black text-[#ff0000] uppercase italic">DANGER_ZONE</h3></div>
|
<section className="space-y-12 animate-in fade-in slide-in-from-right-4 duration-300">
|
||||||
<div className="hxp-border border-[#ff0000] bg-red-900/10 p-6 space-y-4">
|
{/* Backup & Restore */}
|
||||||
<Button onClick={resetScores} className="w-full text-xs py-3 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">RESET_ALL_SCORES</Button>
|
<div className="space-y-8">
|
||||||
<Button onClick={deleteAllChallenges} className="w-full text-xs py-3 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">WIPE_ALL_CHALLENGES</Button>
|
<div className="flex items-center gap-4 border-b-2 border-[#00ff00] pb-2">
|
||||||
|
<Database className="text-[#00ff00]" />
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase italic">SYSTEM_TOOLS</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="hxp-border p-8 bg-black space-y-6">
|
||||||
|
<h4 className="text-xl font-black italic text-white uppercase flex items-center gap-2"><Flag size={20} className="text-[#bf00ff]" /> Challenges Data</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button onClick={async () => {
|
||||||
|
const data = await exportChallenges();
|
||||||
|
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `challenges-${Date.now()}.json`; a.click();
|
||||||
|
}} className="py-4">EXPORT</Button>
|
||||||
|
<Button onClick={() => importChalRef.current?.click()} className="py-4" variant="secondary">IMPORT</Button>
|
||||||
|
<input type="file" ref={importChalRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0]) { await importChallenges(e.target.files[0]); alert('CHALLENGES_IMPORTED'); } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hxp-border p-8 bg-black space-y-6">
|
||||||
|
<h4 className="text-xl font-black italic text-white uppercase flex items-center gap-2"><Database size={20} className="text-[#ffaa00]" /> Full DB Backup</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button onClick={async () => {
|
||||||
|
const data = await backupDatabase();
|
||||||
|
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = url; a.download = `ctf_backup-${Date.now()}.json`; a.click();
|
||||||
|
}} className="py-4 border-[#ffaa00] text-[#ffaa00] hover:bg-[#ffaa00] hover:text-black">BACKUP</Button>
|
||||||
|
<Button onClick={() => restoreDbRef.current?.click()} className="py-4 border-[#00ff00] text-[#00ff00] hover:bg-[#00ff00] hover:text-black" variant="secondary">RESTORE</Button>
|
||||||
|
<input type="file" ref={restoreDbRef} className="hidden" accept=".json" onChange={async e => { if (e.target.files?.[0] && window.confirm("CRITICAL_OVERWRITE: RESTORE WILL WIPE CURRENT DATA. PROCEED?")) { await restoreDatabase(e.target.files[0]); window.location.reload(); } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center gap-4 border-b-2 border-[#ff0000] pb-2">
|
||||||
|
<Skull className="text-[#ff0000]" />
|
||||||
|
<h3 className="text-2xl font-black text-white uppercase italic">DANGER_ZONE</h3>
|
||||||
|
</div>
|
||||||
|
<div className="hxp-border border-[#ff0000] bg-red-900/10 p-8 flex flex-col md:flex-row gap-6">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-xl font-black text-white italic mb-2">IRREVERSIBLE_ACTIONS</h4>
|
||||||
|
<p className="text-sm text-red-500 font-bold uppercase tracking-widest opacity-70">Exercise extreme caution. These actions cannot be undone.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 min-w-[300px]">
|
||||||
|
<Button onClick={() => { if(window.confirm("RESET_ALL_SOLVES_AND_SCORES?")) resetScores(); }} className="w-full py-4 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">RESET_ALL_SCORES</Button>
|
||||||
|
<Button onClick={() => { if(window.confirm("PURGE_ENTIRE_CHALLENGE_DATABASE?")) deleteAllChallenges(); }} className="w-full py-4 bg-black text-[#ff0000] border-[#ff0000] hover:bg-[#ff0000] hover:text-black">WIPE_ALL_CHALLENGES</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
)}
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingChallenge && (
|
{editingChallenge && (
|
||||||
|
|||||||
2
App.tsx
2
App.tsx
@@ -113,7 +113,7 @@ const LayoutShell: React.FC = () => {
|
|||||||
<div className="flex justify-center text-center">
|
<div className="flex justify-center text-center">
|
||||||
<div className="flex items-center gap-3 text-white">
|
<div className="flex items-center gap-3 text-white">
|
||||||
{pageTitle.icon && <div className="text-[#bf00ff]">{pageTitle.icon}</div>}
|
{pageTitle.icon && <div className="text-[#bf00ff]">{pageTitle.icon}</div>}
|
||||||
<span className="text-3xl font-black italic uppercase tracking-tighter truncate">
|
<span className="text-3xl font-black italic uppercase tracking-tighter truncate pr-1">
|
||||||
{pageTitle.text}
|
{pageTitle.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
Auth.tsx
5
Auth.tsx
@@ -80,7 +80,7 @@ export const Login: React.FC = () => {
|
|||||||
{error && <p className="text-red-500 font-black italic animate-pulse uppercase">{error}</p>}
|
{error && <p className="text-red-500 font-black italic animate-pulse uppercase">{error}</p>}
|
||||||
<Button type="submit" className="w-full py-4 text-xl uppercase">LOGIN</Button>
|
<Button type="submit" className="w-full py-4 text-xl uppercase">LOGIN</Button>
|
||||||
</form>
|
</form>
|
||||||
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">New user? <Link to="/register" className="text-[#bf00ff] hover:underline">REGISTER</Link></p>
|
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">NEW PLAYER? <Link to="/register" className="text-[#bf00ff] hover:underline">REGISTER</Link></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +132,7 @@ export const Register: React.FC = () => {
|
|||||||
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">REGISTER</h2>
|
<h2 className="text-5xl font-black italic text-white mb-8 tracking-tighter uppercase">REGISTER</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Player name</label>
|
<label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">PLAYER NAME</label>
|
||||||
<input placeholder="DESIRED NAME" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
|
<input placeholder="DESIRED NAME" className="w-full bg-black hxp-border-purple p-4 text-white font-black" value={name} onChange={e => setName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -168,6 +168,7 @@ export const Register: React.FC = () => {
|
|||||||
{error && <p className="text-red-500 font-black italic animate-pulse uppercase text-[10px]">{error}</p>}
|
{error && <p className="text-red-500 font-black italic animate-pulse uppercase text-[10px]">{error}</p>}
|
||||||
<Button type="submit" className="w-full py-4 text-xl uppercase">REGISTER</Button>
|
<Button type="submit" className="w-full py-4 text-xl uppercase">REGISTER</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<p className="mt-8 text-center text-[10px] font-bold text-slate-500 tracking-widest uppercase">PLAYER ALREADY? <Link to="/login" className="text-[#bf00ff] hover:underline">LOGIN</Link></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
2026-03-11
|
2026-03-11
|
||||||
|
- Redesigned the Admin panel with a new sidebar-based layout for streamlined management
|
||||||
|
- Organized Admin functionality into dedicated tabs: GENERAL, CHALLENGES, BLOG, PLAYERS, and TOOLS
|
||||||
|
- Integrated "Backup & Restore" and "Danger Zone" into the new Admin TOOLS section
|
||||||
|
- Fixed page title clipping by adding right-padding to the navigation bar's dynamic title
|
||||||
- Fixed Challenge Modal overlap issue by adjusting the main stacking context in App.tsx
|
- Fixed Challenge Modal overlap issue by adjusting the main stacking context in App.tsx
|
||||||
- Implemented "click-outside-to-close" functionality for both the Challenge Modal and User Dropdown
|
- 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
|
- Added protocol-specific action buttons for challenges: "Open in new tab" for HTTP and "Copy to clipboard" for NC
|
||||||
@@ -6,6 +10,7 @@
|
|||||||
- Rebranded "TEAM_IDENTIFIER" to "PLAYER" and "TOTAL_POINTS" to "POINTS" across the platform (Scoreboard, Matrix, User Menu)
|
- 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
|
- 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
|
- Modernized User Dropdown menu with a dedicated "PLAYER" header and "LOGOUT" action
|
||||||
|
- Updated Authentication UI: Renamed "New user?" to "NEW PLAYER?", capitalized registration labels, and added "PLAYER ALREADY?" navigation
|
||||||
- Improved Score Matrix and Score Graph titles for consistency with the new "Player" terminology
|
- 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
|
- 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
|
- Optimized frontend assets by migrating Tailwind and JetBrains Mono to local hosting
|
||||||
|
|||||||
Reference in New Issue
Block a user