Files
game-marathon/frontend/src/pages/LobbyPage.tsx

729 lines
28 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 { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
} from 'lucide-react'
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[]>([])
const [pendingGames, setPendingGames] = useState<Game[]>([])
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)
// Moderation
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
// Generate challenges
const [isGenerating, setIsGenerating] = useState(false)
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
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)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
useEffect(() => {
loadData()
}, [id])
const loadData = async () => {
if (!id) return
try {
const marathonData = await marathonsApi.get(parseInt(id))
setMarathon(marathonData)
// Load games - organizers see all, participants see approved + own
const gamesData = await gamesApi.list(parseInt(id))
setGames(gamesData)
// 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([])
}
}
} 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)
}
}
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)
}
}
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)
}
}
const handleGenerateChallenges = async () => {
if (!id) return
setIsGenerating(true)
setGenerateMessage(null)
try {
const result = await gamesApi.previewChallenges(parseInt(id))
if (result.challenges.length === 0) {
setGenerateMessage('Все игры уже имеют задания')
} else {
setPreviewChallenges(result.challenges)
}
} catch (error) {
console.error('Failed to generate challenges:', error)
setGenerateMessage('Не удалось сгенерировать задания')
} finally {
setIsGenerating(false)
}
}
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)
}
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>
)
}
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>
)
return (
<div className="max-w-4xl mx-auto">
{/* 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>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
<p className="text-gray-400">
{isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания'
: 'Предложите игры для марафона'}
</p>
</div>
{isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
<Play className="w-4 h-4 mr-2" />
Запустить марафон
</Button>
)}
</div>
{/* 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>
)}
{/* 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))}
</div>
</CardContent>
</Card>
)}
{/* Generate challenges button */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<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">
Используйте ИИ для генерации заданий для одобренных игр без заданий
</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>
)}
{/* 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>
)}
{/* Games list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Игры</CardTitle>
{/* Показываем кнопку если: 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>
)}
</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}>
{isOrganizer ? 'Добавить' : 'Предложить'}
</Button>
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
Отмена
</Button>
</div>
{!isOrganizer && (
<p className="text-xs text-gray-500">
Ваша игра будет отправлена на модерацию организаторам
</p>
)}
</div>
)}
{/* Games */}
{(() => {
// Организаторы: показываем только одобренные (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>
)
})()}
</CardContent>
</Card>
</div>
)
}