From 86e3f866071d792864216daf3565b74552cd804f Mon Sep 17 00:00:00 2001 From: needs <624097+needs@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:47:51 +0200 Subject: [PATCH] Reflect the rewound snapshot in the server page header Move the timeline state into a SnapshotProvider context so the header can read it, and drop the redundant "drag the bar to rewind" and " clients on " captions from the timeline label. Co-Authored-By: Claude Opus 5 (1M context) --- apps/frontend/app/server/[ip]/[port]/page.tsx | 162 ++++++++---------- apps/frontend/components/ServerHeaderInfo.tsx | 67 ++++++++ apps/frontend/components/SnapshotContext.tsx | 128 ++++++++++++++ apps/frontend/components/SnapshotTimeline.tsx | 111 ++---------- 4 files changed, 281 insertions(+), 187 deletions(-) create mode 100644 apps/frontend/components/ServerHeaderInfo.tsx create mode 100644 apps/frontend/components/SnapshotContext.tsx diff --git a/apps/frontend/app/server/[ip]/[port]/page.tsx b/apps/frontend/app/server/[ip]/[port]/page.tsx index b9cc452..0b159ae 100644 --- a/apps/frontend/app/server/[ip]/[port]/page.tsx +++ b/apps/frontend/app/server/[ip]/[port]/page.tsx @@ -2,13 +2,14 @@ import { paramsSchema } from './schema'; import { z } from 'zod'; import { notFound } from 'next/navigation'; import { isIP } from 'net'; -import Link from 'next/link'; import Image from 'next/image'; import { List, ListCell } from '../../../../components/List'; import { searchParamPageSchema } from '../../../../utils/page'; import prisma from '../../../../utils/prisma'; import { encodeIp, encodeString } from '../../../../utils/encoding'; import { SnapshotTimeline } from '../../../../components/SnapshotTimeline'; +import { SnapshotProvider } from '../../../../components/SnapshotContext'; +import { ServerHeaderInfo } from '../../../../components/ServerHeaderInfo'; import { formatPlayTime } from '../../../../utils/format'; import { GameServer } from '@prisma/client'; import { formatDuration, intervalToDuration } from 'date-fns'; @@ -141,52 +142,7 @@ export default async function Index({ return (
-
- - Server -
-

- {gameServer.gameServerState.name} -

-
- - - {gameServer.gameServerState.map.gameTypeName} - - - - - {gameServer.gameServerState.map.name} - - - {`${gameServer.gameServerState.numClients} / ${gameServer.gameServerState.maxClients} clients`} - - Playtime: {formatPlayTime(gameServer.playTime)} - -
-
-
-
- - ({ id: snapshot.id, createdAt: snapshot.createdAt.toISOString(), @@ -196,51 +152,73 @@ export default async function Index({ gameServer.port }/snapshot`} > - - {gameServer.gameServerState.clients.map((client, index) => ( - <> - - - - - - ))} - - +
+ + Server + + +
+ + + + {gameServer.gameServerState.clients.map((client, index) => ( + <> + + + + + + ))} + + +
); } diff --git a/apps/frontend/components/ServerHeaderInfo.tsx b/apps/frontend/components/ServerHeaderInfo.tsx new file mode 100644 index 0000000..73385c2 --- /dev/null +++ b/apps/frontend/components/ServerHeaderInfo.tsx @@ -0,0 +1,67 @@ +'use client'; + +import Link from 'next/link'; +import { encodeString } from '../utils/encoding'; +import { useSnapshot } from './SnapshotContext'; + +export function ServerHeaderInfo({ + name, + gameTypeName, + mapName, + numClients, + maxClients, + playTime, +}: { + name: string; + gameTypeName: string; + mapName: string; + numClients: number; + maxClients: number; + playTime: string; +}) { + const { selected, snapshot } = useSnapshot(); + const rewound = selected === null ? null : snapshot; + + const displayed = + rewound === null + ? { name, gameTypeName, mapName, numClients, maxClients } + : { + name: rewound.name, + gameTypeName: rewound.map.gameTypeName, + mapName: rewound.map.name, + numClients: rewound.numClients, + maxClients: rewound.maxClients, + }; + + return ( +
+

{displayed.name}

+
+ + + {displayed.gameTypeName} + + + + + {displayed.mapName} + + + {`${displayed.numClients} / ${displayed.maxClients} clients`} + Playtime: {playTime} +
+
+ ); +} diff --git a/apps/frontend/components/SnapshotContext.tsx b/apps/frontend/components/SnapshotContext.tsx new file mode 100644 index 0000000..0a21f0a --- /dev/null +++ b/apps/frontend/components/SnapshotContext.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useDebounce } from '../utils/hooks'; + +export type TimelinePoint = { + id: number; + createdAt: string; + numClients: number; +}; + +export type Snapshot = { + id: number; + createdAt: string; + name: string; + numClients: number; + maxClients: number; + map: { + name: string; + gameTypeName: string; + }; + clients: { + playerName: string; + clanName: string | null; + score: number; + }[]; +}; + +type SnapshotContextValue = { + snapshots: TimelinePoint[]; + selectedIndex: number | null; + setSelectedIndex: (index: number | null) => void; + selected: TimelinePoint | null; + snapshot: Snapshot | null; + stale: boolean; +}; + +const SnapshotContext = createContext({ + snapshots: [], + selectedIndex: null, + setSelectedIndex: () => undefined, + selected: null, + snapshot: null, + stale: false, +}); + +export function useSnapshot() { + return useContext(SnapshotContext); +} + +export function SnapshotProvider({ + snapshots, + apiPath, + children, +}: { + snapshots: TimelinePoint[]; + apiPath: string; + children: React.ReactNode; +}) { + const [selectedIndex, setSelectedIndex] = useState(null); + const [lastLoaded, setLastLoaded] = useState(null); + const cacheRef = useRef>(); + + if (cacheRef.current === undefined) { + cacheRef.current = new Map(); + } + + const cache = cacheRef.current; + const selected = selectedIndex === null ? null : snapshots[selectedIndex]; + const debouncedSelected = useDebounce(selected, 150); + const snapshot = + selected === null ? null : cache.get(selected.id) ?? lastLoaded; + + useEffect(() => { + if (debouncedSelected === null || cache.has(debouncedSelected.id)) { + return; + } + + const controller = new AbortController(); + + (async () => { + try { + const response = await fetch(`${apiPath}/${debouncedSelected.id}`, { + signal: controller.signal, + }); + + if (!response.ok) { + return; + } + + const data: Snapshot = await response.json(); + cache.set(data.id, data); + setLastLoaded(data); + } catch { + return; + } + })(); + + return () => { + controller.abort(); + }; + }, [debouncedSelected, apiPath, cache]); + + const value = useMemo( + () => ({ + snapshots, + selectedIndex, + setSelectedIndex, + selected, + snapshot, + stale: selected !== null && snapshot?.id !== selected.id, + }), + [snapshots, selectedIndex, selected, snapshot] + ); + + return ( + + {children} + + ); +} diff --git a/apps/frontend/components/SnapshotTimeline.tsx b/apps/frontend/components/SnapshotTimeline.tsx index ca5c274..63e0fee 100644 --- a/apps/frontend/components/SnapshotTimeline.tsx +++ b/apps/frontend/components/SnapshotTimeline.tsx @@ -1,33 +1,10 @@ 'use client'; -import { Fragment, useEffect, useRef, useState } from 'react'; +import { Fragment } from 'react'; import { format } from 'date-fns'; import { List, ListCell } from './List'; import { encodeString } from '../utils/encoding'; -import { useDebounce } from '../utils/hooks'; - -export type TimelinePoint = { - id: number; - createdAt: string; - numClients: number; -}; - -type Snapshot = { - id: number; - createdAt: string; - name: string; - numClients: number; - maxClients: number; - map: { - name: string; - gameTypeName: string; - }; - clients: { - playerName: string; - clanName: string | null; - score: number; - }[]; -}; +import { Snapshot, TimelinePoint, useSnapshot } from './SnapshotContext'; function sparklinePath(snapshots: TimelinePoint[]) { const maxClients = Math.max(1, ...snapshots.map(({ numClients }) => numClients)); @@ -92,58 +69,15 @@ function SnapshotList({ snapshot }: { snapshot: Snapshot }) { ); } -export function SnapshotTimeline({ - snapshots, - apiPath, - children, -}: { - snapshots: TimelinePoint[]; - apiPath: string; - children: React.ReactNode; -}) { - const [selectedIndex, setSelectedIndex] = useState(null); - const [lastLoaded, setLastLoaded] = useState(null); - const cacheRef = useRef>(); - - if (cacheRef.current === undefined) { - cacheRef.current = new Map(); - } - - const cache = cacheRef.current; - const selected = selectedIndex === null ? null : snapshots[selectedIndex]; - const debouncedSelected = useDebounce(selected, 150); - const snapshot = - selected === null ? null : cache.get(selected.id) ?? lastLoaded; - - useEffect(() => { - if (debouncedSelected === null || cache.has(debouncedSelected.id)) { - return; - } - - const controller = new AbortController(); - - (async () => { - try { - const response = await fetch(`${apiPath}/${debouncedSelected.id}`, { - signal: controller.signal, - }); - - if (!response.ok) { - return; - } - - const data: Snapshot = await response.json(); - cache.set(data.id, data); - setLastLoaded(data); - } catch { - return; - } - })(); - - return () => { - controller.abort(); - }; - }, [debouncedSelected, apiPath, cache]); +export function SnapshotTimeline({ children }: { children: React.ReactNode }) { + const { + snapshots, + selectedIndex, + setSelectedIndex, + selected, + snapshot, + stale, + } = useSnapshot(); if (snapshots.length === 0) { return <>{children}; @@ -152,29 +86,16 @@ export function SnapshotTimeline({ const lastIndex = snapshots.length - 1; const cursorFraction = selectedIndex === null || lastIndex === 0 ? 1 : selectedIndex / lastIndex; - const stale = selected !== null && snapshot?.id !== selected.id; return ( <>
- {selected === null ? ( - - Live - — drag the bar to rewind - - ) : ( - - - {format(new Date(selected.createdAt), 'MMM d, HH:mm')} - - - {' '} - — {selected.numClients} clients - {!stale && snapshot !== null && ` on ${snapshot.map.name}`} - - - )} + + {selected === null + ? 'Live' + : format(new Date(selected.createdAt), 'MMM d, HH:mm')} + {selected !== null && (