Files
game-marathon/frontend/src/pages/LeaderboardPage.tsx
2025-12-17 20:29:22 +07:00

285 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types'
import { GlassCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadLeaderboard()
}, [id])
const loadLeaderboard = async () => {
if (!id) return
try {
const data = await marathonsApi.getLeaderboard(parseInt(id))
setLeaderboard(data)
} catch (error) {
console.error('Failed to load leaderboard:', error)
} finally {
setIsLoading(false)
}
}
const getRankConfig = (rank: number) => {
switch (rank) {
case 1:
return {
icon: <Crown className="w-6 h-6" />,
color: 'text-yellow-400',
bg: 'bg-yellow-500/20',
border: 'border-yellow-500/30',
glow: 'shadow-[0_0_20px_rgba(234,179,8,0.3)]',
gradient: 'from-yellow-500/20 via-transparent to-transparent',
}
case 2:
return {
icon: <Medal className="w-6 h-6" />,
color: 'text-gray-300',
bg: 'bg-gray-400/20',
border: 'border-gray-400/30',
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.2)]',
gradient: 'from-gray-400/10 via-transparent to-transparent',
}
case 3:
return {
icon: <Award className="w-6 h-6" />,
color: 'text-amber-600',
bg: 'bg-amber-600/20',
border: 'border-amber-600/30',
glow: 'shadow-[0_0_15px_rgba(217,119,6,0.2)]',
gradient: 'from-amber-600/10 via-transparent to-transparent',
}
default:
return {
icon: <span className="text-gray-500 font-mono font-bold">{rank}</span>,
color: 'text-gray-500',
bg: 'bg-dark-700',
border: 'border-dark-600',
glow: '',
gradient: '',
}
}
}
// Top 3 for podium
const topThree = leaderboard.slice(0, 3)
// Calculate stats
const totalPoints = leaderboard.reduce((acc, e) => acc + e.total_points, 0)
const maxStreak = Math.max(...leaderboard.map(e => e.current_streak), 0)
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка рейтинга...</p>
</div>
)
}
return (
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link
to={`/marathons/${id}`}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
</div>
</div>
{leaderboard.length === 0 ? (
<GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<Trophy className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Пока нет участников</h3>
<p className="text-gray-400">Станьте первым в рейтинге!</p>
</GlassCard>
) : (
<>
{/* Podium for top 3 */}
{topThree.length >= 3 && (
<div className="mb-8">
<div className="flex items-end justify-center gap-4 mb-4">
{/* 2nd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-gray-400/10 border border-gray-400/30
shadow-[0_0_20px_rgba(156,163,175,0.2)]
`}>
<span className="text-3xl font-bold text-gray-300">2</span>
</div>
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
</Link>
</div>
{/* 1st place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
<div className={`
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
bg-yellow-500/20 border border-yellow-500/30
shadow-[0_0_30px_rgba(234,179,8,0.4)]
`}>
<Crown className="w-10 h-10 text-yellow-400" />
</div>
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
</Link>
</div>
{/* 3rd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-amber-600/10 border border-amber-600/30
shadow-[0_0_20px_rgba(217,119,6,0.2)]
`}>
<span className="text-3xl font-bold text-amber-600">3</span>
</div>
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
</Link>
</div>
</div>
</div>
)}
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="glass rounded-xl p-4 text-center">
<Trophy className="w-6 h-6 text-neon-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{leaderboard.length}</p>
<p className="text-xs text-gray-400">Участников</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Zap className="w-6 h-6 text-accent-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{totalPoints}</p>
<p className="text-xs text-gray-400">Всего очков</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Flame className="w-6 h-6 text-orange-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{maxStreak}</p>
<p className="text-xs text-gray-400">Макс. серия</p>
</div>
</div>
{/* Full leaderboard */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Target className="w-5 h-5 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-white">Полный рейтинг</h3>
<p className="text-sm text-gray-400">Все участники марафона</p>
</div>
</div>
<div className="space-y-2">
{leaderboard.map((entry, index) => {
const isCurrentUser = entry.user.id === user?.id
const rankConfig = getRankConfig(entry.rank)
return (
<div
key={entry.user.id}
className={`
relative flex items-center gap-4 p-4 rounded-xl
transition-all duration-300 group
${isCurrentUser
? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: `${rankConfig.bg} border ${rankConfig.border} hover:border-neon-500/20`
}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Gradient overlay for top 3 */}
{entry.rank <= 3 && (
<div className={`absolute inset-0 bg-gradient-to-r ${rankConfig.gradient} rounded-xl pointer-events-none`} />
)}
{/* Rank */}
<div className={`
relative w-10 h-10 rounded-xl flex items-center justify-center
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
`}>
{rankConfig.icon}
</div>
{/* User info */}
<div className="relative flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
to={`/users/${entry.user.id}`}
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
>
{entry.user.nickname}
</Link>
{isCurrentUser && (
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
Вы
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{entry.completed_count} выполнено
</span>
{entry.dropped_count > 0 && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{entry.dropped_count} пропущено
</span>
)}
</div>
</div>
{/* Streak */}
{entry.current_streak > 0 && (
<div className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20">
<Flame className="w-4 h-4 text-orange-400" />
<span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
</div>
)}
{/* Points */}
<div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
{entry.total_points}
</div>
<div className="text-xs text-gray-500">очков</div>
</div>
</div>
)
})}
</div>
</GlassCard>
</>
)}
</div>
)
}