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:
co-authored by
Claude Opus 4.6
parent
610e799605
commit
baf153b115
@@ -11,6 +11,7 @@ const links = [
|
||||
{ to: '/join', label: 'FIGHT!' },
|
||||
{ to: '/fight-card', label: 'FIGHT CARD' },
|
||||
{ to: '/arena', label: 'WATCH' },
|
||||
{ to: '/feed', label: 'FEED' },
|
||||
{ to: '/leaderboard', label: 'RANKINGS' },
|
||||
{ to: '/docs', label: 'DOCS' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
@@ -66,6 +66,11 @@ const routes = [
|
||||
name: 'soundboard',
|
||||
component: () => import('./pages/SoundboardPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/feed',
|
||||
name: 'feed',
|
||||
component: () => import('./pages/FeedPage.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
Reference in New Issue
Block a user