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: '/fight-card', label: 'FIGHT CARD' },
|
||||||
{ to: '/arena', label: 'WATCH' },
|
{ to: '/arena', label: 'WATCH' },
|
||||||
{ to: '/feed', label: 'FEED' },
|
{ to: '/feed', label: 'FEED' },
|
||||||
|
{ to: '/tournaments', label: 'TOURNEYS' },
|
||||||
{ to: '/leaderboard', label: 'RANKINGS' },
|
{ to: '/leaderboard', label: 'RANKINGS' },
|
||||||
{ to: '/docs', label: 'DOCS' },
|
{ 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',
|
name: 'feed',
|
||||||
component: () => import('./pages/FeedPage.vue'),
|
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({
|
export const router = createRouter({
|
||||||
|
|||||||
Reference in New Issue
Block a user