Add challenges preview + makefile
This commit is contained in:
119
Makefile
Normal file
119
Makefile
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||||
|
|
||||||
|
DC = sudo docker-compose
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo "Marathon WebApp - Available commands:"
|
||||||
|
@echo ""
|
||||||
|
@echo " Development:"
|
||||||
|
@echo " make dev - Start all services in development mode"
|
||||||
|
@echo " make up - Start all services (detached)"
|
||||||
|
@echo " make down - Stop all services"
|
||||||
|
@echo " make restart - Restart all services"
|
||||||
|
@echo " make logs - Show logs (all services)"
|
||||||
|
@echo " make logs-b - Show backend logs"
|
||||||
|
@echo " make logs-f - Show frontend logs"
|
||||||
|
@echo ""
|
||||||
|
@echo " Build:"
|
||||||
|
@echo " make build - Build all containers (with cache)"
|
||||||
|
@echo " make build-no-cache - Rebuild all containers (no cache)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Database:"
|
||||||
|
@echo " make migrate - Run database migrations"
|
||||||
|
@echo " make db-shell - Open PostgreSQL shell"
|
||||||
|
@echo ""
|
||||||
|
@echo " Shell access:"
|
||||||
|
@echo " make shell - Open backend shell"
|
||||||
|
@echo " make frontend-sh - Open frontend shell"
|
||||||
|
@echo ""
|
||||||
|
@echo " Cleanup:"
|
||||||
|
@echo " make clean - Stop and remove containers, volumes"
|
||||||
|
@echo " make prune - Remove unused Docker resources"
|
||||||
|
|
||||||
|
# Development
|
||||||
|
dev:
|
||||||
|
$(DC) up
|
||||||
|
|
||||||
|
up:
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
$(DC) down
|
||||||
|
|
||||||
|
restart:
|
||||||
|
$(DC) restart
|
||||||
|
|
||||||
|
logs:
|
||||||
|
$(DC) logs -f
|
||||||
|
|
||||||
|
logs-b:
|
||||||
|
$(DC) logs -f backend
|
||||||
|
|
||||||
|
logs-f:
|
||||||
|
$(DC) logs -f frontend
|
||||||
|
|
||||||
|
# Build
|
||||||
|
build:
|
||||||
|
$(DC) build
|
||||||
|
|
||||||
|
build-no-cache:
|
||||||
|
$(DC) build --no-cache
|
||||||
|
|
||||||
|
rebuild-frontend:
|
||||||
|
$(DC) down
|
||||||
|
sudo docker rmi marathon-frontend || true
|
||||||
|
$(DC) build --no-cache frontend
|
||||||
|
$(DC) up -d
|
||||||
|
|
||||||
|
# Database
|
||||||
|
migrate:
|
||||||
|
$(DC) exec backend alembic upgrade head
|
||||||
|
|
||||||
|
migrate-new:
|
||||||
|
@read -p "Migration message: " msg; \
|
||||||
|
$(DC) exec backend alembic revision --autogenerate -m "$$msg"
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
$(DC) exec db psql -U marathon -d marathon
|
||||||
|
|
||||||
|
# Shell access
|
||||||
|
shell:
|
||||||
|
$(DC) exec backend bash
|
||||||
|
|
||||||
|
backend-shell: shell
|
||||||
|
|
||||||
|
frontend-sh:
|
||||||
|
$(DC) exec frontend sh
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
clean:
|
||||||
|
$(DC) down -v --remove-orphans
|
||||||
|
|
||||||
|
prune:
|
||||||
|
sudo docker system prune -f
|
||||||
|
|
||||||
|
# Local development (without Docker)
|
||||||
|
install:
|
||||||
|
cd backend && pip install -r requirements.txt
|
||||||
|
cd frontend && npm install
|
||||||
|
|
||||||
|
run-backend:
|
||||||
|
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
run-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Linting and testing
|
||||||
|
lint-backend:
|
||||||
|
cd backend && ruff check app
|
||||||
|
|
||||||
|
lint-frontend:
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
test-backend:
|
||||||
|
cd backend && pytest
|
||||||
|
|
||||||
|
# Production
|
||||||
|
prod:
|
||||||
|
$(DC) -f docker-compose.yml up -d --build
|
||||||
@@ -10,6 +10,9 @@ from app.schemas import (
|
|||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
GameShort,
|
GameShort,
|
||||||
|
ChallengePreview,
|
||||||
|
ChallengesPreviewResponse,
|
||||||
|
ChallengesSaveRequest,
|
||||||
)
|
)
|
||||||
from app.services.gpt import GPTService
|
from app.services.gpt import GPTService
|
||||||
|
|
||||||
@@ -136,9 +139,9 @@ async def create_challenge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Generate challenges for all games in marathon using GPT"""
|
"""Generate challenges preview for all games in marathon using GPT (without saving)"""
|
||||||
# Check marathon
|
# Check marathon
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -159,7 +162,7 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
|
|||||||
if not games:
|
if not games:
|
||||||
raise HTTPException(status_code=400, detail="No games in marathon")
|
raise HTTPException(status_code=400, detail="No games in marathon")
|
||||||
|
|
||||||
generated_count = 0
|
preview_challenges = []
|
||||||
for game in games:
|
for game in games:
|
||||||
# Check if game already has challenges
|
# Check if game already has challenges
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
@@ -172,8 +175,9 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
|
|||||||
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
|
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
|
||||||
|
|
||||||
for ch_data in challenges_data:
|
for ch_data in challenges_data:
|
||||||
challenge = Challenge(
|
preview_challenges.append(ChallengePreview(
|
||||||
game_id=game.id,
|
game_id=game.id,
|
||||||
|
game_title=game.title,
|
||||||
title=ch_data.title,
|
title=ch_data.title,
|
||||||
description=ch_data.description,
|
description=ch_data.description,
|
||||||
type=ch_data.type,
|
type=ch_data.type,
|
||||||
@@ -182,18 +186,78 @@ async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: D
|
|||||||
estimated_time=ch_data.estimated_time,
|
estimated_time=ch_data.estimated_time,
|
||||||
proof_type=ch_data.proof_type,
|
proof_type=ch_data.proof_type,
|
||||||
proof_hint=ch_data.proof_hint,
|
proof_hint=ch_data.proof_hint,
|
||||||
is_generated=True,
|
))
|
||||||
)
|
|
||||||
db.add(challenge)
|
|
||||||
generated_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log error but continue with other games
|
# Log error but continue with other games
|
||||||
print(f"Error generating challenges for {game.title}: {e}")
|
print(f"Error generating challenges for {game.title}: {e}")
|
||||||
|
|
||||||
|
return ChallengesPreviewResponse(challenges=preview_challenges)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/marathons/{marathon_id}/save-challenges", response_model=MessageResponse)
|
||||||
|
async def save_challenges(
|
||||||
|
marathon_id: int,
|
||||||
|
data: ChallengesSaveRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Save previewed challenges to database"""
|
||||||
|
# Check marathon
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
|
||||||
|
|
||||||
|
await check_participant(db, current_user.id, marathon_id)
|
||||||
|
|
||||||
|
# Verify all games belong to this marathon
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||||
|
)
|
||||||
|
valid_game_ids = set(row[0] for row in result.fetchall())
|
||||||
|
|
||||||
|
saved_count = 0
|
||||||
|
for ch_data in data.challenges:
|
||||||
|
if ch_data.game_id not in valid_game_ids:
|
||||||
|
continue # Skip challenges for invalid games
|
||||||
|
|
||||||
|
# Validate type
|
||||||
|
ch_type = ch_data.type
|
||||||
|
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
||||||
|
ch_type = "completion"
|
||||||
|
|
||||||
|
# Validate difficulty
|
||||||
|
difficulty = ch_data.difficulty
|
||||||
|
if difficulty not in ["easy", "medium", "hard"]:
|
||||||
|
difficulty = "medium"
|
||||||
|
|
||||||
|
# Validate proof_type
|
||||||
|
proof_type = ch_data.proof_type
|
||||||
|
if proof_type not in ["screenshot", "video", "steam"]:
|
||||||
|
proof_type = "screenshot"
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=ch_data.game_id,
|
||||||
|
title=ch_data.title[:100],
|
||||||
|
description=ch_data.description,
|
||||||
|
type=ch_type,
|
||||||
|
difficulty=difficulty,
|
||||||
|
points=max(1, min(500, ch_data.points)),
|
||||||
|
estimated_time=ch_data.estimated_time,
|
||||||
|
proof_type=proof_type,
|
||||||
|
proof_hint=ch_data.proof_hint,
|
||||||
|
is_generated=True,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
saved_count += 1
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message=f"Generated {generated_count} challenges")
|
return MessageResponse(message=f"Сохранено {saved_count} заданий")
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
|
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ from app.schemas.challenge import (
|
|||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
ChallengeGenerated,
|
ChallengeGenerated,
|
||||||
|
ChallengePreview,
|
||||||
|
ChallengesPreviewResponse,
|
||||||
|
ChallengeSaveItem,
|
||||||
|
ChallengesSaveRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.assignment import (
|
from app.schemas.assignment import (
|
||||||
CompleteAssignment,
|
CompleteAssignment,
|
||||||
@@ -74,6 +78,10 @@ __all__ = [
|
|||||||
"ChallengeUpdate",
|
"ChallengeUpdate",
|
||||||
"ChallengeResponse",
|
"ChallengeResponse",
|
||||||
"ChallengeGenerated",
|
"ChallengeGenerated",
|
||||||
|
"ChallengePreview",
|
||||||
|
"ChallengesPreviewResponse",
|
||||||
|
"ChallengeSaveItem",
|
||||||
|
"ChallengesSaveRequest",
|
||||||
# Assignment
|
# Assignment
|
||||||
"CompleteAssignment",
|
"CompleteAssignment",
|
||||||
"AssignmentResponse",
|
"AssignmentResponse",
|
||||||
|
|||||||
@@ -51,3 +51,40 @@ class ChallengeGenerated(BaseModel):
|
|||||||
estimated_time: int | None = None
|
estimated_time: int | None = None
|
||||||
proof_type: str
|
proof_type: str
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePreview(BaseModel):
|
||||||
|
"""Schema for challenge preview (with game info)"""
|
||||||
|
game_id: int
|
||||||
|
game_title: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None = None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesPreviewResponse(BaseModel):
|
||||||
|
"""Response with generated challenges for preview"""
|
||||||
|
challenges: list[ChallengePreview]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeSaveItem(BaseModel):
|
||||||
|
"""Single challenge to save"""
|
||||||
|
game_id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
type: str
|
||||||
|
difficulty: str
|
||||||
|
points: int
|
||||||
|
estimated_time: int | None = None
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesSaveRequest(BaseModel):
|
||||||
|
"""Request to save previewed challenges"""
|
||||||
|
challenges: list[ChallengeSaveItem]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { Game, Challenge } from '@/types'
|
import type { Game, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
||||||
|
|
||||||
export interface CreateGameData {
|
export interface CreateGameData {
|
||||||
title: string
|
title: string
|
||||||
@@ -63,8 +63,13 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
await client.delete(`/challenges/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
generateChallenges: async (marathonId: number): Promise<{ message: string }> => {
|
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => {
|
||||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`)
|
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
saveChallenges: async (marathonId: number, challenges: ChallengePreview[]): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { marathonsApi, gamesApi } from '@/api'
|
import { marathonsApi, gamesApi } from '@/api'
|
||||||
import type { Marathon, Game } from '@/types'
|
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2 } from 'lucide-react'
|
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, ChevronDown, ChevronUp, Edit2, Check } from 'lucide-react'
|
||||||
|
|
||||||
export function LobbyPage() {
|
export function LobbyPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -25,6 +25,14 @@ export function LobbyPage() {
|
|||||||
// Generate challenges
|
// Generate challenges
|
||||||
const [isGenerating, setIsGenerating] = useState(false)
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
|
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
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
@@ -83,15 +91,53 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleGenerateChallenges = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
setGenerateMessage(null)
|
setGenerateMessage(null)
|
||||||
try {
|
try {
|
||||||
const result = await gamesApi.generateChallenges(parseInt(id))
|
const result = await gamesApi.previewChallenges(parseInt(id))
|
||||||
setGenerateMessage(result.message)
|
if (result.challenges.length === 0) {
|
||||||
await loadData()
|
setGenerateMessage('Все игры уже имеют задания')
|
||||||
|
} else {
|
||||||
|
setPreviewChallenges(result.challenges)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate challenges:', error)
|
console.error('Failed to generate challenges:', error)
|
||||||
setGenerateMessage('Не удалось сгенерировать задания')
|
setGenerateMessage('Не удалось сгенерировать задания')
|
||||||
@@ -100,6 +146,42 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const handleStartMarathon = async () => {
|
||||||
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
|
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
|
||||||
|
|
||||||
@@ -166,7 +248,7 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate challenges button */}
|
{/* Generate challenges button */}
|
||||||
{games.length > 0 && (
|
{games.length > 0 && !previewChallenges && (
|
||||||
<Card className="mb-8">
|
<Card className="mb-8">
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -188,6 +270,157 @@ export function LobbyPage() {
|
|||||||
</Card>
|
</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 */}
|
{/* Games list */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
@@ -235,10 +468,22 @@ export function LobbyPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{games.map((game) => (
|
{games.map((game) => (
|
||||||
|
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
|
||||||
|
{/* Game header */}
|
||||||
<div
|
<div
|
||||||
key={game.id}
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-800/50"
|
||||||
className="flex items-center justify-between p-4 bg-gray-900 rounded-lg"
|
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>
|
<div>
|
||||||
<h4 className="font-medium text-white">{game.title}</h4>
|
<h4 className="font-medium text-white">{game.title}</h4>
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
@@ -246,15 +491,73 @@ export function LobbyPage() {
|
|||||||
<span>{game.challenges_count} заданий</span>
|
<span>{game.challenges_count} заданий</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDeleteGame(game.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteGame(game.id)
|
||||||
|
}}
|
||||||
className="text-red-400 hover:text-red-300"
|
className="text-red-400 hover:text-red-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ export interface Challenge {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChallengePreview {
|
||||||
|
game_id: number
|
||||||
|
game_title: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
type: string
|
||||||
|
difficulty: Difficulty
|
||||||
|
points: number
|
||||||
|
estimated_time: number | null
|
||||||
|
proof_type: ProofType
|
||||||
|
proof_hint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChallengesPreviewResponse {
|
||||||
|
challenges: ChallengePreview[]
|
||||||
|
}
|
||||||
|
|
||||||
// Assignment types
|
// Assignment types
|
||||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
|
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user