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

@@ -32,6 +32,16 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
game_choice: <Gamepad2 className="w-4 h-4" />, game_choice: <Gamepad2 className="w-4 h-4" />,
} }
// Default durations for events (in minutes)
const DEFAULT_DURATIONS: Record<EventType, number | null> = {
golden_hour: 45,
common_enemy: null, // Until all complete
double_risk: 120,
jackpot: null, // 1 spin
swap: 60,
game_choice: 120,
}
export function EventControl({ export function EventControl({
marathonId, marathonId,
activeEvent, activeEvent,
@@ -42,9 +52,17 @@ export function EventControl({
const confirm = useConfirm() const confirm = useConfirm()
const [selectedType, setSelectedType] = useState<EventType>('golden_hour') const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null) const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
const [durationMinutes, setDurationMinutes] = useState<number | ''>(45)
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false) const [isStopping, setIsStopping] = useState(false)
// Update duration when event type changes
const handleTypeChange = (type: EventType) => {
setSelectedType(type)
const defaultDuration = DEFAULT_DURATIONS[type]
setDurationMinutes(defaultDuration ?? '')
}
const handleStart = async () => { const handleStart = async () => {
if (selectedType === 'common_enemy' && !selectedChallengeId) { if (selectedType === 'common_enemy' && !selectedChallengeId) {
toast.warning('Выберите челлендж для события "Общий враг"') toast.warning('Выберите челлендж для события "Общий враг"')
@@ -55,6 +73,7 @@ export function EventControl({
try { try {
await eventsApi.start(marathonId, { await eventsApi.start(marathonId, {
type: selectedType, type: selectedType,
duration_minutes: durationMinutes || undefined,
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined, challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
}) })
onEventChange() onEventChange()
@@ -119,7 +138,7 @@ export function EventControl({
{EVENT_TYPES.map((type) => ( {EVENT_TYPES.map((type) => (
<button <button
key={type} key={type}
onClick={() => setSelectedType(type)} onClick={() => handleTypeChange(type)}
className={` className={`
p-3 rounded-lg border-2 transition-all text-left p-3 rounded-lg border-2 transition-all text-left
${selectedType === type ${selectedType === type
@@ -138,6 +157,27 @@ export function EventControl({
))} ))}
</div> </div>
{/* Duration setting */}
{DEFAULT_DURATIONS[selectedType] !== null && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Длительность (минуты)
</label>
<input
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value ? parseInt(e.target.value) : '')}
min={1}
max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
/>
<p className="text-xs text-gray-500 mt-1">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p>
</div>
)}
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && ( {selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-300 mb-2">

View File

@@ -45,6 +45,20 @@ export function LobbyPage() {
const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({}) const [gameChallenges, setGameChallenges] = useState<Record<number, Challenge[]>>({})
const [loadingChallenges, setLoadingChallenges] = useState<number | null>(null) 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 // Start marathon
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
@@ -161,6 +175,7 @@ export function LobbyPage() {
setExpandedGameId(gameId) setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) { if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId) setLoadingChallenges(gameId)
try { try {
@@ -168,12 +183,57 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) { } catch (error) {
console.error('Failed to load challenges:', error) console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally { } finally {
setLoadingChallenges(null) 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 handleDeleteChallenge = async (challengeId: number, gameId: number) => {
const confirmed = await confirm({ const confirmed = await confirm({
title: 'Удалить задание?', title: 'Удалить задание?',
@@ -320,12 +380,12 @@ export function LobbyPage() {
{/* Game header */} {/* Game header */}
<div <div
className={`flex items-center justify-between p-4 ${ 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"> <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"> <span className="text-gray-400 shrink-0">
{expandedGameId === game.id ? ( {expandedGameId === game.id ? (
<ChevronUp className="w-4 h-4" /> <ChevronUp className="w-4 h-4" />
@@ -398,7 +458,9 @@ export function LobbyPage() {
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" /> <Loader2 className="w-5 h-5 animate-spin text-gray-400" />
</div> </div>
) : gameChallenges[game.id]?.length > 0 ? ( ) : (
<>
{gameChallenges[game.id]?.length > 0 ? (
gameChallenges[game.id].map((challenge) => ( gameChallenges[game.id].map((challenge) => (
<div <div
key={challenge.id} key={challenge.id}
@@ -443,6 +505,132 @@ export function LobbyPage() {
Нет заданий Нет заданий
</p> </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={() => {
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"
>
<Plus className="w-4 h-4 mr-1" />
Добавить задание вручную
</Button>
)
)}
</>
)}
</div> </div>
)} )}
</div> </div>