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:
jenz
2026-09-30 16:02:33 +02:00
parent 54827b01ba
commit 6019e6854d
40 changed files with 5256 additions and 21 deletions
@@ -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()} />
</>
);
}