- 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.
660 lines
29 KiB
TypeScript
660 lines
29 KiB
TypeScript
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>
|
|
);
|
|
};
|