ai generated solutions to our ai generated problems
This commit is contained in:
@@ -15,11 +15,13 @@ It currently exposes:
|
|||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
- `GET /api/tss/leaderboard/teams?limit=100`
|
- `GET /api/tss/leaderboard/teams?limit=100`
|
||||||
|
- `GET /api/tss/leaderboard/players?limit=100`
|
||||||
- `GET /api/tss/teams/resolve?name=...`
|
- `GET /api/tss/teams/resolve?name=...`
|
||||||
- `GET /api/tss/teams/search?q=...&limit=10`
|
- `GET /api/tss/teams/search?q=...&limit=10`
|
||||||
- `GET /api/tss/teams/:team`
|
- `GET /api/tss/teams/:team`
|
||||||
- `GET /api/tss/teams/:team/history`
|
- `GET /api/tss/teams/:team/history`
|
||||||
- `GET /api/tss/teams/:team/games`
|
- `GET /api/tss/teams/:team/games`
|
||||||
|
- `GET /api/tss/player/:uid`
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ struct LeaderboardResponse {
|
|||||||
teams: Vec<TeamLeaderboardRow>,
|
teams: Vec<TeamLeaderboardRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlayerLeaderboardResponse {
|
||||||
|
players: Vec<PlayerLeaderboardRow>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct RecentGamesResponse {
|
struct RecentGamesResponse {
|
||||||
matches: Vec<GameRow>,
|
matches: Vec<GameRow>,
|
||||||
@@ -138,6 +143,26 @@ struct TeamLeaderboardRow {
|
|||||||
total_kills: i64,
|
total_kills: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlayerLeaderboardRow {
|
||||||
|
uid: String,
|
||||||
|
nick: Option<String>,
|
||||||
|
total_battles: i64,
|
||||||
|
wins: i64,
|
||||||
|
losses: i64,
|
||||||
|
win_rate: f64,
|
||||||
|
ground_kills: i64,
|
||||||
|
air_kills: i64,
|
||||||
|
total_kills: i64,
|
||||||
|
assists: i64,
|
||||||
|
captures: i64,
|
||||||
|
deaths: i64,
|
||||||
|
score: i64,
|
||||||
|
kdr: f64,
|
||||||
|
teams_seen: i64,
|
||||||
|
last_seen: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct TeamDetail {
|
struct TeamDetail {
|
||||||
team_id: i64,
|
team_id: i64,
|
||||||
@@ -323,6 +348,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.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/leaderboard/players", get(player_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/games/{session_id}", get(game_detail))
|
||||||
.route("/api/tss/teams/resolve", get(resolve_team))
|
.route("/api/tss/teams/resolve", get(resolve_team))
|
||||||
@@ -413,6 +439,16 @@ async fn leaderboard(
|
|||||||
Ok(Json(LeaderboardResponse { teams }))
|
Ok(Json(LeaderboardResponse { teams }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn player_leaderboard(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Query(query): Query<LimitQuery>,
|
||||||
|
) -> ApiResult<PlayerLeaderboardResponse> {
|
||||||
|
let limit = i64::from(query.limit.unwrap_or(100).clamp(1, 100));
|
||||||
|
let battles_conn = open_db(&state.battles_db)?;
|
||||||
|
let players = player_leaderboard_rows(&battles_conn, limit)?;
|
||||||
|
Ok(Json(PlayerLeaderboardResponse { players }))
|
||||||
|
}
|
||||||
|
|
||||||
fn leaderboard_teams(state: &AppState, limit: usize) -> Result<Vec<TeamRecord>, ApiError> {
|
fn leaderboard_teams(state: &AppState, limit: usize) -> Result<Vec<TeamRecord>, ApiError> {
|
||||||
let teams_conn = open_db(&state.teams_db)?;
|
let teams_conn = open_db(&state.teams_db)?;
|
||||||
// Deduplicate teams by name across tournaments — pick the highest team_id
|
// Deduplicate teams by name across tournaments — pick the highest team_id
|
||||||
@@ -951,6 +987,100 @@ fn player_summaries_for(
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn player_leaderboard_rows(
|
||||||
|
conn: &Connection,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<PlayerLeaderboardRow>, ApiError> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare(
|
||||||
|
"WITH per_session AS (
|
||||||
|
SELECT
|
||||||
|
UID,
|
||||||
|
session_id,
|
||||||
|
MAX(victor_bool) AS victor_bool,
|
||||||
|
MAX(ground_kills) AS ground_kills,
|
||||||
|
MAX(air_kills) AS air_kills,
|
||||||
|
MAX(assists) AS assists,
|
||||||
|
MAX(captures) AS captures,
|
||||||
|
MAX(deaths) AS deaths,
|
||||||
|
MAX(score) AS score,
|
||||||
|
MAX(endtime_unix) AS endtime_unix,
|
||||||
|
MAX(team_name) AS team_name
|
||||||
|
FROM player_games_hist
|
||||||
|
WHERE UID IS NOT NULL AND UID != ''
|
||||||
|
GROUP BY UID, session_id
|
||||||
|
),
|
||||||
|
latest_names AS (
|
||||||
|
SELECT UID, nick
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
UID,
|
||||||
|
nick,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY UID
|
||||||
|
ORDER BY endtime_unix DESC, nick COLLATE NOCASE ASC
|
||||||
|
) AS rn
|
||||||
|
FROM player_games_hist
|
||||||
|
WHERE nick IS NOT NULL AND nick != ''
|
||||||
|
)
|
||||||
|
WHERE rn = 1
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.UID,
|
||||||
|
n.nick,
|
||||||
|
COUNT(*) AS battles,
|
||||||
|
COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'WIN' THEN 1 ELSE 0 END), 0) AS wins,
|
||||||
|
COALESCE(SUM(CASE WHEN UPPER(p.victor_bool) = 'LOSS' THEN 1 ELSE 0 END), 0) AS losses,
|
||||||
|
COALESCE(SUM(p.ground_kills), 0) AS ground_kills,
|
||||||
|
COALESCE(SUM(p.air_kills), 0) AS air_kills,
|
||||||
|
COALESCE(SUM(p.assists), 0) AS assists,
|
||||||
|
COALESCE(SUM(p.captures), 0) AS captures,
|
||||||
|
COALESCE(SUM(p.deaths), 0) AS deaths,
|
||||||
|
COALESCE(SUM(p.score), 0) AS score,
|
||||||
|
COUNT(DISTINCT p.team_name) AS teams_seen,
|
||||||
|
MAX(p.endtime_unix) AS last_seen
|
||||||
|
FROM per_session p
|
||||||
|
LEFT JOIN latest_names n ON n.UID = p.UID
|
||||||
|
GROUP BY p.UID
|
||||||
|
ORDER BY score DESC, (ground_kills + air_kills) DESC, battles DESC, p.UID ASC
|
||||||
|
LIMIT ?1",
|
||||||
|
)
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
let players = stmt
|
||||||
|
.query_map(params![limit], |row| {
|
||||||
|
let ground: i64 = row.get(5)?;
|
||||||
|
let air: i64 = row.get(6)?;
|
||||||
|
let battles: i64 = row.get(2)?;
|
||||||
|
let wins: i64 = row.get(3)?;
|
||||||
|
let deaths: i64 = row.get(9)?;
|
||||||
|
let total_kills = ground + air;
|
||||||
|
Ok(PlayerLeaderboardRow {
|
||||||
|
uid: row.get(0)?,
|
||||||
|
nick: row.get(1)?,
|
||||||
|
total_battles: battles,
|
||||||
|
wins,
|
||||||
|
losses: row.get(4)?,
|
||||||
|
win_rate: percent(wins, battles),
|
||||||
|
ground_kills: ground,
|
||||||
|
air_kills: air,
|
||||||
|
total_kills,
|
||||||
|
assists: row.get(7)?,
|
||||||
|
captures: row.get(8)?,
|
||||||
|
deaths,
|
||||||
|
score: row.get(10)?,
|
||||||
|
kdr: ratio(total_kills, deaths),
|
||||||
|
teams_seen: row.get(11)?,
|
||||||
|
last_seen: row.get(12)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(db_error)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()
|
||||||
|
.map_err(db_error)?;
|
||||||
|
|
||||||
|
Ok(players)
|
||||||
|
}
|
||||||
|
|
||||||
fn player_search(conn: &Connection, query: &str, limit: i64) -> Result<Vec<PlayerRef>, ApiError> {
|
fn player_search(conn: &Connection, query: &str, limit: i64) -> Result<Vec<PlayerRef>, ApiError> {
|
||||||
let like = format!("%{}%", escape_like(query));
|
let like = format!("%{}%", escape_like(query));
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
|
|||||||
+131
-1
@@ -15,6 +15,7 @@ const apiEndpoints = {
|
|||||||
viewerEvent: '/api/viewers/event',
|
viewerEvent: '/api/viewers/event',
|
||||||
viewerDelete: '/api/viewers/delete',
|
viewerDelete: '/api/viewers/delete',
|
||||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||||
|
players: '/api/tss/leaderboard/players?limit=100',
|
||||||
homeTeams: '/api/tss/leaderboard/teams?limit=4',
|
homeTeams: '/api/tss/leaderboard/teams?limit=4',
|
||||||
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',
|
||||||
@@ -29,6 +30,7 @@ const apiEndpoints = {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/', label: 'Home' },
|
{ path: '/', label: 'Home' },
|
||||||
{ path: '/teams', label: 'Team Leaderboard' },
|
{ path: '/teams', label: 'Team Leaderboard' },
|
||||||
|
{ path: '/players', label: 'Player Leaderboard' },
|
||||||
{ path: '/battle-logs', label: 'Battle Logs' },
|
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||||
{ path: '/viewers', label: 'Viewers' },
|
{ path: '/viewers', label: 'Viewers' },
|
||||||
{ path: '/docs', label: 'Setup' },
|
{ path: '/docs', label: 'Setup' },
|
||||||
@@ -74,6 +76,7 @@ async function fetchJson(path, signal) {
|
|||||||
function parseRoute(pathname = window.location.pathname) {
|
function parseRoute(pathname = window.location.pathname) {
|
||||||
if (pathname === '/') return { page: 'home', teamName: '' }
|
if (pathname === '/') return { page: 'home', teamName: '' }
|
||||||
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
if (pathname === '/teams') return { page: 'teams', teamName: '' }
|
||||||
|
if (pathname === '/players') return { page: 'players', teamName: '' }
|
||||||
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
if (pathname === '/uptime') return { page: 'uptime', teamName: '' }
|
||||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||||
@@ -102,6 +105,10 @@ function gamePath(gameId) {
|
|||||||
return `/games/${encodeURIComponent(gameId)}`
|
return `/games/${encodeURIComponent(gameId)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function playerPath(uid) {
|
||||||
|
return `/players/${encodeURIComponent(uid)}`
|
||||||
|
}
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return numberFormat.format(Number(value || 0))
|
return numberFormat.format(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -420,6 +427,7 @@ function deviceType() {
|
|||||||
function routeLabel(route) {
|
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 === 'players') return 'Player 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 === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
||||||
if (route.page === 'uptime') return 'Uptime'
|
if (route.page === 'uptime') return 'Uptime'
|
||||||
@@ -437,6 +445,7 @@ function currentPublicOrigin() {
|
|||||||
function canonicalPathForRoute(route) {
|
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 === 'players') return '/players'
|
||||||
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 === 'game' && route.gameId) return gamePath(route.gameId)
|
||||||
if (route.page === 'uptime') return '/uptime'
|
if (route.page === 'uptime') return '/uptime'
|
||||||
@@ -496,6 +505,12 @@ function seoForRoute(route, profileDetail = null) {
|
|||||||
robots: 'index, follow',
|
robots: 'index, follow',
|
||||||
path: '/teams',
|
path: '/teams',
|
||||||
},
|
},
|
||||||
|
players: {
|
||||||
|
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
||||||
|
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
path: '/players',
|
||||||
|
},
|
||||||
'battle-logs': {
|
'battle-logs': {
|
||||||
title: "TSS Battle Logs | Toothless' TSS Bot",
|
title: "TSS Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
||||||
@@ -912,6 +927,7 @@ function App() {
|
|||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [route, setRoute] = useState(() => parseRoute())
|
const [route, setRoute] = useState(() => parseRoute())
|
||||||
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
const [leaderboard, setLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
|
const [playerLeaderboard, setPlayerLeaderboard] = useState({ status: 'idle', data: null, error: null })
|
||||||
const [homeTeams, setHomeTeams] = useState({ status: 'idle', data: null, error: null })
|
const [homeTeams, setHomeTeams] = useState({ status: 'idle', data: null, error: null })
|
||||||
const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 })
|
const [live, setLive] = useState({ status: 'idle', data: null, error: null, updatedAt: 0 })
|
||||||
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
const [uptime, setUptime] = useState({ status: 'idle', checks: [], history: [], updatedAt: null })
|
||||||
@@ -935,6 +951,10 @@ function AppContent() {
|
|||||||
() => leaderboard.data?.teams || leaderboard.data?.squadrons || [],
|
() => leaderboard.data?.teams || leaderboard.data?.squadrons || [],
|
||||||
[leaderboard.data],
|
[leaderboard.data],
|
||||||
)
|
)
|
||||||
|
const players = useMemo(
|
||||||
|
() => playerLeaderboard.data?.players || [],
|
||||||
|
[playerLeaderboard.data],
|
||||||
|
)
|
||||||
const teamsToWatch = useMemo(
|
const teamsToWatch = useMemo(
|
||||||
() => homeTeams.data?.teams || homeTeams.data?.squadrons || teams.slice(0, 4),
|
() => homeTeams.data?.teams || homeTeams.data?.squadrons || teams.slice(0, 4),
|
||||||
[homeTeams.data, teams],
|
[homeTeams.data, teams],
|
||||||
@@ -1187,6 +1207,24 @@ function AppContent() {
|
|||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [leaderboard.status, route.page])
|
}, [leaderboard.status, route.page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.page !== 'players') return
|
||||||
|
if (playerLeaderboard.status === 'ready' || playerLeaderboard.status === 'loading') return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
setPlayerLeaderboard({ status: 'loading', data: null, error: null })
|
||||||
|
|
||||||
|
fetchJson(apiEndpoints.players, controller.signal)
|
||||||
|
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPlayerLeaderboard({ status: 'error', data: null, error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [playerLeaderboard.status, route.page])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (route.page !== 'home') return
|
if (route.page !== 'home') return
|
||||||
if (homeTeams.status === 'ready' || homeTeams.status === 'loading') return
|
if (homeTeams.status === 'ready' || homeTeams.status === 'loading') return
|
||||||
@@ -1558,6 +1596,8 @@ function AppContent() {
|
|||||||
const activeNavPath =
|
const activeNavPath =
|
||||||
route.page === 'team'
|
route.page === 'team'
|
||||||
? '/teams'
|
? '/teams'
|
||||||
|
: route.page === 'player' || route.page === 'players'
|
||||||
|
? '/players'
|
||||||
: route.page === 'battle-logs' || route.page === 'game'
|
: route.page === 'battle-logs' || route.page === 'game'
|
||||||
? '/battle-logs'
|
? '/battle-logs'
|
||||||
: route.page === 'viewers'
|
: route.page === 'viewers'
|
||||||
@@ -1586,6 +1626,26 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}, [route.page])
|
}, [route.page])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.page !== 'players') return
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
fetchJson(apiEndpoints.players, controller.signal)
|
||||||
|
.then((data) => setPlayerLeaderboard({ status: 'ready', data, error: null }))
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setPlayerLeaderboard((current) => ({ ...current, error: error.message }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
controller.abort()
|
||||||
|
}
|
||||||
|
}, [route.page])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-bg text-text">
|
<main className="min-h-screen bg-bg text-text">
|
||||||
<header
|
<header
|
||||||
@@ -1654,6 +1714,9 @@ function AppContent() {
|
|||||||
{route.page === 'teams' ? (
|
{route.page === 'teams' ? (
|
||||||
<TeamsPage leaderboard={leaderboard} navigate={navigate} teams={teams} />
|
<TeamsPage leaderboard={leaderboard} navigate={navigate} teams={teams} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{route.page === 'players' ? (
|
||||||
|
<PlayersPage leaderboard={playerLeaderboard} navigate={navigate} players={players} />
|
||||||
|
) : null}
|
||||||
{route.page === 'team' ? (
|
{route.page === 'team' ? (
|
||||||
<TeamProfilePage
|
<TeamProfilePage
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
@@ -2723,6 +2786,73 @@ function TeamsPage({ leaderboard, navigate, teams }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PlayersPage({ leaderboard, navigate, players }) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-6 pt-24 sm:pt-28">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Player Leaderboard</h1>
|
||||||
|
<p className="mt-2 text-sm text-text-soft">
|
||||||
|
{leaderboard.status === 'loading'
|
||||||
|
? 'Loading player leaderboard'
|
||||||
|
: leaderboard.error || `${players.length} players returned`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[900px]">
|
||||||
|
{players.length ? (
|
||||||
|
<div className="grid grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||||
|
<p>Rank</p>
|
||||||
|
<p>Player</p>
|
||||||
|
<p className="text-right">Score</p>
|
||||||
|
<p className="text-right">Battles</p>
|
||||||
|
<p className="text-right">Kills</p>
|
||||||
|
<p className="text-right">Assists</p>
|
||||||
|
<p className="text-right">WR</p>
|
||||||
|
<p className="text-right">KDR</p>
|
||||||
|
<p className="text-right">Teams</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{players.map((player, index) => (
|
||||||
|
<button
|
||||||
|
className="grid w-full grid-cols-[4rem_minmax(220px,1fr)_repeat(7,92px)] gap-3 border-b border-surface px-5 py-4 text-left text-sm transition hover:bg-surface"
|
||||||
|
key={player.uid}
|
||||||
|
onClick={() => navigate(playerPath(player.uid))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-fury-cyan">#{index + 1}</p>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-base font-semibold">{player.nick || player.uid}</p>
|
||||||
|
<p className="truncate text-xs text-text-soft">
|
||||||
|
{player.uid} · last seen {formatDate(player.last_seen)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-right font-semibold">{formatNumber(player.score)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.total_battles)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.total_kills)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.assists)}</p>
|
||||||
|
<p className="text-right">{Number(player.win_rate || 0).toFixed(1)}%</p>
|
||||||
|
<p className="text-right">{Number(player.kdr || 0).toFixed(2)}</p>
|
||||||
|
<p className="text-right">{formatNumber(player.teams_seen)}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!players.length ? (
|
||||||
|
<p className="px-5 py-10 text-sm text-text-soft">
|
||||||
|
{leaderboard.status === 'loading'
|
||||||
|
? 'Loading player leaderboard'
|
||||||
|
: leaderboard.error || 'No players returned'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
function TeamProfilePage({ navigate, profile, requestedTeam, teams }) {
|
||||||
const detail = profile.detail.data
|
const detail = profile.detail.data
|
||||||
const summary = detail?.team_summary || detail?.squadron_summary
|
const summary = detail?.team_summary || detail?.squadron_summary
|
||||||
@@ -2872,7 +3002,7 @@ function GamePage({ gameId, navigate }) {
|
|||||||
<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 grid-cols-[minmax(220px,1fr)_80px_repeat(5,88px)] 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(playerPath(player.uid))}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 pl-8 sm:pl-12">
|
<div className="min-w-0 pl-8 sm:pl-12">
|
||||||
|
|||||||
+15
@@ -1572,6 +1572,15 @@ function allowedApiTarget(req) {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/tss/leaderboard/players') {
|
||||||
|
const keys = [...params.keys()]
|
||||||
|
const limit = Number(params.get('limit') || 100)
|
||||||
|
if (keys.some((key) => key !== 'limit') || !Number.isInteger(limit) || limit < 1 || limit > 100) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/tss/games/recent') {
|
if (pathname === '/api/tss/games/recent') {
|
||||||
const keys = [...params.keys()]
|
const keys = [...params.keys()]
|
||||||
const limit = Number(params.get('limit') || 50)
|
const limit = Number(params.get('limit') || 50)
|
||||||
@@ -1805,6 +1814,12 @@ function routeSeo(pathname) {
|
|||||||
robots: 'index, follow',
|
robots: 'index, follow',
|
||||||
path: '/teams',
|
path: '/teams',
|
||||||
},
|
},
|
||||||
|
'/players': {
|
||||||
|
title: "TSS Player Leaderboard | Toothless' TSS Bot",
|
||||||
|
description: 'Browse the TSS player leaderboard, compare War Thunder player score, kills, win rate, KDR, and battle activity.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
path: '/players',
|
||||||
|
},
|
||||||
'/battle-logs': {
|
'/battle-logs': {
|
||||||
title: "TSS Battle Logs | Toothless' TSS Bot",
|
title: "TSS Battle Logs | Toothless' TSS Bot",
|
||||||
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
description: 'Read recent TSS battle logs with team names, map history, player counts, battle times, and War Thunder match context.',
|
||||||
|
|||||||
Reference in New Issue
Block a user