Files
game-marathon/frontend/src/pages/MarathonPage.tsx
mamonov.ep 89dbe2c018 Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
2025-12-29 22:23:34 +03:00

827 lines
37 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, MarathonDispute } 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 { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
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,
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
} 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 [showSettings, setShowSettings] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null)
// Disputes for organizers
const [showDisputes, setShowDisputes] = useState(false)
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
const [loadingDisputes, setLoadingDisputes] = useState(false)
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
useEffect(() => {
loadMarathon()
}, [id])
useEffect(() => {
if (showDisputes) {
loadDisputes()
}
}, [showDisputes, disputeFilter])
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 loadDisputes = async () => {
if (!id) return
setLoadingDisputes(true)
try {
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
setDisputes(data)
} catch (error) {
console.error('Failed to load disputes:', error)
toast.error('Не удалось загрузить оспаривания')
} finally {
setLoadingDisputes(false)
}
}
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
if (!id) return
setResolvingDisputeId(disputeId)
try {
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
await loadDisputes()
} catch (error) {
console.error('Failed to resolve dispute:', error)
toast.error('Не удалось разрешить диспут')
} finally {
setResolvingDisputeId(null)
}
}
const formatDisputeDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
const getDisputeTimeRemaining = (expiresAt: string) => {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff <= 0) return 'Истекло'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
return `${hours}ч ${minutes}м`
}
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 */}
{marathon.cover_url ? (
<>
<img
src={marathon.cover_url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 to-transparent" />
</>
) : (
<>
<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={<Gamepad2 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>
)}
{marathon.status === 'preparing' && isOrganizer && (
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
)}
{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>
)}
{/* Disputes management for organizers */}
{marathon.status === 'active' && isOrganizer && (
<GlassCard>
<button
onClick={() => setShowDisputes(!showDisputes)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Оспаривания</h3>
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
</div>
</div>
<div className="flex items-center gap-3">
{disputes.filter(d => d.status === 'open').length > 0 && (
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
{disputes.filter(d => d.status === 'open').length} открыто
</span>
)}
{showDisputes ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
</button>
{showDisputes && (
<div className="mt-6 pt-6 border-t border-dark-600">
{/* Filters */}
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'open'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('open')}
>
Открытые
</button>
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'all'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('all')}
>
Все
</button>
</div>
{/* Loading */}
{loadingDisputes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
</div>
) : disputes.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-gray-400 text-sm">
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
</p>
</div>
) : (
<div className="space-y-3">
{disputes.map((dispute) => (
<div
key={dispute.id}
className={`p-4 bg-dark-700/50 rounded-xl border ${
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Challenge title */}
<h4 className="text-white font-medium truncate mb-1">
{dispute.challenge_title}
</h4>
{/* Participants */}
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
Автор: <span className="text-white">{dispute.participant_nickname}</span>
</span>
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
</span>
</div>
{/* Reason */}
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
{dispute.reason}
</p>
{/* Votes & Time */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5 text-green-400">
<ThumbsUp className="w-3 h-3" />
<span>{dispute.votes_valid}</span>
</div>
<span className="text-gray-600">/</span>
<div className="flex items-center gap-0.5 text-red-400">
<ThumbsDown className="w-3 h-3" />
<span>{dispute.votes_invalid}</span>
</div>
</div>
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
{dispute.status === 'open' && (
<span className="text-orange-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{getDisputeTimeRemaining(dispute.expires_at)}
</span>
)}
</div>
</div>
{/* Right side - Status & Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
{dispute.status === 'open' ? (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
Открыт
</span>
) : dispute.status === 'valid' ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Валидно
</span>
) : (
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
<XCircle className="w-3 h-3" />
Невалидно
</span>
)}
{/* Link to assignment */}
{dispute.assignment_id && (
<Link
to={`/assignments/${dispute.assignment_id}`}
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Открыть
</Link>
)}
{/* Resolution buttons */}
{dispute.status === 'open' && (
<div className="flex gap-1.5 mt-1">
<NeonButton
size="sm"
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, true)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<CheckCircle className="w-3 h-3" />}
>
Валидно
</NeonButton>
<NeonButton
size="sm"
variant="outline"
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, false)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<XCircle className="w-3 h-3" />}
>
Невалидно
</NeonButton>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</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>
{/* Settings Modal */}
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
</div>
)
}