From bb9e9a6e1d1dcc38db99446b45bbfeac7f53c7d3 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Sun, 14 Dec 2025 03:23:50 +0700 Subject: [PATCH] Add challenges preview + makefile --- Makefile | 119 +++++++++++ backend/app/api/v1/challenges.py | 84 +++++++- backend/app/schemas/__init__.py | 8 + backend/app/schemas/challenge.py | 37 ++++ frontend/src/api/games.ts | 11 +- frontend/src/pages/LobbyPage.tsx | 351 ++++++++++++++++++++++++++++--- frontend/src/types/index.ts | 17 ++ 7 files changed, 590 insertions(+), 37 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4666290 --- /dev/null +++ b/Makefile @@ -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 diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index fe64bdd..032712c 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -10,6 +10,9 @@ from app.schemas import ( ChallengeResponse, MessageResponse, GameShort, + ChallengePreview, + ChallengesPreviewResponse, + ChallengesSaveRequest, ) from app.services.gpt import GPTService @@ -136,9 +139,9 @@ async def create_challenge( ) -@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse) -async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): - """Generate challenges for all games in marathon using GPT""" +@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) +async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): + """Generate challenges preview for all games in marathon using GPT (without saving)""" # Check marathon result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) 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: raise HTTPException(status_code=400, detail="No games in marathon") - generated_count = 0 + preview_challenges = [] for game in games: # Check if game already has challenges 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) for ch_data in challenges_data: - challenge = Challenge( + preview_challenges.append(ChallengePreview( game_id=game.id, + game_title=game.title, title=ch_data.title, description=ch_data.description, 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, proof_type=ch_data.proof_type, proof_hint=ch_data.proof_hint, - is_generated=True, - ) - db.add(challenge) - generated_count += 1 + )) except Exception as e: # Log error but continue with other games 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() - return MessageResponse(message=f"Generated {generated_count} challenges") + return MessageResponse(message=f"Сохранено {saved_count} заданий") @router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 8293b81..b26f96d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -28,6 +28,10 @@ from app.schemas.challenge import ( ChallengeUpdate, ChallengeResponse, ChallengeGenerated, + ChallengePreview, + ChallengesPreviewResponse, + ChallengeSaveItem, + ChallengesSaveRequest, ) from app.schemas.assignment import ( CompleteAssignment, @@ -74,6 +78,10 @@ __all__ = [ "ChallengeUpdate", "ChallengeResponse", "ChallengeGenerated", + "ChallengePreview", + "ChallengesPreviewResponse", + "ChallengeSaveItem", + "ChallengesSaveRequest", # Assignment "CompleteAssignment", "AssignmentResponse", diff --git a/backend/app/schemas/challenge.py b/backend/app/schemas/challenge.py index 44411c3..aa7bdda 100644 --- a/backend/app/schemas/challenge.py +++ b/backend/app/schemas/challenge.py @@ -51,3 +51,40 @@ class ChallengeGenerated(BaseModel): estimated_time: int | None = None proof_type: str 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] diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index 00cf257..67ba2b8 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -1,5 +1,5 @@ import client from './client' -import type { Game, Challenge } from '@/types' +import type { Game, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types' export interface CreateGameData { title: string @@ -63,8 +63,13 @@ export const gamesApi = { await client.delete(`/challenges/${id}`) }, - generateChallenges: async (marathonId: number): Promise<{ message: string }> => { - const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`) + previewChallenges: async (marathonId: number): Promise => { + const response = await client.post(`/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 }, } diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index 4a95933..b80a8f7 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' 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 { 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() { const { id } = useParams<{ id: string }>() @@ -25,6 +25,14 @@ export function LobbyPage() { // Generate challenges const [isGenerating, setIsGenerating] = useState(false) const [generateMessage, setGenerateMessage] = useState(null) + const [previewChallenges, setPreviewChallenges] = useState(null) + const [isSaving, setIsSaving] = useState(false) + const [editingIndex, setEditingIndex] = useState(null) + + // View existing challenges + const [expandedGameId, setExpandedGameId] = useState(null) + const [gameChallenges, setGameChallenges] = useState>({}) + const [loadingChallenges, setLoadingChallenges] = useState(null) // Start marathon 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 () => { if (!id) return setIsGenerating(true) setGenerateMessage(null) try { - const result = await gamesApi.generateChallenges(parseInt(id)) - setGenerateMessage(result.message) - await loadData() + 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('Не удалось сгенерировать задания') @@ -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 () => { if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return @@ -166,7 +248,7 @@ export function LobbyPage() { {/* Generate challenges button */} - {games.length > 0 && ( + {games.length > 0 && !previewChallenges && (
@@ -188,6 +270,157 @@ export function LobbyPage() { )} + {/* Challenge preview with editing */} + {previewChallenges && previewChallenges.length > 0 && ( + + +
+ + Предпросмотр заданий ({previewChallenges.length}) +
+
+ + +
+
+ +
+ {previewChallenges.map((challenge, index) => ( +
+ {editingIndex === index ? ( + // Edit mode +
+
+ + {challenge.game_title} + +
+ handleUpdatePreviewChallenge(index, 'title', e.target.value)} + placeholder="Название" + className="bg-gray-800" + /> +