266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
|
|
import { useState, useEffect } from 'react'
|
|||
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|||
|
|
import { marathonsApi, gamesApi } from '@/api'
|
|||
|
|
import type { Marathon, Game } from '@/types'
|
|||
|
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
|||
|
|
import { useAuthStore } from '@/store/auth'
|
|||
|
|
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2 } 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 [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)
|
|||
|
|
|
|||
|
|
// Generate challenges
|
|||
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|||
|
|
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
|
|||
|
|
|
|||
|
|
// Start marathon
|
|||
|
|
const [isStarting, setIsStarting] = useState(false)
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
loadData()
|
|||
|
|
}, [id])
|
|||
|
|
|
|||
|
|
const loadData = async () => {
|
|||
|
|
if (!id) return
|
|||
|
|
try {
|
|||
|
|
const [marathonData, gamesData] = await Promise.all([
|
|||
|
|
marathonsApi.get(parseInt(id)),
|
|||
|
|
gamesApi.list(parseInt(id)),
|
|||
|
|
])
|
|||
|
|
setMarathon(marathonData)
|
|||
|
|
setGames(gamesData)
|
|||
|
|
} 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 handleGenerateChallenges = async () => {
|
|||
|
|
if (!id) return
|
|||
|
|
|
|||
|
|
setIsGenerating(true)
|
|||
|
|
setGenerateMessage(null)
|
|||
|
|
try {
|
|||
|
|
const result = await gamesApi.generateChallenges(parseInt(id))
|
|||
|
|
setGenerateMessage(result.message)
|
|||
|
|
await loadData()
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to generate challenges:', error)
|
|||
|
|
setGenerateMessage('Не удалось сгенерировать задания')
|
|||
|
|
} finally {
|
|||
|
|
setIsGenerating(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 = user?.id === marathon.organizer.id
|
|||
|
|
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="max-w-4xl mx-auto">
|
|||
|
|
<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">Настройка - Добавьте игры и сгенерируйте задания</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isOrganizer && (
|
|||
|
|
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={games.length === 0}>
|
|||
|
|
<Play className="w-4 h-4 mr-2" />
|
|||
|
|
Запустить марафон
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* Stats */}
|
|||
|
|
<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">{games.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>
|
|||
|
|
|
|||
|
|
{/* Generate challenges button */}
|
|||
|
|
{games.length > 0 && (
|
|||
|
|
<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>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Games list */}
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|||
|
|
<CardTitle>Игры</CardTitle>
|
|||
|
|
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
|||
|
|
<Plus className="w-4 h-4 mr-1" />
|
|||
|
|
Добавить игру
|
|||
|
|
</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}>
|
|||
|
|
Добавить
|
|||
|
|
</Button>
|
|||
|
|
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
|||
|
|
Отмена
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* Games */}
|
|||
|
|
{games.length === 0 ? (
|
|||
|
|
<p className="text-center text-gray-400 py-8">
|
|||
|
|
Пока нет игр. Добавьте игры, чтобы начать!
|
|||
|
|
</p>
|
|||
|
|
) : (
|
|||
|
|
<div className="space-y-3">
|
|||
|
|
{games.map((game) => (
|
|||
|
|
<div
|
|||
|
|
key={game.id}
|
|||
|
|
className="flex items-center justify-between p-4 bg-gray-900 rounded-lg"
|
|||
|
|
>
|
|||
|
|
<div>
|
|||
|
|
<h4 className="font-medium text-white">{game.title}</h4>
|
|||
|
|
<div className="text-sm text-gray-400">
|
|||
|
|
{game.genre && <span className="mr-3">{game.genre}</span>}
|
|||
|
|
<span>{game.challenges_count} заданий</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<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>
|
|||
|
|
)}
|
|||
|
|
</CardContent>
|
|||
|
|
</Card>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|