sync
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user