2025-12-14 02:38:35 +07:00
|
|
|
|
import { useState, useEffect } from 'react'
|
2025-12-14 20:21:56 +07:00
|
|
|
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
import { marathonsApi, gamesApi } from '@/api'
|
2025-12-14 03:23:50 +07:00
|
|
|
|
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
|
|
|
|
|
import { useAuthStore } from '@/store/auth'
|
2025-12-14 20:21:56 +07:00
|
|
|
|
import {
|
|
|
|
|
|
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
|
|
|
|
|
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
|
|
|
|
|
} from 'lucide-react'
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
export function LobbyPage() {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>()
|
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
|
const user = useAuthStore((state) => state.user)
|
|
|
|
|
|
|
|
|
|
|
|
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
|
|
|
|
|
const [games, setGames] = useState<Game[]>([])
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const [pendingGames, setPendingGames] = useState<Game[]>([])
|
2025-12-14 02:38:35 +07:00
|
|
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
|
|
|
|
|
|
|
|
|
|
// Add game form
|
|
|
|
|
|
const [showAddGame, setShowAddGame] = useState(false)
|
|
|
|
|
|
const [gameTitle, setGameTitle] = useState('')
|
|
|
|
|
|
const [gameUrl, setGameUrl] = useState('')
|
|
|
|
|
|
const [gameGenre, setGameGenre] = useState('')
|
|
|
|
|
|
const [isAddingGame, setIsAddingGame] = useState(false)
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
// Moderation
|
|
|
|
|
|
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
// Generate challenges
|
|
|
|
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|
|
|
|
|
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
|
2025-12-14 03:23:50 +07:00
|
|
|
|
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
|
|
|
|
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
|
|
|
|
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
|
|
|
|
|
|
|
|
|
|
|
// View existing challenges
|
|
|
|
|
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
|
|
|
|
|
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
|
|
|
|
|
|
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null)
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
// Start marathon
|
|
|
|
|
|
const [isStarting, setIsStarting] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadData()
|
|
|
|
|
|
}, [id])
|
|
|
|
|
|
|
|
|
|
|
|
const loadData = async () => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
try {
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const marathonData = await marathonsApi.get(parseInt(id))
|
2025-12-14 02:38:35 +07:00
|
|
|
|
setMarathon(marathonData)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
// Load games - organizers see all, participants see approved + own
|
|
|
|
|
|
const gamesData = await gamesApi.list(parseInt(id))
|
2025-12-14 02:38:35 +07:00
|
|
|
|
setGames(gamesData)
|
2025-12-14 20:21:56 +07:00
|
|
|
|
|
|
|
|
|
|
// If organizer, load pending games separately
|
|
|
|
|
|
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pending = await gamesApi.listPending(parseInt(id))
|
|
|
|
|
|
setPendingGames(pending)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// If not authorized, just ignore
|
|
|
|
|
|
setPendingGames([])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load data:', error)
|
|
|
|
|
|
navigate('/marathons')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleAddGame = async () => {
|
|
|
|
|
|
if (!id || !gameTitle.trim() || !gameUrl.trim()) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsAddingGame(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await gamesApi.create(parseInt(id), {
|
|
|
|
|
|
title: gameTitle.trim(),
|
|
|
|
|
|
download_url: gameUrl.trim(),
|
|
|
|
|
|
genre: gameGenre.trim() || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
setGameTitle('')
|
|
|
|
|
|
setGameUrl('')
|
|
|
|
|
|
setGameGenre('')
|
|
|
|
|
|
setShowAddGame(false)
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to add game:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsAddingGame(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteGame = async (gameId: number) => {
|
|
|
|
|
|
if (!confirm('Удалить эту игру?')) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await gamesApi.delete(gameId)
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to delete game:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
const handleApproveGame = async (gameId: number) => {
|
|
|
|
|
|
setModeratingGameId(gameId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await gamesApi.approve(gameId)
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to approve game:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setModeratingGameId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleRejectGame = async (gameId: number) => {
|
|
|
|
|
|
if (!confirm('Отклонить эту игру?')) return
|
|
|
|
|
|
|
|
|
|
|
|
setModeratingGameId(gameId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await gamesApi.reject(gameId)
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to reject game:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setModeratingGameId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 03:23:50 +07:00
|
|
|
|
const handleToggleGameChallenges = async (gameId: number) => {
|
|
|
|
|
|
if (expandedGameId === gameId) {
|
|
|
|
|
|
setExpandedGameId(null)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setExpandedGameId(gameId)
|
|
|
|
|
|
|
|
|
|
|
|
if (!gameChallenges[gameId]) {
|
|
|
|
|
|
setLoadingChallenges(gameId)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const challenges = await gamesApi.getChallenges(gameId)
|
|
|
|
|
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to load challenges:', error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingChallenges(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
|
|
|
|
|
|
if (!confirm('Удалить это задание?')) return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await gamesApi.deleteChallenge(challengeId)
|
|
|
|
|
|
// Refresh challenges for this game
|
|
|
|
|
|
const challenges = await gamesApi.getChallenges(gameId)
|
|
|
|
|
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
|
|
|
|
|
await loadData() // Refresh game counts
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to delete challenge:', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
const handleGenerateChallenges = async () => {
|
|
|
|
|
|
if (!id) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsGenerating(true)
|
|
|
|
|
|
setGenerateMessage(null)
|
|
|
|
|
|
try {
|
2025-12-14 03:23:50 +07:00
|
|
|
|
const result = await gamesApi.previewChallenges(parseInt(id))
|
|
|
|
|
|
if (result.challenges.length === 0) {
|
|
|
|
|
|
setGenerateMessage('Все игры уже имеют задания')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPreviewChallenges(result.challenges)
|
|
|
|
|
|
}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to generate challenges:', error)
|
|
|
|
|
|
setGenerateMessage('Не удалось сгенерировать задания')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsGenerating(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 03:23:50 +07:00
|
|
|
|
const handleSaveChallenges = async () => {
|
|
|
|
|
|
if (!id || !previewChallenges) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsSaving(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges)
|
|
|
|
|
|
setGenerateMessage(result.message)
|
|
|
|
|
|
setPreviewChallenges(null)
|
|
|
|
|
|
setGameChallenges({}) // Clear cache to reload
|
|
|
|
|
|
await loadData()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to save challenges:', error)
|
|
|
|
|
|
setGenerateMessage('Не удалось сохранить задания')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleRemovePreviewChallenge = (index: number) => {
|
|
|
|
|
|
if (!previewChallenges) return
|
|
|
|
|
|
setPreviewChallenges(previewChallenges.filter((_, i) => i !== index))
|
|
|
|
|
|
if (editingIndex === index) setEditingIndex(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleUpdatePreviewChallenge = (index: number, field: keyof ChallengePreview, value: string | number) => {
|
|
|
|
|
|
if (!previewChallenges) return
|
|
|
|
|
|
setPreviewChallenges(previewChallenges.map((ch, i) =>
|
|
|
|
|
|
i === index ? { ...ch, [field]: value } : ch
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCancelPreview = () => {
|
|
|
|
|
|
setPreviewChallenges(null)
|
|
|
|
|
|
setEditingIndex(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
const handleStartMarathon = async () => {
|
|
|
|
|
|
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
|
|
|
|
|
|
|
|
|
|
|
|
setIsStarting(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await marathonsApi.start(parseInt(id))
|
|
|
|
|
|
navigate(`/marathons/${id}/play`)
|
|
|
|
|
|
} catch (err: unknown) {
|
|
|
|
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
|
|
|
|
|
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsStarting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
|
|
|
const approvedGames = games.filter(g => g.status === 'approved')
|
|
|
|
|
|
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
|
|
|
|
|
|
|
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
|
|
|
|
switch (status) {
|
|
|
|
|
|
case 'approved':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400">
|
|
|
|
|
|
<CheckCircle className="w-3 h-3" />
|
|
|
|
|
|
Одобрено
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
case 'pending':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400">
|
|
|
|
|
|
<Clock className="w-3 h-3" />
|
|
|
|
|
|
На модерации
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
case 'rejected':
|
|
|
|
|
|
return (
|
|
|
|
|
|
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400">
|
|
|
|
|
|
<XCircle className="w-3 h-3" />
|
|
|
|
|
|
Отклонено
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const renderGameCard = (game: Game, showModeration = false) => (
|
|
|
|
|
|
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
|
|
|
|
|
|
{/* Game header */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`flex items-center justify-between p-4 ${
|
|
|
|
|
|
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
|
|
|
|
|
}`}
|
|
|
|
|
|
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
|
|
|
|
{game.challenges_count > 0 && (
|
|
|
|
|
|
<span className="text-gray-400 shrink-0">
|
|
|
|
|
|
{expandedGameId === game.id ? (
|
|
|
|
|
|
<ChevronUp className="w-4 h-4" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ChevronDown className="w-4 h-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
|
<h4 className="font-medium text-white">{game.title}</h4>
|
|
|
|
|
|
{getStatusBadge(game.status)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
|
|
|
|
|
{game.genre && <span>{game.genre}</span>}
|
|
|
|
|
|
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>}
|
|
|
|
|
|
{game.proposed_by && (
|
|
|
|
|
|
<span className="flex items-center gap-1 text-gray-500">
|
|
|
|
|
|
<User className="w-3 h-3" />
|
|
|
|
|
|
{game.proposed_by.nickname}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
{showModeration && game.status === 'pending' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleApproveGame(game.id)}
|
|
|
|
|
|
disabled={moderatingGameId === game.id}
|
|
|
|
|
|
className="text-green-400 hover:text-green-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
{moderatingGameId === game.id ? (
|
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CheckCircle className="w-4 h-4" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleRejectGame(game.id)}
|
|
|
|
|
|
disabled={moderatingGameId === game.id}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<XCircle className="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleDeleteGame(game.id)}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Expanded challenges list */}
|
|
|
|
|
|
{expandedGameId === game.id && (
|
|
|
|
|
|
<div className="border-t border-gray-800 p-4 space-y-2">
|
|
|
|
|
|
{loadingChallenges === game.id ? (
|
|
|
|
|
|
<div className="flex justify-center py-4">
|
|
|
|
|
|
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : gameChallenges[game.id]?.length > 0 ? (
|
|
|
|
|
|
gameChallenges[game.id].map((challenge) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={challenge.id}
|
|
|
|
|
|
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-1">
|
|
|
|
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
|
|
|
|
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
|
|
|
|
|
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
|
|
|
|
|
'bg-red-900/50 text-red-400'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
|
|
|
|
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-primary-400 font-medium">
|
|
|
|
|
|
+{challenge.points}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{challenge.is_generated && (
|
|
|
|
|
|
<span className="text-xs text-gray-500">
|
|
|
|
|
|
<Sparkles className="w-3 h-3 inline" /> ИИ
|
|
|
|
|
|
</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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isOrganizer && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300 shrink-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-3 h-3" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-center text-gray-500 py-2 text-sm">
|
|
|
|
|
|
Нет заданий
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
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/${id}`} 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
|
|
|
|
<div className="flex justify-between items-center mb-8">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
<p className="text-gray-400">
|
|
|
|
|
|
{isOrganizer
|
|
|
|
|
|
? 'Настройка - Добавьте игры и сгенерируйте задания'
|
|
|
|
|
|
: 'Предложите игры для марафона'}
|
|
|
|
|
|
</p>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isOrganizer && (
|
2025-12-14 20:21:56 +07:00
|
|
|
|
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
|
2025-12-14 02:38:35 +07:00
|
|
|
|
<Play className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Запустить марафон
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Stats - только для организаторов */}
|
|
|
|
|
|
{isOrganizer && (
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{approvedGames.length}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
|
|
|
|
<Gamepad2 className="w-4 h-4" />
|
|
|
|
|
|
Игр одобрено
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardContent className="text-center py-4">
|
|
|
|
|
|
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
|
|
|
|
|
|
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
|
|
|
|
<Sparkles className="w-4 h-4" />
|
|
|
|
|
|
Заданий
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Pending games for moderation (organizers only) */}
|
|
|
|
|
|
{isOrganizer && pendingGames.length > 0 && (
|
|
|
|
|
|
<Card className="mb-8 border-yellow-900/50">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2 text-yellow-400">
|
|
|
|
|
|
<Clock className="w-5 h-5" />
|
|
|
|
|
|
На модерации ({pendingGames.length})
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{pendingGames.map((game) => renderGameCard(game, true))}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
|
|
|
|
|
|
{/* Generate challenges button */}
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
2025-12-14 02:38:35 +07:00
|
|
|
|
<Card className="mb-8">
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h3 className="font-medium text-white">Генерация заданий</h3>
|
|
|
|
|
|
<p className="text-sm text-gray-400">
|
2025-12-14 20:21:56 +07:00
|
|
|
|
Используйте ИИ для генерации заданий для одобренных игр без заданий
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
|
|
|
|
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
|
|
|
|
Сгенерировать
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{generateMessage && (
|
|
|
|
|
|
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-14 03:23:50 +07:00
|
|
|
|
{/* Challenge preview with editing */}
|
|
|
|
|
|
{previewChallenges && previewChallenges.length > 0 && (
|
|
|
|
|
|
<Card className="mb-8">
|
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Eye className="w-5 h-5 text-primary-400" />
|
|
|
|
|
|
<CardTitle>Предпросмотр заданий ({previewChallenges.length})</CardTitle>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button onClick={handleCancelPreview} variant="ghost" size="sm">
|
|
|
|
|
|
<X className="w-4 h-4 mr-1" />
|
|
|
|
|
|
Отмена
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button onClick={handleSaveChallenges} isLoading={isSaving} size="sm">
|
|
|
|
|
|
<Save className="w-4 h-4 mr-1" />
|
|
|
|
|
|
Сохранить все
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
|
|
|
|
|
|
{previewChallenges.map((challenge, index) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
className="p-4 bg-gray-900 rounded-lg border border-gray-800"
|
|
|
|
|
|
>
|
|
|
|
|
|
{editingIndex === index ? (
|
|
|
|
|
|
// Edit mode
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
|
|
|
|
|
|
{challenge.game_title}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={challenge.title}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
|
|
|
|
|
|
placeholder="Название"
|
|
|
|
|
|
className="bg-gray-800"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={challenge.description}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
|
|
|
|
|
|
placeholder="Описание"
|
|
|
|
|
|
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none"
|
|
|
|
|
|
rows={2}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={challenge.difficulty}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)}
|
|
|
|
|
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="easy">Легко</option>
|
|
|
|
|
|
<option value="medium">Средне</option>
|
|
|
|
|
|
<option value="hard">Сложно</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
value={challenge.points}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
|
|
|
|
|
|
placeholder="Очки"
|
|
|
|
|
|
className="bg-gray-800"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={challenge.proof_type}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)}
|
|
|
|
|
|
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="screenshot">Скриншот</option>
|
|
|
|
|
|
<option value="video">Видео</option>
|
|
|
|
|
|
<option value="steam">Steam</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={challenge.proof_hint || ''}
|
|
|
|
|
|
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
|
|
|
|
|
|
placeholder="Подсказка для подтверждения"
|
|
|
|
|
|
className="bg-gray-800"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button size="sm" onClick={() => setEditingIndex(null)}>
|
|
|
|
|
|
<Check className="w-4 h-4 mr-1" />
|
|
|
|
|
|
Готово
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleRemovePreviewChallenge(index)}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
|
|
|
|
Удалить
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
// View mode
|
|
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-1">
|
|
|
|
|
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
|
|
|
|
|
|
{challenge.game_title}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
|
|
|
|
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
|
|
|
|
|
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
|
|
|
|
|
'bg-red-900/50 text-red-400'
|
|
|
|
|
|
}`}>
|
|
|
|
|
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
|
|
|
|
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-xs text-primary-400 font-medium">
|
|
|
|
|
|
+{challenge.points} очков
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
|
|
|
|
|
<p className="text-sm text-gray-400">{challenge.description}</p>
|
|
|
|
|
|
{challenge.proof_hint && (
|
|
|
|
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
|
|
|
|
Подтверждение: {challenge.proof_hint}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-1 shrink-0">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setEditingIndex(index)}
|
|
|
|
|
|
className="text-gray-400 hover:text-white"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit2 className="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => handleRemovePreviewChallenge(index)}
|
|
|
|
|
|
className="text-red-400 hover:text-red-300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X className="w-4 h-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-12-14 02:38:35 +07:00
|
|
|
|
{/* Games list */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
|
|
|
|
<CardTitle>Игры</CardTitle>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
|
|
|
|
|
|
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
|
|
|
|
|
|
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
|
|
|
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
|
|
|
|
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{/* Add game form */}
|
|
|
|
|
|
{showAddGame && (
|
|
|
|
|
|
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="Название игры"
|
|
|
|
|
|
value={gameTitle}
|
|
|
|
|
|
onChange={(e) => setGameTitle(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="Ссылка для скачивания"
|
|
|
|
|
|
value={gameUrl}
|
|
|
|
|
|
onChange={(e) => setGameUrl(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
placeholder="Жанр (необязательно)"
|
|
|
|
|
|
value={gameGenre}
|
|
|
|
|
|
onChange={(e) => setGameGenre(e.target.value)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{isOrganizer ? 'Добавить' : 'Предложить'}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
|
|
|
|
|
Отмена
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{!isOrganizer && (
|
|
|
|
|
|
<p className="text-xs text-gray-500">
|
|
|
|
|
|
Ваша игра будет отправлена на модерацию организаторам
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Games */}
|
2025-12-14 20:21:56 +07:00
|
|
|
|
{(() => {
|
|
|
|
|
|
// Организаторы: показываем только одобренные (pending в секции модерации)
|
|
|
|
|
|
// Участники: показываем одобренные + свои pending
|
|
|
|
|
|
const visibleGames = isOrganizer
|
|
|
|
|
|
? games.filter(g => g.status !== 'pending')
|
|
|
|
|
|
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
|
|
|
|
|
|
|
|
|
|
|
|
return visibleGames.length === 0 ? (
|
|
|
|
|
|
<p className="text-center text-gray-400 py-8">
|
|
|
|
|
|
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{visibleGames.map((game) => renderGameCard(game, false))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
})()}
|
2025-12-14 02:38:35 +07:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|