Skip to content

Commit 803ae6c

Browse files
ryanbarlow97claude
andauthored
Keep the player's header and tabs still when switching to Movement (#148)
The profile and the movement page now share one layout (PlayerFrame), so the name, details and tabs stay mounted and in the same place on every tab. Before, Movement dropped the header, which moved the tabs up, and leaving it reloaded the profile with a "Loading…" flash. The header is two lines on every tab, with "All players" beside the name, so the movement map gives up as little height as possible. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8abd0a9 commit 803ae6c

9 files changed

Lines changed: 272 additions & 168 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Suspense } from "react";
2+
import AdminColumn from "../../../components/admin/AdminColumn";
3+
import PlayerFrame from "../../../components/admin/PlayerFrame";
4+
5+
/** The player's name, details and tabs stay mounted between the profile and the movement page. */
6+
export default async function AdminPlayerLayout({
7+
params,
8+
children,
9+
}: {
10+
params: Promise<{ uuid: string }>;
11+
children: React.ReactNode;
12+
}) {
13+
const { uuid } = await params;
14+
const id = decodeURIComponent(uuid);
15+
return (
16+
// The current tab lives partly in the URL's query, which needs a Suspense boundary.
17+
// Keyed so moving between players starts afresh, with no earlier player's responses landing.
18+
<Suspense
19+
fallback={
20+
<AdminColumn>
21+
<p className="mt-6 text-[var(--tfmc-mist)]">Loading…</p>
22+
</AdminColumn>
23+
}
24+
>
25+
<PlayerFrame key={id} uuid={id}>
26+
{children}
27+
</PlayerFrame>
28+
</Suspense>
29+
);
30+
}
Lines changed: 12 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,22 @@
1-
import Link from "next/link";
21
import { Suspense } from "react";
32
import AdminColumn from "../../../../components/admin/AdminColumn";
43
import PlayerMovementPage from "../../../../components/admin/PlayerMovementPage";
5-
import PlayerTabs from "../../../../components/admin/PlayerTabs";
64

5+
/** Under the player's frame (layout.tsx). Only those who may see movement get this far; the page refuses everyone else. */
76
export default async function AdminPlayerMovementPage({ params }: { params: Promise<{ uuid: string }> }) {
87
const { uuid } = await params;
98
const id = decodeURIComponent(uuid);
109
return (
11-
<>
12-
<AdminColumn>
13-
<Link href="/admin/players" className="mt-4 text-sm text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]">
14-
← All players
15-
</Link>
16-
{/* Only those who may see movement get this far; the page itself refuses everyone else. */}
17-
<PlayerTabs uuid={id} current="movement" movement />
18-
</AdminColumn>
19-
{/* The view lives in the URL's query, which needs a Suspense boundary. Keyed so a new player starts afresh.
20-
Outside the column: the map's workspace takes the frame's full width. */}
21-
<Suspense
22-
fallback={
23-
<AdminColumn>
24-
<p className="mt-6 text-[var(--tfmc-mist)]">Loading…</p>
25-
</AdminColumn>
26-
}
27-
>
28-
<PlayerMovementPage key={id} uuid={id} />
29-
</Suspense>
30-
</>
10+
// The view lives in the URL's query, which needs a Suspense boundary.
11+
// Outside the column: the map's workspace takes the frame's full width.
12+
<Suspense
13+
fallback={
14+
<AdminColumn>
15+
<p className="mt-6 text-[var(--tfmc-mist)]">Loading…</p>
16+
</AdminColumn>
17+
}
18+
>
19+
<PlayerMovementPage uuid={id} />
20+
</Suspense>
3121
);
3222
}

‎frontend/app/admin/players/[uuid]/page.tsx‎

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import Link from "next/link";
21
import { Suspense } from "react";
32
import AdminColumn from "../../../components/admin/AdminColumn";
43
import PlayerProfile from "../../../components/admin/PlayerProfile";
54

6-
export default async function AdminPlayerPage({ params }: { params: Promise<{ uuid: string }> }) {
7-
const { uuid } = await params;
8-
const id = decodeURIComponent(uuid);
5+
/** Under the player's frame (layout.tsx): the profile's tabs bar Movement. */
6+
export default function AdminPlayerPage() {
97
return (
108
<AdminColumn>
11-
<Link href="/admin/players" className="mt-4 text-sm text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]">
12-
← All players
13-
</Link>
14-
{/* The tab lives in the URL's query, which needs a Suspense boundary.
15-
Keyed so moving between players starts afresh, with no earlier player's responses landing. */}
9+
{/* The tab lives in the URL's query, which needs a Suspense boundary. */}
1610
<Suspense fallback={<p className="mt-6 text-[var(--tfmc-mist)]">Loading…</p>}>
17-
<PlayerProfile key={id} uuid={id} />
11+
<PlayerProfile />
1812
</Suspense>
1913
</AdminColumn>
2014
);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/** @vitest-environment jsdom */
2+
import { cleanup, render, screen, within } from "@testing-library/react";
3+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
4+
5+
import PlayerFrame, { usePlayer } from "./PlayerFrame";
6+
import { getAdminMe, getPlayer, type PlayerProfile as Profile } from "../../../lib/admin/api";
7+
8+
const UUID = "0615a817-8cb4-4aef-95f7-f6c9bf7611b8";
9+
vi.mock("next/navigation", () => ({
10+
useSearchParams: () => new URLSearchParams(),
11+
usePathname: () => `/admin/players/${UUID}/movement`,
12+
}));
13+
vi.mock("../../../lib/admin/api", async (importOriginal) => ({
14+
...(await importOriginal<typeof import("../../../lib/admin/api")>()),
15+
getAdminMe: vi.fn(),
16+
getPlayer: vi.fn(),
17+
}));
18+
19+
const PROFILE = {
20+
uuid: UUID, minecraft_name: "MrEnzo99", past_names: [], first_seen: null, last_seen: null, online: true,
21+
discord: null, account: null, characters: [], coreprotect: { status: "available", server_label: "Vardera", ping_seconds: 60 },
22+
} as unknown as Profile;
23+
24+
function Name() {
25+
return <p>Page for {usePlayer()?.profile.minecraft_name}</p>;
26+
}
27+
28+
beforeEach(() => {
29+
vi.mocked(getPlayer).mockResolvedValue(PROFILE);
30+
vi.mocked(getAdminMe).mockResolvedValue({ capabilities: ["view_player_movement"] } as never);
31+
});
32+
afterEach(() => {
33+
cleanup();
34+
vi.clearAllMocks();
35+
});
36+
37+
it("keeps the player's header and tabs over the movement page, and hands the page the player", async () => {
38+
render(<PlayerFrame uuid={UUID}><Name /></PlayerFrame>);
39+
expect(await screen.findByText("Page for MrEnzo99")).toBeTruthy();
40+
expect(screen.getByRole("heading", { name: "MrEnzo99" })).toBeTruthy();
41+
const tabs = screen.getByRole("navigation", { name: "Player" });
42+
expect((await within(tabs).findByRole("link", { name: "Movement" })).getAttribute("aria-current")).toBe("page");
43+
expect(getPlayer).toHaveBeenCalledTimes(1);
44+
});
45+
46+
it("holds its page back until the player has loaded", () => {
47+
vi.mocked(getPlayer).mockReturnValue(new Promise(() => undefined));
48+
render(<PlayerFrame uuid={UUID}><Name /></PlayerFrame>);
49+
expect(screen.getByText("Loading…")).toBeTruthy();
50+
expect(screen.queryByText(/Page for/)).toBeNull();
51+
});
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { usePathname, useSearchParams } from "next/navigation";
5+
import { createContext, useCallback, useContext, useEffect, useState } from "react";
6+
import { AccountApiError } from "../../../lib/account/api";
7+
import {
8+
adminErrorMessage,
9+
coreProtectMessage,
10+
getAdminMe,
11+
getPlayer,
12+
type AdminMe,
13+
type PlayerProfile as Profile,
14+
} from "../../../lib/admin/api";
15+
import { formatAgo, formatEpoch } from "../../../lib/admin/time";
16+
import AdminColumn from "./AdminColumn";
17+
import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate";
18+
import PlayerTabs, { profileTab } from "./PlayerTabs";
19+
20+
type Load = { kind: "loading" } | { kind: GateKind } | { kind: "failed"; message: string } | { kind: "ready"; profile: Profile };
21+
22+
export type PlayerData = {
23+
profile: Profile;
24+
me: AdminMe | null;
25+
/** Admins and the owner; mods see the profile without it. */
26+
movement: boolean;
27+
reload: () => void;
28+
};
29+
30+
const PlayerContext = createContext<PlayerData | null>(null);
31+
32+
/** The player the frame has loaded; null outside one. */
33+
export function usePlayer(): PlayerData | null {
34+
return useContext(PlayerContext);
35+
}
36+
37+
/**
38+
* The player's name, details and tabs, shared by the profile and the movement
39+
* page so they stay put from tab to tab. Its pages render once the player has loaded,
40+
* so nothing arrives above them later and pushes them down.
41+
*/
42+
export default function PlayerFrame({ uuid, children }: { uuid: string; children: React.ReactNode }) {
43+
const [load, setLoad] = useState<Load>({ kind: "loading" });
44+
const [me, setMe] = useState<AdminMe | null>(null);
45+
const pathname = usePathname();
46+
const search = useSearchParams();
47+
const current = pathname.endsWith("/movement") ? "movement" : profileTab(search.get("tab"));
48+
49+
useEffect(() => {
50+
let live = true;
51+
getAdminMe()
52+
.then((value) => live && setMe(value))
53+
.catch(() => undefined);
54+
return () => {
55+
live = false;
56+
};
57+
}, []);
58+
59+
const loadProfile = useCallback(() => {
60+
let live = true;
61+
getPlayer(uuid)
62+
.then((profile) => live && setLoad({ kind: "ready", profile }))
63+
.catch((err) => {
64+
if (!live) return;
65+
const status = err instanceof AccountApiError ? err.status : 0;
66+
setLoad(status === 400 || status === 404 ? { kind: "failed", message: adminErrorMessage(err) } : { kind: gateKind(err) });
67+
});
68+
return () => {
69+
live = false;
70+
};
71+
}, [uuid]);
72+
73+
useEffect(() => loadProfile(), [loadProfile]);
74+
75+
const back = (
76+
<Link href="/admin/players" className="text-sm text-[var(--tfmc-stone)] hover:text-[var(--tfmc-cream)]">
77+
← All players
78+
</Link>
79+
);
80+
if (load.kind !== "ready") {
81+
return (
82+
<AdminColumn>
83+
<p className="mt-4">{back}</p>
84+
{load.kind === "loading" ? (
85+
<p className="mt-6 text-[var(--tfmc-mist)]">Loading…</p>
86+
) : load.kind === "failed" ? (
87+
<p className="mt-6 text-[var(--tfmc-mist)]" role="alert">
88+
{load.message}
89+
</p>
90+
) : (
91+
<StaffGateMessage kind={load.kind} />
92+
)}
93+
</AdminColumn>
94+
);
95+
}
96+
97+
const { profile } = load;
98+
const movement = me?.capabilities.includes("view_player_movement") ?? false;
99+
const notice = coreProtectMessage(profile.coreprotect);
100+
const label = profile.coreprotect.server_label;
101+
const seen = [
102+
profile.online ? "Seen just now" : `Last seen ${formatAgo(profile.last_seen).toLowerCase()}`,
103+
profile.first_seen ? `first seen ${formatEpoch(profile.first_seen)}` : null,
104+
label,
105+
profile.past_names.length ? `previously ${profile.past_names.map((n) => n.name).join(", ")}` : null,
106+
].filter(Boolean);
107+
108+
return (
109+
<>
110+
<AdminColumn>
111+
{/* Two lines on every tab, so the tabs under them never move and the map keeps its height. */}
112+
<header className="mt-4">
113+
<div className="flex flex-wrap items-baseline gap-x-3">
114+
{back}
115+
<h2 className="font-[family-name:var(--font-fraunces)] text-2xl text-[var(--tfmc-cream)]">
116+
{profile.minecraft_name ?? "Unknown name"}
117+
</h2>
118+
<span className="font-mono text-xs text-[var(--tfmc-stone)]">{profile.uuid}</span>
119+
</div>
120+
<p className="mt-1 text-sm text-[var(--tfmc-mist)]">
121+
{seen.join(" · ")}
122+
{" · "}
123+
<Link href={`/admin/ranks/players/${profile.uuid}`} className="text-[var(--tfmc-accent)] underline-offset-2 hover:underline">
124+
In-game ranks and permissions →
125+
</Link>
126+
</p>
127+
{notice ? (
128+
<p className="mt-2 text-sm text-[#e8c48a]" role="status">
129+
{notice}
130+
</p>
131+
) : null}
132+
</header>
133+
<PlayerTabs uuid={profile.uuid} current={current} movement={movement} />
134+
</AdminColumn>
135+
<PlayerContext.Provider value={{ profile, me, movement, reload: loadProfile }}>{children}</PlayerContext.Provider>
136+
</>
137+
);
138+
}

‎frontend/app/components/admin/PlayerMovementPage.test.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
44

55
import PlayerMovementPage from "./PlayerMovementPage";
66
import { AccountApiError } from "../../../lib/account/api";
7-
import { getPlayer, getPlayerSessions, type PlayerSession } from "../../../lib/admin/api";
7+
import { getPlayerSessions, type PlayerSession } from "../../../lib/admin/api";
88
import { getPlayerMovement, getSessionMovement } from "../../../lib/admin/movement";
99

1010
process.env.TZ = "Europe/London";
@@ -38,7 +38,6 @@ vi.mock("next/navigation", async () => {
3838
vi.mock("../../../lib/admin/urlState", () => ({ writeUrl: (url: string) => nav.go(url) }));
3939
vi.mock("../../../lib/admin/api", async (importOriginal) => ({
4040
...(await importOriginal<typeof import("../../../lib/admin/api")>()),
41-
getPlayer: vi.fn(),
4241
getPlayerSessions: vi.fn(),
4342
}));
4443
vi.mock("../../../lib/admin/movement", async (importOriginal) => ({
@@ -72,7 +71,6 @@ const SESSION: PlayerSession = {
7271

7372
beforeEach(() => {
7473
nav.state.params = new URLSearchParams();
75-
vi.mocked(getPlayer).mockResolvedValue({ minecraft_name: "MrEnzo99" } as never);
7674
vi.mocked(getPlayerSessions).mockResolvedValue({ sessions: [SESSION], next: null, coreprotect: { status: "available" } });
7775
vi.mocked(getSessionMovement).mockResolvedValue({
7876
session: SESSION,

‎frontend/app/components/admin/PlayerMovementPage.tsx‎

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { usePathname, useSearchParams } from "next/navigation";
44
import { useCallback, useEffect, useMemo, useState } from "react";
55

66
import { AccountApiError } from "../../../lib/account/api";
7-
import { adminErrorMessage, coreProtectMessage, getPlayer, type PlayerSession } from "../../../lib/admin/api";
7+
import { adminErrorMessage, coreProtectMessage, type PlayerSession } from "../../../lib/admin/api";
88
import {
99
ACTION_PING,
1010
PLAYER_WINDOW_SECONDS,
@@ -45,6 +45,7 @@ import {
4545
useMinuteClock,
4646
type Range,
4747
} from "./MovementControls";
48+
import { usePlayer } from "./PlayerFrame";
4849
import SessionList from "./SessionList";
4950
import { StaffGateMessage, gateKind, type GateKind } from "./StaffGate";
5051

@@ -130,22 +131,7 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
130131
update({ at: String(time) }, true)
131132
);
132133

133-
const [name, setName] = useState<string | null>(null);
134-
const [gate, setGate] = useState<GateKind | null>(null);
135-
useEffect(() => {
136-
let live = true;
137-
getPlayer(uuid)
138-
.then((profile) => live && setName(profile.minecraft_name ?? "Unknown name"))
139-
.catch((err) => {
140-
if (!live) return;
141-
const status = err instanceof AccountApiError ? err.status : 0;
142-
if (status === 400 || status === 404) setName("Unknown player");
143-
else setGate(gateKind(err));
144-
});
145-
return () => {
146-
live = false;
147-
};
148-
}, [uuid]);
134+
const name = usePlayer()?.profile.minecraft_name ?? null;
149135

150136
// The clock only ticks while something follows it.
151137
const now = useMinuteClock((mode === "range" && follow) || (mode === "session" && followSession));
@@ -234,13 +220,6 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
234220
[parts, view]
235221
);
236222

237-
if (gate) {
238-
return (
239-
<AdminColumn>
240-
<StaffGateMessage kind={gate} />
241-
</AdminColumn>
242-
);
243-
}
244223
if (load.kind === "gate") {
245224
return (
246225
<AdminColumn>
@@ -291,15 +270,12 @@ export default function PlayerMovementPage({ uuid }: { uuid: string }) {
291270

292271
return (
293272
<MapWorkspace
294-
// The links and the player's tabs above.
295-
above="6rem"
273+
// The player's two-line header (PlayerFrame) and their tabs above.
274+
above="8.5rem"
296275
// The timeline under the map: one row of bands, its labels and its key.
297276
stripHeight="7.25rem"
298277
panel={
299278
<>
300-
<h2 className="font-[family-name:var(--font-fraunces)] text-2xl text-[var(--tfmc-cream)]">
301-
{name ?? "…"}
302-
</h2>
303279
<p className={mutedClass}>Lines between positions are estimates. Views are logged.</p>
304280
{view ? <CopyButton text={shareUrl()} label="Copy link to this view" /> : null}
305281
{notice ? <p className="text-sm text-[#e8c48a]">{notice}</p> : null}

0 commit comments

Comments
 (0)