feat: tournament bracket UI

TournamentListPage with status filters, TournamentPage with CSS grid
bracket display, lobby view for open tournaments, champion banner,
live polling for active tournaments. Routes at /tournaments and
/tournament/:id. NavBar link added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 21:00:48 +00:00
co-authored by Claude Opus 4.6
parent b70c49075e
commit b0926db9ed
4 changed files with 359 additions and 0 deletions
+1
View File
@@ -12,6 +12,7 @@ const links = [
{ to: '/fight-card', label: 'FIGHT CARD' },
{ to: '/arena', label: 'WATCH' },
{ to: '/feed', label: 'FEED' },
{ to: '/tournaments', label: 'TOURNEYS' },
{ to: '/leaderboard', label: 'RANKINGS' },
{ to: '/docs', label: 'DOCS' },
]
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { RouterLink } from 'vue-router'
interface Tournament {
id: string
name: string
format: string
size: number
entrySats: number
prizeSats: number
status: 'open' | 'active' | 'finished'
currentRound: number
createdAt: string
startedAt: string | null
finishedAt: string | null
}
const tournaments = ref<Tournament[]>([])
const isLoading = ref(true)
const filter = ref<'all' | 'open' | 'active' | 'finished'>('all')
const filtered = computed(() => {
if (filter.value === 'all') return tournaments.value
return tournaments.value.filter(t => t.status === filter.value)
})
async function fetchTournaments() {
isLoading.value = true
try {
const res = await fetch('/api/tournaments')
if (res.ok) {
const data = await res.json()
tournaments.value = data.tournaments || []
}
} catch {
console.warn('[Tournaments] load failed')
}
isLoading.value = false
}
function statusLabel(s: string) {
if (s === 'open') return 'OPEN'
if (s === 'active') return 'LIVE'
return 'FINISHED'
}
function statusColor(s: string) {
if (s === 'open') return 'text-neon-green'
if (s === 'active') return 'text-neon-pink'
return 'text-text-muted'
}
onMounted(fetchTournaments)
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-8 space-y-6">
<h1 class="font-display font-black text-2xl text-neon-cyan tracking-wider">TOURNAMENTS</h1>
<div class="flex gap-2">
<button
v-for="f in (['all', 'open', 'active', 'finished'] as const)"
:key="f"
class="px-3 py-1 text-xs font-display font-bold tracking-wider rounded border transition-colors"
:class="filter === f
? 'border-neon-cyan text-neon-cyan bg-neon-cyan/10'
: 'border-border text-text-secondary hover:text-neon-cyan hover:border-neon-cyan/50'"
@click="filter = f"
>
{{ f.toUpperCase() }}
</button>
</div>
<div v-if="isLoading" class="text-text-muted text-sm font-mono">Loading...</div>
<div v-else-if="filtered.length === 0" class="text-text-muted text-sm font-mono">
No tournaments found.
</div>
<div v-else class="space-y-3">
<RouterLink
v-for="t in filtered"
:key="t.id"
:to="`/tournament/${t.id}`"
class="block border border-border rounded-lg p-4 bg-surface/50 hover:border-neon-cyan/50 transition-colors"
>
<div class="flex items-center justify-between">
<h2 class="font-display font-bold text-text-primary tracking-wide">{{ t.name }}</h2>
<span class="text-xs font-display font-bold tracking-wider" :class="statusColor(t.status)">
{{ statusLabel(t.status) }}
</span>
</div>
<div class="mt-2 flex gap-4 text-xs text-text-muted font-mono">
<span>{{ t.format === 'single_elim' ? 'Single Elim' : 'Round Robin' }}</span>
<span>{{ t.size }} bots</span>
<span v-if="t.entrySats > 0">{{ t.entrySats }} sats entry</span>
<span v-if="t.prizeSats > 0">{{ t.prizeSats }} sats prize</span>
<span>Round {{ t.currentRound }}</span>
</div>
</RouterLink>
</div>
</div>
</template>
+244
View File
@@ -0,0 +1,244 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
interface TournamentData {
id: string
name: string
format: string
size: number
entrySats: number
prizeSats: number
status: string
currentRound: number
createdAt: string
startedAt: string | null
finishedAt: string | null
}
interface Entry {
id: string
botId: string
botName: string
seed: number
eliminated: boolean
}
interface Match {
id: string
round: number
matchIndex: number
botAId: string | null
botBId: string | null
botAName: string | null
botBName: string | null
fightId: string | null
winnerId: string | null
status: string
}
const route = useRoute()
const tournament = ref<TournamentData | null>(null)
const entries = ref<Entry[]>([])
const matches = ref<Match[]>([])
const isLoading = ref(true)
const error = ref<string | null>(null)
let pollTimer: ReturnType<typeof setInterval> | null = null
const totalRounds = computed(() => {
if (!tournament.value) return 0
return Math.ceil(Math.log2(tournament.value.size))
})
const rounds = computed(() => {
const result: Match[][] = []
for (let r = 1; r <= totalRounds.value; r++) {
result.push(
matches.value
.filter(m => m.round === r)
.sort((a, b) => a.matchIndex - b.matchIndex)
)
}
return result
})
const champion = computed(() => {
if (tournament.value?.status !== 'finished') return null
// Champion is the winner of the final round's single match
const finalRound = rounds.value[rounds.value.length - 1]
if (!finalRound || finalRound.length === 0) return null
const finalMatch = finalRound[0]
if (!finalMatch.winnerId) return null
const entry = entries.value.find(e => e.botId === finalMatch.winnerId)
return entry ?? null
})
async function fetchBracket() {
try {
const res = await fetch(`/api/tournaments/${route.params.id}`)
if (!res.ok) {
error.value = 'Tournament not found'
return
}
const data = await res.json()
tournament.value = data.tournament
entries.value = data.entries || []
matches.value = data.matches || []
} catch {
error.value = 'Failed to load tournament'
}
isLoading.value = false
}
function roundLabel(round: number): string {
const remaining = totalRounds.value - round
if (remaining === 0) return 'FINAL'
if (remaining === 1) return 'SEMIS'
if (remaining === 2) return 'QUARTERS'
return `ROUND ${round}`
}
function slotClass(match: Match, side: 'a' | 'b'): string {
const botId = side === 'a' ? match.botAId : match.botBId
if (!botId) return 'text-text-muted/30'
if (match.winnerId === botId) return 'text-neon-green font-bold'
if (match.winnerId && match.winnerId !== botId) return 'text-text-muted line-through'
return 'text-text-primary'
}
onMounted(() => {
fetchBracket()
// Poll for updates while tournament is active
pollTimer = setInterval(() => {
if (tournament.value?.status === 'active') fetchBracket()
}, 10_000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-8 space-y-6">
<RouterLink to="/tournaments" class="text-xs text-text-muted hover:text-neon-cyan font-mono">
&lt; back to tournaments
</RouterLink>
<div v-if="isLoading" class="text-text-muted text-sm font-mono">Loading...</div>
<div v-else-if="error" class="text-red-400 text-sm font-mono">{{ error }}</div>
<template v-else-if="tournament">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="font-display font-black text-2xl text-neon-cyan tracking-wider">
{{ tournament.name }}
</h1>
<p class="text-xs text-text-muted font-mono mt-1">
{{ tournament.format === 'single_elim' ? 'Single Elimination' : 'Round Robin' }}
&middot; {{ tournament.size }} bots
<template v-if="tournament.prizeSats > 0"> &middot; {{ tournament.prizeSats }} sats prize</template>
</p>
</div>
<span
class="text-sm font-display font-bold tracking-wider"
:class="{
'text-neon-green': tournament.status === 'open',
'text-neon-pink glow-pink': tournament.status === 'active',
'text-text-muted': tournament.status === 'finished',
}"
>
{{ tournament.status === 'open' ? 'OPEN' : tournament.status === 'active' ? 'LIVE' : 'FINISHED' }}
</span>
</div>
<!-- Champion banner -->
<div
v-if="champion"
class="border border-neon-gold/50 bg-neon-gold/5 rounded-lg p-4 flex items-center gap-4"
>
<SpritePreview :seed="champion.botName" :size="48" />
<div>
<p class="text-xs text-neon-gold font-display font-bold tracking-wider">CHAMPION</p>
<RouterLink
:to="`/bot/${champion.botName}`"
class="font-display font-bold text-lg text-text-primary hover:text-neon-cyan"
>
{{ champion.botName }}
</RouterLink>
</div>
</div>
<!-- Lobby (open tournament) -->
<div v-if="tournament.status === 'open'" class="space-y-3">
<h2 class="font-display font-bold text-sm text-text-secondary tracking-wider">
REGISTERED ({{ entries.length }}/{{ tournament.size }})
</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div
v-for="entry in entries"
:key="entry.id"
class="border border-border rounded p-2 flex items-center gap-2 bg-surface/50"
>
<SpritePreview :seed="entry.botName" :size="24" />
<span class="text-xs text-text-primary font-mono truncate">{{ entry.botName }}</span>
</div>
<div
v-for="i in (tournament.size - entries.length)"
:key="'empty-' + i"
class="border border-border/30 rounded p-2 flex items-center justify-center"
>
<span class="text-xs text-text-muted/30 font-mono">empty</span>
</div>
</div>
</div>
<!-- Bracket -->
<div v-if="rounds.length > 0" class="overflow-x-auto">
<div class="flex gap-6 min-w-max py-4">
<div v-for="(roundMatches, ri) in rounds" :key="ri" class="flex flex-col gap-4">
<h3 class="text-xs font-display font-bold text-text-muted tracking-wider text-center mb-2">
{{ roundLabel(ri + 1) }}
</h3>
<div
class="flex flex-col justify-around flex-1 gap-4"
>
<div
v-for="match in roundMatches"
:key="match.id"
class="border border-border rounded bg-surface/50 w-48"
>
<!-- Bot A -->
<div
class="px-3 py-2 text-xs font-mono border-b border-border/50 flex items-center justify-between"
:class="slotClass(match, 'a')"
>
<span class="truncate">{{ match.botAName || 'BYE' }}</span>
<span v-if="match.winnerId === match.botAId" class="text-neon-green ml-1">W</span>
</div>
<!-- Bot B -->
<div
class="px-3 py-2 text-xs font-mono flex items-center justify-between"
:class="slotClass(match, 'b')"
>
<span class="truncate">{{ match.botBName || 'BYE' }}</span>
<span v-if="match.winnerId === match.botBId" class="text-neon-green ml-1">W</span>
</div>
<!-- Fight link -->
<RouterLink
v-if="match.fightId"
:to="`/arena/${match.fightId}`"
class="block text-center text-[10px] text-neon-cyan hover:underline py-1 border-t border-border/50"
>
{{ match.status === 'live' ? 'WATCH LIVE' : 'REPLAY' }}
</RouterLink>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
+10
View File
@@ -71,6 +71,16 @@ const routes = [
name: 'feed',
component: () => import('./pages/FeedPage.vue'),
},
{
path: '/tournaments',
name: 'tournaments',
component: () => import('./pages/TournamentListPage.vue'),
},
{
path: '/tournament/:id',
name: 'tournament',
component: () => import('./pages/TournamentPage.vue'),
},
]
export const router = createRouter({