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:
co-authored by
Claude Opus 4.6
parent
b70c49075e
commit
b0926db9ed
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
< 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' }}
|
||||
· {{ tournament.size }} bots
|
||||
<template v-if="tournament.prizeSats > 0"> · {{ 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>
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user