Add challenges preview + makefile
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { marathonsApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Game } from '@/types'
|
||||
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 } from 'lucide-react'
|
||||
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, ChevronDown, ChevronUp, Edit2, Check } from 'lucide-react'
|
||||
|
||||
export function LobbyPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -25,6 +25,14 @@ export function LobbyPage() {
|
||||
// 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)
|
||||
@@ -83,15 +91,53 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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.generateChallenges(parseInt(id))
|
||||
setGenerateMessage(result.message)
|
||||
await loadData()
|
||||
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('Не удалось сгенерировать задания')
|
||||
@@ -100,6 +146,42 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -166,7 +248,7 @@ export function LobbyPage() {
|
||||
</div>
|
||||
|
||||
{/* Generate challenges button */}
|
||||
{games.length > 0 && (
|
||||
{games.length > 0 && !previewChallenges && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -188,6 +270,157 @@ export function LobbyPage() {
|
||||
</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">
|
||||
@@ -235,25 +468,95 @@ export function LobbyPage() {
|
||||
) : (
|
||||
<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"
|
||||
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
{/* Game header */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||||
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
{game.challenges_count > 0 && (
|
||||
<span className="text-gray-400">
|
||||
{expandedGameId === game.id ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteGame(game.id)
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user