feat: fight feed page with filters

Reverse-chronological activity stream of recent fights showing winner,
KO/decision result, arena, sats, and time ago. Filter by tier and bot
name. Click any fight to watch replay. Added to NavBar as FEED link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 19:48:27 +00:00
co-authored by Claude Opus 4.6
parent 610e799605
commit baf153b115
3 changed files with 198 additions and 0 deletions
+1
View File
@@ -11,6 +11,7 @@ const links = [
{ to: '/join', label: 'FIGHT!' }, { to: '/join', label: 'FIGHT!' },
{ 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: '/leaderboard', label: 'RANKINGS' }, { to: '/leaderboard', label: 'RANKINGS' },
{ to: '/docs', label: 'DOCS' }, { to: '/docs', label: 'DOCS' },
] ]
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
interface FeedFight {
id: string
winnerId: string | null
botAHp: number
botBHp: number
totalRounds: number
mode: string
potSats: number
createdAt: string
status: string
botA: { name: string; eloRating: number; tier: number; archetype: string } | null
botB: { name: string; eloRating: number; tier: number; archetype: string } | null
winner: { name: string; eloRating: number; tier: number } | null
arenaInfo: { name: string } | null
}
const fights = ref<FeedFight[]>([])
const isLoading = ref(true)
const filterTier = ref<number | null>(null)
const filterName = ref('')
const filteredFights = computed(() => {
let result = fights.value
if (filterTier.value !== null) {
result = result.filter(f =>
(f.botA?.tier === filterTier.value) || (f.botB?.tier === filterTier.value)
)
}
if (filterName.value.trim()) {
const q = filterName.value.trim().toLowerCase()
result = result.filter(f =>
f.botA?.name.toLowerCase().includes(q) || f.botB?.name.toLowerCase().includes(q)
)
}
return result
})
function timeAgo(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diffSec = Math.floor((now - then) / 1000)
if (diffSec < 60) return `${diffSec}s ago`
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin}m ago`
const diffHr = Math.floor(diffMin / 60)
if (diffHr < 24) return `${diffHr}h ago`
const diffDay = Math.floor(diffHr / 24)
return `${diffDay}d ago`
}
function resultType(f: FeedFight): string {
if (!f.winnerId) return 'DRAW'
const loserHp = f.winnerId === f.botA?.name ? f.botBHp : f.botAHp
if (loserHp <= 0) return 'KO'
return 'DECISION'
}
function resultColor(f: FeedFight): string {
if (!f.winnerId) return 'text-text-muted'
const loserHp = f.winnerId === f.botA?.name ? f.botBHp : f.botAHp
if (loserHp <= 0) return 'text-ko'
return 'text-neon-yellow'
}
function watchFight(id: string) {
router.push(`/arena/${id}`)
}
onMounted(async () => {
try {
const res = await fetch('/api/fights')
if (res.ok) {
fights.value = await res.json()
}
} catch (err) {
console.warn('[FeedPage] Failed to load fights:', err)
}
isLoading.value = false
})
</script>
<template>
<div class="max-w-3xl mx-auto px-3 py-4 sm:py-6">
<h1 class="font-funky text-2xl sm:text-3xl text-neon-purple tracking-widest mb-4">FIGHT FEED</h1>
<!-- Filters -->
<div class="flex flex-wrap gap-2 mb-4">
<input
v-model="filterName"
type="text"
placeholder="Filter by bot name..."
class="flex-1 min-w-[140px] px-3 py-1.5 bg-black border border-border rounded-lg
font-mono text-sm text-text-primary placeholder:text-text-muted
focus:border-neon-cyan/50 focus:outline-none transition-colors"
/>
<div class="flex gap-1">
<button
v-for="t in [null, 0, 1, 2, 3, 4, 5]"
:key="String(t)"
class="px-2.5 py-1.5 font-pixel text-[10px] tracking-wider border rounded-md transition-all"
:class="filterTier === t
? 'border-neon-cyan bg-neon-cyan/15 text-neon-cyan'
: 'border-border text-text-muted hover:border-white/20 hover:text-text-secondary'"
@click="filterTier = t"
>
{{ t === null ? 'ALL' : `T${t}` }}
</button>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="flex items-center justify-center py-16">
<div class="w-10 h-10 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
</div>
<!-- Empty state -->
<div v-else-if="filteredFights.length === 0" class="text-center py-16">
<p class="font-display text-text-muted tracking-wider">
{{ fights.length === 0 ? 'No fights yet.' : 'No fights match your filters.' }}
</p>
</div>
<!-- Fight list -->
<div v-else class="space-y-2">
<button
v-for="f in filteredFights"
:key="f.id"
class="w-full text-left px-3 sm:px-4 py-3 bg-surface/50 border border-border rounded-lg
hover:border-neon-cyan/30 hover:bg-surface-raised/50 transition-all group"
@click="watchFight(f.id)"
>
<div class="flex items-center gap-2 sm:gap-3">
<!-- Result badge -->
<span
class="font-display font-black text-[10px] tracking-wider px-2 py-0.5 rounded-md border shrink-0"
:class="f.winnerId
? (f.botAHp <= 0 || f.botBHp <= 0)
? 'text-ko border-ko/30 bg-ko/10'
: 'text-neon-yellow border-neon-yellow/30 bg-neon-yellow/10'
: 'text-text-muted border-border bg-surface'"
>
{{ resultType(f) }}
</span>
<!-- Fighter names + winner -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 text-sm">
<span
class="font-marker truncate"
:class="f.winnerId && f.winner?.name === f.botA?.name ? 'text-neon-cyan font-bold' : 'text-text-secondary'"
>{{ f.botA?.name || '???' }}</span>
<span class="text-text-muted font-pixel text-[9px]">vs</span>
<span
class="font-marker truncate"
:class="f.winnerId && f.winner?.name === f.botB?.name ? 'text-neon-pink font-bold' : 'text-text-secondary'"
>{{ f.botB?.name || '???' }}</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<span v-if="f.winner" class="font-mono text-[10px] text-neon-green">
{{ f.winner.name }} wins
</span>
<span v-if="f.arenaInfo" class="font-pixel text-[9px] text-text-muted">
{{ f.arenaInfo.name }}
</span>
<span v-if="f.mode === 'ranked'" class="font-pixel text-[9px] text-neon-cyan">
{{ f.potSats }} SATS
</span>
</div>
</div>
<!-- Rounds + time -->
<div class="text-right shrink-0">
<p class="font-mono text-[10px] text-text-muted">R{{ f.totalRounds }}</p>
<p class="font-pixel text-[9px] text-text-muted">{{ timeAgo(f.createdAt) }}</p>
</div>
<!-- Watch arrow -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" class="w-4 h-4 text-text-muted group-hover:text-neon-cyan transition-colors shrink-0">
<path d="M9 18l6-6-6-6"/>
</svg>
</div>
</button>
</div>
</div>
</template>
+5
View File
@@ -66,6 +66,11 @@ const routes = [
name: 'soundboard', name: 'soundboard',
component: () => import('./pages/SoundboardPage.vue'), component: () => import('./pages/SoundboardPage.vue'),
}, },
{
path: '/feed',
name: 'feed',
component: () => import('./pages/FeedPage.vue'),
},
] ]
export const router = createRouter({ export const router = createRouter({