193 lines
6.7 KiB
Vue
193 lines
6.7 KiB
Vue
<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>
|