Add manual add for challanges
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user