Introduce Player Insights and Context Menu components
- Added `PlayerInsights` for detailed player data visualization and note management. - Added `PlayerContextMenu` for performing player actions like viewing insights, kick, and ban. - Refactored gateway response handling to use `writeError` for improved consistency. - Simplified community onboarding logic for new registrations, now using an empty community state.
This commit is contained in:
659
web/dashboard/src/components/Players.tsx
Normal file
659
web/dashboard/src/components/Players.tsx
Normal file
@@ -0,0 +1,659 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Users, Wifi, Shield, RefreshCw, Loader2, AlertCircle,
|
||||
UserX, Ban, Clock, CheckCircle, MoreVertical,
|
||||
ChevronRight,
|
||||
ShieldAlert,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useVault } from '../contexts/VaultContext';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Input } from './ui/input';
|
||||
import { PlayerContextMenu } from './PlayerContextMenu';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { cn, base64ToUint8Array, uint8ArrayToBase64 } from '../lib/utils';
|
||||
|
||||
const apiHeaders = () => ({
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
|
||||
});
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
nameHash: string;
|
||||
data: string;
|
||||
joinedAt: string;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
interface DecryptedPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
joinedAt: string;
|
||||
nameHash: string;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ label: '30 minutes', minutes: 30 },
|
||||
{ label: '1 hour', minutes: 60 },
|
||||
{ label: '6 hours', minutes: 360 },
|
||||
{ label: '24 hours', minutes: 1440 },
|
||||
{ label: '7 days', minutes: 10080 },
|
||||
{ label: 'Permanent', minutes: 0 },
|
||||
];
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
return `${Math.floor(diff / 3600)}h ago`;
|
||||
}
|
||||
|
||||
function formatExpiry(iso: string | null) {
|
||||
if (!iso) return 'Permanent';
|
||||
const d = new Date(iso);
|
||||
const diff = Math.floor((d.getTime() - Date.now()) / 1000 / 60);
|
||||
if (diff <= 0) return 'Expired';
|
||||
if (diff < 60) return `${diff}m left`;
|
||||
if (diff < 1440) return `${Math.floor(diff / 60)}h left`;
|
||||
return `${Math.floor(diff / 1440)}d left`;
|
||||
}
|
||||
|
||||
// ─── Kick inline form ────────────────────────────────────────────────────────
|
||||
|
||||
const KickForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({
|
||||
player, onDone, onCancel,
|
||||
}) => {
|
||||
const [reason, setReason] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res = await fetch('/api/players/kick', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ playerNameHash: player.nameHash, reason }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Kick failed');
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="flex items-center gap-3 mt-4 p-3 bg-amber-500/5 rounded-xl border border-amber-500/20 animate-in slide-in-from-top-2 duration-300">
|
||||
<Input
|
||||
autoFocus
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="Reason (optional)"
|
||||
className="h-9 bg-background/50 border-amber-500/30 focus-visible:ring-amber-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="h-9 bg-amber-500/10 hover:bg-amber-500/20 text-amber-500 border-amber-500/30 font-bold"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <UserX className="w-4 h-4 mr-2" />}
|
||||
Kick Player
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel} className="h-9 text-muted-foreground">
|
||||
Cancel
|
||||
</Button>
|
||||
{err && <span className="text-xs text-destructive">{err}</span>}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Ban inline form ─────────────────────────────────────────────────────────
|
||||
|
||||
const BanForm: React.FC<{ player: DecryptedPlayer; onDone: () => void; onCancel: () => void }> = ({
|
||||
player, onDone, onCancel,
|
||||
}) => {
|
||||
const [reason, setReason] = useState('');
|
||||
const [duration, setDuration] = useState(0); // 0 = permanent
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const { encrypt, username } = useVault();
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
// Encrypt ban details
|
||||
const banData = JSON.stringify({
|
||||
playerName: player.name,
|
||||
reason: reason || 'Banned by admin',
|
||||
bannedBy: username || 'Admin',
|
||||
});
|
||||
const encrypted = await encrypt(banData);
|
||||
const dataBase64 = uint8ArrayToBase64(encrypted);
|
||||
|
||||
const res = await fetch('/api/players/ban', {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({
|
||||
playerNameHash: player.nameHash,
|
||||
data: dataBase64,
|
||||
durationMinutes: duration
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? 'Ban failed');
|
||||
onDone();
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-4 p-5 bg-destructive/5 border border-destructive/20 rounded-2xl space-y-4 animate-in slide-in-from-top-2 duration-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="w-4 h-4 text-destructive" />
|
||||
<span className="text-xs font-black uppercase text-destructive tracking-widest">Ban: {player.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="Reason for ban (optional)"
|
||||
className="bg-background/50 border-destructive/30 focus-visible:ring-destructive"
|
||||
disabled={loading}
|
||||
/>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={e => setDuration(Number(e.target.value))}
|
||||
className="h-9 px-3 bg-background/50 border border-destructive/30 rounded-md text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-destructive"
|
||||
disabled={loading}
|
||||
>
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<option key={opt.minutes} value={opt.minutes}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{err && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="w-3.5 h-3.5" /> {err}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
variant="destructive"
|
||||
className="font-bold shadow-lg shadow-destructive/20"
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Ban className="w-4 h-4 mr-2" />}
|
||||
Ban Player
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={onCancel} className="text-muted-foreground">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PlayersProps {
|
||||
onOpenInsights?: (player: { name: string; nameHash: string }) => void;
|
||||
}
|
||||
|
||||
export const Players: React.FC<PlayersProps> = ({ onOpenInsights }) => {
|
||||
const [tab, setTab] = useState<'online' | 'history' | 'bans'>('online');
|
||||
const [players, setPlayers] = useState<DecryptedPlayer[]>([]);
|
||||
const [history, setHistory] = useState<DecryptedPlayer[]>([]);
|
||||
const [bans, setBans] = useState<DecryptedBan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionPlayerId, setActionPlayerId] = useState<{ id: string; type: 'kick' | 'ban' } | null>(null);
|
||||
const { decrypt, isLocked } = useVault();
|
||||
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
if (isLocked) return;
|
||||
try {
|
||||
const res = await fetch('/api/players', { headers: apiHeaders() });
|
||||
const data = await res.json();
|
||||
const rawPlayers = (data.players ?? []) as Player[];
|
||||
|
||||
const decrypted = await Promise.all(rawPlayers.map(async p => {
|
||||
try {
|
||||
const decryptedData = await decrypt(base64ToUint8Array(p.data));
|
||||
const parsed = JSON.parse(decryptedData);
|
||||
return {
|
||||
id: p.id,
|
||||
name: parsed.name,
|
||||
ip: parsed.ip,
|
||||
joinedAt: p.joinedAt,
|
||||
nameHash: p.nameHash
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: 'Decryption Error',
|
||||
ip: '?.?.?.?',
|
||||
joinedAt: p.joinedAt,
|
||||
nameHash: p.nameHash
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
setPlayers(decrypted);
|
||||
} catch {}
|
||||
}, [decrypt, isLocked]);
|
||||
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (isLocked) return;
|
||||
try {
|
||||
const res = await fetch('/api/players/search?q=', { headers: apiHeaders() });
|
||||
const data = await res.json();
|
||||
const rawPlayers = (data.results ?? []) as Player[];
|
||||
|
||||
const decrypted = await Promise.all(rawPlayers.map(async p => {
|
||||
try {
|
||||
const decryptedData = await decrypt(base64ToUint8Array(p.data));
|
||||
const parsed = JSON.parse(decryptedData);
|
||||
return {
|
||||
id: p.id,
|
||||
name: parsed.name,
|
||||
ip: parsed.ip,
|
||||
joinedAt: p.joinedAt,
|
||||
nameHash: p.nameHash
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: p.id,
|
||||
name: 'Decryption Error',
|
||||
ip: '?.?.?.?',
|
||||
joinedAt: p.joinedAt,
|
||||
nameHash: p.nameHash
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
setHistory(decrypted);
|
||||
} catch {}
|
||||
}, [decrypt, isLocked]);
|
||||
|
||||
const fetchBans = useCallback(async () => {
|
||||
if (isLocked) return;
|
||||
try {
|
||||
const res = await fetch('/api/bans', { headers: apiHeaders() });
|
||||
const data = await res.json();
|
||||
const rawBans = (data.bans ?? []) as BanEntry[];
|
||||
|
||||
const decrypted = await Promise.all(rawBans.map(async b => {
|
||||
try {
|
||||
const decryptedData = await decrypt(base64ToUint8Array(b.data));
|
||||
const parsed = JSON.parse(decryptedData);
|
||||
return {
|
||||
id: b.id,
|
||||
playerName: parsed.playerName,
|
||||
reason: parsed.reason,
|
||||
bannedBy: parsed.bannedBy,
|
||||
bannedAt: b.bannedAt,
|
||||
expiresAt: b.expiresAt,
|
||||
playerNameHash: b.playerNameHash
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
id: b.id,
|
||||
playerName: 'Decryption Error',
|
||||
reason: 'N/A',
|
||||
bannedBy: 'N/A',
|
||||
bannedAt: b.bannedAt,
|
||||
expiresAt: b.expiresAt,
|
||||
playerNameHash: b.playerNameHash
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
setBans(decrypted);
|
||||
} catch {}
|
||||
}, [decrypt, isLocked]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchPlayers(), fetchHistory(), fetchBans()]);
|
||||
setLoading(false);
|
||||
}, [fetchPlayers, fetchHistory, fetchBans]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const t = setInterval(fetchPlayers, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [refresh, fetchPlayers]);
|
||||
|
||||
const handleKickDone = () => {
|
||||
setActionPlayerId(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleBanDone = () => {
|
||||
setActionPlayerId(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleUnban = async (banId: string) => {
|
||||
try {
|
||||
await fetch(`/api/bans?id=${banId}`, { method: 'DELETE', headers: apiHeaders() });
|
||||
fetchBans();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black tracking-tight text-foreground">Players</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">See who's online, browse history, and manage bans.</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="rounded-full px-6 border-primary/20 hover:bg-primary/5 transition-all"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-2", loading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="online" onValueChange={(v) => setTab(v as any)} className="w-full">
|
||||
<TabsList className="bg-muted/30 p-1 h-12 rounded-xl mb-6">
|
||||
<TabsTrigger value="online" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
|
||||
<Wifi className="w-3.5 h-3.5 mr-2" />
|
||||
Online ({players.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
|
||||
<Clock className="w-3.5 h-3.5 mr-2" />
|
||||
History ({history.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bans" className="rounded-lg px-6 font-black uppercase tracking-widest text-[10px]">
|
||||
<Shield className="w-3.5 h-3.5 mr-2" />
|
||||
Bans ({bans.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="online" className="space-y-4">
|
||||
{players.length === 0 ? (
|
||||
<Card className="border-dashed border-2 py-24 bg-muted/10">
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-bold">No players online</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-sm mx-auto">
|
||||
No players are currently connected to your servers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{players.map(player => {
|
||||
const isActioning = actionPlayerId?.id === player.id;
|
||||
return (
|
||||
<PlayerContextMenu
|
||||
key={player.id}
|
||||
player={player}
|
||||
onViewInsights={onOpenInsights ?? (() => {})}
|
||||
onKick={(p) => setActionPlayerId({ id: p.id, type: 'kick' })}
|
||||
onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })}
|
||||
>
|
||||
<Card className={cn(
|
||||
"group border-border/50 bg-card/40 backdrop-blur-sm transition-all duration-300 hover:border-primary/40",
|
||||
isActioning && "ring-2 ring-primary/20"
|
||||
)}>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="relative">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-black text-xl shadow-inner group-hover:scale-110 transition-transform">
|
||||
{player.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-emerald-500 border-4 border-background rounded-full shadow-lg" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-black text-lg tracking-tight group-hover:text-primary transition-colors">{player.name}</h3>
|
||||
{player.warningCount > 0 && (
|
||||
<Badge variant="destructive" className="bg-destructive/10 text-destructive border-destructive/20 text-[9px] font-black uppercase flex items-center gap-1">
|
||||
<AlertCircle className="w-2.5 h-2.5" />
|
||||
{player.warningCount} Warnings
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-[9px] font-black uppercase bg-emerald-500/10 text-emerald-500 border-emerald-500/20">Active_Link</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-muted-foreground">
|
||||
<span className="text-[11px] font-mono bg-muted/50 px-2 py-0.5 rounded border border-border/50">{player.ip}</span>
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-wider">
|
||||
<Clock className="w-3 h-3" />
|
||||
Joined {timeAgo(player.joinedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full hover:bg-primary/10 hover:text-primary transition-all">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-card/95 backdrop-blur-xl border-border/50">
|
||||
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInsights?.(player)} className="font-bold">
|
||||
<Clock className="w-4 h-4 mr-2" /> View History
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'kick' })} className="text-amber-500 focus:text-amber-500 font-black">
|
||||
<UserX className="w-4 h-4 mr-2" /> Kick
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black">
|
||||
<Ban className="w-4 h-4 mr-2" /> Ban
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenInsights?.(player)} className="rounded-full text-muted-foreground hover:text-primary transition-all">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActioning && actionPlayerId?.type === 'kick' && (
|
||||
<KickForm
|
||||
player={player}
|
||||
onDone={handleKickDone}
|
||||
onCancel={() => setActionPlayerId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isActioning && actionPlayerId?.type === 'ban' && (
|
||||
<BanForm
|
||||
player={player}
|
||||
onDone={handleBanDone}
|
||||
onCancel={() => setActionPlayerId(null)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PlayerContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="history" className="space-y-4">
|
||||
{history.length === 0 ? (
|
||||
<Card className="border-dashed border-2 py-24 bg-muted/10">
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
|
||||
<Clock className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-muted-foreground">No history yet</h3>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{history.map(player => {
|
||||
const isActioning = actionPlayerId?.id === player.id;
|
||||
return (
|
||||
<PlayerContextMenu
|
||||
key={player.id}
|
||||
player={player}
|
||||
onViewInsights={onOpenInsights ?? (() => {})}
|
||||
onBan={(p) => setActionPlayerId({ id: p.id, type: 'ban' })}
|
||||
>
|
||||
<Card className="bg-card/40 backdrop-blur-sm border-border/50 group hover:border-primary/30 transition-all">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted border border-border/50 flex items-center justify-center text-muted-foreground font-black group-hover:bg-primary/5 transition-colors">
|
||||
{player.name.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-foreground tracking-tight">{player.name}</h4>
|
||||
{player.warningCount > 0 && (
|
||||
<Badge variant="destructive" className="bg-destructive/10 text-destructive border-destructive/20 text-[8px] font-black uppercase h-4 px-1.5">
|
||||
{player.warningCount}W
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground font-mono mt-0.5">ID: {player.nameHash.slice(0, 12)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full hover:bg-primary/10 hover:text-primary transition-all">
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-card/95 backdrop-blur-xl border-border/50">
|
||||
<DropdownMenuLabel className="text-[10px] uppercase font-black tracking-widest text-muted-foreground/60">Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onOpenInsights?.(player)} className="font-bold">
|
||||
<Clock className="w-4 h-4 mr-2" /> View History
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setActionPlayerId({ id: player.id, type: 'ban' })} className="text-destructive focus:text-destructive font-black">
|
||||
<Ban className="w-4 h-4 mr-2" /> Ban
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Badge variant="ghost" className="text-[9px] font-black uppercase text-muted-foreground/60">
|
||||
Archived {new Date(player.joinedAt).toLocaleDateString()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActioning && actionPlayerId?.type === 'ban' && (
|
||||
<BanForm
|
||||
player={player}
|
||||
onDone={handleBanDone}
|
||||
onCancel={() => setActionPlayerId(null)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PlayerContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bans" className="space-y-4">
|
||||
{bans.length === 0 ? (
|
||||
<Card className="border-dashed border-2 py-24 bg-muted/10">
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-muted rounded-2xl flex items-center justify-center">
|
||||
<Shield className="w-8 h-8 text-muted-foreground/30" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-muted-foreground">No active bans</h3>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{bans.map(ban => (
|
||||
<Card key={ban.id} className="bg-destructive/5 border-destructive/20 relative overflow-hidden group">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-destructive/40" />
|
||||
<CardContent className="p-5 flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-5 min-w-0">
|
||||
<div className="w-12 h-12 rounded-xl bg-destructive/10 border border-destructive/20 flex items-center justify-center flex-shrink-0">
|
||||
<Ban className="w-5 h-5 text-destructive" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-black text-lg tracking-tight">{ban.playerName}</span>
|
||||
<Badge variant="outline" className={cn(
|
||||
"text-[10px] font-black px-3 py-0.5 rounded-full border",
|
||||
ban.expiresAt ? "bg-amber-500/10 border-amber-500/20 text-amber-500" : "bg-destructive/10 border-destructive/20 text-destructive"
|
||||
)}>
|
||||
{formatExpiry(ban.expiresAt)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-2">
|
||||
<span className="bg-destructive/10 text-destructive px-1.5 py-0.5 rounded font-bold uppercase text-[9px]">Reason</span>
|
||||
<span className="truncate italic">"{ban.reason}"</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-[10px] font-black uppercase text-muted-foreground/60 tracking-widest">Banned by</p>
|
||||
<p className="text-xs font-bold">{ban.bannedBy}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnban(ban.id)}
|
||||
className="font-black uppercase tracking-widest text-[10px] h-9 border-emerald-500/20 text-emerald-500 hover:bg-emerald-500/10"
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5 mr-2" />
|
||||
Unban
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user