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,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()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user