2025-12-14 02:38:35 +07:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useParams, Link } from 'react-router-dom'
|
|
|
|
|
|
import { marathonsApi } from '@/api'
|
|
|
|
|
|
import type { LeaderboardEntry } from '@/types'
|
2025-12-17 02:03:33 +07:00
|
|
|
|
import { GlassCard } from '@/components/ui'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
import { useAuthStore } from '@/store/auth'
|
2025-12-17 02:03:33 +07:00
|
|
|
|
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
const getRankConfig = (rank: number) => {
|
2025-12-14 02:38:35 +07:00
|
|
|
|
switch (rank) {
|
|
|
|
|
|
case 1:
|
2025-12-17 02:03:33 +07:00
|
|
|
|
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',
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
case 2:
|
2025-12-17 02:03:33 +07:00
|
|
|
|
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',
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
case 3:
|
2025-12-17 02:03:33 +07:00
|
|
|
|
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',
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
default:
|
2025-12-17 02:03:33 +07:00
|
|
|
|
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: '',
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
// 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)
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
if (isLoading) {
|
|
|
|
|
|
return (
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<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>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<div className="max-w-3xl mx-auto">
|
|
|
|
|
|
{/* Header */}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
<div className="flex items-center gap-4 mb-8">
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<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" />
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</Link>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
|
|
|
|
|
|
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
|
|
|
|
|
|
</div>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
{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>
|
|
|
|
|
|
<div className="glass rounded-xl p-4 text-center w-28">
|
|
|
|
|
|
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
|
|
|
|
|
|
<p className="text-sm font-medium text-white truncate">{topThree[1].user.nickname}</p>
|
|
|
|
|
|
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</div>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
<div className="glass-neon rounded-xl p-4 text-center w-32">
|
|
|
|
|
|
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
|
|
|
|
|
|
<p className="font-semibold text-white truncate">{topThree[0].user.nickname}</p>
|
|
|
|
|
|
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</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>
|
|
|
|
|
|
<div className="glass rounded-xl p-4 text-center w-28">
|
|
|
|
|
|
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
|
|
|
|
|
|
<p className="text-sm font-medium text-white truncate">{topThree[2].user.nickname}</p>
|
|
|
|
|
|
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
{/* 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
|
2025-12-17 14:53:56 +07:00
|
|
|
|
? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
2025-12-17 02:03:33 +07:00
|
|
|
|
: `${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}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-17 02:03:33 +07:00
|
|
|
|
{/* User info */}
|
|
|
|
|
|
<div className="relative flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className={`font-semibold truncate ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
|
|
|
|
|
{entry.user.nickname}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{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>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
2025-12-17 02:03:33 +07:00
|
|
|
|
</GlassCard>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|