ip geolocation + ping analysis
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 { 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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
0–100 (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">
|
||||
0–100 (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 0–3).");
|
||||
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 0–3 (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 0–3).",
|
||||
);
|
||||
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 0–3 (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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user