ai generated solutions to our ai generated problems
This commit is contained in:
@@ -201,6 +201,12 @@ struct GamesResponse {
|
||||
games: Vec<GameRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameResponse {
|
||||
game: GameRow,
|
||||
participants: Vec<GameParticipant>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameRow {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -230,6 +236,14 @@ struct GameStats {
|
||||
team_kills_stat: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameParticipant {
|
||||
team_name: String,
|
||||
result: String,
|
||||
player_count: i64,
|
||||
stats: GameStats,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PlayerSearchResponse {
|
||||
players: Vec<PlayerRef>,
|
||||
@@ -302,6 +316,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/health", get(health))
|
||||
.route("/api/tss/leaderboard/teams", get(leaderboard))
|
||||
.route("/api/tss/games/recent", get(recent_games))
|
||||
.route("/api/tss/games/{session_id}", get(game_detail))
|
||||
.route("/api/tss/teams/resolve", get(resolve_team))
|
||||
.route("/api/tss/teams/search", get(search_teams))
|
||||
.route("/api/tss/teams/{team}", get(team_detail))
|
||||
@@ -494,6 +509,18 @@ async fn recent_games(
|
||||
Ok(Json(RecentGamesResponse { matches }))
|
||||
}
|
||||
|
||||
async fn game_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> ApiResult<GameResponse> {
|
||||
let session_id = validate_session_id(&session_id)?;
|
||||
let battles_conn = open_db(&state.battles_db)?;
|
||||
let game = game_for(&battles_conn, session_id)?
|
||||
.ok_or_else(|| ApiError::not_found("Game not found"))?;
|
||||
let participants = game_participants_for(&battles_conn, session_id)?;
|
||||
Ok(Json(GameResponse { game, participants }))
|
||||
}
|
||||
|
||||
async fn resolve_team(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ResolveQuery>,
|
||||
@@ -1259,6 +1286,121 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> {
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
p.session_id,
|
||||
COALESCE(m.endtime_unix, MAX(p.endtime_unix), 0) AS timestamp,
|
||||
m.mission_name,
|
||||
m.mission_mode,
|
||||
COUNT(DISTINCT p.UID),
|
||||
COALESCE(SUM(p.ground_kills), 0),
|
||||
COALESCE(SUM(p.air_kills), 0),
|
||||
COALESCE(SUM(p.assists), 0),
|
||||
COALESCE(SUM(p.captures), 0),
|
||||
COALESCE(SUM(p.deaths), 0),
|
||||
COALESCE(SUM(p.score), 0),
|
||||
COALESCE(SUM(p.missile_evades), 0),
|
||||
COALESCE(SUM(p.shell_interceptions), 0),
|
||||
COALESCE(SUM(p.team_kills_stat), 0),
|
||||
m.winning_slot,
|
||||
m.losing_slot
|
||||
FROM player_games_hist p
|
||||
LEFT JOIN match_summary m ON m.session_id = p.session_id
|
||||
WHERE p.session_id = ?1
|
||||
GROUP BY p.session_id",
|
||||
params![session_id],
|
||||
|row| {
|
||||
let timestamp: i64 = row.get(1)?;
|
||||
Ok(GameRow {
|
||||
team_name: None,
|
||||
session_id: row.get(0)?,
|
||||
timestamp,
|
||||
endtime_unix: timestamp,
|
||||
map_name: row.get(2)?,
|
||||
mission_mode: row.get(3)?,
|
||||
result: String::new(),
|
||||
player_count: row.get(4)?,
|
||||
winning_team: row.get(14)?,
|
||||
losing_team: row.get(15)?,
|
||||
stats: GameStats {
|
||||
ground_kills: row.get(5)?,
|
||||
air_kills: row.get(6)?,
|
||||
assists: row.get(7)?,
|
||||
captures: row.get(8)?,
|
||||
deaths: row.get(9)?,
|
||||
score: row.get(10)?,
|
||||
missile_evades: row.get(11)?,
|
||||
shell_interceptions: row.get(12)?,
|
||||
team_kills_stat: row.get(13)?,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn game_participants_for(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> Result<Vec<GameParticipant>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT
|
||||
p.team_name,
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 'Win'
|
||||
ELSE 'Loss'
|
||||
END AS result,
|
||||
COUNT(DISTINCT p.UID),
|
||||
COALESCE(SUM(p.ground_kills), 0),
|
||||
COALESCE(SUM(p.air_kills), 0),
|
||||
COALESCE(SUM(p.assists), 0),
|
||||
COALESCE(SUM(p.captures), 0),
|
||||
COALESCE(SUM(p.deaths), 0),
|
||||
COALESCE(SUM(p.score), 0),
|
||||
COALESCE(SUM(p.missile_evades), 0),
|
||||
COALESCE(SUM(p.shell_interceptions), 0),
|
||||
COALESCE(SUM(p.team_kills_stat), 0)
|
||||
FROM player_games_hist p
|
||||
WHERE p.session_id = ?1 AND p.team_name IS NOT NULL AND p.team_name != ''
|
||||
GROUP BY p.team_name COLLATE NOCASE
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN p.victor_bool = 'Win' THEN 1 ELSE 0 END) = 1 THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
p.team_name COLLATE NOCASE",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![session_id], |row| {
|
||||
Ok(GameParticipant {
|
||||
team_name: row.get(0)?,
|
||||
result: row.get(1)?,
|
||||
player_count: row.get(2)?,
|
||||
stats: GameStats {
|
||||
ground_kills: row.get(3)?,
|
||||
air_kills: row.get(4)?,
|
||||
assists: row.get(5)?,
|
||||
captures: row.get(6)?,
|
||||
deaths: row.get(7)?,
|
||||
score: row.get(8)?,
|
||||
missile_evades: row.get(9)?,
|
||||
shell_interceptions: row.get(10)?,
|
||||
team_kills_stat: row.get(11)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
|
||||
@@ -1287,6 +1429,19 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 96
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return Err(ApiError::bad_request("Invalid game ID"));
|
||||
}
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn decode_path_team(value: &str) -> Result<String, ApiError> {
|
||||
let decoded = urlencoding::decode(value)
|
||||
.map_err(|_| ApiError::bad_request("Invalid team name"))?
|
||||
|
||||
+192
-41
@@ -17,6 +17,7 @@ const apiEndpoints = {
|
||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||
recentGames: '/api/tss/games/recent?limit=50',
|
||||
game: (gameId) => `/api/tss/games/${encodeURIComponent(gameId)}`,
|
||||
resolve: (name) => `/api/tss/teams/resolve?name=${encodeURIComponent(name)}`,
|
||||
searchTeams: (name) => `/api/tss/teams/search?q=${encodeURIComponent(name)}&limit=10`,
|
||||
detail: (name) => `/api/tss/teams/${encodeURIComponent(name)}`,
|
||||
@@ -79,6 +80,10 @@ function parseRoute(pathname = window.location.pathname) {
|
||||
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
||||
return { page: 'player', teamName: '', uid }
|
||||
}
|
||||
if (pathname.startsWith('/games/')) {
|
||||
const gameId = decodeURIComponent(pathname.slice('/games/'.length))
|
||||
return { page: 'game', teamName: '', gameId }
|
||||
}
|
||||
if (pathname.startsWith('/teams/')) {
|
||||
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||
return { page: 'team', teamName }
|
||||
@@ -91,6 +96,10 @@ function teamPath(name) {
|
||||
return `/teams/${encodeURIComponent(name)}`
|
||||
}
|
||||
|
||||
function gamePath(gameId) {
|
||||
return `/games/${encodeURIComponent(gameId)}`
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormat.format(Number(value || 0))
|
||||
}
|
||||
@@ -100,6 +109,47 @@ function formatDate(timestamp) {
|
||||
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
||||
}
|
||||
|
||||
function gameParticipants(game) {
|
||||
const winner = game?.winning_team || ''
|
||||
const loser = game?.losing_team || ''
|
||||
|
||||
if (winner || loser) {
|
||||
return [
|
||||
winner ? { name: winner, result: 'win' } : null,
|
||||
loser ? { name: loser, result: 'loss' } : null,
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
const fallbackName = game?.team_name || ''
|
||||
if (!fallbackName) return []
|
||||
|
||||
return [
|
||||
{
|
||||
name: fallbackName,
|
||||
result: String(game?.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function ParticipantNames({ participants }) {
|
||||
if (!participants.length) {
|
||||
return <p className="truncate text-sm font-semibold text-text-soft">Participants unknown</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap gap-x-3 gap-y-1">
|
||||
{participants.map((participant) => (
|
||||
<span
|
||||
className={`truncate text-sm font-semibold ${participant.result === 'win' ? 'text-fury-violet' : 'text-text-soft'}`}
|
||||
key={`${participant.result}-${participant.name}`}
|
||||
>
|
||||
{participant.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function bestTeamName(team) {
|
||||
return team?.name || ''
|
||||
}
|
||||
@@ -360,6 +410,7 @@ function routeLabel(route) {
|
||||
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
||||
if (route.page === 'teams') return 'Team Leaderboard'
|
||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
||||
if (route.page === 'uptime') return 'Uptime'
|
||||
if (route.page === 'viewers') return 'viewers'
|
||||
if (route.page === 'privacy') return 'Privacy notice'
|
||||
@@ -376,6 +427,7 @@ function canonicalPathForRoute(route) {
|
||||
if (route.page === 'team' && route.teamName) return teamPath(route.teamName)
|
||||
if (route.page === 'teams') return '/teams'
|
||||
if (route.page === 'battle-logs') return '/battle-logs'
|
||||
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
|
||||
if (route.page === 'uptime') return '/uptime'
|
||||
if (route.page === 'viewers') return '/viewers'
|
||||
if (route.page === 'privacy') return '/privacy'
|
||||
@@ -417,6 +469,15 @@ function seoForRoute(route, profileDetail = null) {
|
||||
}
|
||||
}
|
||||
|
||||
if (route.page === 'game' && route.gameId) {
|
||||
return {
|
||||
title: `Game ${route.gameId} | Toothless' TSS Bot`,
|
||||
description: `TSS battle log details for game ${route.gameId}, including participants, map, player counts, and combat stats.`,
|
||||
robots: 'noindex, follow',
|
||||
path: canonicalPathForRoute(route),
|
||||
}
|
||||
}
|
||||
|
||||
const byPage = {
|
||||
teams: {
|
||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
||||
@@ -1463,7 +1524,7 @@ function AppContent() {
|
||||
const activeNavPath =
|
||||
route.page === 'team'
|
||||
? '/teams'
|
||||
: route.page === 'battle-logs'
|
||||
: route.page === 'battle-logs' || route.page === 'game'
|
||||
? '/battle-logs'
|
||||
: route.page === 'viewers'
|
||||
? '/viewers'
|
||||
@@ -1561,12 +1622,15 @@ function AppContent() {
|
||||
teams={teams}
|
||||
/>
|
||||
) : null}
|
||||
{route.page === 'battle-logs' ? <BattleLogsPage live={live} matches={matches} /> : null}
|
||||
{route.page === 'battle-logs' ? (
|
||||
<BattleLogsPage live={live} matches={matches} navigate={navigate} />
|
||||
) : null}
|
||||
{route.page === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
||||
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
||||
{route.page === 'docs' ? <DocsPage /> : null}
|
||||
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
||||
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||||
</section>
|
||||
<Footer navigate={navigate} />
|
||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||
@@ -2304,9 +2368,11 @@ function RecentGamesSection({ live, matches, navigate }) {
|
||||
|
||||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||
{recentMatches.map((match) => (
|
||||
<article
|
||||
className="rounded-lg border border-border bg-bg p-4 shadow-sm"
|
||||
<button
|
||||
className="rounded-lg border border-border bg-bg p-4 text-left shadow-sm transition hover:border-ring hover:bg-surface"
|
||||
key={match.session_id}
|
||||
onClick={() => navigate(gamePath(match.session_id))}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -2315,15 +2381,10 @@ function RecentGamesSection({ live, matches, navigate }) {
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-text-soft">{formatDate(match.timestamp)}</p>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-md bg-surface px-2 py-1 text-xs font-semibold text-text-soft">
|
||||
{match.result || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
|
||||
<p className="truncate font-semibold text-fury-cyan">
|
||||
{match.team_name || 'TSS team'}
|
||||
</p>
|
||||
<ParticipantNames participants={gameParticipants(match)} />
|
||||
<p className="text-text-soft">
|
||||
{formatNumber(match.player_count)} players
|
||||
</p>
|
||||
@@ -2333,7 +2394,7 @@ function RecentGamesSection({ live, matches, navigate }) {
|
||||
<span>{formatNumber(match.stats?.air_kills)} air</span>
|
||||
<span>{formatNumber(match.stats?.deaths)} deaths</span>
|
||||
</div>
|
||||
</article>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2663,7 +2724,112 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||
|
||||
<RosterTable players={players} status={profile.detail.status} />
|
||||
|
||||
<BattleResults games={games} status={profile.games.status} />
|
||||
<BattleResults games={games} navigate={navigate} status={profile.games.status} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GamePage({ gameId, navigate }) {
|
||||
const [gameState, setGameState] = useState({ status: 'loading', data: null, error: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
setGameState({ status: 'loading', data: null, error: null })
|
||||
|
||||
fetchJson(apiEndpoints.game(gameId), controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setGameState({ status: 'ready', data, error: null })
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setGameState({ status: 'error', data: null, error: error.message })
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [gameId])
|
||||
|
||||
const game = gameState.data?.game
|
||||
const participants = gameState.data?.participants || []
|
||||
const participantNames = participants.length
|
||||
? participants.map((participant) => ({
|
||||
name: participant.team_name,
|
||||
result: String(participant.result || '').toLowerCase() === 'win' ? 'win' : 'loss',
|
||||
}))
|
||||
: gameParticipants(game)
|
||||
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
<button
|
||||
className="text-sm font-semibold text-fury-cyan transition hover:text-text"
|
||||
onClick={() => navigate('/battle-logs')}
|
||||
type="button"
|
||||
>
|
||||
Back to battle logs
|
||||
</button>
|
||||
|
||||
<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>
|
||||
<h1 className="mt-1 text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||
<p className="mt-2 break-all text-sm text-text-soft">
|
||||
{game ? `${formatDate(game.timestamp)} · ${game.session_id}` : gameId}
|
||||
</p>
|
||||
|
||||
{gameState.status === 'error' ? (
|
||||
<p className="mt-4 text-sm text-danger">{gameState.error}</p>
|
||||
) : null}
|
||||
|
||||
{game ? (
|
||||
<div className="mt-6 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat label="Players" value={formatNumber(game.player_count)} />
|
||||
<Stat label="Ground" value={formatNumber(game.stats?.ground_kills)} />
|
||||
<Stat label="Air" value={formatNumber(game.stats?.air_kills)} />
|
||||
<Stat label="Deaths" value={formatNumber(game.stats?.deaths)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{participants.map((participant) => {
|
||||
const won = String(participant.result || '').toLowerCase() === 'win'
|
||||
return (
|
||||
<button
|
||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(5,auto)] md:items-center"
|
||||
key={participant.team_name}
|
||||
onClick={() => navigate(teamPath(participant.team_name))}
|
||||
type="button"
|
||||
>
|
||||
<p className={`truncate font-semibold ${won ? 'text-fury-violet' : 'text-text-soft'}`}>
|
||||
{participant.team_name}
|
||||
</p>
|
||||
<p className="text-sm">{formatNumber(participant.player_count)} players</p>
|
||||
<p className="text-sm">{formatNumber(participant.stats?.ground_kills)} ground</p>
|
||||
<p className="text-sm">{formatNumber(participant.stats?.air_kills)} air</p>
|
||||
<p className="text-sm">{formatNumber(participant.stats?.assists)} assists</p>
|
||||
<p className="text-sm">{formatNumber(participant.stats?.deaths)} deaths</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{!participants.length ? (
|
||||
<p className="px-5 py-10 text-sm text-text-soft">
|
||||
{gameState.status === 'loading' ? 'Loading game' : 'No participants returned'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2708,7 +2874,7 @@ function RosterTable({ players, status }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BattleResults({ games, status }) {
|
||||
function BattleResults({ games, navigate, status }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
<div className="border-b border-surface px-5 py-4">
|
||||
@@ -2717,9 +2883,11 @@ function BattleResults({ games, status }) {
|
||||
</div>
|
||||
<div className="max-h-[560px] overflow-auto">
|
||||
{games.map((game) => (
|
||||
<div
|
||||
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_auto_repeat(5,auto)] md:items-center"
|
||||
<button
|
||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_repeat(5,auto)] md:items-center"
|
||||
key={game.session_id}
|
||||
onClick={() => navigate(gamePath(game.session_id))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{game.map_name || 'Unknown map'}</p>
|
||||
@@ -2727,20 +2895,13 @@ function BattleResults({ games, status }) {
|
||||
{formatDate(game.timestamp)} · {game.session_id}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={`rounded-md px-3 py-1 text-sm font-semibold ${String(game.result).toLowerCase() === 'win'
|
||||
? 'bg-surface text-fury-cyan'
|
||||
: 'bg-fury-ice text-fury-violet'
|
||||
}`}
|
||||
>
|
||||
{game.result || 'Unknown'}
|
||||
</p>
|
||||
<ParticipantNames participants={gameParticipants(game)} />
|
||||
<p className="text-sm">{formatNumber(game.player_count)} players</p>
|
||||
<p className="text-sm">{formatNumber(game.stats?.ground_kills)} ground</p>
|
||||
<p className="text-sm">{formatNumber(game.stats?.air_kills)} air</p>
|
||||
<p className="text-sm">{formatNumber(game.stats?.assists)} assists</p>
|
||||
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!games.length ? (
|
||||
<p className="px-5 py-10 text-sm text-text-soft">
|
||||
@@ -2752,7 +2913,7 @@ function BattleResults({ games, status }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BattleLogsPage({ live, matches }) {
|
||||
function BattleLogsPage({ live, matches, navigate }) {
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
<div>
|
||||
@@ -2764,9 +2925,11 @@ function BattleLogsPage({ live, matches }) {
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
{matches.map((match) => (
|
||||
<div
|
||||
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_1fr_auto_repeat(4,auto)] md:items-center"
|
||||
<button
|
||||
className="grid w-full gap-4 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_minmax(10rem,0.8fr)_repeat(4,auto)] md:items-center"
|
||||
key={match.session_id}
|
||||
onClick={() => navigate(gamePath(match.session_id))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
|
||||
@@ -2774,24 +2937,12 @@ function BattleLogsPage({ live, matches }) {
|
||||
{formatDate(match.timestamp)} · {match.session_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-fury-cyan">
|
||||
{match.team_name || 'TSS team'}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
className={`w-fit rounded-md px-3 py-1 text-sm font-semibold ${String(match.result).toLowerCase() === 'win'
|
||||
? 'bg-surface text-fury-cyan'
|
||||
: 'bg-fury-ice text-fury-violet'
|
||||
}`}
|
||||
>
|
||||
{match.result || 'Unknown'}
|
||||
</p>
|
||||
<ParticipantNames participants={gameParticipants(match)} />
|
||||
<p className="text-sm">{formatNumber(match.player_count)} players</p>
|
||||
<p className="text-sm">{formatNumber(match.stats?.ground_kills)} ground</p>
|
||||
<p className="text-sm">{formatNumber(match.stats?.air_kills)} air</p>
|
||||
<p className="text-sm">{formatNumber(match.stats?.deaths)} deaths</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!matches.length ? (
|
||||
|
||||
@@ -1581,6 +1581,11 @@ function allowedApiTarget(req) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(pathname)) {
|
||||
if ([...params.keys()].length) return null
|
||||
return url
|
||||
}
|
||||
|
||||
if (pathname === '/api/tss/teams/resolve') {
|
||||
const keys = [...params.keys()]
|
||||
const name = params.get('name') || ''
|
||||
|
||||
@@ -26,6 +26,10 @@ function isAllowedApiUrl(req) {
|
||||
return keys.every((key) => key === 'limit') && Number.isInteger(limit) && limit >= 1 && limit <= 100
|
||||
}
|
||||
|
||||
if (/^\/api\/tss\/games\/[A-Za-z0-9_-]{1,96}$/.test(url.pathname)) {
|
||||
return [...params.keys()].length === 0
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tss/teams/resolve') {
|
||||
const keys = [...params.keys()]
|
||||
const name = params.get('name') || ''
|
||||
|
||||
Reference in New Issue
Block a user