ip geolocation + ping analysis

This commit is contained in:
NextJS
2026-06-02 10:24:58 +00:00
parent dc8377177a
commit 4ed4a10eae
21 changed files with 1141 additions and 168 deletions
+60 -5
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 { PingAnalyticsResult } from "@/lib/ping-analytics";
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
import {
@@ -223,6 +224,7 @@ type Props = {
analysisTo: string;
analysisPlayerIds: number[];
matchAnalysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
};
function StatCard({
@@ -277,8 +279,10 @@ export function AdminDashboard({
analysisTo,
analysisPlayerIds,
matchAnalysis,
pingAnalytics,
}: Props) {
const [hideNoWinner, setHideNoWinner] = useState(true);
const [playersSearch, setPlayersSearch] = useState("");
const [lbSortKey, setLbSortKey] = useState<LeaderboardSortKey>("wins");
const [lbSortDir, setLbSortDir] = useState<"asc" | "desc">("desc");
@@ -330,6 +334,25 @@ export function AdminDashboard({
if (!hideNoWinner) return filteredMatches;
return filteredMatches.filter((m) => matchHasRecordedWinner(m.winner_id));
}, [filteredMatches, hideNoWinner]);
const filteredUsers = useMemo(() => {
const q = playersSearch.trim().toLowerCase();
if (!q) return users;
return users.filter((u) => {
const haystack = [
String(u.id),
u.username ?? "",
u.email ?? "",
u.cc == null ? "" : String(u.cc),
u.rc == null ? "" : String(u.rc),
formatTs(u.created_at),
formatTs(u.last_logged_at),
u.ip_address ?? "",
]
.join(" ")
.toLowerCase();
return haystack.includes(q);
});
}, [users, playersSearch]);
useEffect(() => {
if (tab !== "players" || !highlightId) return;
@@ -546,6 +569,7 @@ export function AdminDashboard({
activeDir={lbSortDir}
onActivate={onLbSort}
/>
<th className="px-4 py-3 font-medium">Email</th>
<LeaderboardSortTh
label="RC balance"
sortKey="rc"
@@ -575,7 +599,7 @@ export function AdminDashboard({
{(statsBundle?.leaderboard ?? []).length === 0 ? (
<tr>
<td
colSpan={5}
colSpan={6}
className="px-4 py-8 text-center text-zinc-500"
>
No wins recorded yet (no rows with a winner).
@@ -603,6 +627,9 @@ export function AdminDashboard({
)}
</span>
</td>
<td className="max-w-48 truncate px-4 py-2 text-zinc-700 dark:text-zinc-300">
{row.email?.trim() ? row.email.trim() : "—"}
</td>
<td className="max-w-56 truncate px-4 py-2 font-mono text-xs tabular-nums text-zinc-700 dark:text-zinc-300">
{formatRcBalanceWithCoins(row.rcBalance)}
</td>
@@ -630,31 +657,51 @@ export function AdminDashboard({
{usersError ? (
<p className="text-sm text-red-600 dark:text-red-400">{usersError}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<label className="min-w-[16rem] flex-1">
<span className="sr-only">Search players</span>
<input
type="search"
value={playersSearch}
onChange={(e) => setPlayersSearch(e.target.value)}
placeholder="Search by any field (id, username, email, CC, RC, timestamps)"
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-500 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/30 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-400"
/>
</label>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Showing {filteredUsers.length} of {users.length}
</span>
</div>
<div className="overflow-x-auto rounded-xl border border-zinc-200 bg-white shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-800 dark:bg-zinc-800/50 dark:text-zinc-400">
<tr>
<th className="px-4 py-3 font-medium">ID</th>
<th className="px-4 py-3 font-medium">Username</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">CC</th>
<th className="px-4 py-3 font-medium">RC</th>
<th className="px-4 py-3 font-medium">Created</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">IP</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{users.length === 0 ? (
{filteredUsers.length === 0 ? (
<tr>
<td
colSpan={7}
colSpan={9}
className="px-4 py-8 text-center text-zinc-500"
>
No players yet.
{users.length === 0
? "No players yet."
: "No players match this search."}
</td>
</tr>
) : (
users.map((u) => {
filteredUsers.map((u) => {
const isHi =
highlightId != null &&
highlightId === String(u.id);
@@ -672,6 +719,9 @@ export function AdminDashboard({
<ClickableUserId id={u.id} />
</td>
<td className="px-4 py-2">{u.username ?? "—"}</td>
<td className="max-w-48 truncate px-4 py-2">
{u.email?.trim() ? u.email.trim() : "—"}
</td>
<td className="px-4 py-2">{u.cc ?? "—"}</td>
<td className="px-4 py-2">{u.rc ?? "—"}</td>
<td className="px-4 py-2 whitespace-nowrap">
@@ -680,6 +730,9 @@ export function AdminDashboard({
<td className="px-4 py-2 whitespace-nowrap">
{formatTs(u.last_logged_at)}
</td>
<td className="px-4 py-2 font-mono text-xs whitespace-nowrap">
{u.ip_address?.trim() ? u.ip_address.trim() : "—"}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
@@ -705,6 +758,7 @@ export function AdminDashboard({
</tbody>
</table>
</div>
</div>
)}
</section>
) : tab === "matchmaker" ? (
@@ -751,6 +805,7 @@ export function AdminDashboard({
) : tab === "analysis" ? (
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
+222 -1
View File
@@ -1,13 +1,14 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
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 { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database";
function formatDuration(seconds: number): string {
@@ -78,8 +79,22 @@ function StatBlock({
);
}
function Stat({ 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>
);
}
type Props = {
analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
@@ -90,6 +105,7 @@ type Props = {
export function AdminMatchAnalysis({
analysis,
pingAnalytics,
users,
analysisFrom,
analysisTo,
@@ -116,6 +132,34 @@ export function AdminMatchAnalysis({
? (analysis.matchesWithWinnerPatch / analysis.matchesWithLogFiles) * 100
: null;
const fmtMs = (value: number | null): string =>
value == null ? "—" : `${Math.round(value).toLocaleString("en-US")} ms`;
const fmtPct = (value: number | null): string =>
value == null
? "—"
: `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })}%`;
const reports = pingAnalytics.summary.totalReports;
const avgPing = pingAnalytics.summary.avgPing;
const p95Ping = pingAnalytics.summary.p95Ping;
const badThreshold = pingAnalytics.summary.badThresholdMs;
const badReports = pingAnalytics.summary.badReports;
const badRate = pingAnalytics.summary.badRatePercent;
const goodCount =
reports > 0 && avgPing != null
? Math.max(0, reports - badReports)
: Math.max(0, reports - badReports);
const topCountryRows = pingAnalytics.byCountries.slice(0, 8);
const [pingView, setPingView] = useState<"matches" | "players" | "countries">(
"matches",
);
const pingRows =
pingView === "matches"
? pingAnalytics.byMatches
: pingView === "players"
? pingAnalytics.byPlayers
: pingAnalytics.byCountries;
return (
<section className="space-y-6">
{analysis.configError ? (
@@ -394,6 +438,183 @@ export function AdminMatchAnalysis({
)}
</section>
) : null}
<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>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping latency analytics
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Visual summary of current ping health and worst latency countries in this filter.
</p>
</div>
{pingAnalytics.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 ping reports: {pingAnalytics.error}
</div>
) : (
<>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Stat
label="Ping reports"
value={reports.toLocaleString("en-US")}
/>
<Stat label="Average ping" value={fmtMs(avgPing)} />
<Stat label="P95 ping" value={fmtMs(p95Ping)} />
<Stat
label={`Bad ping >= ${badThreshold}ms`}
value={`${badReports.toLocaleString("en-US")} (${fmtPct(badRate)})`}
/>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping quality split
</h4>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<SplitBar
label={`Below ${badThreshold}ms`}
value={goodCount}
total={reports}
colorClassName="bg-emerald-500"
/>
<SplitBar
label={`${badThreshold}ms or above`}
value={badReports}
total={reports}
colorClassName="bg-rose-500"
/>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-700">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Ping breakdown
</h4>
<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={() => setPingView("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "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(" ")}
>
By matches
</button>
<button
type="button"
onClick={() => setPingView("players")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "players"
? "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(" ")}
>
By players
</button>
<button
type="button"
onClick={() => setPingView("countries")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
pingView === "countries"
? "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(" ")}
>
By country
</button>
</div>
</div>
{pingRows.length === 0 ? (
<p className="mt-3 text-sm text-zinc-500 dark:text-zinc-400">
No ping data for this filter.
</p>
) : (
<div className="mt-3 space-y-2">
{pingRows.map((row) => (
<PingEntryBar
key={row.key}
label={row.label}
minPing={row.minPing}
avgPing={row.avgPing}
maxPing={row.maxPing}
/>
))}
</div>
)}
</div>
</>
)}
</section>
</section>
);
}
function SplitBar({
label,
value,
total,
colorClassName,
}: {
label: string;
value: number;
total: number;
colorClassName: string;
}) {
const pct = total > 0 ? Math.max(0, Math.min(100, (value / total) * 100)) : 0;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-xs text-zinc-600 dark:text-zinc-300">
<span>{label}</span>
<span className="font-mono tabular-nums">
{value.toLocaleString("en-US")} ({pct.toLocaleString("en-US", { maximumFractionDigits: 1 })}%)
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className={`h-2.5 rounded-full ${colorClassName}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
function PingEntryBar({
label,
minPing,
avgPing,
maxPing,
}: {
label: string;
minPing: number;
avgPing: number;
maxPing: number;
}) {
const clamped = Math.max(0, Math.min(400, avgPing));
const widthPct = (clamped / 400) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="font-mono text-zinc-700 dark:text-zinc-300">{label}</span>
<span className="font-mono tabular-nums text-zinc-600 dark:text-zinc-400">
min {Math.round(minPing).toLocaleString("en-US")} ms · avg {Math.round(avgPing).toLocaleString("en-US")} ms · max {Math.round(maxPing).toLocaleString("en-US")} ms
</span>
</div>
<div className="h-2.5 rounded-full bg-zinc-200 dark:bg-zinc-800">
<div
className="h-2.5 rounded-full bg-amber-500"
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}
+199 -130
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import {
insertSetting,
deleteSetting,
updateSetting,
} from "@/app/actions/settings-actions";
import {
@@ -20,39 +21,58 @@ type Props = {
};
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
const [value, setValue] = useState(row.value ?? "");
const saveDisabled = value.trim() === "";
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`setting-value-${index}`}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`setting-value-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<input
id={`setting-value-${index}`}
name="value"
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
</div>
<button
type="submit"
disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<input
id={`setting-value-${index}`}
name="value"
type="text"
defaultValue={row.value ?? ""}
autoComplete="off"
className="mt-1.5 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -64,50 +84,64 @@ function BetFeeRow({ row, index }: { row: DbSetting; index: number }) {
const [v, setV] = useState(initial);
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`bet-fee-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form action={updateSetting}>
<input type="hidden" name="key" value={row.key} />
<input type="hidden" name="value" value={String(v)} />
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0 flex-1 space-y-3">
<div>
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`bet-fee-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
</label>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
0100 (saved as the number shown).
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<input
id={`bet-fee-${index}`}
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(Number(e.target.value))}
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
/>
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{v}
</span>
</label>
<p className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
0100 (saved as the number shown).
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<input
id={`bet-fee-${index}`}
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(Number(e.target.value))}
className="h-2 w-full min-w-[200px] max-w-md cursor-pointer accent-zinc-900 dark:accent-zinc-100"
/>
<span className="min-w-[3ch] tabular-nums text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{v}
</span>
</div>
</div>
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -123,79 +157,103 @@ function EntryFeeRow({ row, index }: { row: DbSetting; index: number }) {
return rcToCoins(Number(t));
}, [rcText]);
const saveDisabled = rcText.trim() === "";
return (
<form
action={updateSetting}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900"
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError("Enter an RC value (one decimal place; tenths digit 03).");
return;
}
const rc = Number(trimmed);
const coins = rcToCoins(rc);
if (coins === null) {
e.preventDefault();
setLocalError(
"Enter a valid RC with one decimal place; the tenths digit must be 03 (e.g. 5.1).",
);
}
}}
>
<input type="hidden" name="key" value={row.key} />
<input
type="hidden"
name="value"
value={coinsPreview === null ? "" : String(coinsPreview)}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`entry-fee-rc-${index}`}
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<form
action={updateSetting}
onSubmit={(e) => {
setLocalError(null);
const trimmed = rcText.trim();
if (trimmed === "") {
e.preventDefault();
setLocalError(
"Enter an RC value (one decimal place; tenths digit 03).",
);
return;
}
const rc = Number(trimmed);
const coins = rcToCoins(rc);
if (coins === null) {
e.preventDefault();
setLocalError(
"Enter a valid RC with one decimal place; the tenths digit must be 03 (e.g. 5.1).",
);
}
}}
>
<input type="hidden" name="key" value={row.key} />
<input
type="hidden"
name="value"
value={coinsPreview === null ? "" : String(coinsPreview)}
/>
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="min-w-0 flex-1">
<label
className="block text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400"
htmlFor={`entry-fee-rc-${index}`}
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
(edit as RC; stored as coins)
</span>
</label>
<input
id={`entry-fee-rc-${index}`}
type="text"
inputMode="decimal"
autoComplete="off"
value={rcText}
onChange={(e) => setRcText(e.target.value)}
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{localError ? (
<p
className="mt-2 text-sm text-red-600 dark:text-red-400"
role="alert"
>
{localError}
</p>
) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in
the database.
</p>
) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Not a valid RC encoding yet fix the value to save.
</p>
) : null}
</div>
<button
type="submit"
disabled={saveDisabled}
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 disabled:cursor-not-allowed disabled:bg-zinc-600 disabled:hover:bg-zinc-600 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 dark:disabled:bg-zinc-700"
>
<span className="font-mono text-sm normal-case tracking-normal text-zinc-900 dark:text-zinc-100">
{row.key}
</span>
<span className="ml-2 font-normal normal-case text-zinc-500 dark:text-zinc-400">
(edit as RC; stored as coins)
</span>
</label>
<input
id={`entry-fee-rc-${index}`}
type="text"
inputMode="decimal"
autoComplete="off"
value={rcText}
onChange={(e) => setRcText(e.target.value)}
className="mt-1.5 w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
{localError ? (
<p className="mt-2 text-sm text-red-600 dark:text-red-400" role="alert">
{localError}
</p>
) : coinsPreview !== null ? (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-400">
Saves as {coinsPreview} coin{coinsPreview === 1 ? "" : "s"} in the
database.
</p>
) : rcText.trim() !== "" ? (
<p className="mt-2 text-xs text-amber-700 dark:text-amber-300">
Not a valid RC encoding yet fix the value to save.
</p>
) : null}
Save
</button>
</div>
</form>
<form action={deleteSetting} className="mt-4 flex justify-end">
<input type="hidden" name="key" value={row.key} />
<button
type="submit"
className="shrink-0 rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
className="rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition hover:bg-red-50 dark:border-red-900 dark:bg-zinc-950 dark:text-red-300 dark:hover:bg-red-950/40"
onClick={(e) => {
if (!window.confirm(`Delete setting "${row.key}"?`)) {
e.preventDefault();
}
}}
>
Save
Delete
</button>
</div>
</form>
</form>
</div>
);
}
@@ -214,6 +272,9 @@ export function AdminSettingsEditor({
saveError,
addError,
}: Props) {
const [newKey, setNewKey] = useState("");
const [newValue, setNewValue] = useState("");
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
@@ -255,6 +316,10 @@ export function AdminSettingsEditor({
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty key.
</p>
) : addError === "missingValue" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Enter a non-empty value.
</p>
) : addError === "config" ? (
<p className="mt-3 text-sm text-red-600 dark:text-red-400">
Supabase admin client is not configured.
@@ -277,7 +342,8 @@ export function AdminSettingsEditor({
id="new-setting-key"
name="newKey"
type="text"
required
value={newKey}
onChange={(e) => setNewKey(e.target.value)}
autoComplete="off"
placeholder="e.g. maintenance_message"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
@@ -294,6 +360,8 @@ export function AdminSettingsEditor({
id="new-setting-value"
name="newValue"
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
autoComplete="off"
className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100"
/>
@@ -301,6 +369,7 @@ export function AdminSettingsEditor({
<div className="flex justify-end">
<button
type="submit"
disabled={newKey.trim() === "" || newValue.trim() === ""}
className="rounded-md bg-zinc-900 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
Add row
+26
View File
@@ -16,6 +16,7 @@ import {
formatRcBalanceWithCoins,
formatRcLabelFromCoinsBigInt,
} from "@/lib/coins-rc";
import { countryFlagFromCode } from "@/lib/ip-geolocation";
import { usePlayerCard } from "@/components/player-card-context";
function formatTsUtc(value: string | null): string {
@@ -153,6 +154,12 @@ export function PlayerCardModal() {
</div>
{load.status === "ok" ? (
<div className="mt-3 grid gap-1 text-sm text-zinc-600 dark:text-zinc-400">
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Email</span>
<span className="truncate font-mono text-zinc-800 dark:text-zinc-200">
{load.data.email?.trim() ? load.data.email.trim() : "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Account created</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
@@ -165,6 +172,25 @@ export function PlayerCardModal() {
{formatTsUtc(load.data.lastSeen)}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Last logged in IP</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{load.data.lastLoggedInIp?.trim()
? load.data.lastLoggedInIp.trim()
: "—"}
</span>
</div>
<div className="flex flex-wrap justify-between gap-x-4 gap-y-0.5">
<span>Country code</span>
<span className="font-mono text-zinc-800 dark:text-zinc-200">
{(() => {
const code = load.data.lastLoggedInCountryCode?.trim();
if (!code) return "—";
const flag = countryFlagFromCode(code);
return flag ? `${flag} ${code}` : code;
})()}
</span>
</div>
</div>
) : null}
</div>