add stuff for tournaments
This commit is contained in:
+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))
|
||||
|
||||
Reference in New Issue
Block a user