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

729 lines
28 KiB
TypeScript
Raw Normal View History

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>
)
}