mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
Enhance admin dashboard: add new player count, active sessions, and online player details; improve stats display and layout
This commit is contained in:
@@ -1680,16 +1680,83 @@ public class AdminController(
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult> Stats()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var totalPlayers = await db.Players.CountAsync();
|
||||
var totalRooms = await db.Rooms.CountAsync();
|
||||
var totalInventions = await db.Inventions.CountAsync();
|
||||
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
|
||||
var bannedNow = await db.Players.CountAsync(p =>
|
||||
p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow);
|
||||
p.BannedUntil != null && p.BannedUntil > now);
|
||||
var activeIpBans = await db.IpBans.CountAsync(b =>
|
||||
b.Until == null || b.Until > DateTime.UtcNow);
|
||||
b.Until == null || b.Until > now);
|
||||
|
||||
var online = onlinePresence.OnlinePlayerIds().ToArray();
|
||||
var onlineSet = online.ToHashSet();
|
||||
|
||||
var currentRoomsByPlayer = online
|
||||
.Select(id => new { PlayerId = id, Room = playerPresence.GetRoom(id) })
|
||||
.Where(x => x.Room is not null)
|
||||
.ToDictionary(x => x.PlayerId, x => x.Room!);
|
||||
|
||||
var roomIds = currentRoomsByPlayer.Values.Select(r => r.RoomId).Distinct().ToList();
|
||||
var roomNames = roomIds.Count == 0
|
||||
? new Dictionary<long, string>()
|
||||
: await db.Rooms
|
||||
.Where(r => roomIds.Contains(r.Id))
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToDictionaryAsync(r => r.Id, r => r.Name);
|
||||
|
||||
var onlinePlayers = await db.Players
|
||||
.Where(p => onlineSet.Contains(p.Id))
|
||||
.OrderBy(p => p.DisplayName)
|
||||
.ThenBy(p => p.Username)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
p.Level,
|
||||
p.ProfileImageName,
|
||||
p.IsAdmin,
|
||||
p.IsDeveloper,
|
||||
p.IsCommunityTeam,
|
||||
p.IsJunior,
|
||||
p.IsVerified,
|
||||
p.BannedUntil,
|
||||
p.LastSeenAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
var onlinePlayerCards = onlinePlayers.Select(p =>
|
||||
{
|
||||
currentRoomsByPlayer.TryGetValue(p.Id, out var room);
|
||||
roomNames.TryGetValue(room?.RoomId ?? 0, out var roomName);
|
||||
return new
|
||||
{
|
||||
p.Id,
|
||||
p.Username,
|
||||
p.DisplayName,
|
||||
p.Level,
|
||||
p.ProfileImageName,
|
||||
p.IsAdmin,
|
||||
p.IsDeveloper,
|
||||
p.IsCommunityTeam,
|
||||
p.IsJunior,
|
||||
p.IsVerified,
|
||||
p.BannedUntil,
|
||||
p.LastSeenAt,
|
||||
CurrentRoom = room is null ? null : new
|
||||
{
|
||||
room.RoomId,
|
||||
Name = roomName ?? room.Name,
|
||||
room.SubRoomId,
|
||||
room.RoomInstanceId,
|
||||
room.PhotonRoomId,
|
||||
room.PhotonRegionId,
|
||||
room.IsPrivate,
|
||||
room.MaxCapacity,
|
||||
},
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var topRooms = await db.Rooms
|
||||
.OrderByDescending(r => r.VisitCount)
|
||||
@@ -1697,6 +1764,31 @@ public class AdminController(
|
||||
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
|
||||
.ToListAsync();
|
||||
|
||||
var activeSessions = await db.GameSessions
|
||||
.Where(s => s.PlayerCount > 0)
|
||||
.OrderByDescending(s => s.PlayerCount)
|
||||
.ThenByDescending(s => s.CreatedAt)
|
||||
.Take(8)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.RoomId,
|
||||
s.ActivityLevelId,
|
||||
s.Region,
|
||||
s.PhotonRoomName,
|
||||
s.PlayerCount,
|
||||
s.MaxCapacity,
|
||||
s.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
var inGamePlayerCount = await db.GameSessions.SumAsync(s => (int?)s.PlayerCount) ?? 0;
|
||||
var activeSessionCount = await db.GameSessions.CountAsync(s => s.PlayerCount > 0);
|
||||
var photosToday = await db.Photos.CountAsync(p =>
|
||||
p.CreatedAt >= now.AddDays(-1) && p.DeletedAt == null);
|
||||
var newPlayersToday = await db.Players.CountAsync(p => p.CreatedAt >= now.AddDays(-1));
|
||||
var totalVisitCount = await db.Rooms.SumAsync(r => (long)r.VisitCount);
|
||||
var totalCheerCount = await db.Rooms.SumAsync(r => (long)r.CheerCount);
|
||||
|
||||
var recentJoins = await db.Players
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(10)
|
||||
@@ -1710,16 +1802,28 @@ public class AdminController(
|
||||
Total = totalPlayers,
|
||||
OnlineNow = online.Length,
|
||||
BannedNow = bannedNow,
|
||||
NewToday = newPlayersToday,
|
||||
Online = onlinePlayerCards,
|
||||
},
|
||||
Rooms = new
|
||||
{
|
||||
Total = totalRooms,
|
||||
TotalVisits = totalVisitCount,
|
||||
TotalCheers = totalCheerCount,
|
||||
TopByVisits = topRooms,
|
||||
ActiveSessions = activeSessions,
|
||||
ActiveSessionCount = activeSessionCount,
|
||||
InGamePlayerCount = inGamePlayerCount,
|
||||
},
|
||||
Rooms = new { Total = totalRooms, TopByVisits = topRooms },
|
||||
Inventions = totalInventions,
|
||||
Photos = new { Today = photosToday },
|
||||
Moderation = new
|
||||
{
|
||||
OpenReports = openReports,
|
||||
ActiveIpBans = activeIpBans,
|
||||
},
|
||||
RecentJoins = recentJoins,
|
||||
ServerTime = DateTime.UtcNow,
|
||||
ServerTime = now,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -170,12 +170,56 @@ export interface Report {
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
players: { total: number; onlineNow: number; bannedNow: number };
|
||||
players: {
|
||||
total: number;
|
||||
onlineNow: number;
|
||||
bannedNow: number;
|
||||
newToday: number;
|
||||
online: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
level: number;
|
||||
profileImageName: string | null;
|
||||
isAdmin: boolean;
|
||||
isDeveloper: boolean;
|
||||
isCommunityTeam: boolean;
|
||||
isJunior: boolean;
|
||||
isVerified: boolean;
|
||||
bannedUntil: string | null;
|
||||
lastSeenAt: string;
|
||||
currentRoom: null | {
|
||||
roomId: number;
|
||||
name: string;
|
||||
subRoomId: number;
|
||||
roomInstanceId: number;
|
||||
photonRoomId: string;
|
||||
photonRegionId: string;
|
||||
isPrivate: boolean;
|
||||
maxCapacity: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
rooms: {
|
||||
total: number;
|
||||
totalVisits: number;
|
||||
totalCheers: number;
|
||||
activeSessionCount: number;
|
||||
inGamePlayerCount: number;
|
||||
topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: number }>;
|
||||
activeSessions: Array<{
|
||||
id: number;
|
||||
roomId: string;
|
||||
activityLevelId: string;
|
||||
region: string;
|
||||
photonRoomName: string;
|
||||
playerCount: number;
|
||||
maxCapacity: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
inventions: number;
|
||||
photos: { today: number };
|
||||
moderation: { openReports: number; activeIpBans: number };
|
||||
recentJoins: Array<{ id: number; username: string; createdAt: string }>;
|
||||
serverTime: string;
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '../lib/api';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, get } from '../lib/api';
|
||||
import type { Stats } from '../lib/types';
|
||||
import { RefreshCw } from '../components/Icons';
|
||||
import { absoluteTime, num, relativeTime } from '../lib/format';
|
||||
import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||
import { useToast } from '../components/Toast';
|
||||
import { Activity, Ban, Building2, Gift, Megaphone, RefreshCw, Users } from '../components/Icons';
|
||||
|
||||
type ActionState = { playerId: number; username: string; kind: 'kick' | 'ban' } | null;
|
||||
|
||||
export function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [action, setAction] = useState<ActionState>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const data = await get<Stats>('/stats');
|
||||
setStats(data);
|
||||
setStats(await get<Stats>('/stats'));
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
@@ -28,95 +35,230 @@ export function Dashboard() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const runAction = async (reason: string) => {
|
||||
if (!action) return;
|
||||
try {
|
||||
if (action.kind === 'kick') {
|
||||
await api(`/players/${action.playerId}/kick`, { method: 'POST', body: { Reason: reason } });
|
||||
toast.push(`Kicked @${action.username}`, 'success');
|
||||
} else {
|
||||
await api(`/players/${action.playerId}/ban`, { method: 'POST', body: { DurationDays: 1, Reason: reason || 'Quick ban from live dashboard' } });
|
||||
toast.push(`Banned @${action.username} for 1 day`, 'success');
|
||||
}
|
||||
setAction(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.push((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const onlinePlayers = stats?.players.online ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Dashboard</h1>
|
||||
<p className="text-sm text-ink-400">Server snapshot, refreshed every 10s.</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Live Ops</h1>
|
||||
<p className="text-sm text-ink-400">Online players, active rooms, and server health.</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link to="/broadcast" className="btn-secondary text-xs"><Megaphone /> Broadcast</Link>
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Stat label="Players online" value={stats?.players.onlineNow} accent="success" sub={stats && `${stats.players.total} total`} />
|
||||
<Stat label="Banned now" value={stats?.players.bannedNow} accent="danger" sub={stats && `${stats.moderation.activeIpBans} IP bans`} />
|
||||
<Stat label="Open reports" value={stats?.moderation.openReports} accent="warn" />
|
||||
<Stat label="Total rooms" value={stats?.rooms.total} accent="neutral" sub={stats && `${stats.inventions} inventions`} />
|
||||
</div>
|
||||
<section className="grid grid-cols-2 xl:grid-cols-6 gap-3">
|
||||
<Stat icon={<Users />} label="Online" value={stats?.players.onlineNow} sub={`${stats?.players.total ?? 0} accounts`} tone="success" />
|
||||
<Stat icon={<Activity />} label="In game" value={stats?.rooms.inGamePlayerCount} sub={`${stats?.rooms.activeSessionCount ?? 0} sessions`} tone="brand" />
|
||||
<Stat icon={<Building2 />} label="Rooms" value={stats?.rooms.total} sub={`${num(stats?.rooms.totalVisits ?? 0)} visits`} tone="neutral" />
|
||||
<Stat icon={<Gift />} label="Inventions" value={stats?.inventions} sub={`${num(stats?.rooms.totalCheers ?? 0)} room cheers`} tone="neutral" />
|
||||
<Stat icon={<Users />} label="New today" value={stats?.players.newToday} sub={`${stats?.photos.today ?? 0} photos`} tone="brand" />
|
||||
<Stat icon={<Ban />} label="Moderation" value={stats?.moderation.openReports} sub={`${stats?.players.bannedNow ?? 0} banned`} tone="warn" />
|
||||
</section>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-100">Top rooms by visits</h2>
|
||||
<p className="text-xs text-ink-400 mb-3">All-time visit counts.</p>
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-2">Room</th>
|
||||
<th className="text-right font-medium pb-2">Visits</th>
|
||||
<th className="text-right font-medium pb-2">Visitors</th>
|
||||
<th className="text-right font-medium pb-2">Cheers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{stats?.rooms.topByVisits.map(r => (
|
||||
<tr key={r.id} className="table-row-hover">
|
||||
<td className="py-2 text-ink-100">{r.name}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitorCount.toLocaleString()}</td>
|
||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.cheerCount.toLocaleString()}</td>
|
||||
<section className="grid grid-cols-1 2xl:grid-cols-[minmax(0,1fr)_380px] gap-4">
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b border-ink-800 px-4 py-3 flex flex-wrap items-center gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-ink-50">Online players</h2>
|
||||
<p className="text-xs text-ink-400">{onlinePlayers.length} connected right now</p>
|
||||
</div>
|
||||
<Link to="/players" className="btn-ghost text-xs ml-auto">All players</Link>
|
||||
</div>
|
||||
<div className="table-scroll">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/60 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium px-4 py-2.5">Player</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Room</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Instance</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Level</th>
|
||||
<th className="text-left font-medium px-4 py-2.5">Seen</th>
|
||||
<th className="text-right font-medium px-4 py-2.5">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
{!stats && <tr><td colSpan={4} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{onlinePlayers.map(p => (
|
||||
<tr key={p.id} className="table-row-hover">
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={34} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-ink-50 truncate">{p.displayName || p.username}</div>
|
||||
<div className="text-xs text-ink-400 truncate">@{p.username} <span className="text-ink-600">·</span> #{p.id}</div>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-1">
|
||||
{p.isAdmin && <span className="badge-admin">Admin</span>}
|
||||
{p.isJunior && <span className="badge-junior">Junior</span>}
|
||||
{p.isVerified && <span className="badge-neutral">Verified</span>}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{p.currentRoom ? (
|
||||
<Link to={`/rooms/${p.currentRoom.roomId}`} className="text-ink-100 hover:text-brand-200">
|
||||
{p.currentRoom.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-ink-500">Unknown</span>
|
||||
)}
|
||||
{p.currentRoom?.isPrivate && <span className="badge-neutral ml-2">Private</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-ink-300">
|
||||
{p.currentRoom ? (
|
||||
<span title={p.currentRoom.photonRoomId}>
|
||||
#{p.currentRoom.roomInstanceId} · {p.currentRoom.photonRegionId}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums text-ink-200">{p.level}</td>
|
||||
<td className="px-4 py-2.5 text-xs text-ink-300" title={absoluteTime(p.lastSeenAt)}>
|
||||
{relativeTime(p.lastSeenAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Link to={`/players?query=${encodeURIComponent(p.username)}`} className="btn-ghost text-xs">Open</Link>
|
||||
<Link to={`/gift?player=${p.id}`} className="btn-ghost text-xs">Gift</Link>
|
||||
<button onClick={() => setAction({ playerId: p.id, username: p.username, kind: 'kick' })} className="btn-secondary text-xs">Kick</button>
|
||||
{!p.isAdmin && <button onClick={() => setAction({ playerId: p.id, username: p.username, kind: 'ban' })} className="btn-danger text-xs">Ban 1d</button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{stats && onlinePlayers.length === 0 && (
|
||||
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">No online players.</td></tr>
|
||||
)}
|
||||
{!stats && !err && (
|
||||
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">Loading live players...</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-100">Recent signups</h2>
|
||||
<p className="text-xs text-ink-400 mb-3">Latest 10 accounts.</p>
|
||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
||||
<tr>
|
||||
<th className="text-left font-medium pb-2">Username</th>
|
||||
<th className="text-left font-medium pb-2">Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-ink-800">
|
||||
{stats?.recentJoins.map(j => (
|
||||
<tr key={j.id} className="table-row-hover">
|
||||
<td className="py-2 text-ink-100">{j.username} <span className="text-ink-500 text-xs">#{j.id}</span></td>
|
||||
<td className="py-2 text-ink-300 text-xs">{new Date(j.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
<div className="space-y-4">
|
||||
<Panel title="Active sessions" subtitle={`${stats?.rooms.activeSessionCount ?? 0} open`}>
|
||||
<div className="space-y-2">
|
||||
{stats?.rooms.activeSessions.map(s => (
|
||||
<div key={s.id} className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-xs text-ink-200 truncate">{s.photonRoomName || `session-${s.id}`}</div>
|
||||
<span className="badge-online">{s.playerCount}/{s.maxCapacity}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-ink-500">
|
||||
room {s.roomId || 'unknown'} · {s.region} · {relativeTime(s.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!stats && <tr><td colSpan={2} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
{stats && stats.rooms.activeSessions.length === 0 && <div className="text-xs text-ink-500 py-4">No active sessions.</div>}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}</p>
|
||||
<Panel title="Top rooms" subtitle="By visits">
|
||||
<div className="space-y-2">
|
||||
{stats?.rooms.topByVisits.slice(0, 6).map((r, idx) => (
|
||||
<Link key={r.id} to={`/rooms/${r.id}`} className="block rounded-lg border border-ink-800 bg-ink-950/40 px-3 py-2 hover:bg-ink-800/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-ink-500 w-5 tabular-nums">{idx + 1}</span>
|
||||
<span className="text-sm text-ink-100 truncate flex-1">{r.name}</span>
|
||||
<span className="text-xs text-ink-300 tabular-nums">{num(r.visitCount)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '-'}</p>
|
||||
|
||||
{action && (
|
||||
<QuickAction
|
||||
action={action}
|
||||
onCancel={() => setAction(null)}
|
||||
onConfirm={runAction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, accent }: { label: string; value: number | undefined; sub?: string | null; accent: 'success' | 'danger' | 'warn' | 'neutral' }) {
|
||||
const accentRing = {
|
||||
success: 'before:bg-success',
|
||||
danger: 'before:bg-danger',
|
||||
warn: 'before:bg-warn',
|
||||
neutral: 'before:bg-brand-500',
|
||||
}[accent];
|
||||
function Stat({ label, value, sub, icon, tone }: { label: string; value: number | undefined; sub?: string; icon: React.ReactNode; tone: 'success' | 'warn' | 'brand' | 'neutral' }) {
|
||||
const toneClass = {
|
||||
success: 'text-success bg-success/10 border-success/25',
|
||||
warn: 'text-warn bg-warn/10 border-warn/25',
|
||||
brand: 'text-brand-200 bg-brand-500/10 border-brand-500/25',
|
||||
neutral: 'text-ink-200 bg-ink-800/60 border-ink-700',
|
||||
}[tone];
|
||||
return (
|
||||
<div className={`card p-4 relative overflow-hidden before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 ${accentRing}`}>
|
||||
<div className="text-[11px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '—'}</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-ink-400">{sub}</div>}
|
||||
<div className="card !rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className={`rounded-md border p-2 ${toneClass}`}>{icon}</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{sub && <div className="mt-2 text-xs text-ink-400 truncate">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="card !rounded-lg overflow-hidden">
|
||||
<div className="border-b border-ink-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-ink-50">{title}</h2>
|
||||
<p className="text-xs text-ink-400">{subtitle}</p>
|
||||
</div>
|
||||
<div className="p-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAction({ action, onCancel, onConfirm }: { action: Exclude<ActionState, null>; onCancel: () => void; onConfirm: (reason: string) => void }) {
|
||||
const [reason, setReason] = useState(action.kind === 'kick' ? 'Quick kick from live dashboard' : 'Quick ban from live dashboard');
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-ink-950/70 px-4">
|
||||
<div className="card !rounded-lg w-full max-w-md p-4">
|
||||
<h2 className="text-sm font-semibold text-ink-50">
|
||||
{action.kind === 'kick' ? 'Kick player' : 'Ban player for 1 day'}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-ink-400">@{action.username}</p>
|
||||
<label className="label block mt-4">Reason</label>
|
||||
<textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} className="input mt-1.5" />
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={onCancel} className="btn-ghost text-xs">Cancel</button>
|
||||
<button onClick={() => onConfirm(reason)} className={action.kind === 'kick' ? 'btn-secondary text-xs' : 'btn-danger text-xs'}>
|
||||
{action.kind === 'kick' ? 'Kick' : 'Ban 1d'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api, get } from '../lib/api';
|
||||
import type { Player, PlayerDetail } from '../lib/types';
|
||||
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
|
||||
@@ -11,7 +12,8 @@ import { PlayerAvatar } from '../components/PlayerAvatar';
|
||||
import { RefreshCw, Search } from '../components/Icons';
|
||||
|
||||
export function Players() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [params] = useSearchParams();
|
||||
const [query, setQuery] = useState(params.get('query') ?? '');
|
||||
const [rows, setRows] = useState<Player[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
@@ -38,6 +40,11 @@ export function Players() {
|
||||
|
||||
useEffect(() => { search(query); }, [query, search]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = params.get('query') ?? '';
|
||||
if (next !== query) setQuery(next);
|
||||
}, [params, query]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
|
||||
Reference in New Issue
Block a user