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: '/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' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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',
|
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({
|
||||||
|
|||||||
Reference in New Issue
Block a user