Files
game-marathon/frontend/src/pages/MarathonsPage.tsx
2025-12-17 02:03:33 +07:00

267 lines
9.9 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 { Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonsPage() {
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [joinCode, setJoinCode] = useState('')
const [joinError, setJoinError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
const [showJoinSection, setShowJoinSection] = useState(false)
useEffect(() => {
loadMarathons()
}, [])
const loadMarathons = async () => {
try {
const data = await marathonsApi.list()
setMarathons(data)
} catch (error) {
console.error('Failed to load marathons:', error)
} finally {
setIsLoading(false)
}
}
const handleJoin = async () => {
if (!joinCode.trim()) return
setJoinError(null)
setIsJoining(true)
try {
await marathonsApi.join(joinCode.trim())
setJoinCode('')
setShowJoinSection(false)
await loadMarathons()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setJoinError(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
const getStatusConfig = (status: string) => {
switch (status) {
case 'preparing':
return {
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
text: 'Подготовка',
dot: 'bg-yellow-500',
}
case 'active':
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
text: 'Активен',
dot: 'bg-neon-500 animate-pulse',
}
case 'finished':
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: 'Завершён',
dot: 'bg-gray-500',
}
default:
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: status,
dot: 'bg-gray-500',
}
}
}
// Stats
const activeCount = marathons.filter(m => m.status === 'active').length
const completedCount = marathons.filter(m => m.status === 'finished').length
const totalParticipants = marathons.reduce((acc, m) => acc + m.participants_count, 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-5xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Мои марафоны</h1>
<p className="text-gray-400">Управляйте своими игровыми соревнованиями</p>
</div>
<div className="flex gap-3">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(!showJoinSection)}
icon={<Hash className="w-4 h-4" />}
>
По коду
</NeonButton>
<Link to="/marathons/create">
<NeonButton icon={<Plus className="w-4 h-4" />}>
Создать
</NeonButton>
</Link>
</div>
</div>
{/* Stats */}
{marathons.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatsCard
label="Всего"
value={marathons.length}
icon={<Gamepad2 className="w-6 h-6" />}
color="default"
/>
<StatsCard
label="Активных"
value={activeCount}
icon={<Sparkles className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Завершено"
value={completedCount}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Участников"
value={totalParticipants}
icon={<Users className="w-6 h-6" />}
color="pink"
/>
</div>
)}
{/* Join marathon */}
{showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Hash className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Присоединиться к марафону</h3>
<p className="text-sm text-gray-400">Введите код приглашения</p>
</div>
</div>
<div className="flex gap-3">
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && handleJoin()}
placeholder="XXXXXX"
className="input flex-1 font-mono text-center tracking-widest uppercase"
maxLength={10}
/>
<NeonButton
onClick={handleJoin}
isLoading={isJoining}
color="purple"
>
Присоединиться
</NeonButton>
</div>
{joinError && (
<p className="mt-3 text-sm text-red-400 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{joinError}
</p>
)}
</GlassCard>
)}
{/* Marathon list */}
{marathons.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">
<Gamepad2 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 mb-6 max-w-sm mx-auto">
Создайте свой первый марафон или присоединитесь к существующему по коду
</p>
<div className="flex gap-3 justify-center">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(true)}
icon={<Hash className="w-4 h-4" />}
>
Ввести код
</NeonButton>
<Link to="/marathons/create">
<NeonButton icon={<Plus className="w-4 h-4" />}>
Создать марафон
</NeonButton>
</Link>
</div>
</GlassCard>
) : (
<div className="space-y-4">
{marathons.map((marathon, index) => {
const status = getStatusConfig(marathon.status)
return (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<div
className="group glass rounded-xl p-5 border border-dark-600 transition-all duration-300 hover:border-neon-500/30 hover:-translate-y-0.5 hover:shadow-[0_10px_40px_rgba(0,240,255,0.1)]"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
{/* Info */}
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-neon-400 transition-colors mb-1">
{marathon.title}
</h3>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
{marathon.participants_count}
</span>
{marathon.start_date && (
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'd MMM yyyy', { locale: ru })}
</span>
)}
</div>
</div>
</div>
{/* Status & Arrow */}
<div className="flex items-center gap-4">
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-2 ${status.color}`}>
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
{status.text}
</span>
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-neon-400 transition-colors" />
</div>
</div>
</div>
</Link>
)
})}
</div>
)}
</div>
)
}