ai generated solutions to our ai generated problems
This commit is contained in:
@@ -201,6 +201,12 @@ struct GamesResponse {
|
|||||||
games: Vec<GameRow>,
|
games: Vec<GameRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GameResponse {
|
||||||
|
game: GameRow,
|
||||||
|
participants: Vec<GameParticipant>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GameRow {
|
struct GameRow {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -230,6 +236,14 @@ struct GameStats {
|
|||||||
team_kills_stat: i64,
|
team_kills_stat: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GameParticipant {
|
||||||
|
team_name: String,
|
||||||
|
result: String,
|
||||||
|
player_count: i64,
|
||||||
|
stats: GameStats,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct PlayerSearchResponse {
|
struct PlayerSearchResponse {
|
||||||
players: Vec<PlayerRef>,
|
players: Vec<PlayerRef>,
|
||||||
@@ -302,6 +316,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/api/tss/leaderboard/teams", get(leaderboard))
|
.route("/api/tss/leaderboard/teams", get(leaderboard))
|
||||||
.route("/api/tss/games/recent", get(recent_games))
|
.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/resolve", get(resolve_team))
|
||||||
.route("/api/tss/teams/search", get(search_teams))
|
.route("/api/tss/teams/search", get(search_teams))
|
||||||
.route("/api/tss/teams/{team}", get(team_detail))
|
.route("/api/tss/teams/{team}", get(team_detail))
|
||||||
@@ -494,6 +509,18 @@ async fn recent_games(
|
|||||||
Ok(Json(RecentGamesResponse { matches }))
|
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(
|
async fn resolve_team(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Query(query): Query<ResolveQuery>,
|
Query(query): Query<ResolveQuery>,
|
||||||
@@ -1259,6 +1286,121 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
|||||||
Ok(rows)
|
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> {
|
fn validate_team_name(name: &str) -> Result<&str, ApiError> {
|
||||||
let trimmed = name.trim();
|
let trimmed = name.trim();
|
||||||
if trimmed.len() < 2 || trimmed.len() > MAX_TEAM_NAME_LENGTH {
|
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())
|
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> {
|
fn decode_path_team(value: &str) -> Result<String, ApiError> {
|
||||||
let decoded = urlencoding::decode(value)
|
let decoded = urlencoding::decode(value)
|
||||||
.map_err(|_| ApiError::bad_request("Invalid team name"))?
|
.map_err(|_| ApiError::bad_request("Invalid team name"))?
|
||||||
|
|||||||
+192
-41
@@ -17,6 +17,7 @@ const apiEndpoints = {
|
|||||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||||
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)}`,
|
||||||
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)}`,
|
||||||
@@ -79,6 +80,10 @@ function parseRoute(pathname = window.location.pathname) {
|
|||||||
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
||||||
return { page: 'player', teamName: '', uid }
|
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/')) {
|
if (pathname.startsWith('/teams/')) {
|
||||||
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
const teamName = decodeURIComponent(pathname.slice('/teams/'.length))
|
||||||
return { page: 'team', teamName }
|
return { page: 'team', teamName }
|
||||||
@@ -91,6 +96,10 @@ function teamPath(name) {
|
|||||||
return `/teams/${encodeURIComponent(name)}`
|
return `/teams/${encodeURIComponent(name)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gamePath(gameId) {
|
||||||
|
return `/games/${encodeURIComponent(gameId)}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return numberFormat.format(Number(value || 0))
|
return numberFormat.format(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -100,6 +109,47 @@ function formatDate(timestamp) {
|
|||||||
return dateFormat.format(new Date(Number(timestamp) * 1000))
|
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) {
|
function bestTeamName(team) {
|
||||||
return team?.name || ''
|
return team?.name || ''
|
||||||
}
|
}
|
||||||
@@ -360,6 +410,7 @@ function routeLabel(route) {
|
|||||||
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
if (route.page === 'team' && route.teamName) return `Team: ${route.teamName}`
|
||||||
if (route.page === 'teams') return 'Team Leaderboard'
|
if (route.page === 'teams') return 'Team Leaderboard'
|
||||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
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 === 'uptime') return 'Uptime'
|
||||||
if (route.page === 'viewers') return 'viewers'
|
if (route.page === 'viewers') return 'viewers'
|
||||||
if (route.page === 'privacy') return 'Privacy notice'
|
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 === 'team' && route.teamName) return teamPath(route.teamName)
|
||||||
if (route.page === 'teams') return '/teams'
|
if (route.page === 'teams') return '/teams'
|
||||||
if (route.page === 'battle-logs') return '/battle-logs'
|
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 === 'uptime') return '/uptime'
|
||||||
if (route.page === 'viewers') return '/viewers'
|
if (route.page === 'viewers') return '/viewers'
|
||||||
if (route.page === 'privacy') return '/privacy'
|
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 = {
|
const byPage = {
|
||||||
teams: {
|
teams: {
|
||||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
||||||
@@ -1463,7 +1524,7 @@ function AppContent() {
|
|||||||
const activeNavPath =
|
const activeNavPath =
|
||||||
route.page === 'team'
|
route.page === 'team'
|
||||||
? '/teams'
|
? '/teams'
|
||||||
: route.page === 'battle-logs'
|
: route.page === 'battle-logs' || route.page === 'game'
|
||||||
? '/battle-logs'
|
? '/battle-logs'
|
||||||
: route.page === 'viewers'
|
: route.page === 'viewers'
|
||||||
? '/viewers'
|
? '/viewers'
|
||||||
@@ -1561,12 +1622,15 @@ function AppContent() {
|
|||||||
teams={teams}
|
teams={teams}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 === 'uptime' ? <UptimePage uptime={uptime} /> : null}
|
||||||
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
{route.page === 'viewers' ? <ViewersPage viewers={viewers} /> : null}
|
||||||
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
{route.page === 'privacy' ? <PrivacyPage /> : null}
|
||||||
{route.page === 'docs' ? <DocsPage /> : null}
|
{route.page === 'docs' ? <DocsPage /> : null}
|
||||||
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
{route.page === 'player' ? <PlayerPage uid={route.uid} navigate={navigate} /> : null}
|
||||||
|
{route.page === 'game' ? <GamePage gameId={route.gameId} navigate={navigate} /> : null}
|
||||||
</section>
|
</section>
|
||||||
<Footer navigate={navigate} />
|
<Footer navigate={navigate} />
|
||||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
<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">
|
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||||||
{recentMatches.map((match) => (
|
{recentMatches.map((match) => (
|
||||||
<article
|
<button
|
||||||
className="rounded-lg border border-border bg-bg p-4 shadow-sm"
|
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}
|
key={match.session_id}
|
||||||
|
onClick={() => navigate(gamePath(match.session_id))}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -2315,15 +2381,10 @@ function RecentGamesSection({ live, matches, navigate }) {
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-text-soft">{formatDate(match.timestamp)}</p>
|
<p className="mt-1 text-xs text-text-soft">{formatDate(match.timestamp)}</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
|
<div className="mt-4 grid grid-cols-[1fr_auto] items-center gap-3 text-sm">
|
||||||
<p className="truncate font-semibold text-fury-cyan">
|
<ParticipantNames participants={gameParticipants(match)} />
|
||||||
{match.team_name || 'TSS team'}
|
|
||||||
</p>
|
|
||||||
<p className="text-text-soft">
|
<p className="text-text-soft">
|
||||||
{formatNumber(match.player_count)} players
|
{formatNumber(match.player_count)} players
|
||||||
</p>
|
</p>
|
||||||
@@ -2333,7 +2394,7 @@ function RecentGamesSection({ live, matches, navigate }) {
|
|||||||
<span>{formatNumber(match.stats?.air_kills)} air</span>
|
<span>{formatNumber(match.stats?.air_kills)} air</span>
|
||||||
<span>{formatNumber(match.stats?.deaths)} deaths</span>
|
<span>{formatNumber(match.stats?.deaths)} deaths</span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2663,7 +2724,112 @@ function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
|||||||
|
|
||||||
<RosterTable players={players} status={profile.detail.status} />
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2708,7 +2874,7 @@ function RosterTable({ players, status }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BattleResults({ games, status }) {
|
function BattleResults({ games, navigate, status }) {
|
||||||
return (
|
return (
|
||||||
<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">
|
<div className="border-b border-surface px-5 py-4">
|
||||||
@@ -2717,9 +2883,11 @@ function BattleResults({ games, status }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-[560px] overflow-auto">
|
<div className="max-h-[560px] overflow-auto">
|
||||||
{games.map((game) => (
|
{games.map((game) => (
|
||||||
<div
|
<button
|
||||||
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_auto_repeat(5,auto)] md:items-center"
|
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}
|
key={game.session_id}
|
||||||
|
onClick={() => navigate(gamePath(game.session_id))}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold">{game.map_name || 'Unknown map'}</p>
|
<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}
|
{formatDate(game.timestamp)} · {game.session_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<ParticipantNames participants={gameParticipants(game)} />
|
||||||
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>
|
|
||||||
<p className="text-sm">{formatNumber(game.player_count)} players</p>
|
<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?.ground_kills)} ground</p>
|
||||||
<p className="text-sm">{formatNumber(game.stats?.air_kills)} air</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?.assists)} assists</p>
|
||||||
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
|
<p className="text-sm">{formatNumber(game.stats?.deaths)} deaths</p>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
{!games.length ? (
|
{!games.length ? (
|
||||||
<p className="px-5 py-10 text-sm text-text-soft">
|
<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 (
|
return (
|
||||||
<section className="space-y-6 pt-24 sm:pt-28">
|
<section className="space-y-6 pt-24 sm:pt-28">
|
||||||
<div>
|
<div>
|
||||||
@@ -2764,9 +2925,11 @@ function BattleLogsPage({ live, matches }) {
|
|||||||
|
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
{matches.map((match) => (
|
{matches.map((match) => (
|
||||||
<div
|
<button
|
||||||
className="grid gap-4 border-b border-surface px-5 py-4 md:grid-cols-[1fr_1fr_auto_repeat(4,auto)] md:items-center"
|
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}
|
key={match.session_id}
|
||||||
|
onClick={() => navigate(gamePath(match.session_id))}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate font-semibold">{match.map_name || 'Unknown map'}</p>
|
<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}
|
{formatDate(match.timestamp)} · {match.session_id}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<ParticipantNames participants={gameParticipants(match)} />
|
||||||
<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>
|
|
||||||
<p className="text-sm">{formatNumber(match.player_count)} players</p>
|
<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?.ground_kills)} ground</p>
|
||||||
<p className="text-sm">{formatNumber(match.stats?.air_kills)} air</p>
|
<p className="text-sm">{formatNumber(match.stats?.air_kills)} air</p>
|
||||||
<p className="text-sm">{formatNumber(match.stats?.deaths)} deaths</p>
|
<p className="text-sm">{formatNumber(match.stats?.deaths)} deaths</p>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!matches.length ? (
|
{!matches.length ? (
|
||||||
|
|||||||
@@ -1581,6 +1581,11 @@ function allowedApiTarget(req) {
|
|||||||
return url
|
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') {
|
if (pathname === '/api/tss/teams/resolve') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const name = params.get('name') || ''
|
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
|
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') {
|
if (url.pathname === '/api/tss/teams/resolve') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const name = params.get('name') || ''
|
const name = params.get('name') || ''
|
||||||
|
|||||||
Reference in New Issue
Block a user