Files
projects-jenz/RaceTimer_2026/racetimer_react/src/pages/PlayerPage.tsx
T

350 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()} />
</>
);
}