729 lines
28 KiB
TypeScript
729 lines
28 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||
import { marathonsApi, gamesApi } from '@/api'
|
||
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, X, Save, Eye,
|
||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
||
} 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 [pendingGames, setPendingGames] = 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)
|
||
|
||
// Moderation
|
||
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
|
||
|
||
// 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)
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [id])
|
||
|
||
const loadData = async () => {
|
||
if (!id) return
|
||
try {
|
||
const marathonData = await marathonsApi.get(parseInt(id))
|
||
setMarathon(marathonData)
|
||
|
||
// Load games - organizers see all, participants see approved + own
|
||
const gamesData = await gamesApi.list(parseInt(id))
|
||
setGames(gamesData)
|
||
|
||
// If organizer, load pending games separately
|
||
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
|
||
try {
|
||
const pending = await gamesApi.listPending(parseInt(id))
|
||
setPendingGames(pending)
|
||
} catch {
|
||
// If not authorized, just ignore
|
||
setPendingGames([])
|
||
}
|
||
}
|
||
} 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 handleApproveGame = async (gameId: number) => {
|
||
setModeratingGameId(gameId)
|
||
try {
|
||
await gamesApi.approve(gameId)
|
||
await loadData()
|
||
} catch (error) {
|
||
console.error('Failed to approve game:', error)
|
||
} finally {
|
||
setModeratingGameId(null)
|
||
}
|
||
}
|
||
|
||
const handleRejectGame = async (gameId: number) => {
|
||
if (!confirm('Отклонить эту игру?')) return
|
||
|
||
setModeratingGameId(gameId)
|
||
try {
|
||
await gamesApi.reject(gameId)
|
||
await loadData()
|
||
} catch (error) {
|
||
console.error('Failed to reject game:', error)
|
||
} finally {
|
||
setModeratingGameId(null)
|
||
}
|
||
}
|
||
|
||
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.previewChallenges(parseInt(id))
|
||
if (result.challenges.length === 0) {
|
||
setGenerateMessage('Все игры уже имеют задания')
|
||
} else {
|
||
setPreviewChallenges(result.challenges)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to generate challenges:', error)
|
||
setGenerateMessage('Не удалось сгенерировать задания')
|
||
} finally {
|
||
setIsGenerating(false)
|
||
}
|
||
}
|
||
|
||
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
|
||
|
||
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 = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
|
||
const approvedGames = games.filter(g => g.status === 'approved')
|
||
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'approved':
|
||
return (
|
||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400">
|
||
<CheckCircle className="w-3 h-3" />
|
||
Одобрено
|
||
</span>
|
||
)
|
||
case 'pending':
|
||
return (
|
||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400">
|
||
<Clock className="w-3 h-3" />
|
||
На модерации
|
||
</span>
|
||
)
|
||
case 'rejected':
|
||
return (
|
||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400">
|
||
<XCircle className="w-3 h-3" />
|
||
Отклонено
|
||
</span>
|
||
)
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
const renderGameCard = (game: Game, showModeration = false) => (
|
||
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
|
||
{/* Game header */}
|
||
<div
|
||
className={`flex items-center justify-between p-4 ${
|
||
game.challenges_count > 0 ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
||
}`}
|
||
onClick={() => game.challenges_count > 0 && handleToggleGameChallenges(game.id)}
|
||
>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
{game.challenges_count > 0 && (
|
||
<span className="text-gray-400 shrink-0">
|
||
{expandedGameId === game.id ? (
|
||
<ChevronUp className="w-4 h-4" />
|
||
) : (
|
||
<ChevronDown className="w-4 h-4" />
|
||
)}
|
||
</span>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<h4 className="font-medium text-white">{game.title}</h4>
|
||
{getStatusBadge(game.status)}
|
||
</div>
|
||
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
||
{game.genre && <span>{game.genre}</span>}
|
||
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>}
|
||
{game.proposed_by && (
|
||
<span className="flex items-center gap-1 text-gray-500">
|
||
<User className="w-3 h-3" />
|
||
{game.proposed_by.nickname}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
{showModeration && game.status === 'pending' && (
|
||
<>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleApproveGame(game.id)}
|
||
disabled={moderatingGameId === game.id}
|
||
className="text-green-400 hover:text-green-300"
|
||
>
|
||
{moderatingGameId === game.id ? (
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
) : (
|
||
<CheckCircle className="w-4 h-4" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleRejectGame(game.id)}
|
||
disabled={moderatingGameId === game.id}
|
||
className="text-red-400 hover:text-red-300"
|
||
>
|
||
<XCircle className="w-4 h-4" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
||
<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>
|
||
|
||
{/* 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>
|
||
{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>
|
||
))
|
||
) : (
|
||
<p className="text-center text-gray-500 py-2 text-sm">
|
||
Нет заданий
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* Back button */}
|
||
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||
<ArrowLeft className="w-4 h-4" />
|
||
К марафону
|
||
</Link>
|
||
|
||
<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">
|
||
{isOrganizer
|
||
? 'Настройка - Добавьте игры и сгенерируйте задания'
|
||
: 'Предложите игры для марафона'}
|
||
</p>
|
||
</div>
|
||
|
||
{isOrganizer && (
|
||
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
|
||
<Play className="w-4 h-4 mr-2" />
|
||
Запустить марафон
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Stats - только для организаторов */}
|
||
{isOrganizer && (
|
||
<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">{approvedGames.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>
|
||
)}
|
||
|
||
{/* Pending games for moderation (organizers only) */}
|
||
{isOrganizer && pendingGames.length > 0 && (
|
||
<Card className="mb-8 border-yellow-900/50">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2 text-yellow-400">
|
||
<Clock className="w-5 h-5" />
|
||
На модерации ({pendingGames.length})
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{pendingGames.map((game) => renderGameCard(game, true))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Generate challenges button */}
|
||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||
<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>
|
||
)}
|
||
|
||
{/* 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">
|
||
<CardTitle>Игры</CardTitle>
|
||
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
|
||
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
|
||
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
||
<Plus className="w-4 h-4 mr-1" />
|
||
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
|
||
</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}>
|
||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||
</Button>
|
||
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
||
Отмена
|
||
</Button>
|
||
</div>
|
||
{!isOrganizer && (
|
||
<p className="text-xs text-gray-500">
|
||
Ваша игра будет отправлена на модерацию организаторам
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Games */}
|
||
{(() => {
|
||
// Организаторы: показываем только одобренные (pending в секции модерации)
|
||
// Участники: показываем одобренные + свои pending
|
||
const visibleGames = isOrganizer
|
||
? games.filter(g => g.status !== 'pending')
|
||
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
|
||
|
||
return visibleGames.length === 0 ? (
|
||
<p className="text-center text-gray-400 py-8">
|
||
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
||
</p>
|
||
) : (
|
||
<div className="space-y-3">
|
||
{visibleGames.map((game) => renderGameCard(game, false))}
|
||
</div>
|
||
)
|
||
})()}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|