2025-12-14 02:38:35 +07:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
|
|
|
|
|
import { marathonsApi } from '@/api'
|
|
|
|
|
|
import type { Marathon } from '@/types'
|
|
|
|
|
|
import { Button, Card, CardContent } from '@/components/ui'
|
|
|
|
|
|
import { useAuthStore } from '@/store/auth'
|
2025-12-14 20:21:56 +07:00
|
|
|
|
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft } from 'lucide-react'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
import { format } from 'date-fns'
|
|
|
|
|
|
|
|
|
|
|
|
export function MarathonPage() {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const user = useAuthStore((state) => state.user)
|
|
|
|
|
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
|
|
const [copied, setCopied] = useState(false)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
|
|
const [isJoining, setIsJoining] = useState(false)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadMarathon()
|
|
|
|
|
|
}, [id])
|
|
|
|
|
|
|
|
|
|
|
|
const loadMarathon = async () => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await marathonsApi.get(parseInt(id))
|
|
|
|
|
|
setMarathon(data)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load marathon:', error)
|
|
|
|
|
|
navigate('/marathons')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const copyInviteCode = () => {
|
|
|
|
|
|
if (marathon) {
|
|
|
|
|
|
navigator.clipboard.writeText(marathon.invite_code)
|
|
|
|
|
|
setCopied(true)
|
|
|
|
|
|
setTimeout(() => setCopied(false), 2000)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const handleDelete = async () => {
|
|
|
|
|
|
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsDeleting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await marathonsApi.delete(marathon.id)
|
|
|
|
|
|
navigate('/marathons')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to delete marathon:', error)
|
|
|
|
|
|
alert('Не удалось удалить марафон')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDeleting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleJoinPublic = async () => {
|
|
|
|
|
|
if (!marathon) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsJoining(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const updated = await marathonsApi.joinPublic(marathon.id)
|
|
|
|
|
|
setMarathon(updated)
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
|
|
|
|
|
alert(error.response?.data?.detail || 'Не удалось присоединиться')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsJoining(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
if (isLoading || !marathon) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex justify-center py-12">
|
|
|
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
const isParticipant = !!marathon.my_participation
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const isCreator = marathon.creator.id === user?.id
|
|
|
|
|
|
const canDelete = isCreator || user?.role === 'admin'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="max-w-4xl mx-auto">
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Back button */}
|
|
|
|
|
|
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
|
|
|
|
|
<ArrowLeft className="w-4 h-4" />
|
|
|
|
|
|
К списку марафонов
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<div className="flex justify-between items-start mb-8">
|
|
|
|
|
|
<div>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
|
|
|
|
|
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
|
|
|
|
|
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
|
|
|
|
|
marathon.is_public
|
|
|
|
|
|
? 'bg-green-900/50 text-green-400'
|
|
|
|
|
|
: 'bg-gray-700 text-gray-300'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{marathon.is_public ? (
|
|
|
|
|
|
<><Globe className="w-3 h-3" /> Открытый</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<><Lock className="w-3 h-3" /> Закрытый</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
{marathon.description && (
|
|
|
|
|
|
<p className="text-gray-400">{marathon.description}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex gap-2">
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Кнопка присоединиться для открытых марафонов */}
|
|
|
|
|
|
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
|
|
|
|
|
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
|
|
|
|
|
<UserPlus className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Присоединиться
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Настройка для организаторов */}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
{marathon.status === 'preparing' && isOrganizer && (
|
|
|
|
|
|
<Link to={`/marathons/${id}/lobby`}>
|
|
|
|
|
|
<Button variant="secondary">
|
|
|
|
|
|
<Settings className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Настройка
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
|
|
|
|
|
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
|
|
|
|
|
<Link to={`/marathons/${id}/lobby`}>
|
|
|
|
|
|
<Button variant="secondary">
|
|
|
|
|
|
<Gamepad2 className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Предложить игру
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
{marathon.status === 'active' && isParticipant && (
|
|
|
|
|
|
<Link to={`/marathons/${id}/play`}>
|
|
|
|
|
|
<Button>
|
|
|
|
|
|
<Play className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Играть
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Link to={`/marathons/${id}/leaderboard`}>
|
|
|
|
|
|
<Button variant="secondary">
|
|
|
|
|
|
<Trophy className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Рейтинг
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Link>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
{canDelete && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
|
isLoading={isDeleting}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stats */}
|
2025-12-14 20:21:56 +07:00
|
|
|
|
<div className="grid md:grid-cols-5 gap-4 mb-8">
|
2025-12-14 02:38:35 +07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
|
|
|
|
<Users className="w-4 h-4" />
|
|
|
|
|
|
Участников
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Игр</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">
|
|
|
|
|
|
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
|
|
|
|
<Calendar className="w-4 h-4" />
|
2025-12-14 20:21:56 +07:00
|
|
|
|
Начало
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">
|
|
|
|
|
|
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
|
|
|
|
<CalendarCheck className="w-4 h-4" />
|
|
|
|
|
|
Конец
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className={`text-2xl font-bold ${
|
|
|
|
|
|
marathon.status === 'active' ? 'text-green-500' :
|
|
|
|
|
|
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Статус</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Invite code */}
|
|
|
|
|
|
{marathon.status !== 'finished' && (
|
|
|
|
|
|
<Card className="mb-8">
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<h3 className="font-medium text-white mb-3">Код приглашения</h3>
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
|
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono">
|
|
|
|
|
|
{marathon.invite_code}
|
|
|
|
|
|
</code>
|
|
|
|
|
|
<Button variant="secondary" onClick={copyInviteCode}>
|
|
|
|
|
|
{copied ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Check className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Скопировано!
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Copy className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Копировать
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm text-gray-500 mt-2">
|
|
|
|
|
|
Поделитесь этим кодом с друзьями, чтобы они могли присоединиться к марафону
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* My stats */}
|
|
|
|
|
|
{marathon.my_participation && (
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-4 text-center">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-2xl font-bold text-primary-500">
|
|
|
|
|
|
{marathon.my_participation.total_points}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Очков</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-2xl font-bold text-yellow-500">
|
|
|
|
|
|
{marathon.my_participation.current_streak}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Серия</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="text-2xl font-bold text-gray-400">
|
|
|
|
|
|
{marathon.my_participation.drop_count}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400">Пропусков</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|