further changes to backend for adjusting points, showing timestamps, and how categories are listed. uploaded build of react and actual frontend source
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { LEADERBOARD_PAGE } from "../api/types";
|
||||
import { LoadMore, PlayerCell, Rank, ServerSwitch } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { server, setServer } = useServer();
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["leaderboard", server],
|
||||
queryFn: ({ pageParam }) => api.leaderboard(server, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < LEADERBOARD_PAGE ? undefined : all.length * LEADERBOARD_PAGE),
|
||||
});
|
||||
const players = q.data?.pages.flat() ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>{serverLabel(server)} leaderboard</h1>
|
||||
<p className="lead">
|
||||
Players ranked by racetimer points on {serverLabel(server)}. ZE1 and ZE2 are ranked separately.{" "}
|
||||
<Link to="/points">How points work</Link>
|
||||
</p>
|
||||
</div>
|
||||
<ServerSwitch value={server} onChange={setServer} />
|
||||
</section>
|
||||
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && players.length === 0 && <Empty>No one has a time on {serverLabel(server)} yet.</Empty>}
|
||||
|
||||
{players.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="num">Rank</th>
|
||||
<th>Player</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="num hide-sm">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.map((p) => (
|
||||
<tr key={p.steamID}>
|
||||
<td className="num">
|
||||
<Rank value={p.Rank} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
|
||||
</td>
|
||||
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
|
||||
<td className="num hide-sm">{formatNumber(p.Times)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Fragment } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { CATEGORY_PAGE, type Category, type CategoryBoard, type Stage } from "../api/types";
|
||||
import {
|
||||
BonusChip,
|
||||
InvalidatedBy,
|
||||
InvalidChip,
|
||||
LegacyChip,
|
||||
LoadMore,
|
||||
PlayerCell,
|
||||
PointsValue,
|
||||
Rank,
|
||||
RecordedAt,
|
||||
ServerTagChip,
|
||||
} from "../components/Bits";
|
||||
import { AdminToggle } from "../components/AdminToggle";
|
||||
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
|
||||
import { formatDelta, formatNumber, formatTime, pointedCount, serverLabel } from "../format";
|
||||
|
||||
export function MapPage() {
|
||||
const { mapname = "" } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const map = useQuery({ queryKey: ["map", mapname.toLowerCase()], queryFn: () => api.map(mapname) });
|
||||
|
||||
if (map.isPending) return <Loading />;
|
||||
if (map.isError) {
|
||||
return isNotFound(map.error) ? <NotFound what="map" /> : <ErrorBox error={map.error} onRetry={() => map.refetch()} />;
|
||||
}
|
||||
const m = map.data;
|
||||
const stageNo = Number(params.get("stage")) || m.stages[0]?.stage;
|
||||
const stage = m.stages.find((s) => s.stage === stageNo) ?? m.stages[0];
|
||||
if (!stage) return <Empty>This map has no categories yet.</Empty>;
|
||||
const catId = Number(params.get("category"));
|
||||
const category =
|
||||
stage.categories.find((c) => c.id === catId) ??
|
||||
stage.categories.find((c) => !c.isInvalid) ??
|
||||
stage.categories[0];
|
||||
|
||||
const select = (s: number, c?: number) => {
|
||||
const next = new URLSearchParams();
|
||||
next.set("stage", String(s));
|
||||
if (c) next.set("category", String(c));
|
||||
setParams(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1 className="map-title">{m.mapName}</h1>
|
||||
<p className="lead">
|
||||
{m.stages.length === 1 ? "1 stage" : `${m.stages.length} stages`}
|
||||
{m.allCategoriesInvalid && (
|
||||
<>
|
||||
{" "}
|
||||
· <InvalidChip label="Every category on this map is invalid" />
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{m.stages.length > 1 && (
|
||||
<div className="tabs" role="tablist" aria-label="Stages">
|
||||
{m.stages.map((s) => (
|
||||
<button
|
||||
key={s.stage}
|
||||
role="tab"
|
||||
aria-selected={s.stage === stage.stage}
|
||||
className={s.stage === stage.stage ? "on" : ""}
|
||||
onClick={() => select(s.stage)}
|
||||
>
|
||||
Stage {s.stage}
|
||||
{s.allCategoriesInvalid && <span className="tab-flag" title="Every category of this stage is invalid"> ⚠</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stage.allCategoriesInvalid && (
|
||||
<div className="alert alert-warn">
|
||||
Every category of stage {stage.stage} has been marked invalid by an admin, so this stage gives no points.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CategoryPicker stage={stage} selected={category} onSelect={(c) => select(stage.stage, c)} />
|
||||
<CategoryDetails stage={stage} category={category} />
|
||||
<Board category={category} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryPicker({
|
||||
stage,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
stage: Stage;
|
||||
selected: Category;
|
||||
onSelect: (id: number) => void;
|
||||
}) {
|
||||
if (stage.categories.length <= 1) return null;
|
||||
return (
|
||||
<div className="cat-picker" role="tablist" aria-label="Categories">
|
||||
{stage.categories.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
role="tab"
|
||||
aria-selected={c.id === selected.id}
|
||||
className={`cat-card ${c.id === selected.id ? "on" : ""} ${c.isInvalid ? "is-invalid" : ""}`}
|
||||
onClick={() => onSelect(c.id)}
|
||||
>
|
||||
<span className="cat-card-title">
|
||||
Category {c.categoryNumber} <ServerTagChip tag={c.serverTag} />
|
||||
</span>
|
||||
<span className="cat-card-sub">
|
||||
{formatNumber(c.completions)} records
|
||||
{c.fastestTime !== null && <> · best {formatTime(c.fastestTime)}</>}
|
||||
</span>
|
||||
<span className="cell-chips">
|
||||
{c.isLegacy && <LegacyChip />}
|
||||
{c.isInvalid && <InvalidChip />}
|
||||
</span>
|
||||
{c.isInvalid && <InvalidatedBy info={c.invalidation} className="small" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Server settings of the selected category; values that differ between this stage's categories are highlighted. */
|
||||
function CategoryDetails({ stage, category }: { stage: Stage; category: Category }) {
|
||||
const others = stage.categories.filter((c) => c.id !== category.id && !c.isLegacy);
|
||||
const differs = (name: string, value: string) =>
|
||||
others.some((o) => {
|
||||
const v = o.cvars.find((x) => x.name === name);
|
||||
return v !== undefined && v.value !== value;
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<h2>
|
||||
Category {category.categoryNumber} <ServerTagChip tag={category.serverTag} />
|
||||
{category.isLegacy && <LegacyChip />}
|
||||
{category.isInvalid && <InvalidChip />}
|
||||
</h2>
|
||||
<AdminToggle
|
||||
kind="category"
|
||||
id={category.id}
|
||||
invalid={category.isInvalid}
|
||||
what={`category ${category.categoryNumber} of stage ${category.stage}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{category.isInvalid && (
|
||||
<div className="alert alert-warn">
|
||||
<span>
|
||||
<strong>
|
||||
<InvalidatedBy info={category.invalidation} what="Category marked invalid" />
|
||||
</strong>
|
||||
. Its times are still listed, but none of them give points.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category.isLegacy ? (
|
||||
<p className="muted">
|
||||
Times from the old racetimer (CLASSIC RACETIMER). The server settings were not recorded back then, the exact dates are
|
||||
unknown, and points from this category are divided by 10.
|
||||
</p>
|
||||
) : category.cvars.length > 0 ? (
|
||||
<>
|
||||
<p className="hint">
|
||||
Server settings when these times were set.
|
||||
{others.length > 0 && " Highlighted values differ from another category of this stage."}
|
||||
</p>
|
||||
<dl className="cvars">
|
||||
{category.cvars.map((c) => {
|
||||
const diff = differs(c.name, c.value);
|
||||
return (
|
||||
<div key={c.name} className={diff ? "cvar diff" : "cvar"}>
|
||||
<dt>{c.name}</dt>
|
||||
<dd>
|
||||
{c.value}
|
||||
{diff && <span className="sr-only"> (differs)</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted mono">{category.serverCvars}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Board({ category }: { category: Category }) {
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["category", category.id],
|
||||
queryFn: ({ pageParam }) => api.category(category.id, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last: CategoryBoard, all) =>
|
||||
last.entries.length < CATEGORY_PAGE ? undefined : all.length * CATEGORY_PAGE,
|
||||
});
|
||||
const pages = q.data?.pages ?? [];
|
||||
const entries = pages.flatMap((p) => p.entries);
|
||||
const hasInvalid = entries.some((e) => e.isInvalid);
|
||||
const hasRaised = entries.some((e) => e.pointsRaised);
|
||||
const fresh = pages[0]?.category ?? category;
|
||||
const cut = fresh.completions >= 200 ? pointedCount(fresh.completions) : null;
|
||||
const best = entries.find((e) => !e.isInvalid)?.time;
|
||||
|
||||
if (q.isPending) return <Loading />;
|
||||
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
|
||||
|
||||
// Position of the previous valid row, for drawing the points cut-off line.
|
||||
let lastValidPosition = 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="board-head">
|
||||
<h2>Leaderboard</h2>
|
||||
<span className="muted">
|
||||
{formatNumber(fresh.completions)} {fresh.completions === 1 ? "record" : "records"}
|
||||
{cut !== null && <> · top {formatNumber(cut)} earn points</>}
|
||||
{fresh.completions >= 100 && <> · top 5% get a bonus</>}
|
||||
</span>
|
||||
</div>
|
||||
{hasInvalid && (
|
||||
<p className="hint">
|
||||
Rows marked <InvalidChip /> were invalidated by an admin (shown under the name, with the date). They are listed where
|
||||
their time would place them, but they have no position and give no points; the player's previous valid time counts
|
||||
instead.
|
||||
</p>
|
||||
)}
|
||||
{hasRaised && (
|
||||
<p className="hint">
|
||||
<span className="raised-mark">↑</span> Classic points are divided by 10, which on a small board would give almost
|
||||
everyone the same points. So every time that earns points gets at least 1, and each of the top 10 places gets at least 1
|
||||
point more than the place below it. <Link to="/points">How points work</Link>
|
||||
</p>
|
||||
)}
|
||||
{entries.length === 0 ? (
|
||||
<Empty>No times in this category yet.</Empty>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="num">#</th>
|
||||
<th>Player</th>
|
||||
<th className="num">Time</th>
|
||||
<th className="num hide-sm">Gap</th>
|
||||
<th className="hide-sm">Bonus</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="hide-sm">Set</th>
|
||||
<th className="hide-sm" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const showCut =
|
||||
!e.isInvalid && cut !== null && lastValidPosition > 0 && lastValidPosition <= cut && e.position > cut;
|
||||
if (!e.isInvalid) lastValidPosition = e.position;
|
||||
const what = `${e.name}'s ${formatTime(e.time)}`;
|
||||
return (
|
||||
<Fragment key={e.recordId}>
|
||||
{showCut && (
|
||||
<tr className="cut-row">
|
||||
<td colSpan={8}>Points cut-off: only the faster half of this board earns points</td>
|
||||
</tr>
|
||||
)}
|
||||
{e.isInvalid ? (
|
||||
<tr className="row-invalid run-invalid">
|
||||
<td className="num muted" title="Invalidated runs have no position">
|
||||
–
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-with-chip">
|
||||
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
|
||||
<InvalidChip />
|
||||
</div>
|
||||
<InvalidatedBy info={e.invalidation} className="small" />
|
||||
</td>
|
||||
<td className="num mono strike">{formatTime(e.time)}</td>
|
||||
<td className="num mono muted hide-sm" />
|
||||
<td className="hide-sm" />
|
||||
<td className="num muted">0</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid what={what} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td className="num">
|
||||
<Rank value={e.position} />
|
||||
</td>
|
||||
<td>
|
||||
<PlayerCell steamID={e.steamID} name={e.name} avatar={e.avatar} badges={e.badgesUrls} />
|
||||
</td>
|
||||
<td className="num mono strong">{formatTime(e.time)}</td>
|
||||
<td className="num mono muted hide-sm">
|
||||
{best !== undefined && e.time > best ? formatDelta(e.time - best) : ""}
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<BonusChip multiplier={e.bonusMultiplier} position={e.position} completions={fresh.completions} />
|
||||
</td>
|
||||
<td className="num">
|
||||
<PointsValue points={e.points} raised={e.pointsRaised} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid={false} what={what} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
<p className="hint">
|
||||
Showing {serverLabel(fresh.serverTag)} times for {fresh.mapName}, stage {fresh.stage}, category {fresh.categoryNumber}.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { MapInfo } from "../api/types";
|
||||
import { InvalidChip, ServerTagChip } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber } from "../format";
|
||||
|
||||
export function mapSummary(m: MapInfo) {
|
||||
const cats = m.stages.flatMap((s) => s.categories);
|
||||
const servers = Array.from(new Set(cats.map((c) => c.serverTag))).sort();
|
||||
const completions = cats.reduce((sum, c) => sum + c.completions, 0);
|
||||
return { categories: cats.length, servers, completions };
|
||||
}
|
||||
|
||||
export function MapsTable({ maps }: { maps: MapInfo[] }) {
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Map</th>
|
||||
<th className="num">Stages</th>
|
||||
<th className="num hide-sm">Categories</th>
|
||||
<th className="hide-sm">Servers</th>
|
||||
<th className="num">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{maps.map((m) => {
|
||||
const s = mapSummary(m);
|
||||
return (
|
||||
<tr key={m.mapName} className={m.allCategoriesInvalid ? "row-invalid" : ""}>
|
||||
<td>
|
||||
<Link to={`/map/${encodeURIComponent(m.mapName)}`} className="map-link">
|
||||
{m.mapName}
|
||||
</Link>
|
||||
{m.allCategoriesInvalid && (
|
||||
<>
|
||||
{" "}
|
||||
<InvalidChip label="Disabled" />
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="num">{m.stages.length}</td>
|
||||
<td className="num hide-sm">{s.categories}</td>
|
||||
<td className="hide-sm">
|
||||
<span className="cell-chips">
|
||||
{s.servers.map((t) => (
|
||||
<ServerTagChip key={t} tag={t} />
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatNumber(s.completions)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MapsPage() {
|
||||
const q = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps });
|
||||
const [text, setText] = useState("");
|
||||
const maps = (q.data ?? []).filter((m) => m.mapName.toLowerCase().includes(text.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>Maps</h1>
|
||||
<p className="lead">Every map with a racetimer. Open a map to see its stages, categories and leaderboards.</p>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Filter maps"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
aria-label="Filter maps"
|
||||
/>
|
||||
</section>
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && maps.length === 0 && <Empty>No maps match “{text}”.</Empty>}
|
||||
{maps.length > 0 && <MapsTable maps={maps} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { HISTORY_PAGE, PLAYER_MAPS_PAGE, SERVERS, type HistoryEntry, type Player, type PlayerMapRow } from "../api/types";
|
||||
import {
|
||||
hideBroken,
|
||||
BonusChip,
|
||||
CategoryName,
|
||||
InvalidatedBy,
|
||||
InvalidChip,
|
||||
PointsValue,
|
||||
LoadMore,
|
||||
RecordedAt,
|
||||
ServerTagChip,
|
||||
} from "../components/Bits";
|
||||
import { TimeChart } from "../components/TimeChart";
|
||||
import { AdminToggle } from "../components/AdminToggle";
|
||||
import { Empty, ErrorBox, isNotFound, Loading, NotFound } from "../components/States";
|
||||
import { formatNumber, formatTime, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
|
||||
type Tab = "records" | "history";
|
||||
|
||||
export function PlayerPage() {
|
||||
const { steamid = "" } = useParams();
|
||||
const { server } = useServer();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab: Tab = params.get("tab") === "history" ? "history" : "records";
|
||||
const categoryId = Number(params.get("category")) || undefined;
|
||||
|
||||
const player = useQuery({ queryKey: ["player", steamid, server], queryFn: () => api.player(steamid, server) });
|
||||
|
||||
if (player.isPending) return <Loading />;
|
||||
if (player.isError) {
|
||||
return isNotFound(player.error) ? (
|
||||
<NotFound what="player" />
|
||||
) : (
|
||||
<ErrorBox error={player.error} onRetry={() => player.refetch()} />
|
||||
);
|
||||
}
|
||||
const p = player.data;
|
||||
|
||||
const setTab = (t: Tab, category?: number) => {
|
||||
const next = new URLSearchParams();
|
||||
if (t === "history") next.set("tab", "history");
|
||||
if (category) next.set("category", String(category));
|
||||
setParams(next, { replace: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProfileHeader p={p} />
|
||||
<div className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === "records"} className={tab === "records" ? "on" : ""} onClick={() => setTab("records")}>
|
||||
Records
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === "history"} className={tab === "history" ? "on" : ""} onClick={() => setTab("history")}>
|
||||
Improvement history
|
||||
</button>
|
||||
</div>
|
||||
{tab === "records" ? (
|
||||
<RecordsTab steamid={p.steamID} onShowHistory={(id) => setTab("history", id)} />
|
||||
) : (
|
||||
<HistoryTab steamid={p.steamID} categoryId={categoryId} onCategory={(id) => setTab("history", id)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileHeader({ p }: { p: Player }) {
|
||||
const { server } = useServer();
|
||||
return (
|
||||
<section className="profile">
|
||||
<img src={p.Avatar} alt="" className="profile-avatar" width={96} height={96} onError={hideBroken} />
|
||||
<div className="profile-main">
|
||||
<h1>{p.name}</h1>
|
||||
<div className="profile-ids">
|
||||
<code>{p.steamID}</code>
|
||||
{p.steamID64 && (
|
||||
<a href={`https://steamcommunity.com/profiles/${p.steamID64}`} target="_blank" rel="noreferrer">
|
||||
Steam profile ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{p.badges.length > 0 && (
|
||||
<div className="profile-badges">
|
||||
{p.badges.map((b) => (
|
||||
<span key={b.url} className="badge-pill">
|
||||
<img src={b.url} alt="" onError={hideBroken} />
|
||||
{b.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="stat-row">
|
||||
{SERVERS.map((s) => {
|
||||
const st = p.servers[s];
|
||||
return (
|
||||
<div key={s} className={`stat ${s === server ? "stat-on" : ""}`}>
|
||||
<div className="stat-label">{serverLabel(s)} points</div>
|
||||
<div className="stat-value">{formatNumber(st.points)}</div>
|
||||
<div className="stat-sub">
|
||||
{st.times > 0 ? (
|
||||
<>
|
||||
Rank #{formatNumber(st.rank)} · {formatNumber(st.times)} records
|
||||
</>
|
||||
) : (
|
||||
"No records yet"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordsTab({ steamid, onShowHistory }: { steamid: string; onShowHistory: (categoryId: number) => void }) {
|
||||
const [serverFilter, setServerFilter] = useState<"all" | "ze1" | "ze2">("all");
|
||||
const [text, setText] = useState("");
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["playerMaps", steamid],
|
||||
queryFn: ({ pageParam }) => api.playerMaps(steamid, pageParam),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < PLAYER_MAPS_PAGE ? undefined : all.length * PLAYER_MAPS_PAGE),
|
||||
});
|
||||
const rows: PlayerMapRow[] = q.data?.pages.flat() ?? [];
|
||||
const shown = rows.filter(
|
||||
(r) =>
|
||||
(serverFilter === "all" || r.serverTag === serverFilter) &&
|
||||
(text === "" || r.mapName.toLowerCase().includes(text.toLowerCase())),
|
||||
);
|
||||
|
||||
if (q.isPending) return <Loading />;
|
||||
if (q.isError) return <ErrorBox error={q.error} onRetry={() => q.refetch()} />;
|
||||
if (rows.length === 0) return <Empty>This player has no records yet.</Empty>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<div className="segmented" role="group" aria-label="Server filter">
|
||||
{(["all", "ze1", "ze2"] as const).map((s) => (
|
||||
<button key={s} aria-pressed={serverFilter === s} className={serverFilter === s ? "on" : ""} onClick={() => setServerFilter(s)}>
|
||||
{s === "all" ? "All" : serverLabel(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder="Filter maps"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
aria-label="Filter maps"
|
||||
/>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Map</th>
|
||||
<th className="num">Time</th>
|
||||
<th className="num">Place</th>
|
||||
<th className="hide-sm">Bonus</th>
|
||||
<th className="num">Points</th>
|
||||
<th className="hide-sm">Set</th>
|
||||
<th className="hide-sm" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r) => (
|
||||
<tr key={r.recordId} className={r.categoryInvalid || r.isInvalid ? "row-invalid" : ""}>
|
||||
<td className="map-cell">
|
||||
<Link to={`/map/${encodeURIComponent(r.mapName)}?stage=${r.stage}&category=${r.categoryId}`} className="map-link">
|
||||
{r.mapName}
|
||||
</Link>
|
||||
<div className="map-meta">
|
||||
<span className="sub">
|
||||
Stage {r.stage} · <CategoryName number={r.categoryNumber} legacy={r.isLegacy} />
|
||||
</span>
|
||||
<ServerTagChip tag={r.serverTag} />
|
||||
{r.isInvalid && <InvalidChip label="Run invalidated" />}
|
||||
{r.categoryInvalid && <InvalidChip label="Category invalid" />}
|
||||
</div>
|
||||
{r.isInvalid && <InvalidatedBy info={r.invalidation} what="Run invalidated" className="small" />}
|
||||
{r.categoryInvalid && (
|
||||
<InvalidatedBy info={r.categoryInvalidation} what="Category invalidated" className="small" />
|
||||
)}
|
||||
</td>
|
||||
<td className={r.isInvalid ? "num mono strike" : "num mono"}>{formatTime(r.time)}</td>
|
||||
<td className="num">
|
||||
{r.isInvalid ? (
|
||||
<span className="muted" title="Invalidated runs have no position">
|
||||
–
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
#{formatNumber(r.position)}
|
||||
<span className="sub"> / {formatNumber(r.completions)}</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
{!r.isInvalid && (
|
||||
<BonusChip multiplier={r.bonusMultiplier} position={r.position} completions={r.completions} />
|
||||
)}
|
||||
</td>
|
||||
<td className={r.isInvalid ? "num muted" : "num strong"}>
|
||||
<PointsValue points={r.points} raised={r.pointsRaised} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<RecordedAt at={r.recordedAt} legacy={r.isLegacy} />
|
||||
</td>
|
||||
<td className="hide-sm">
|
||||
<div className="row-actions">
|
||||
{!r.isLegacy && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => onShowHistory(r.categoryId)}>
|
||||
History
|
||||
</button>
|
||||
)}
|
||||
<AdminToggle kind="record" id={r.recordId} invalid={r.isInvalid} what={`this ${formatTime(r.time)} run`} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{shown.length === 0 && <Empty>No records match the filter.</Empty>}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryTab({
|
||||
steamid,
|
||||
categoryId,
|
||||
onCategory,
|
||||
}: {
|
||||
steamid: string;
|
||||
categoryId?: number;
|
||||
onCategory: (id?: number) => void;
|
||||
}) {
|
||||
const maps = useQuery({
|
||||
queryKey: ["playerMapsAll", steamid],
|
||||
// Every category the player has a time in, for the filter drop-down.
|
||||
queryFn: async () => {
|
||||
const out: PlayerMapRow[] = [];
|
||||
for (let offset = 0; ; offset += PLAYER_MAPS_PAGE) {
|
||||
const page = await api.playerMaps(steamid, offset);
|
||||
out.push(...page);
|
||||
if (page.length < PLAYER_MAPS_PAGE) return out;
|
||||
}
|
||||
},
|
||||
});
|
||||
const q = useInfiniteQuery({
|
||||
queryKey: ["history", steamid, categoryId ?? 0],
|
||||
queryFn: ({ pageParam }) => api.playerHistory(steamid, pageParam, categoryId),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last, all) => (last.length < HISTORY_PAGE ? undefined : all.length * HISTORY_PAGE),
|
||||
});
|
||||
const entries: HistoryEntry[] = q.data?.pages.flat() ?? [];
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
(maps.data ?? [])
|
||||
.filter((r) => !r.isLegacy)
|
||||
.sort((a, b) => a.mapName.localeCompare(b.mapName) || a.stage - b.stage || a.categoryNumber - b.categoryNumber),
|
||||
[maps.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="toolbar">
|
||||
<label className="field">
|
||||
<span>Category</span>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId ?? ""}
|
||||
onChange={(e) => onCategory(e.target.value ? Number(e.target.value) : undefined)}
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{options.map((r) => (
|
||||
<option key={r.categoryId} value={r.categoryId}>
|
||||
{r.mapName} · Stage {r.stage} · Category {r.categoryNumber} · {serverLabel(r.serverTag)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="hint">
|
||||
Every saved run was a new personal best when it was set. Classic (migrated) records have no date and are listed last.
|
||||
</p>
|
||||
{q.isPending && <Loading />}
|
||||
{q.isError && <ErrorBox error={q.error} onRetry={() => q.refetch()} />}
|
||||
{q.isSuccess && entries.length === 0 && <Empty>No improvements to show.</Empty>}
|
||||
{categoryId && entries.length > 0 && <TimeChart entries={entries} />}
|
||||
{entries.length > 0 && (
|
||||
<ol className="timeline">
|
||||
{entries.map((e) => (
|
||||
<li key={e.recordId} className={e.isInvalid ? "tl-invalid" : ""}>
|
||||
<div className="tl-when">
|
||||
<RecordedAt at={e.recordedAt} legacy={e.isLegacy} />
|
||||
</div>
|
||||
<div className="tl-body">
|
||||
<div className="tl-title">
|
||||
<span className="mono strong">{formatTime(e.time)}</span>
|
||||
{e.improvedBy !== null && e.improvedBy > 0 && (
|
||||
<span className="good">
|
||||
{" "}
|
||||
improved by {formatTime(e.improvedBy)}s
|
||||
</span>
|
||||
)}
|
||||
{e.previousTime === null && !e.isInvalid && <span className="muted"> first time</span>}
|
||||
{e.isCurrentBest && <span className="chip chip-best">Current best</span>}
|
||||
{e.isInvalid && <InvalidChip label="Run invalidated" />}
|
||||
</div>
|
||||
<div className="sub">
|
||||
{!categoryId ? (
|
||||
<button className="linklike" onClick={() => onCategory(e.categoryId)}>
|
||||
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{e.mapName} · Stage {e.stage} · Category {e.categoryNumber}
|
||||
</>
|
||||
)}{" "}
|
||||
· {serverLabel(e.serverTag)}
|
||||
{e.previousTime !== null && <> · previous best {formatTime(e.previousTime)}</>}
|
||||
</div>
|
||||
{e.isInvalid && <InvalidatedBy info={e.invalidation} what="Run invalidated" className="small" />}
|
||||
{e.categoryInvalid && (
|
||||
<InvalidatedBy info={e.categoryInvalidation} what="Category invalidated" className="small" />
|
||||
)}
|
||||
</div>
|
||||
<div className="tl-admin">
|
||||
<AdminToggle kind="record" id={e.recordId} invalid={e.isInvalid} what={`this ${formatTime(e.time)} run`} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
<LoadMore hasMore={!!q.hasNextPage} loading={q.isFetchingNextPage} onClick={() => q.fetchNextPage()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export function PointsPage() {
|
||||
return (
|
||||
<article className="prose">
|
||||
<h1>How points work</h1>
|
||||
<p className="lead">
|
||||
Every map stage can have several <strong>categories</strong>: one per set of server settings (gravity, air
|
||||
acceleration, tickrate and so on) and per server. Each category has its own leaderboard, and each gives points on its
|
||||
own.
|
||||
</p>
|
||||
|
||||
<h2>Points per category</h2>
|
||||
<ul>
|
||||
<li>Only your best valid time in a category counts. Players with the same time share the same place.</li>
|
||||
<li>
|
||||
On a board with <em>n</em> records, first place gets <em>n</em> points, second place <em>n − 1</em>, and so on.
|
||||
</li>
|
||||
<li>
|
||||
Once a board has <strong>200 or more records</strong>, only the faster half earns points. With 200 records the top 100
|
||||
do, with 210 records the top 105.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Top 5% bonus</h2>
|
||||
<p>
|
||||
On boards with at least 100 records, the fastest players get a multiplier that rises smoothly the closer they are to
|
||||
first place:
|
||||
</p>
|
||||
<ul>
|
||||
<li>4× for first place, sliding down to 2× at the top 1%</li>
|
||||
<li>2× at the top 1%, sliding down to no bonus at the top 5%</li>
|
||||
</ul>
|
||||
<p>The multiplier is shown next to each time on the leaderboards and on your profile.</p>
|
||||
|
||||
<h2>Classic records</h2>
|
||||
<p>
|
||||
Times from the old racetimer are kept as <strong>Classic</strong> categories. Their points are divided by 10, and they
|
||||
show as “Legacy record” because the exact date was not saved back then.
|
||||
</p>
|
||||
<p>
|
||||
On small Classic boards, dividing by 10 would give almost everyone the same few points. So two extra rules apply:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Every time that earns points gets <strong>at least 1 point</strong>.
|
||||
</li>
|
||||
<li>
|
||||
Each of the <strong>top 10 places</strong> gets at least 1 point more than the place below it.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For example, a Classic board with 9 records gives 9, 8, 7 … 1 points instead of 1, 1, 1, 1, 1, 0, 0, 0, 0. On big
|
||||
Classic boards the top places are already far apart, so nothing changes there. Points that were raised by these rules are
|
||||
marked with <span className="raised-mark">↑</span> on the leaderboard.
|
||||
</p>
|
||||
|
||||
<h2>ZE1 and ZE2</h2>
|
||||
<p>Points and ranks are counted separately for each server. Use the ZE1 / ZE2 switch on the leaderboard.</p>
|
||||
|
||||
<h2>Invalid records</h2>
|
||||
<p>
|
||||
Admins can mark a single run or a whole category as invalid, for example if it was cheated or the map was broken. An
|
||||
invalid run gives no points and your previous valid time counts again. An invalid category stays visible but gives no
|
||||
points to anyone.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { PlayerCell, Rank } from "../components/Bits";
|
||||
import { Empty, ErrorBox, Loading } from "../components/States";
|
||||
import { formatNumber, serverLabel } from "../format";
|
||||
import { useServer } from "../server";
|
||||
import { MapsTable } from "./MapsPage";
|
||||
|
||||
export function SearchPage() {
|
||||
const [params] = useSearchParams();
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const { server } = useServer();
|
||||
const players = useQuery({
|
||||
queryKey: ["searchPlayers", q, server],
|
||||
queryFn: () => api.searchPlayers(q, server),
|
||||
enabled: q.length > 0,
|
||||
});
|
||||
const maps = useQuery({ queryKey: ["allmaps"], queryFn: api.allMaps, enabled: q.length > 0 });
|
||||
const mapHits = (maps.data ?? []).filter((m) => m.mapName.toLowerCase().includes(q.toLowerCase()));
|
||||
|
||||
if (!q) return <Empty>Type a player name, Steam ID or map name in the search box.</Empty>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-head">
|
||||
<div>
|
||||
<h1>Search: “{q}”</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2>Players</h2>
|
||||
{players.isPending && <Loading />}
|
||||
{players.isError && <ErrorBox error={players.error} onRetry={() => players.refetch()} />}
|
||||
{players.isSuccess && players.data.length === 0 && <Empty>No players found.</Empty>}
|
||||
{players.isSuccess && players.data.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th className="num">{serverLabel(server)} rank</th>
|
||||
<th className="num">{serverLabel(server)} points</th>
|
||||
<th className="hide-sm">Steam ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.data.map((p) => (
|
||||
<tr key={p.steamID}>
|
||||
<td>
|
||||
<PlayerCell steamID={p.steamID} name={p.name} avatar={p.Avatar} badges={p.UrlBanners} />
|
||||
</td>
|
||||
<td className="num">{p.Rank > 0 ? <Rank value={p.Rank} /> : <span className="muted">–</span>}</td>
|
||||
<td className="num strong">{formatNumber(p.PlayerPoints)}</td>
|
||||
<td className="hide-sm mono muted">{p.steamID}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>Maps</h2>
|
||||
{maps.isPending && <Loading />}
|
||||
{maps.isError && <ErrorBox error={maps.error} onRetry={() => maps.refetch()} />}
|
||||
{maps.isSuccess && mapHits.length === 0 && <Empty>No maps found.</Empty>}
|
||||
{mapHits.length > 0 && <MapsTable maps={mapHits} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user