587 lines
27 KiB
Vue
587 lines
27 KiB
Vue
<script setup>
|
|
import { User, Trophy } from 'lucide-vue-next'
|
|
import { ref, onMounted, nextTick, inject } from 'vue'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '@/components/ui/card'
|
|
import { useAPIStore } from '@/stores/api'
|
|
import { useRouter } from 'vue-router'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import axios from 'axios'
|
|
|
|
const API_BASE_URL = inject('apiBaseURL')
|
|
const apiStore = useAPIStore()
|
|
const router = useRouter()
|
|
const openGames = ref([])
|
|
const selectedMode = ref('9')
|
|
const gObserverTarget = ref(null);
|
|
const gObserver = ref(null);
|
|
const gLoading = ref(false);
|
|
const gPage = ref(1);
|
|
const openMatches = ref([])
|
|
const mObserverTarget = ref(null);
|
|
const mObserver = ref(null);
|
|
const mLoading = ref(false);
|
|
const mPage = ref(1);
|
|
const showHostModal = ref(false)
|
|
const showJoinModal = ref(false)
|
|
const selectedGame = ref(null)
|
|
const isReady = ref(false)
|
|
const showLeaderboardModal = ref(false)
|
|
const showStatsModal = ref(false)
|
|
const userStats = ref(null)
|
|
const statsLoading = ref(false)
|
|
const leaderboardEntries = ref([])
|
|
const lbLoading = ref(false)
|
|
const lbPage = ref(1)
|
|
const lbObserverTarget = ref(null)
|
|
const lbObserver = ref(null)
|
|
const lbMorePages = ref(true)
|
|
const publicStats = ref(null)
|
|
const isLoggedIn = useAuthStore().isLoggedIn
|
|
|
|
const setupMatchesObserver = () => {
|
|
if (mObserver.value) mObserver.value.disconnect();
|
|
|
|
mObserver.value = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !mLoading.value && openMatches.value.length > 0) {
|
|
getPendingMatches();
|
|
}
|
|
}, { threshold: 0.1 });
|
|
|
|
if (mObserverTarget.value) {
|
|
mObserver.value.observe(mObserverTarget.value);
|
|
}
|
|
}
|
|
|
|
const setupGamesObserver = () => {
|
|
if (gObserver.value) gObserver.value.disconnect();
|
|
|
|
gObserver.value = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !gLoading.value && openGames.value.length > 0) {
|
|
getPendingGames();
|
|
}
|
|
}, { threshold: 0.1 });
|
|
|
|
if (gObserverTarget.value) {
|
|
gObserver.value.observe(gObserverTarget.value);
|
|
}
|
|
}
|
|
|
|
const getPendingGames = async (mode = selectedMode.value) => {
|
|
if (gLoading.value) return;
|
|
gLoading.value = true;
|
|
|
|
try {
|
|
const response = await axios.get(`${API_BASE_URL}/games/open`, {
|
|
params: {
|
|
type: mode,
|
|
page: gPage.value
|
|
}
|
|
});
|
|
|
|
const newGames = response.data.data;
|
|
openGames.value = [...openGames.value, ...newGames];
|
|
} finally {
|
|
gLoading.value = false;
|
|
}
|
|
}
|
|
|
|
const getPendingMatches = async (mode = selectedMode.value) => {
|
|
if (mLoading.value) return;
|
|
mLoading.value = true;
|
|
|
|
try {
|
|
const response = await axios.get(`${API_BASE_URL}/matches/open`, {
|
|
params: {
|
|
type: mode,
|
|
page: mPage.value
|
|
}
|
|
});
|
|
|
|
const newMatches = response.data.data;
|
|
openMatches.value = [...openMatches.value, ...newMatches];
|
|
} finally {
|
|
mLoading.value = false;
|
|
}
|
|
}
|
|
|
|
const fetchPublicStats = async () => {
|
|
try {
|
|
const response = await apiStore.getPublicStats()
|
|
publicStats.value = response.data
|
|
} catch (error) {
|
|
console.error("Failed to fetch public stats", error)
|
|
}
|
|
}
|
|
|
|
const openStats = async () => {
|
|
showStatsModal.value = true
|
|
statsLoading.value = true
|
|
try {
|
|
const response = await apiStore.getCurrentUserStats()
|
|
userStats.value = response.data
|
|
} catch (error) {
|
|
console.error("Failed to fetch stats", error)
|
|
} finally {
|
|
statsLoading.value = false
|
|
}
|
|
}
|
|
|
|
const getAvatarUrl = (filename) => {
|
|
return filename
|
|
? `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${filename}`
|
|
: `${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/anonymous.png`;
|
|
}
|
|
|
|
const fetchLeaderboard = async () => {
|
|
if (lbLoading.value || !lbMorePages.value) return;
|
|
lbLoading.value = true;
|
|
try {
|
|
const response = await apiStore.getLeaderboard({ page: lbPage.value });
|
|
const newEntries = response.data.data;
|
|
|
|
if (newEntries.length < 10) {
|
|
lbMorePages.value = false;
|
|
}
|
|
|
|
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
|
|
lbPage.value++;
|
|
} finally {
|
|
lbLoading.value = false;
|
|
}
|
|
}
|
|
|
|
const openLeaderboard = async () => {
|
|
leaderboardEntries.value = [];
|
|
lbPage.value = 1;
|
|
showLeaderboardModal.value = true;
|
|
await nextTick();
|
|
setupLeaderboardObserver();
|
|
await fetchLeaderboard();
|
|
}
|
|
|
|
const setupLeaderboardObserver = () => {
|
|
if (lbObserver.value) lbObserver.value.disconnect();
|
|
lbObserver.value = new IntersectionObserver((entries) => {
|
|
if (entries[0].isIntersecting && !lbLoading.value && leaderboardEntries.value.length > 0) {
|
|
fetchLeaderboard();
|
|
}
|
|
}, { threshold: 0.1 });
|
|
|
|
if (lbObserverTarget.value) {
|
|
lbObserver.value.observe(lbObserverTarget.value);
|
|
}
|
|
}
|
|
|
|
const clickMode = async (mode) => {
|
|
if (selectedMode.value === mode) return;
|
|
selectedMode.value = mode;
|
|
openGames.value = [];
|
|
gPage.value = 1;
|
|
openMatches.value = [];
|
|
mPage.value = 1;
|
|
await Promise.all([getPendingGames(), getPendingMatches()]);
|
|
setupGamesObserver();
|
|
setupMatchesObserver();
|
|
}
|
|
|
|
const startSingle = () => {
|
|
router.push({ name: 'bisca' + selectedMode.value });
|
|
}
|
|
|
|
const toggleReady = () => {
|
|
isReady.value = !isReady.value
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await fetchPublicStats();
|
|
if (isLoggedIn) {
|
|
await Promise.all([getPendingGames(), getPendingMatches()]);
|
|
await nextTick();
|
|
setupGamesObserver();
|
|
setupMatchesObserver();
|
|
}
|
|
})
|
|
|
|
const hostGame = async () => {
|
|
showHostModal.value = true
|
|
|
|
try {
|
|
const response = await axios.post(`${API_BASE_URL}/games/host`, {
|
|
type: selectedMode.value
|
|
})
|
|
|
|
const gameId = response.data.id
|
|
|
|
console.log('Game hosted:', gameId)
|
|
|
|
router.push({
|
|
name: 'multiplayer-game',
|
|
params: { id: gameId },
|
|
query: { type: selectedMode.value }
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to host game:', error)
|
|
console.error('Error details:', error.response?.data)
|
|
showHostModal.value = false
|
|
alert('Failed to host game: ' + (error.response?.data?.message || error.message))
|
|
}
|
|
}
|
|
|
|
const joinGame = async (game) => {
|
|
selectedGame.value = game
|
|
|
|
try {
|
|
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
|
|
|
|
console.log('Joined game:', game.id)
|
|
|
|
router.push({
|
|
name: 'multiplayer-game',
|
|
params: { id: game.id },
|
|
query: { type: selectedMode.value }
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to join game:', error)
|
|
console.error('Error details:', error.response?.data)
|
|
alert('Failed to join game: ' + (error.response?.data?.message || error.message))
|
|
}
|
|
}
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="min-h-screen bg-cover bg-center transition-all duration-700">
|
|
|
|
<div class="flex flex-col justify-center items-center gap-5 pt-10 pb-10 px-4">
|
|
|
|
<Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm">
|
|
<CardContent class="p-4">
|
|
<div class="flex justify-around items-center">
|
|
<div class="text-center">
|
|
<p class="text-xs uppercase font-bold opacity-70">Players</p>
|
|
<p class="text-xl font-black">{{ publicStats.total_players }}</p>
|
|
</div>
|
|
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
|
|
<div class="text-center">
|
|
<p class="text-xs uppercase font-bold opacity-70">Total Games</p>
|
|
<p class="text-xl font-black">{{ publicStats.total_games }}</p>
|
|
</div>
|
|
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
|
|
<div class="text-center">
|
|
<p class="text-xs uppercase font-bold opacity-70">Active</p>
|
|
<p class="text-xl font-black">{{ publicStats.active_games }}</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden">
|
|
<div class="relative h-14 flex items-center">
|
|
|
|
<button @click="clickMode('9')"
|
|
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0"
|
|
:class="{
|
|
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '9',
|
|
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '9'
|
|
}">
|
|
<span>Bisca de 9</span>
|
|
</button>
|
|
|
|
<button @click="clickMode('3')"
|
|
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
|
|
:class="{
|
|
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '3',
|
|
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '3'
|
|
}">
|
|
<span>Bisca de 3</span>
|
|
</button>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col items-center gap-5 w-[100vw]">
|
|
<Card class="w-full max-w-2xl">
|
|
<CardHeader>
|
|
<CardTitle class="text-3xl font-bold text-center">
|
|
Single Player
|
|
</CardTitle>
|
|
<CardDescription class="text-center">
|
|
Go against one of our bots!
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="space-y-6">
|
|
<div class="flex justify-center">
|
|
<Button @click="startSingle()" size="lg" variant="secondary"
|
|
class="hover:bg-purple-500 hover:text-slate-200">
|
|
Start Game
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card class="w-full max-w-2xl flex flex-col">
|
|
<CardHeader>
|
|
<CardTitle class="text-3xl font-bold text-center">Multi Player</CardTitle>
|
|
<CardDescription class="text-center">Go one on one with another player!</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent class="flex-1 flex flex-col space-y-6">
|
|
<div class="flex flex-col flex-1">
|
|
<div class="flex justify-between items-end mb-2">
|
|
<label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open
|
|
Matches</label>
|
|
<Button v-if="isLoggedIn" @click="showHostModal()" size="sm" variant="outline"
|
|
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white transition-all duration-300 font-bold">
|
|
Host
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
|
<div v-if="openGames.length === 0"
|
|
class="p-6 text-center text-sm text-muted-foreground">
|
|
No open macthes yet. Try hosting one!
|
|
</div>
|
|
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
|
|
<div v-for="(match, index) in openMatches" :key="match.id"
|
|
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-red-100 text-red-700">
|
|
{{ index + 1 }}
|
|
</div>
|
|
<div>
|
|
<div class="font-medium text-sm">Match ID: {{ match.id }}</div>
|
|
<div class="text-[10px] text-muted-foreground">{{ new
|
|
Date(game.began_at).toLocaleDateString() }} • {{ new
|
|
Date(game.began_at).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit' }) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div ref="mObserverTarget" class="h-10 flex items-center justify-center">
|
|
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading
|
|
matches...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col flex-1">
|
|
<div class="flex justify-between items-end mb-2">
|
|
<label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open
|
|
Games</label>
|
|
<Button v-if="isLoggedIn" @click="hostGame()" size="sm" variant="outline"
|
|
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white transition-all duration-300 font-bold">
|
|
Host
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
|
|
<div v-if="openGames.length === 0"
|
|
class="p-6 text-center text-sm text-muted-foreground">
|
|
No open games yet. Try hosting one!
|
|
</div>
|
|
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
|
|
<div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)"
|
|
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
|
|
<div class="flex items-center gap-3">
|
|
<div
|
|
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
|
|
{{ index + 1 }}
|
|
</div>
|
|
<div>
|
|
<div class="font-medium text-sm">Host: {{ game.player1?.nickname ||
|
|
'Anonymous' }}</div>
|
|
<div class="text-[10px] text-muted-foreground">{{ new
|
|
Date(game.began_at).toLocaleDateString() }} • {{ new
|
|
Date(game.began_at).toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit' }) }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div ref="gObserverTarget" class="h-10 flex items-center justify-center">
|
|
<span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading
|
|
games...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
<div v-if="showHostModal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<Card class="w-full max-w-sm border-purple-500 shadow-2xl">
|
|
<CardHeader>
|
|
<CardTitle class="text-center">Hosting Game</CardTitle>
|
|
</CardHeader>
|
|
<CardContent class="flex flex-col items-center gap-6 py-10">
|
|
<div class="relative">
|
|
<div
|
|
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
|
|
</div>
|
|
</div>
|
|
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p>
|
|
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
<div v-if="showJoinModal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<Card class="w-full max-w-md border-purple-500 shadow-2xl">
|
|
<CardHeader>
|
|
<CardTitle>Join Game</CardTitle>
|
|
<CardDescription v-if="selectedGame">
|
|
Hosted by {{ selectedGame.player1?.nickname || 'Anonymous' }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
|
|
<CardContent class="space-y-4">
|
|
<div class="rounded-lg bg-muted p-4 text-sm">
|
|
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p>
|
|
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p>
|
|
</div>
|
|
|
|
<div v-if="isReady"
|
|
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400">
|
|
<span class="animate-pulse font-semibold">Waiting for the host to start...</span>
|
|
</div>
|
|
|
|
<div class="flex gap-3 pt-4">
|
|
<Button variant="destructive" class="flex-1" @click="showJoinModal = false">
|
|
Leave
|
|
</Button>
|
|
|
|
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'"
|
|
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady">
|
|
{{ isReady ? 'Cancel Ready' : 'Ready up' }}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div v-if="showStatsModal"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<Card class="w-full max-w-sm max-h-2/3 border-purple-500 shadow-2xl">
|
|
<CardHeader class="border-b bg-muted/20">
|
|
<div class="flex justify-between items-center">
|
|
<CardTitle class="flex items-center gap-2">
|
|
<User class="text-yellow-500" /> My Statistics
|
|
</CardTitle>
|
|
<Button variant="ghost" size="sm" @click="showStatsModal = false">X</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="py-6">
|
|
<div v-if="statsLoading" class="flex flex-col items-center py-10 gap-4">
|
|
<div class="h-8 w-8 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
|
|
</div>
|
|
<p class="text-sm text-muted-foreground">Calculating your glory...</p>
|
|
</div>
|
|
|
|
<div v-else-if="userStats" class="space-y-6">
|
|
<div class="text-center">
|
|
<h3 class="text-2xl font-bold text-purple-600">{{ userStats.nickname }}</h3>
|
|
<p class="text-sm text-muted-foreground">Performance Overview</p>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div
|
|
class="rounded-lg bg-muted/50 p-4 text-center border border-transparent hover:border-purple-500/30 transition-all">
|
|
<p class="text-xs uppercase text-muted-foreground font-semibold">Total Games</p>
|
|
<p class="text-2xl font-bold">{{ userStats.total_games }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-purple-500/10 p-4 text-center border border-purple-500/20">
|
|
<p class="text-xs uppercase text-purple-600 font-semibold">Win Rate</p>
|
|
<p class="text-2xl font-bold text-purple-600">{{ userStats.win_rate }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-green-500/10 p-4 text-center border border-green-500/20">
|
|
<p class="text-xs uppercase text-green-600 font-semibold">Wins</p>
|
|
<p class="text-2xl font-bold text-green-600">{{ userStats.total_wins }}</p>
|
|
</div>
|
|
<div class="rounded-lg bg-red-500/10 p-4 text-center border border-red-500/20">
|
|
<p class="text-xs uppercase text-red-600 font-semibold">Losses</p>
|
|
<p class="text-2xl font-bold text-red-600">{{ userStats.total_losses }}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between text-xs font-medium">
|
|
<span>Win/Loss Ratio</span>
|
|
<span>{{ userStats.total_wins }}W - {{ userStats.total_losses }}L</span>
|
|
</div>
|
|
<div class="h-2 w-full bg-red-500/20 rounded-full overflow-hidden flex">
|
|
<div class="bg-green-500 h-full" :style="{ width: userStats.win_rate }"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div v-if="showLeaderboardModal"
|
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
|
<Card class="w-full max-w-lg max-h-1/2 h-[80vh] flex flex-col border-purple-500 shadow-2xl">
|
|
<CardHeader class="border-b">
|
|
<div class="flex justify-between items-center">
|
|
<CardTitle class="flex items-center gap-2">
|
|
<Trophy class="text-purple-500" /> Leaderboard
|
|
</CardTitle>
|
|
<Button variant="ghost" size="sm" @click="showLeaderboardModal = false">X</Button>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent class="flex-1 overflow-y-auto custom-scrollbar p-0">
|
|
<div v-for="(user, index) in leaderboardEntries" :key="user.id"
|
|
class="flex items-center justify-between p-4 border-b last:border-0 hover:bg-muted/30 transition-colors">
|
|
|
|
<div class="flex items-center gap-4">
|
|
<span class="font-bold text-muted-foreground w-6 text-center"
|
|
:class="{ 'text-yellow-500': index === 0, 'text-slate-400': index === 1, 'text-orange-400': index === 2 }">
|
|
#{{ index + 1 }}
|
|
</span>
|
|
<img :src="getAvatarUrl(user.photo_avatar_filename)"
|
|
class="h-10 w-10 rounded-full border border-purple-200 object-cover" alt="Avatar" />
|
|
<div>
|
|
<p class="font-semibold text-sm">{{ user.nickname }}</p>
|
|
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">Player</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-right">
|
|
<p class="text-lg font-bold text-purple-600">{{ user.won_games_count }}</p>
|
|
<p class="text-[10px] text-muted-foreground uppercase">Wins</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div ref="lbObserverTarget" class="h-20 flex items-center justify-center">
|
|
<span v-if="lbLoading" class="text-purple-500 animate-bounce">Loading rankings...</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
|
|
<Button @click="openLeaderboard" variant="outline" size="icon"
|
|
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
|
|
<Trophy class="h-6 w-6" />
|
|
</Button>
|
|
<Button v-if="useAuthStore().isLoggedIn" @click="openStats" variant="outline" size="icon"
|
|
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
|
|
<User class="h-6 w-6" />
|
|
</Button>
|
|
</div>
|
|
</template>
|