Add 3 roles, settings for marathons
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
||||
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react'
|
||||
import type { GameProposalMode } from '@/types'
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1, 'Название обязательно').max(100),
|
||||
description: z.string().optional(),
|
||||
start_date: z.string().min(1, 'Дата начала обязательна'),
|
||||
duration_days: z.number().min(1).max(365).default(30),
|
||||
is_public: z.boolean().default(false),
|
||||
game_proposal_mode: z.enum(['all_participants', 'organizer_only']).default('all_participants'),
|
||||
})
|
||||
|
||||
type CreateForm = z.infer<typeof createSchema>
|
||||
@@ -23,21 +27,32 @@ export function CreateMarathonPage() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<CreateForm>({
|
||||
resolver: zodResolver(createSchema),
|
||||
defaultValues: {
|
||||
duration_days: 30,
|
||||
is_public: false,
|
||||
game_proposal_mode: 'all_participants',
|
||||
},
|
||||
})
|
||||
|
||||
const isPublic = watch('is_public')
|
||||
const gameProposalMode = watch('game_proposal_mode')
|
||||
|
||||
const onSubmit = async (data: CreateForm) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const marathon = await marathonsApi.create({
|
||||
...data,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
start_date: new Date(data.start_date).toISOString(),
|
||||
duration_days: data.duration_days,
|
||||
is_public: data.is_public,
|
||||
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
|
||||
})
|
||||
navigate(`/marathons/${marathon.id}/lobby`)
|
||||
} catch (err: unknown) {
|
||||
@@ -50,6 +65,12 @@ export function CreateMarathonPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
К списку марафонов
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать марафон</CardTitle>
|
||||
@@ -94,6 +115,92 @@ export function CreateMarathonPage() {
|
||||
{...register('duration_days', { valueAsNumber: true })}
|
||||
/>
|
||||
|
||||
{/* Тип марафона */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Тип марафона
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('is_public', false)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
!isPublic
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Lock className={`w-5 h-5 mx-auto mb-1 ${!isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
Закрытый
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Вход по коду
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('is_public', true)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
isPublic
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Globe className={`w-5 h-5 mx-auto mb-1 ${isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
Открытый
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Виден всем
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кто может предлагать игры */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Кто может предлагать игры
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('game_proposal_mode', 'all_participants')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
gameProposalMode === 'all_participants'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Users className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'all_participants' ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Все участники
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
С модерацией
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
gameProposalMode === 'organizer_only'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<UserCog className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'organizer_only' ? 'text-primary-400' : 'text-gray-400'}`} />
|
||||
<div className={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Только организатор
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Без модерации
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
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 } from 'lucide-react'
|
||||
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 }>()
|
||||
@@ -13,6 +16,7 @@ export function LobbyPage() {
|
||||
|
||||
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
|
||||
@@ -22,6 +26,9 @@ export function LobbyPage() {
|
||||
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)
|
||||
@@ -44,12 +51,23 @@ export function LobbyPage() {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, gamesData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
gamesApi.list(parseInt(id)),
|
||||
])
|
||||
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')
|
||||
@@ -91,6 +109,32 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -205,57 +249,248 @@ export function LobbyPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const isOrganizer = user?.id === marathon.organizer.id
|
||||
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
|
||||
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">Настройка - Добавьте игры и сгенерируйте задания</p>
|
||||
<p className="text-gray-400">
|
||||
{isOrganizer
|
||||
? 'Настройка - Добавьте игры и сгенерируйте задания'
|
||||
: 'Предложите игры для марафона'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isOrganizer && (
|
||||
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={games.length === 0}>
|
||||
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить марафон
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<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">{games.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>
|
||||
{/* 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" />
|
||||
Заданий
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate challenges button */}
|
||||
{games.length > 0 && !previewChallenges && (
|
||||
{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">
|
||||
@@ -425,10 +660,13 @@ export function LobbyPage() {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Игры</CardTitle>
|
||||
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Добавить игру
|
||||
</Button>
|
||||
{/* Показываем кнопку если: 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 */}
|
||||
@@ -451,116 +689,38 @@ export function LobbyPage() {
|
||||
/>
|
||||
<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 */}
|
||||
{games.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-8">
|
||||
Пока нет игр. Добавьте игры, чтобы начать!
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{games.map((game) => (
|
||||
<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)}
|
||||
>
|
||||
<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>
|
||||
{(() => {
|
||||
// Организаторы: показываем только одобренные (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))
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { marathonsApi } from '@/api'
|
||||
import type { Marathon } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2 } from 'lucide-react'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
export function MarathonPage() {
|
||||
@@ -14,6 +14,8 @@ export function MarathonPage() {
|
||||
const [marathon, setMarathon] = useState<Marathon | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
@@ -40,6 +42,36 @@ export function MarathonPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return
|
||||
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await marathonsApi.delete(marathon.id)
|
||||
navigate('/marathons')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete marathon:', error)
|
||||
alert('Не удалось удалить марафон')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleJoinPublic = async () => {
|
||||
if (!marathon) return
|
||||
|
||||
setIsJoining(true)
|
||||
try {
|
||||
const updated = await marathonsApi.joinPublic(marathon.id)
|
||||
setMarathon(updated)
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
alert(error.response?.data?.detail || 'Не удалось присоединиться')
|
||||
} finally {
|
||||
setIsJoining(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading || !marathon) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
@@ -48,21 +80,51 @@ export function MarathonPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const isOrganizer = user?.id === marathon.organizer.id
|
||||
const isOrganizer = marathon.my_participation?.role === 'organizer' || user?.role === 'admin'
|
||||
const isParticipant = !!marathon.my_participation
|
||||
const isCreator = marathon.creator.id === user?.id
|
||||
const canDelete = isCreator || user?.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
К списку марафонов
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">{marathon.title}</h1>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||
marathon.is_public
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}>
|
||||
{marathon.is_public ? (
|
||||
<><Globe className="w-3 h-3" /> Открытый</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400">{marathon.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Кнопка присоединиться для открытых марафонов */}
|
||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Присоединиться
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Настройка для организаторов */}
|
||||
{marathon.status === 'preparing' && isOrganizer && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
@@ -72,6 +134,16 @@ export function MarathonPage() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
||||
Предложить игру
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{marathon.status === 'active' && isParticipant && (
|
||||
<Link to={`/marathons/${id}/play`}>
|
||||
<Button>
|
||||
@@ -87,11 +159,22 @@ export function MarathonPage() {
|
||||
Рейтинг
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
isLoading={isDeleting}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="grid md:grid-cols-5 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||
@@ -116,7 +199,19 @@ export function MarathonPage() {
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Дата начала
|
||||
Начало
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<CalendarCheck className="w-4 h-4" />
|
||||
Конец
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user