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:
Sebastian Unterschütz
2026-05-01 14:35:08 +02:00
parent f5466f9062
commit 3d0eea5782
29 changed files with 3767 additions and 247 deletions

View 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>
);
};