This commit is contained in:
NextJS
2026-07-12 17:55:44 +00:00
parent 4ed4a10eae
commit e324a95625
28 changed files with 1087 additions and 114 deletions
+16 -7
View File
@@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
@@ -225,6 +226,8 @@ type Props = {
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
readOnly: boolean;
};
function StatCard({
@@ -280,6 +283,8 @@ export function AdminDashboard({
analysisPlayerIds,
matchAnalysis,
pingAnalytics,
geolocationAnalytics,
readOnly,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
@@ -735,13 +740,15 @@ export function AdminDashboard({
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
{!readOnly ? (
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
) : null}
<Link
href={`/?tab=matches&participant=${u.id}`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
@@ -806,6 +813,7 @@ export function AdminDashboard({
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
@@ -831,6 +839,7 @@ export function AdminDashboard({
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
readOnly={readOnly}
/>
) : (
<section className="space-y-3">
+10 -1
View File
@@ -1,6 +1,10 @@
"use client";
export function AdminHeader() {
type Props = {
readOnly?: boolean;
};
export function AdminHeader({ readOnly = false }: Props) {
async function logout() {
await fetch("/api/auth/logout", { method: "POST" });
window.location.href = "/login";
@@ -12,6 +16,11 @@ export function AdminHeader() {
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard
</h1>
{readOnly ? (
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Read-only access
</p>
) : null}
</div>
<button
type="button"
+12 -8
View File
@@ -91,6 +91,7 @@ type Props = {
ledgerFrom: string;
ledgerTo: string;
supplyError: boolean;
readOnly: boolean;
};
export function AdminLedger({
@@ -110,6 +111,7 @@ export function AdminLedger({
ledgerFrom,
ledgerTo,
supplyError,
readOnly,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
@@ -316,13 +318,15 @@ export function AdminLedger({
All transactions
</p>
</div>
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
{!readOnly ? (
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
) : null}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
@@ -999,7 +1003,7 @@ export function AdminLedger({
</div>
) : null}
{supplyModalOpen ? (
{supplyModalOpen && !readOnly ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
+10
View File
@@ -5,8 +5,10 @@ import { useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { AnalysisGeolocationMap } from "@/components/analysis-geolocation-map";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database";
@@ -95,6 +97,7 @@ function Stat({ label, value }: { label: string; value: string }) {
type Props = {
analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
@@ -106,6 +109,7 @@ type Props = {
export function AdminMatchAnalysis({
analysis,
pingAnalytics,
geolocationAnalytics,
users,
analysisFrom,
analysisTo,
@@ -554,6 +558,12 @@ export function AdminMatchAnalysis({
</>
)}
</section>
<AnalysisGeolocationMap
userAccounts={geolocationAnalytics.userAccounts}
matches={geolocationAnalytics.matches}
error={geolocationAnalytics.error}
/>
</section>
);
}
+56
View File
@@ -18,8 +18,22 @@ type Props = {
rows: DbSetting[];
saveError: boolean;
addError: string | null;
readOnly?: boolean;
};
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
{row.key}
</p>
<p className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-300">
{row.value?.trim() ? row.value : "—"}
</p>
</div>
);
}
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
const [value, setValue] = useState(row.value ?? "");
const saveDisabled = value.trim() === "";
@@ -271,10 +285,52 @@ export function AdminSettingsEditor({
rows,
saveError,
addError,
readOnly = false,
}: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
if (readOnly) {
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Settings are view-only for your account. Contact an admin to make
changes.
</div>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing keys
</h2>
{rows.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No settings rows yet.
</p>
) : (
<div className="space-y-3">
{rows.map((row) => (
<ReadOnlySettingRow key={row.key} row={row} />
))}
</div>
)}
</section>
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
<Link
href="/"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
Back to dashboard
</Link>
</p>
</div>
);
}
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
+360
View File
@@ -0,0 +1,360 @@
"use client";
import { useMemo, useState } from "react";
import {
ComposableMap,
Geographies,
Geography,
ZoomableGroup,
} from "react-simple-maps";
import {
countryDisplayName,
heatmapColorForCount,
NO_DATA_FILL,
NO_DATA_FILL_DARK,
} from "@/lib/heatmap-color";
import type { GeolocationCountryStat } from "@/lib/geolocation-analytics";
const GEO_URL =
"https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson";
type MapMode = "userAccounts" | "matches";
type Props = {
userAccounts: GeolocationCountryStat[];
matches: GeolocationCountryStat[];
error: string | null;
};
type TooltipState = {
countryCode: string;
x: number;
y: number;
} | null;
function countryCodeFromGeo(geo: {
properties?: Record<string, unknown>;
id?: string | number;
}): string | null {
const props = geo.properties ?? {};
const candidates = [
props.ISO_A2,
props.iso_a2,
props.ISO3166_1_Alpha_2,
props["ISO3166-1-Alpha-2"],
geo.id,
];
for (const candidate of candidates) {
const code = String(candidate ?? "")
.trim()
.toUpperCase();
if (/^[A-Z]{2}$/.test(code)) return code;
}
return null;
}
function buildCountMap(rows: GeolocationCountryStat[]): Map<string, GeolocationCountryStat> {
const map = new Map<string, GeolocationCountryStat>();
for (const row of rows) {
map.set(row.countryCode.toUpperCase(), row);
}
return map;
}
function countRange(rows: GeolocationCountryStat[]): {
min: number;
max: number;
} {
if (rows.length === 0) return { min: 0, max: 0 };
const counts = rows.map((r) => r.count);
return { min: Math.min(...counts), max: Math.max(...counts) };
}
export function AnalysisGeolocationMap({
userAccounts,
matches,
error,
}: Props) {
const [mode, setMode] = useState<MapMode>("userAccounts");
const [tooltip, setTooltip] = useState<TooltipState>(null);
const activeRows = mode === "userAccounts" ? userAccounts : matches;
const countByCountry = useMemo(() => buildCountMap(activeRows), [activeRows]);
const { min: minCount, max: maxCount } = useMemo(
() => countRange(activeRows),
[activeRows],
);
const totalCount = activeRows.reduce((acc, row) => acc + row.count, 0);
const countriesWithData = activeRows.length;
const tooltipStat = tooltip
? countByCountry.get(tooltip.countryCode.toUpperCase())
: null;
return (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Player geolocation
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
World heatmap by country blue is fewest, red is most. Gray means no
players in that country.
</p>
</div>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setMode("userAccounts")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "userAccounts"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
User accounts
</button>
<button
type="button"
onClick={() => setMode("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
Matches
</button>
</div>
</div>
{error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load geolocation data: {error}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
label={mode === "userAccounts" ? "Users mapped" : "Matches mapped"}
value={totalCount.toLocaleString("en-US")}
/>
<StatPill
label="Countries"
value={countriesWithData.toLocaleString("en-US")}
/>
<StatPill
label="Range per country"
value={
maxCount > 0
? `${minCount.toLocaleString("en-US")}${maxCount.toLocaleString("en-US")}`
: "—"
}
/>
</div>
<div className="relative overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950/40">
<ComposableMap
projection="geoEqualEarth"
projectionConfig={{ scale: 145 }}
width={800}
height={420}
style={{ width: "100%", height: "auto" }}
>
<ZoomableGroup center={[0, 10]} zoom={1} minZoom={1} maxZoom={4}>
<Geographies geography={GEO_URL}>
{({ geographies }) =>
geographies.map((geo) => {
const countryCode = countryCodeFromGeo(geo);
const stat = countryCode
? countByCountry.get(countryCode)
: undefined;
const count = stat?.count ?? 0;
const fill =
count > 0
? heatmapColorForCount(count, minCount, maxCount)
: undefined;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fill ?? NO_DATA_FILL}
stroke="#a1a1aa"
strokeWidth={0.25}
style={{
default: {
outline: "none",
transition: "fill 150ms ease",
},
hover: {
outline: "none",
fill:
fill ??
(typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? NO_DATA_FILL_DARK
: NO_DATA_FILL),
filter: count > 0 ? "brightness(1.08)" : "none",
cursor: countryCode ? "pointer" : "default",
},
pressed: { outline: "none" },
}}
onMouseEnter={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseMove={(event) => {
if (!countryCode) return;
setTooltip({
countryCode,
x: event.clientX,
y: event.clientY,
});
}}
onMouseLeave={() => setTooltip(null)}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && tooltipStat ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-700 dark:text-zinc-300">
{mode === "userAccounts" ? "Users" : "Matches"}:{" "}
<span className="font-mono tabular-nums">
{tooltipStat.count.toLocaleString("en-US")}
</span>
</p>
{mode === "matches" && tooltipStat.avgPing != null ? (
<p className="text-zinc-700 dark:text-zinc-300">
Avg ping:{" "}
<span className="font-mono tabular-nums">
{Math.round(tooltipStat.avgPing).toLocaleString("en-US")} ms
</span>
</p>
) : null}
</div>
) : tooltip ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-500">No data</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span>Low</span>
<div
className="h-2.5 flex-1 min-w-[8rem] rounded-full"
style={{
background:
"linear-gradient(to right, hsl(220, 68%, 42%), hsl(120, 68%, 42%), hsl(0, 68%, 42%))",
}}
/>
<span>High</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: NO_DATA_FILL }}
/>
No players
</span>
</div>
{activeRows.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-50 text-zinc-500 dark:bg-zinc-950/60 dark:text-zinc-400">
<tr>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Country
</th>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
{mode === "userAccounts" ? "Users" : "Matches"}
</th>
{mode === "matches" ? (
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Avg ping
</th>
) : null}
</tr>
</thead>
<tbody>
{activeRows.slice(0, 12).map((row) => (
<tr
key={row.countryCode}
className="border-t border-zinc-200 dark:border-zinc-800"
>
<td className="px-3 py-2 text-zinc-800 dark:text-zinc-200">
{countryDisplayName(row.countryCode)}{" "}
<span className="font-mono text-zinc-500">
({row.countryCode})
</span>
</td>
<td className="px-3 py-2 font-mono tabular-nums">
{row.count.toLocaleString("en-US")}
</td>
{mode === "matches" ? (
<td className="px-3 py-2 font-mono tabular-nums">
{row.avgPing != null
? `${Math.round(row.avgPing).toLocaleString("en-US")} ms`
: "—"}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No geolocation data for this mode and filter.
</p>
)}
</section>
);
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}