Add manual add for challanges

This commit is contained in:
2025-12-16 03:27:57 +07:00
parent a199952383
commit fe6012b7a3
2 changed files with 269 additions and 41 deletions

View File

@@ -45,6 +45,20 @@ export function LobbyPage() {
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null)
// Manual challenge creation
const [addingChallengeToGameId, setAddingChallengeToGameId] = useState<number | null>(null)
const [newChallenge, setNewChallenge] = useState({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
@@ -161,6 +175,7 @@ export function LobbyPage() {
setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId)
try {
@@ -168,12 +183,57 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) {
console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally {
setLoadingChallenges(null)
}
}
}
const handleCreateChallenge = async (gameId: number) => {
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
toast.warning('Заполните название и описание')
return
}
setIsCreatingChallenge(true)
try {
await gamesApi.createChallenge(gameId, {
title: newChallenge.title.trim(),
description: newChallenge.description.trim(),
type: newChallenge.type,
difficulty: newChallenge.difficulty,
points: newChallenge.points,
estimated_time: newChallenge.estimated_time || undefined,
proof_type: newChallenge.proof_type,
proof_hint: newChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание добавлено')
// Reset form
setNewChallenge({
title: '',
description: '',
type: 'completion',
difficulty: 'medium',
points: 50,
estimated_time: 30,
proof_type: 'screenshot',
proof_hint: '',
})
setAddingChallengeToGameId(null)
// Refresh challenges
const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось добавить задание')
} finally {
setIsCreatingChallenge(false)
}
}
const handleDeleteChallenge = async (challengeId: number, gameId: number) => {
const confirmed = await confirm({
title: 'Удалить задание?',
@@ -320,12 +380,12 @@ export function LobbyPage() {
{/* Game header */}
<div
className={`flex items-center justify-between p-4 ${
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : ''
}`}
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{game.challenges_count > 0 && (
{game.status === 'approved' && (
<span className="text-gray-400 shrink-0">
{expandedGameId === game.id ? (
<ChevronUp className="w-4 h-4" />
@@ -398,50 +458,178 @@ export function LobbyPage() {
<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>
) : (
<>
{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>
<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 && (
))
) : (
<p className="text-center text-gray-500 py-2 text-sm">
Нет заданий
</p>
)}
{/* Add challenge form */}
{isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700">
<h4 className="font-medium text-white text-sm">Новое задание</h4>
<Input
placeholder="Название задания"
value={newChallenge.title}
onChange={(e) => setNewChallenge(prev => ({ ...prev, title: e.target.value }))}
/>
<textarea
placeholder="Описание (что нужно сделать)"
value={newChallenge.description}
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
<select
value={newChallenge.type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option>
<option value="speedrun">Спидран</option>
<option value="collection">Коллекция</option>
<option value="achievement">Достижение</option>
<option value="challenge_run">Челлендж-ран</option>
</select>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
<select
value={newChallenge.difficulty}
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="easy">Легко (20-40 очков)</option>
<option value="medium">Средне (45-75 очков)</option>
<option value="hard">Сложно (90-150 очков)</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
<Input
type="number"
value={newChallenge.points}
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
min={1}
max={500}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Время (мин)</label>
<Input
type="number"
value={newChallenge.estimated_time}
onChange={(e) => setNewChallenge(prev => ({ ...prev, estimated_time: parseInt(e.target.value) || 0 }))}
min={1}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label>
<select
value={newChallenge.proof_type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 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>
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
<Input
placeholder="Что должно быть на пруфе"
value={newChallenge.proof_hint}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
>
<Plus className="w-4 h-4 mr-1" />
Добавить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setAddingChallengeToGameId(null)}
>
Отмена
</Button>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="text-red-400 hover:text-red-300 shrink-0"
onClick={() => {
setAddingChallengeToGameId(game.id)
setExpandedGameId(game.id)
}}
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
>
<Trash2 className="w-3 h-3" />
<Plus className="w-4 h-4 mr-1" />
Добавить задание вручную
</Button>
)}
</div>
))
) : (
<p className="text-center text-gray-500 py-2 text-sm">
Нет заданий
</p>
)
)}
</>
)}
</div>
)}