feat(web): rebuild game detail — vehicles, icons, full scoreboard, logs
This commit is contained in:
+102
-25
@@ -19,6 +19,7 @@ const apiEndpoints = {
|
|||||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||||
recentGames: '/api/tss/games/recent?limit=50',
|
recentGames: '/api/tss/games/recent?limit=50',
|
||||||
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
|
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
|
||||||
|
gameLogs: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}/logs`,
|
||||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||||
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
|
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||||
@@ -116,6 +117,14 @@ function formatDate(timestamp) {
|
|||||||
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const total = Math.round(Number(seconds || 0))
|
||||||
|
if (!total) return ''
|
||||||
|
const m = Math.floor(total / 60)
|
||||||
|
const s = total % 60
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
function gameParticipants(game) {
|
function gameParticipants(game) {
|
||||||
const winner = displayTeamName(game?.winning_team)
|
const winner = displayTeamName(game?.winning_team)
|
||||||
const loser = displayTeamName(game?.losing_team)
|
const loser = displayTeamName(game?.losing_team)
|
||||||
@@ -2769,14 +2778,18 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCOREBOARD_GRID = 'grid-cols-[minmax(220px,1fr)_repeat(6,minmax(52px,72px))]'
|
||||||
|
|
||||||
function GamePage({ gameId, navigate }) {
|
function GamePage({ gameId, navigate }) {
|
||||||
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
|
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
|
||||||
|
const [logs, setLogs] = useState({ chat_log: [], battle_log: [] })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gameId) return
|
if (!gameId) return
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
setGameState({ status: 'loading', data: null, error: null })
|
setGameState({ status: 'loading', data: null, error: null })
|
||||||
|
setLogs({ chat_log: [], battle_log: [] })
|
||||||
|
|
||||||
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -2790,6 +2803,19 @@ function GamePage({ gameId, navigate }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fetchJson(apiEndpoints.gameLogs(gameId), controller.signal)
|
||||||
|
.then((data) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setLogs({
|
||||||
|
chat_log: Array.isArray(data?.chat_log) ? data.chat_log : [],
|
||||||
|
battle_log: Array.isArray(data?.battle_log) ? data.battle_log : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Logs are non-critical; leave them empty on failure.
|
||||||
|
})
|
||||||
|
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [gameId])
|
}, [gameId])
|
||||||
|
|
||||||
@@ -2802,6 +2828,9 @@ function GamePage({ gameId, navigate }) {
|
|||||||
}))
|
}))
|
||||||
: gameParticipants(game)
|
: gameParticipants(game)
|
||||||
|
|
||||||
|
const subtitle = [game?.mission_mode, game?.tournament_name].filter(Boolean).join(' · ')
|
||||||
|
const duration = formatDuration(game?.duration)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6 pt-24 sm:pt-28">
|
<section className="space-y-6 pt-24 sm:pt-28">
|
||||||
<button
|
<button
|
||||||
@@ -2814,36 +2843,41 @@ function GamePage({ gameId, navigate }) {
|
|||||||
|
|
||||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
|
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
|
||||||
<h1 className="mt-1 text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||||
|
{game?.draw ? (
|
||||||
|
<span className="rounded bg-surface px-2 py-1 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||||
|
Draw
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
|
||||||
<p className="mt-2 break-all text-sm text-text-soft">
|
<p className="mt-2 break-all text-sm text-text-soft">
|
||||||
{game ? `${formatDate(game.timestamp)} · ${game.session_id}` : gameId}
|
{game ? formatDate(game.timestamp) : ''}
|
||||||
|
{duration ? ` · ${duration}` : ''}
|
||||||
|
{game ? ` · ${game.session_id}` : gameId}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<ParticipantNames participants={participantNames} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{gameState.status === 'error' ? (
|
{gameState.status === 'error' ? (
|
||||||
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
|
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
<div className="border-b border-surface px-5 py-4">
|
|
||||||
<h2 className="text-lg font-semibold">Participants</h2>
|
|
||||||
<div className="mt-1">
|
|
||||||
<ParticipantNames participants={participantNames} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="min-w-[760px]">
|
<div className="min-w-[720px]">
|
||||||
{participants.length ? (
|
{participants.length ? (
|
||||||
<div className="grid grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
<div className={`grid ${SCOREBOARD_GRID} gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft`}>
|
||||||
<p>Team / player</p>
|
<p>Team / player</p>
|
||||||
<p className="text-right">Players</p>
|
|
||||||
<p className="text-right">Ground</p>
|
|
||||||
<p className="text-right">Air</p>
|
<p className="text-right">Air</p>
|
||||||
|
<p className="text-right">Ground</p>
|
||||||
<p className="text-right">Assists</p>
|
<p className="text-right">Assists</p>
|
||||||
<p className="text-right">Score</p>
|
|
||||||
<p className="text-right">Deaths</p>
|
<p className="text-right">Deaths</p>
|
||||||
|
<p className="text-right">Caps</p>
|
||||||
|
<p className="text-right">Score</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -2852,39 +2886,64 @@ function GamePage({ gameId, navigate }) {
|
|||||||
return (
|
return (
|
||||||
<div className="border-b border-surface" key={participant.team_name}>
|
<div className="border-b border-surface" key={participant.team_name}>
|
||||||
<button
|
<button
|
||||||
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-3 text-left transition hover:bg-surface"
|
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-3 text-left transition hover:bg-surface`}
|
||||||
onClick={() => navigate(teamPath(participant.team_name))}
|
onClick={() => navigate(teamPath(participant.team_name))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
|
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
|
||||||
{participant.team_name}
|
{participant.team_name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.player_count)}</p>
|
<p className="text-xs text-text-muted">
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
{won ? 'Win' : 'Loss'} · {formatNumber(participant.player_count)} players
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
<p className="text-right text-sm">{formatNumber(participant.stats?.air_kills)}</p>
|
||||||
|
<p className="text-right text-sm">{formatNumber(participant.stats?.ground_kills)}</p>
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
|
<p className="text-right text-sm">{formatNumber(participant.stats?.assists)}</p>
|
||||||
<p className="text-right text-sm text-text-muted">-</p>
|
|
||||||
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
<p className="text-right text-sm">{formatNumber(participant.stats?.deaths)}</p>
|
||||||
|
<p className="text-right text-sm">{formatNumber(participant.stats?.captures)}</p>
|
||||||
|
<p className="text-right text-sm font-semibold">{formatNumber(participant.stats?.score)}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
{(participant.players || []).map((player) => (
|
{(participant.players || []).map((player) => (
|
||||||
<button
|
<button
|
||||||
className="grid w-full grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface"
|
className={`grid w-full ${SCOREBOARD_GRID} items-center gap-3 px-5 py-2 text-left text-sm transition hover:bg-surface`}
|
||||||
key={player.uid}
|
key={player.uid}
|
||||||
onClick={() => navigate(`/players/${encodeURIComponent(player.uid)}`)}
|
onClick={() => navigate(`/players/${encodeURIComponent(player.uid)}`)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 pl-8 sm:pl-12">
|
<div className="min-w-0 pl-4 sm:pl-8">
|
||||||
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
|
<p className="truncate font-semibold text-text">{player.nick || player.uid}</p>
|
||||||
<p className="text-xs text-text-soft">{player.uid}</p>
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
|
{(player.vehicles || []).length ? (
|
||||||
|
player.vehicles.map((vehicle) => (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-text-soft"
|
||||||
|
key={vehicle.cdk}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
className="h-4 w-4 object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
||||||
|
src={`/vehicle-icons/${vehicle.icon}`}
|
||||||
|
/>
|
||||||
|
{vehicle.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-text-muted">{player.uid}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-right text-text-muted">-</p>
|
|
||||||
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
|
|
||||||
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
|
<p className="text-right">{formatNumber(player.stats?.air_kills)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.stats?.ground_kills)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
|
<p className="text-right">{formatNumber(player.stats?.assists)}</p>
|
||||||
<p className="text-right">{formatNumber(player.stats?.score)}</p>
|
|
||||||
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
|
<p className="text-right">{formatNumber(player.stats?.deaths)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.stats?.captures)}</p>
|
||||||
|
<p className="text-right font-semibold">{formatNumber(player.stats?.score)}</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -2900,6 +2959,24 @@ function GamePage({ gameId, navigate }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{logs.battle_log.length ? (
|
||||||
|
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<summary className="cursor-pointer px-5 py-4 font-semibold">Battle Log</summary>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap px-5 py-3 text-xs leading-relaxed text-text-soft">
|
||||||
|
{logs.battle_log.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{logs.chat_log.length ? (
|
||||||
|
<details className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<summary className="cursor-pointer px-5 py-4 font-semibold">Chat Log</summary>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap px-5 py-3 text-xs leading-relaxed text-text-soft">
|
||||||
|
{logs.chat_log.join('\n')}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user