Files
game-marathon/frontend/src/pages/MarathonPage.tsx
2025-12-20 01:07:24 +07:00

539 lines
24 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, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [challenges, setChallenges] = useState<Challenge[]>([])
const [isLoading, setIsLoading] = useState(true)
const [copied, setCopied] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false)
const [isFinishing, setIsFinishing] = useState(false)
const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => {
loadMarathon()
}, [id])
const loadMarathon = async () => {
if (!id) return
try {
const data = await marathonsApi.get(parseInt(id))
setMarathon(data)
if (data.status === 'active' && data.my_participation) {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
// Load challenges for all participants
try {
const challengesData = await challengesApi.list(parseInt(id))
setChallenges(challengesData)
} catch {
// Ignore if no challenges
}
}
} catch (error) {
console.error('Failed to load marathon:', error)
navigate('/marathons')
} finally {
setIsLoading(false)
}
}
const refreshEvent = async () => {
if (!id) return
try {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
activityFeedRef.current?.refresh()
} catch (error) {
console.error('Failed to refresh event:', error)
}
}
const getInviteLink = () => {
if (!marathon) return ''
return `${window.location.origin}/invite/${marathon.invite_code}`
}
const copyInviteLink = () => {
if (marathon) {
navigator.clipboard.writeText(getInviteLink())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleDelete = async () => {
if (!marathon) return
const confirmed = await confirm({
title: 'Удалить марафон?',
message: 'Все данные марафона будут удалены безвозвратно.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setIsDeleting(true)
try {
await marathonsApi.delete(marathon.id)
navigate('/marathons')
} catch (error) {
console.error('Failed to delete marathon:', error)
toast.error('Не удалось удалить марафон')
} 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 } } }
toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
const handleFinish = async () => {
if (!marathon) return
const confirmed = await confirm({
title: 'Завершить марафон?',
message: 'Марафон будет завершён досрочно. Участники больше не смогут выполнять задания.',
confirmText: 'Завершить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsFinishing(true)
try {
const updated = await marathonsApi.finish(marathon.id)
setMarathon(updated)
toast.success('Марафон завершён')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось завершить марафон')
} finally {
setIsFinishing(false)
}
}
if (isLoading || !marathon) {
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>
)
}
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
const isParticipant = !!marathon.my_participation
const isCreator = marathon.creator.id === user?.id
const canDelete = isCreator || user?.role === 'admin'
const statusConfig = {
active: { color: 'text-neon-400', bg: 'bg-neon-500/20', border: 'border-neon-500/30', label: 'Активен' },
preparing: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', label: 'Подготовка' },
finished: { color: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30', label: 'Завершён' },
}
const status = statusConfig[marathon.status as keyof typeof statusConfig] || statusConfig.finished
return (
<div className="max-w-7xl mx-auto">
{/* Back button */}
<Link
to="/marathons"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К списку марафонов
</Link>
{/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
<div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
{/* Title & Description */}
<div className="flex-1">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
marathon.is_public
? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-dark-700 text-gray-300 border-dark-600'
}`}>
{marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
{marathon.is_public ? 'Открытый' : 'Закрытый'}
</span>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${status.bg} ${status.color} ${status.border}`}>
<span className={`w-2 h-2 rounded-full ${marathon.status === 'active' ? 'bg-neon-500 animate-pulse' : marathon.status === 'preparing' ? 'bg-yellow-500' : 'bg-gray-500'}`} />
{status.label}
</span>
</div>
{marathon.description && (
<p className="text-gray-400 max-w-2xl">{marathon.description}</p>
)}
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
Присоединиться
</NeonButton>
)}
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
Настройка
</NeonButton>
</Link>
)}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}>
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
Предложить игру
</NeonButton>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<NeonButton icon={<Play className="w-4 h-4" />}>
Играть
</NeonButton>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
Рейтинг
</NeonButton>
</Link>
{marathon.status === 'active' && isOrganizer && (
<button
onClick={handleFinish}
disabled={isFinishing}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border border-yellow-500/30 bg-dark-600 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Flag className="w-4 h-4" />}
Завершить
</button>
)}
{canDelete && (
<NeonButton
variant="ghost"
onClick={handleDelete}
isLoading={isDeleting}
className="!text-red-400 hover:!bg-red-500/10"
icon={<Trash2 className="w-4 h-4" />}
/>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatsCard
label="Участников"
value={marathon.participants_count}
icon={<Users className="w-5 h-5" />}
color="neon"
/>
<StatsCard
label="Игр"
value={marathon.games_count}
icon={<Gamepad2 className="w-5 h-5" />}
color="purple"
/>
<StatsCard
label="Начало"
value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
icon={<Calendar className="w-5 h-5" />}
color="default"
/>
<StatsCard
label="Конец"
value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
icon={<CalendarCheck className="w-5 h-5" />}
color="default"
/>
<StatsCard
label="Статус"
value={status.label}
icon={<Target className="w-5 h-5" />}
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
/>
</div>
{/* Telegram Bot Banner */}
<TelegramBotBanner />
{/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && (
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
)}
{/* Event control for organizers */}
{marathon.status === 'active' && isOrganizer && (
<GlassCard>
<button
onClick={() => setShowEventControl(!showEventControl)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-yellow-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Управление событиями</h3>
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
</div>
</div>
{showEventControl ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showEventControl && activeEvent && (
<div className="mt-6 pt-6 border-t border-dark-600">
<EventControl
marathonId={marathon.id}
activeEvent={activeEvent}
challenges={challenges}
onEventChange={refreshEvent}
/>
</div>
)}
</GlassCard>
)}
{/* Invite link */}
{marathon.status !== 'finished' && (
<GlassCard>
<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">
<Link2 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 items-center gap-3">
<code className="flex-1 px-4 py-3 bg-dark-700 rounded-xl text-neon-400 font-mono text-sm overflow-hidden text-ellipsis border border-dark-600">
{getInviteLink()}
</code>
<NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
{copied ? 'Скопировано!' : 'Копировать'}
</NeonButton>
</div>
</GlassCard>
)}
{/* My stats */}
{marathon.my_participation && (
<GlassCard variant="neon">
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-500" />
Ваша статистика
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-neon-400">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400 mt-1">Очков</div>
</div>
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-yellow-400 flex items-center justify-center gap-1">
{marathon.my_participation.current_streak}
{marathon.my_participation.current_streak > 0 && (
<span className="text-lg">🔥</span>
)}
</div>
<div className="text-sm text-gray-400 mt-1">Серия</div>
</div>
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
{marathon.my_participation.drop_count}
<TrendingDown className="w-5 h-5" />
</div>
<div className="text-sm text-gray-400 mt-1">Пропусков</div>
</div>
</div>
</GlassCard>
)}
{/* All challenges viewer */}
{marathon.status === 'active' && isParticipant && challenges.length > 0 && (
<GlassCard>
<button
onClick={() => setShowChallenges(!showChallenges)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-accent-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Все задания</h3>
<p className="text-sm text-gray-400">{challenges.length} заданий для {new Set(challenges.map(c => c.game.id)).size} игр</p>
</div>
</div>
{showChallenges ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showChallenges && (
<div className="mt-6 pt-6 border-t border-dark-600 space-y-4">
{/* Group challenges by game */}
{Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => {
const gameChallenges = challenges.filter(c => c.game.id === gameId)
const game = gameChallenges[0]?.game
if (!game) return null
const isExpanded = expandedGameId === gameId
return (
<div key={gameId} className="glass rounded-xl overflow-hidden border border-dark-600">
<button
onClick={() => setExpandedGameId(isExpanded ? null : gameId)}
className="w-full flex items-center justify-between p-4 hover:bg-dark-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-gray-400">
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</span>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-4 h-4 text-neon-400" />
</div>
<div className="text-left">
<h4 className="font-semibold text-white">{game.title}</h4>
<span className="text-xs text-gray-400">{gameChallenges.length} заданий</span>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
{gameChallenges.map(challenge => (
<div
key={challenge.id}
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
<span className="text-xs text-gray-500">
{challenge.type === 'completion' ? 'Прохождение' :
challenge.type === 'no_death' ? 'Без смертей' :
challenge.type === 'speedrun' ? 'Спидран' :
challenge.type === 'collection' ? 'Коллекция' :
challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'}
</span>
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Target className="w-3 h-3" />
Пруф: {challenge.proof_hint}
</p>
)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</GlassCard>
)}
</div>
{/* Activity Feed - right sidebar */}
{isParticipant && (
<div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-24">
<ActivityFeed
ref={activityFeedRef}
marathonId={marathon.id}
className="lg:max-h-[calc(100vh-8rem)]"
/>
</div>
</div>
)}
</div>
</div>
)
}