add stuff for tournaments
This commit is contained in:
+319
-2
@@ -9,7 +9,7 @@ use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
collections::{BTreeMap, HashMap, HashSet},
|
||||
env, fs,
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
path::{Path as FsPath, PathBuf},
|
||||
@@ -28,6 +28,7 @@ const LEADERBOARD_CACHE_TTL: Duration = Duration::from_secs(300);
|
||||
struct AppState {
|
||||
battles_db: PathBuf,
|
||||
teams_db: PathBuf,
|
||||
tournaments_db: PathBuf,
|
||||
leaderboard_cache: Mutex<Option<CachedLeaderboard>>,
|
||||
vehicle_names: HashMap<String, HashMap<String, String>>,
|
||||
vehicle_icons: HashMap<String, String>,
|
||||
@@ -239,6 +240,72 @@ struct GameResponse {
|
||||
participants: Vec<GameParticipant>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentsResponse {
|
||||
tournaments: Vec<TournamentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentSummary {
|
||||
tournament_id: i64,
|
||||
name: Option<String>,
|
||||
format: Option<String>,
|
||||
status: Option<String>,
|
||||
match_count: i64,
|
||||
team_count: i64,
|
||||
date_start: Option<i64>,
|
||||
date_end: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentDetailResponse {
|
||||
tournament_id: i64,
|
||||
name: Option<String>,
|
||||
format: Option<String>,
|
||||
status: Option<String>,
|
||||
match_count: i64,
|
||||
team_count: i64,
|
||||
date_start: Option<i64>,
|
||||
date_end: Option<i64>,
|
||||
matches: Vec<TournamentMatchRow>,
|
||||
standings: Vec<TournamentStandingRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentMatchRow {
|
||||
match_id: String,
|
||||
type_bracket: String,
|
||||
side: Option<String>,
|
||||
round: Option<i64>,
|
||||
position: Option<i64>,
|
||||
team_a_name: Option<String>,
|
||||
team_b_name: Option<String>,
|
||||
winner_name: Option<String>,
|
||||
score_a: i64,
|
||||
score_b: i64,
|
||||
status: String,
|
||||
battles: Vec<TournamentBattleRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentBattleRow {
|
||||
session_hex: String,
|
||||
position: Option<i64>,
|
||||
have_replay: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TournamentStandingRow {
|
||||
group_index: i64,
|
||||
team_name: Option<String>,
|
||||
points: i64,
|
||||
wins: i64,
|
||||
draws: i64,
|
||||
losses: i64,
|
||||
buchholz: f64,
|
||||
rank: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GameLogsResponse {
|
||||
chat_log: Vec<String>,
|
||||
@@ -260,6 +327,8 @@ struct GameRow {
|
||||
winning_team: Option<String>,
|
||||
losing_team: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tournament_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tournament_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
duration: Option<f64>,
|
||||
@@ -371,6 +440,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let state = Arc::new(AppState {
|
||||
battles_db: resolve_db_path("TSS_BATTLES_DB", "tss_battles.db"),
|
||||
teams_db: resolve_db_path("TSS_TEAMS_DB", "tss_teams.db"),
|
||||
tournaments_db: resolve_db_path("TSS_TOURNAMENTS_DB", "tss_tournaments.db"),
|
||||
leaderboard_cache: Mutex::new(None),
|
||||
vehicle_names: load_vehicle_names(&resolve_db_path(
|
||||
"VEHICLE_TRANSLATIONS_JSON",
|
||||
@@ -393,6 +463,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.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/tournaments", get(tournaments))
|
||||
.route("/api/tss/tournaments/{tournament_id}", get(tournament_detail))
|
||||
.route("/api/tss/games/{session_id}", get(game_detail))
|
||||
.route("/api/tss/games/{session_id}/logs", get(game_logs))
|
||||
.route("/api/tss/teams/resolve", get(resolve_team))
|
||||
@@ -597,6 +669,38 @@ async fn recent_games(
|
||||
Ok(Json(RecentGamesResponse { matches }))
|
||||
}
|
||||
|
||||
async fn tournaments(State(state): State<Arc<AppState>>) -> ApiResult<TournamentsResponse> {
|
||||
let conn = open_db(&state.tournaments_db)?;
|
||||
let tournaments = tournaments_list(&conn)?;
|
||||
Ok(Json(TournamentsResponse { tournaments }))
|
||||
}
|
||||
|
||||
async fn tournament_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(tournament_id): Path<String>,
|
||||
) -> ApiResult<TournamentDetailResponse> {
|
||||
let tid = validate_tournament_id(&tournament_id)?;
|
||||
let conn = open_db(&state.tournaments_db)?;
|
||||
let summary = tournament_summary_for(&conn, tid)?
|
||||
.ok_or_else(|| ApiError::not_found("Tournament not found"))?;
|
||||
let standings = tournament_standings_for(&conn, tid)?;
|
||||
let mut matches = tournament_match_rows_for(&conn, tid)?;
|
||||
attach_battles(&conn, &state.battles_db, tid, &mut matches)?;
|
||||
|
||||
Ok(Json(TournamentDetailResponse {
|
||||
tournament_id: summary.tournament_id,
|
||||
name: summary.name,
|
||||
format: summary.format,
|
||||
status: summary.status,
|
||||
match_count: summary.match_count,
|
||||
team_count: summary.team_count,
|
||||
date_start: summary.date_start,
|
||||
date_end: summary.date_end,
|
||||
matches,
|
||||
standings,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn game_detail(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
@@ -1449,6 +1553,7 @@ fn games_for(conn: &Connection, team_name: &str) -> Result<Vec<GameRow>, ApiErro
|
||||
player_count: row.get(8)?,
|
||||
winning_team: row.get(18)?,
|
||||
losing_team: row.get(19)?,
|
||||
tournament_id: None,
|
||||
tournament_name: row.get(4)?,
|
||||
duration: row.get(5)?,
|
||||
draw: draw_int != 0,
|
||||
@@ -1541,6 +1646,7 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
||||
player_count: row.get(7)?,
|
||||
winning_team: row.get(8)?,
|
||||
losing_team: row.get(9)?,
|
||||
tournament_id: None,
|
||||
tournament_name: row.get(4)?,
|
||||
duration: row.get(5)?,
|
||||
draw: draw_int != 0,
|
||||
@@ -1564,6 +1670,207 @@ fn recent_games_for(conn: &Connection, limit: i64) -> Result<Vec<GameRow>, ApiEr
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn tournaments_list(conn: &Connection) -> Result<Vec<TournamentSummary>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT tournament_id, name, format, status, match_count, team_count,
|
||||
date_start, date_end
|
||||
FROM tournaments
|
||||
ORDER BY COALESCE(date_end, scanned_unix, 0) DESC, tournament_id DESC
|
||||
LIMIT 500",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
let rows = stmt
|
||||
.query_map([], read_tournament_summary)
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn tournament_summary_for(
|
||||
conn: &Connection,
|
||||
tid: i64,
|
||||
) -> Result<Option<TournamentSummary>, ApiError> {
|
||||
conn.query_row(
|
||||
"SELECT tournament_id, name, format, status, match_count, team_count,
|
||||
date_start, date_end
|
||||
FROM tournaments WHERE tournament_id = ?1",
|
||||
params![tid],
|
||||
read_tournament_summary,
|
||||
)
|
||||
.optional()
|
||||
.map_err(db_error)
|
||||
}
|
||||
|
||||
fn read_tournament_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<TournamentSummary> {
|
||||
Ok(TournamentSummary {
|
||||
tournament_id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
format: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
match_count: row.get(4)?,
|
||||
team_count: row.get(5)?,
|
||||
date_start: row.get(6)?,
|
||||
date_end: row.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn tournament_match_rows_for(
|
||||
conn: &Connection,
|
||||
tid: i64,
|
||||
) -> Result<Vec<TournamentMatchRow>, ApiError> {
|
||||
// Order: winner side, then final, then loser; group/swiss after. Within a
|
||||
// side, by round then position (the schedule's round/matchNumber). NULLs last.
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT match_id, type_bracket, side, round, position,
|
||||
team_a_name, team_b_name, winner_name, score_a, score_b, status
|
||||
FROM tournament_matches
|
||||
WHERE tournament_id = ?1
|
||||
ORDER BY
|
||||
CASE side WHEN 'winner' THEN 0 WHEN 'final' THEN 1 WHEN 'loser' THEN 2
|
||||
WHEN 'group' THEN 3 WHEN 'swiss' THEN 4 ELSE 5 END,
|
||||
round IS NULL, round,
|
||||
position IS NULL, position,
|
||||
time_start, match_id",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
let rows = stmt
|
||||
.query_map(params![tid], |row| {
|
||||
Ok(TournamentMatchRow {
|
||||
match_id: row.get(0)?,
|
||||
type_bracket: row.get(1)?,
|
||||
side: row.get(2)?,
|
||||
round: row.get(3)?,
|
||||
position: row.get(4)?,
|
||||
team_a_name: row.get(5)?,
|
||||
team_b_name: row.get(6)?,
|
||||
winner_name: row.get(7)?,
|
||||
score_a: row.get(8)?,
|
||||
score_b: row.get(9)?,
|
||||
status: row.get(10)?,
|
||||
battles: Vec::new(),
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn tournament_standings_for(
|
||||
conn: &Connection,
|
||||
tid: i64,
|
||||
) -> Result<Vec<TournamentStandingRow>, ApiError> {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT group_index, team_name, points, wins, draws, losses, buchholz, rank
|
||||
FROM tournament_standings
|
||||
WHERE tournament_id = ?1
|
||||
ORDER BY group_index, rank IS NULL, rank, points DESC",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
let rows = stmt
|
||||
.query_map(params![tid], |row| {
|
||||
Ok(TournamentStandingRow {
|
||||
group_index: row.get(0)?,
|
||||
team_name: row.get(1)?,
|
||||
points: row.get(2)?,
|
||||
wins: row.get(3)?,
|
||||
draws: row.get(4)?,
|
||||
losses: row.get(5)?,
|
||||
buchholz: row.get(6)?,
|
||||
rank: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
// Attach each match's battles and mark which ones we actually hold a replay for
|
||||
// (session_hex present in match_summary over in the battles DB).
|
||||
fn attach_battles(
|
||||
conn: &Connection,
|
||||
battles_db: &FsPath,
|
||||
tid: i64,
|
||||
matches: &mut [TournamentMatchRow],
|
||||
) -> Result<(), ApiError> {
|
||||
let mut index: HashMap<(String, String), usize> = HashMap::new();
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
index.insert((m.match_id.clone(), m.type_bracket.clone()), i);
|
||||
}
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT match_id, type_bracket, session_hex, position
|
||||
FROM tournament_battles
|
||||
WHERE tournament_id = ?1
|
||||
ORDER BY match_id, type_bracket, position IS NULL, position",
|
||||
)
|
||||
.map_err(db_error)?;
|
||||
let battles = stmt
|
||||
.query_map(params![tid], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<i64>>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(db_error)?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(db_error)?;
|
||||
|
||||
let hexes: Vec<String> = battles.iter().map(|(_, _, h, _)| h.clone()).collect();
|
||||
let held = held_session_ids(battles_db, &hexes)?;
|
||||
|
||||
for (match_id, type_bracket, session_hex, position) in battles {
|
||||
if let Some(&idx) = index.get(&(match_id, type_bracket)) {
|
||||
let have_replay = held.contains(&session_hex);
|
||||
matches[idx].battles.push(TournamentBattleRow {
|
||||
session_hex,
|
||||
position,
|
||||
have_replay,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn held_session_ids(
|
||||
battles_db: &FsPath,
|
||||
hexes: &[String],
|
||||
) -> Result<HashSet<String>, ApiError> {
|
||||
let mut held = HashSet::new();
|
||||
if hexes.is_empty() {
|
||||
return Ok(held);
|
||||
}
|
||||
let conn = open_db(battles_db)?;
|
||||
for chunk in hexes.chunks(400) {
|
||||
let placeholders = std::iter::repeat("?")
|
||||
.take(chunk.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let sql = format!(
|
||||
"SELECT session_id FROM match_summary WHERE session_id IN ({placeholders})"
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql).map_err(db_error)?;
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(chunk.iter()), |row| {
|
||||
row.get::<_, String>(0)
|
||||
})
|
||||
.map_err(db_error)?;
|
||||
for r in rows {
|
||||
held.insert(r.map_err(db_error)?);
|
||||
}
|
||||
}
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
|
||||
fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiError> {
|
||||
// player_games_hist stores one row per used vehicle per player, with the
|
||||
// per-player stats duplicated across those rows. Reduce to one row per UID
|
||||
@@ -1604,7 +1911,8 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
|
||||
AND pg.victor_bool = 'Loss'
|
||||
GROUP BY pg.team_name COLLATE NOCASE
|
||||
ORDER BY COUNT(DISTINCT pg.UID) DESC, pg.team_name COLLATE NOCASE
|
||||
LIMIT 1)
|
||||
LIMIT 1),
|
||||
m.tournament_id
|
||||
FROM (
|
||||
SELECT
|
||||
COUNT(*) AS player_count,
|
||||
@@ -1652,6 +1960,7 @@ fn game_for(conn: &Connection, session_id: &str) -> Result<Option<GameRow>, ApiE
|
||||
player_count: row.get(7)?,
|
||||
winning_team: row.get(17)?,
|
||||
losing_team: row.get(18)?,
|
||||
tournament_id: row.get(19)?,
|
||||
tournament_name: row.get(4)?,
|
||||
duration: row.get(5)?,
|
||||
draw: draw_int != 0,
|
||||
@@ -1853,6 +2162,14 @@ fn validate_uid(value: &str) -> Result<String, ApiError> {
|
||||
Ok(trimmed.to_string())
|
||||
}
|
||||
|
||||
fn validate_tournament_id(value: &str) -> Result<i64, ApiError> {
|
||||
let trimmed = value.trim();
|
||||
match trimmed.parse::<i64>() {
|
||||
Ok(id) if id > 0 => Ok(id),
|
||||
_ => Err(ApiError::bad_request("Invalid tournament ID")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_session_id(value: &str) -> Result<&str, ApiError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > 96
|
||||
|
||||
+509
-4
@@ -17,6 +17,8 @@ const apiEndpoints = {
|
||||
viewerDelete: '/api/viewers/delete',
|
||||
teams: '/api/tss/leaderboard/teams?limit=100',
|
||||
players: '/api/tss/leaderboard/players?limit=100',
|
||||
tournaments: '/api/tss/tournaments',
|
||||
tournament: (id) => `/api/tss/tournaments/${encodeURIComponent(id)}`,
|
||||
homeTeams: '/api/tss/leaderboard/teams?limit=4',
|
||||
teamsHealth: '/api/tss/leaderboard/teams?limit=1',
|
||||
recentGames: '/api/tss/games/recent?limit=50',
|
||||
@@ -35,6 +37,7 @@ const navItems = [
|
||||
{ path: '/teams', label: 'Team Leaderboard' },
|
||||
{ path: '/players', label: 'Player Leaderboard' },
|
||||
{ path: '/battle-logs', label: 'Battle Logs' },
|
||||
{ path: '/tournaments', label: 'Tournaments' },
|
||||
{ path: '/viewers', label: 'Viewers' },
|
||||
{ path: '/docs', label: 'Setup' },
|
||||
]
|
||||
@@ -180,6 +183,11 @@ function parseRoute(pathname = window.location.pathname) {
|
||||
if (pathname === '/viewers') return { page: 'viewers', teamName: '' }
|
||||
if (pathname === '/privacy') return { page: 'privacy', teamName: '' }
|
||||
if (pathname === '/docs') return { page: 'docs', teamName: '' }
|
||||
if (pathname === '/tournaments') return { page: 'tournaments-list', teamName: '' }
|
||||
if (pathname.startsWith('/tournaments/')) {
|
||||
const tournamentId = decodeURIComponent(pathname.slice('/tournaments/'.length))
|
||||
return { page: 'tournament', teamName: '', tournamentId }
|
||||
}
|
||||
if (pathname.startsWith('/players/')) {
|
||||
const uid = decodeURIComponent(pathname.slice('/players/'.length))
|
||||
return { page: 'player', teamName: '', uid }
|
||||
@@ -208,6 +216,10 @@ function playerPath(uid) {
|
||||
return `/players/${encodeURIComponent(uid)}`
|
||||
}
|
||||
|
||||
function tournamentPath(tournamentId) {
|
||||
return `/tournaments/${encodeURIComponent(tournamentId)}`
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return numberFormat.format(Number(value || 0))
|
||||
}
|
||||
@@ -601,6 +613,8 @@ function routeLabel(route) {
|
||||
if (route.page === 'players') return 'Player Leaderboard'
|
||||
if (route.page === 'battle-logs') return 'Battle Logs'
|
||||
if (route.page === 'game') return route.gameId ? `Game ${route.gameId}` : 'Game'
|
||||
if (route.page === 'tournaments-list') return 'Tournaments'
|
||||
if (route.page === 'tournament') return route.tournamentId ? `Tournament ${route.tournamentId}` : 'Tournament'
|
||||
if (route.page === 'uptime') return 'Uptime'
|
||||
if (route.page === 'viewers') return 'viewers'
|
||||
if (route.page === 'privacy') return 'Privacy notice'
|
||||
@@ -619,6 +633,8 @@ function canonicalPathForRoute(route) {
|
||||
if (route.page === 'players') return '/players'
|
||||
if (route.page === 'battle-logs') return '/battle-logs'
|
||||
if (route.page === 'game' && route.gameId) return gamePath(route.gameId)
|
||||
if (route.page === 'tournaments-list') return '/tournaments'
|
||||
if (route.page === 'tournament' && route.tournamentId) return tournamentPath(route.tournamentId)
|
||||
if (route.page === 'uptime') return '/uptime'
|
||||
if (route.page === 'viewers') return '/viewers'
|
||||
if (route.page === 'privacy') return '/privacy'
|
||||
@@ -669,6 +685,15 @@ function seoForRoute(route, profileDetail = null) {
|
||||
}
|
||||
}
|
||||
|
||||
if (route.page === 'tournament' && route.tournamentId) {
|
||||
return {
|
||||
title: `Tournament ${route.tournamentId} | Toothless' TSS Bot`,
|
||||
description: `TSS tournament ${route.tournamentId} bracket, matches, standings, and games tracked by Toothless' TSS Bot.`,
|
||||
robots: 'index, follow',
|
||||
path: canonicalPathForRoute(route),
|
||||
}
|
||||
}
|
||||
|
||||
const byPage = {
|
||||
teams: {
|
||||
title: "TSS Team Leaderboard | Toothless' TSS Bot",
|
||||
@@ -688,6 +713,12 @@ function seoForRoute(route, profileDetail = null) {
|
||||
robots: 'index, follow',
|
||||
path: '/battle-logs',
|
||||
},
|
||||
'tournaments-list': {
|
||||
title: "TSS Tournaments | Toothless' TSS Bot",
|
||||
description: 'Browse tracked TSS tournaments with authoritative brackets, standings, and linked replay availability.',
|
||||
robots: 'index, follow',
|
||||
path: '/tournaments',
|
||||
},
|
||||
uptime: {
|
||||
title: "TSS Bot Uptime Status | Toothless' TSS Bot",
|
||||
description: 'Check Toothless TSS Bot uptime, API health, TSS data proxy status, and recent availability history.',
|
||||
@@ -1789,6 +1820,8 @@ function AppContent() {
|
||||
? '/players'
|
||||
: route.page === 'battle-logs' || route.page === 'game'
|
||||
? '/battle-logs'
|
||||
: route.page === 'tournaments-list' || route.page === 'tournament'
|
||||
? '/tournaments'
|
||||
: route.page === 'viewers'
|
||||
? '/viewers'
|
||||
: window.location.pathname
|
||||
@@ -1923,6 +1956,8 @@ function AppContent() {
|
||||
{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}
|
||||
{route.page === 'tournaments-list' ? <TournamentsPage navigate={navigate} /> : null}
|
||||
{route.page === 'tournament' ? <TournamentDetailPage tournamentId={route.tournamentId} navigate={navigate} /> : null}
|
||||
</section>
|
||||
<Footer navigate={navigate} />
|
||||
<ConsentBanner preferences={analyticsPreferences} onChoose={chooseAnalyticsConsent} />
|
||||
@@ -3278,9 +3313,19 @@ function GamePage({ gameId, navigate }) {
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">Game</p>
|
||||
<span className="break-all text-xs text-text-muted opacity-70">{game?.session_id || gameId}</span>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||
Game — <span className="break-all text-text-muted opacity-70">{game?.session_id || gameId}</span>
|
||||
</p>
|
||||
{game?.tournament_id ? (
|
||||
<button
|
||||
className="text-sm font-semibold uppercase tracking-wide text-fury-violet transition hover:text-text"
|
||||
onClick={() => navigate(tournamentPath(game.tournament_id))}
|
||||
type="button"
|
||||
>
|
||||
Tournament — {game.tournament_id}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-4xl font-bold">{game?.map_name || 'Battle log'}</h1>
|
||||
@@ -3290,7 +3335,19 @@ function GamePage({ gameId, navigate }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subtitle ? <p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p> : null}
|
||||
{subtitle ? (
|
||||
game?.tournament_id ? (
|
||||
<button
|
||||
className="mt-1 block text-left text-sm font-medium text-fury-violet underline-offset-2 transition hover:text-text hover:underline"
|
||||
onClick={() => navigate(tournamentPath(game.tournament_id))}
|
||||
type="button"
|
||||
>
|
||||
{subtitle}
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1 text-sm font-medium text-fury-violet">{subtitle}</p>
|
||||
)
|
||||
) : null}
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
{game ? formatDate(game.timestamp) : ''}
|
||||
{duration ? ` · ${duration}` : ''}
|
||||
@@ -3621,6 +3678,454 @@ function BattleLogsPage({ live, matches, navigate }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatUnixDate(seconds) {
|
||||
return seconds ? formatDate(seconds) : 'Unknown'
|
||||
}
|
||||
|
||||
function tournamentDateRange(first, last) {
|
||||
if (!first && !last) return 'No dates'
|
||||
const a = formatUnixDate(first)
|
||||
const b = formatUnixDate(last)
|
||||
return a === b ? a : `${a} – ${b}`
|
||||
}
|
||||
|
||||
function tournamentFormatMeta(format, matches = []) {
|
||||
const raw = String(format || '').toLowerCase()
|
||||
const sides = matches.map((match) => String(match.side || match.type_bracket || '').toLowerCase())
|
||||
const hasSide = (needle) => sides.some((side) => side.includes(needle))
|
||||
const hasGroup = raw === 'group' || hasSide('group')
|
||||
const hasSwiss = raw === 'swiss' || hasSide('swiss')
|
||||
const hasLoser = raw === 'double-elim' || hasSide('loser') || hasSide('looser')
|
||||
const hasElim = hasLoser || raw === 'single-elim' || hasSide('winner') || hasSide('final')
|
||||
|
||||
if ((hasGroup || hasSwiss) && hasElim) {
|
||||
return { label: hasSwiss ? 'Swiss + playoffs' : 'Group stage + playoffs', mode: 'mixed' }
|
||||
}
|
||||
if (hasSwiss) return { label: 'Swiss', mode: 'standings' }
|
||||
if (hasGroup) return { label: 'Group stage', mode: 'standings' }
|
||||
if (hasLoser) return { label: 'Double elimination', mode: 'bracket' }
|
||||
if (raw === 'single-elim' || hasElim) return { label: 'Single elimination', mode: 'bracket' }
|
||||
return { label: 'Matches', mode: 'matches' }
|
||||
}
|
||||
|
||||
function tournamentStatusLabel(status) {
|
||||
const raw = String(status || '').trim()
|
||||
if (!raw) return 'Unknown'
|
||||
return raw.charAt(0).toUpperCase() + raw.slice(1)
|
||||
}
|
||||
|
||||
function sideFromMatch(match) {
|
||||
const side = String(match?.side || '').toLowerCase()
|
||||
if (side) return side
|
||||
const bracket = String(match?.type_bracket || '').toLowerCase()
|
||||
if (bracket.includes('swiss')) return 'swiss'
|
||||
if (bracket.includes('group')) return 'group'
|
||||
if (bracket.includes('looser') || bracket.includes('loser')) return 'loser'
|
||||
if (bracket.includes('final') || bracket.includes('semifinal')) return 'final'
|
||||
if (bracket.includes('winner')) return 'winner'
|
||||
return 'matches'
|
||||
}
|
||||
|
||||
function sideLabel(side) {
|
||||
const labels = {
|
||||
winner: 'Winner bracket',
|
||||
loser: 'Loser bracket',
|
||||
final: 'Finals',
|
||||
group: 'Group matches',
|
||||
swiss: 'Swiss matches',
|
||||
matches: 'Matches',
|
||||
}
|
||||
return labels[side] || tournamentStatusLabel(side)
|
||||
}
|
||||
|
||||
function sidePriority(side) {
|
||||
return { winner: 0, final: 1, loser: 2, group: 3, swiss: 4, matches: 5 }[side] ?? 6
|
||||
}
|
||||
|
||||
function compareTournamentMatches(a, b) {
|
||||
const roundA = a.round ?? Number.MAX_SAFE_INTEGER
|
||||
const roundB = b.round ?? Number.MAX_SAFE_INTEGER
|
||||
const posA = a.position ?? Number.MAX_SAFE_INTEGER
|
||||
const posB = b.position ?? Number.MAX_SAFE_INTEGER
|
||||
return roundA - roundB || posA - posB || String(a.match_id).localeCompare(String(b.match_id))
|
||||
}
|
||||
|
||||
function TournamentsPage({ navigate }) {
|
||||
const [state, setState] = useState({ status: 'loading', data: null, error: null })
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setState({ status: 'loading', data: null, error: null })
|
||||
fetchJson(apiEndpoints.tournaments, controller.signal)
|
||||
.then((data) => setState({ status: 'ready', data, error: null }))
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ status: 'error', data: null, error: error.message })
|
||||
}
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [])
|
||||
|
||||
const tournaments = state.data?.tournaments || []
|
||||
|
||||
return (
|
||||
<section className="space-y-6 pt-24 sm:pt-28">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tournaments</h1>
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
{state.status === 'loading'
|
||||
? 'Loading tournaments'
|
||||
: state.error || `${tournaments.length} tournaments returned`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm">
|
||||
{tournaments.map((tournament) => (
|
||||
<button
|
||||
className="grid w-full gap-x-4 gap-y-1 border-b border-surface px-5 py-4 text-left transition hover:bg-surface md:grid-cols-[1fr_repeat(3,auto)] md:items-center"
|
||||
key={tournament.tournament_id}
|
||||
onClick={() => navigate(tournamentPath(tournament.tournament_id))}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-lg font-semibold">
|
||||
{tournament.name || `Tournament ${tournament.tournament_id}`}
|
||||
</p>
|
||||
<p className="text-xs text-text-soft">
|
||||
TID {tournament.tournament_id} · {tournamentDateRange(tournament.date_start, tournament.date_end)}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm">{formatNumber(tournament.match_count)} matches</span>
|
||||
<span className="text-sm">{formatNumber(tournament.team_count)} teams</span>
|
||||
<span className="text-sm font-semibold text-fury-cyan">View</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{!tournaments.length ? (
|
||||
<p className="px-5 py-10 text-sm text-text-soft">
|
||||
{state.status === 'loading' ? 'Loading tournaments' : 'No tournaments returned'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function groupMatchesBySide(matches) {
|
||||
const bySide = new Map()
|
||||
matches.forEach((match) => {
|
||||
const side = sideFromMatch(match)
|
||||
if (!bySide.has(side)) bySide.set(side, [])
|
||||
bySide.get(side).push(match)
|
||||
})
|
||||
return [...bySide.entries()]
|
||||
.map(([raw, sideMatches]) => ({
|
||||
raw,
|
||||
label: sideLabel(raw),
|
||||
isGroup: raw === 'group' || raw === 'swiss',
|
||||
matches: [...sideMatches].sort(compareTournamentMatches),
|
||||
}))
|
||||
.sort((a, b) => sidePriority(a.raw) - sidePriority(b.raw))
|
||||
}
|
||||
|
||||
function roundsForSide(matches) {
|
||||
const byRound = new Map()
|
||||
matches.forEach((match) => {
|
||||
const key = match.round ?? 'matches'
|
||||
if (!byRound.has(key)) byRound.set(key, [])
|
||||
byRound.get(key).push(match)
|
||||
})
|
||||
return [...byRound.entries()]
|
||||
.sort(([a], [b]) => {
|
||||
if (a === 'matches') return 1
|
||||
if (b === 'matches') return -1
|
||||
return Number(a) - Number(b)
|
||||
})
|
||||
.map(([round, roundMatches]) => ({
|
||||
round,
|
||||
matches: [...roundMatches].sort(compareTournamentMatches),
|
||||
}))
|
||||
}
|
||||
|
||||
function roundLabel(side, round, index, total) {
|
||||
if (side === 'final' && total === 1) return 'Final'
|
||||
if (round === 'matches') return 'Matches'
|
||||
if (side === 'final') return index === total - 1 ? 'Final' : `Round ${Number(round) + 1}`
|
||||
return `Round ${Number(round) + 1}`
|
||||
}
|
||||
|
||||
function TournamentMatchCard({ match, navigate }) {
|
||||
const winner = displayTeamName(match.winner_name).toLowerCase()
|
||||
const teamA = displayTeamName(match.team_a_name)
|
||||
const teamB = displayTeamName(match.team_b_name)
|
||||
const aWon = winner && teamA && winner === teamA.toLowerCase()
|
||||
const bWon = winner && teamB && winner === teamB.toLowerCase()
|
||||
const battles = Array.isArray(match.battles) ? match.battles : []
|
||||
|
||||
const teamRow = (name, score, won) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{name ? (
|
||||
<button
|
||||
className={`min-w-0 truncate text-left font-semibold transition hover:underline ${won ? 'text-win' : 'text-text'}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
navigate(teamPath(name))
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate font-semibold text-text-muted">TBD</span>
|
||||
)}
|
||||
<span className={`shrink-0 tabular-nums ${won ? 'text-win' : 'text-text-soft'}`}>{formatNumber(score)}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-bg p-2.5 text-sm shadow-sm">
|
||||
{teamRow(teamA, match.score_a, aWon)}
|
||||
<div className="mt-1">{teamRow(teamB, match.score_b, bWon)}</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] font-semibold uppercase tracking-wide text-text-muted">
|
||||
<span>{tournamentStatusLabel(match.status)}</span>
|
||||
{match.position !== null && match.position !== undefined ? <span>Slot {Number(match.position) + 1}</span> : null}
|
||||
</div>
|
||||
{battles.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{battles.map((battle, index) => (
|
||||
battle.have_replay ? (
|
||||
<button
|
||||
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-soft transition hover:text-text"
|
||||
key={battle.session_hex}
|
||||
onClick={() => navigate(gamePath(battle.session_hex))}
|
||||
type="button"
|
||||
>
|
||||
G{index + 1}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="rounded bg-surface px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-text-muted opacity-70"
|
||||
key={battle.session_hex}
|
||||
>
|
||||
G{index + 1}
|
||||
</span>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentBracketSide({ side, navigate }) {
|
||||
const rounds = roundsForSide(side.matches)
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="overflow-x-auto pb-2">
|
||||
<div className="flex gap-4">
|
||||
{rounds.map((round, roundIndex) => (
|
||||
<div className="flex min-w-[190px] flex-col gap-3" key={round.round}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{roundLabel(side.raw, round.round, roundIndex, rounds.length)}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{round.matches.map((match) => (
|
||||
<TournamentMatchCard
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentStandings({ standings }) {
|
||||
const rows = Array.isArray(standings) ? standings : []
|
||||
if (!rows.length) return null
|
||||
const grouped = new Map()
|
||||
rows.forEach((row) => {
|
||||
const group = row.group_index ?? 0
|
||||
if (!grouped.has(group)) grouped.set(group, [])
|
||||
grouped.get(group).push(row)
|
||||
})
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[...grouped.entries()].map(([group, groupRows]) => (
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-fury-white shadow-sm" key={group}>
|
||||
{grouped.size > 1 ? (
|
||||
<p className="border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-fury-cyan">
|
||||
Group {Number(group) + 1}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] gap-2 border-b border-border px-5 py-3 text-xs font-semibold uppercase tracking-wide text-text-soft">
|
||||
<p>#</p>
|
||||
<p>Team</p>
|
||||
<p className="text-center">Pts</p>
|
||||
<p className="text-center">W</p>
|
||||
<p className="text-center">D</p>
|
||||
<p className="text-center">L</p>
|
||||
<p className="text-center">Buch.</p>
|
||||
</div>
|
||||
{groupRows.map((row, index) => (
|
||||
<div
|
||||
className="grid grid-cols-[3rem_1fr_4rem_3rem_3rem_3rem_5rem] items-center gap-2 border-b border-surface px-5 py-2.5 text-sm"
|
||||
key={`${group}-${row.team_name || index}`}
|
||||
>
|
||||
<span className="font-semibold text-fury-cyan">#{row.rank || index + 1}</span>
|
||||
<span className="min-w-0 truncate font-semibold">{row.team_name || 'Unknown team'}</span>
|
||||
<span className="text-center">{formatNumber(row.points)}</span>
|
||||
<span className="text-center">{formatNumber(row.wins)}</span>
|
||||
<span className="text-center">{formatNumber(row.draws)}</span>
|
||||
<span className="text-center">{formatNumber(row.losses)}</span>
|
||||
<span className="text-center">{formatNumber(row.buchholz)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentMatchList({ sides, navigate }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sides.map((side) => (
|
||||
<div key={side.raw || 'matches'}>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{side.matches.map((match) => (
|
||||
<TournamentMatchCard
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentDetailPage({ tournamentId, navigate }) {
|
||||
const [state, setState] = useState({ status: 'loading', data: null, error: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!tournamentId) return undefined
|
||||
const controller = new AbortController()
|
||||
setState({ status: 'loading', data: null, error: null })
|
||||
fetchJson(apiEndpoints.tournament(tournamentId), controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) setState({ status: 'ready', data, error: null })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setState({ status: 'error', data: null, error: error.message })
|
||||
}
|
||||
})
|
||||
return () => controller.abort()
|
||||
}, [tournamentId])
|
||||
|
||||
const data = state.data
|
||||
const matches = useMemo(() => data?.matches || [], [data])
|
||||
const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches])
|
||||
const sides = useMemo(() => groupMatchesBySide(matches), [matches])
|
||||
const elimSides = sides.filter((side) => !side.isGroup)
|
||||
const groupSides = sides.filter((side) => side.isGroup)
|
||||
const standings = data?.standings || []
|
||||
|
||||
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('/tournaments')}
|
||||
type="button"
|
||||
>
|
||||
Back to tournaments
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-border bg-fury-white p-6 shadow-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-fury-cyan">
|
||||
Tournament — <span className="text-text-muted opacity-70">{tournamentId}</span>
|
||||
</p>
|
||||
<span className="rounded bg-surface px-2 py-0.5 text-xs font-semibold uppercase tracking-wide text-fury-violet">
|
||||
{format.label}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="mt-1 text-4xl font-bold">
|
||||
{data?.name || `Tournament ${tournamentId}`}
|
||||
</h1>
|
||||
{data ? (
|
||||
<p className="mt-2 text-sm text-text-soft">
|
||||
{formatNumber(data.match_count)} matches · {formatNumber(data.team_count)} teams ·{' '}
|
||||
{tournamentDateRange(data.date_start, data.date_end)}
|
||||
{data.status ? ` · ${tournamentStatusLabel(data.status)}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
{state.status === 'error' ? (
|
||||
<p className="mt-4 text-sm text-danger">{state.error}</p>
|
||||
) : null}
|
||||
{state.status === 'loading' ? (
|
||||
<p className="mt-4 text-sm text-text-soft">Loading tournament</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{state.status === 'ready' && !matches.length ? (
|
||||
<p className="rounded-lg border border-border bg-fury-white px-5 py-10 text-sm text-text-soft shadow-sm">
|
||||
No games linked to this tournament yet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'standings' && matches.length ? (
|
||||
<>
|
||||
<TournamentStandings standings={standings} />
|
||||
<TournamentMatchList sides={sides} navigate={navigate} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'bracket' && matches.length ? (
|
||||
<div className="space-y-6">
|
||||
{elimSides.map((side) => (
|
||||
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'mixed' && matches.length ? (
|
||||
<div className="space-y-8">
|
||||
{groupSides.length ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Group stage</h2>
|
||||
<TournamentStandings standings={standings} />
|
||||
<TournamentMatchList sides={groupSides} navigate={navigate} />
|
||||
</div>
|
||||
) : null}
|
||||
{elimSides.length ? (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold">Playoffs</h2>
|
||||
{elimSides.map((side) => (
|
||||
<TournamentBracketSide key={side.raw || 'bracket'} side={side} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{format.mode === 'matches' && matches.length ? (
|
||||
<TournamentMatchList sides={sides} navigate={navigate} />
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function relativeSeconds(timestamp) {
|
||||
if (!timestamp) return 'unknown'
|
||||
const seconds = Math.max(0, Math.round((Date.now() - new Date(timestamp).getTime()) / 1000))
|
||||
|
||||
+10
@@ -2009,6 +2009,16 @@ function allowedApiTarget(req) {
|
||||
return url
|
||||
}
|
||||
|
||||
if (pathname === '/api/tss/tournaments') {
|
||||
if ([...params.keys()].length) return null
|
||||
return url
|
||||
}
|
||||
|
||||
if (/^\/api\/tss\/tournaments\/[0-9]{1,18}$/.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') || ''
|
||||
|
||||
Reference in New Issue
Block a user