From 8e634994bd0457d05e599c0d85d36feb87d291a3 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Thu, 18 Dec 2025 23:47:11 +0700 Subject: [PATCH] Add challenges promotion --- .../versions/011_add_challenge_proposals.py | 28 + backend/app/api/v1/challenges.py | 324 +++++++-- backend/app/models/challenge.py | 11 + backend/app/schemas/challenge.py | 18 +- backend/app/services/telegram_notifier.py | 36 + frontend/src/api/games.ts | 31 + frontend/src/pages/LobbyPage.tsx | 686 ++++++++++++++++-- frontend/src/types/index.ts | 9 + 8 files changed, 1022 insertions(+), 121 deletions(-) create mode 100644 backend/alembic/versions/011_add_challenge_proposals.py diff --git a/backend/alembic/versions/011_add_challenge_proposals.py b/backend/alembic/versions/011_add_challenge_proposals.py new file mode 100644 index 0000000..52311fa --- /dev/null +++ b/backend/alembic/versions/011_add_challenge_proposals.py @@ -0,0 +1,28 @@ +"""Add challenge proposals support + +Revision ID: 011_add_challenge_proposals +Revises: 010_add_telegram_profile +Create Date: 2024-12-18 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '011_add_challenge_proposals' +down_revision: Union[str, None] = '010_add_telegram_profile' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True)) + op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False)) + + +def downgrade() -> None: + op.drop_column('challenges', 'status') + op.drop_column('challenges', 'proposed_by_id') diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index 2a69966..b85088f 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -3,7 +3,8 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant -from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge +from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User +from app.models.challenge import ChallengeStatus from app.schemas import ( ChallengeCreate, ChallengeUpdate, @@ -15,7 +16,9 @@ from app.schemas import ( ChallengesSaveRequest, ChallengesGenerateRequest, ) +from app.schemas.challenge import ChallengePropose, ProposedByUser from app.services.gpt import gpt_service +from app.services.telegram_notifier import telegram_notifier router = APIRouter(tags=["challenges"]) @@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"]) async def get_challenge_or_404(db, challenge_id: int) -> Challenge: result = await db.execute( select(Challenge) - .options(selectinload(Challenge.game)) + .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .where(Challenge.id == challenge_id) ) challenge = result.scalar_one_or_none() @@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge: return challenge +def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse: + """Helper to build ChallengeResponse with proposed_by""" + proposed_by = None + if challenge.proposed_by: + proposed_by = ProposedByUser( + id=challenge.proposed_by.id, + nickname=challenge.proposed_by.nickname + ) + + return ChallengeResponse( + id=challenge.id, + title=challenge.title, + description=challenge.description, + type=challenge.type, + difficulty=challenge.difficulty, + points=challenge.points, + estimated_time=challenge.estimated_time, + proof_type=challenge.proof_type, + proof_hint=challenge.proof_hint, + game=GameShort(id=game.id, title=game.title, cover_url=None), + is_generated=challenge.is_generated, + created_at=challenge.created_at, + status=challenge.status, + proposed_by=proposed_by, + ) + + @router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse]) async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession): - """List challenges for a game. Participants can view challenges for approved games only.""" + """List challenges for a game. Participants can view approved and pending challenges.""" # Get game and check access result = await db.execute( select(Game).where(Game.id == game_id) @@ -54,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id: raise HTTPException(status_code=403, detail="Game not accessible") - result = await db.execute( - select(Challenge) - .where(Challenge.game_id == game_id) - .order_by(Challenge.difficulty, Challenge.created_at) - ) + # Get challenges with proposed_by + query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id) + + # Regular participants see approved and pending challenges (but not rejected) + if not current_user.is_admin and participant and not participant.is_organizer: + query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value])) + + result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at)) challenges = result.scalars().all() - return [ - ChallengeResponse( - id=c.id, - title=c.title, - description=c.description, - type=c.type, - difficulty=c.difficulty, - points=c.points, - estimated_time=c.estimated_time, - proof_type=c.proof_type, - proof_hint=c.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None), - is_generated=c.is_generated, - created_at=c.created_at, - ) - for c in challenges - ] + return [build_challenge_response(c, game) for c in challenges] @router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse]) @@ -94,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, if not current_user.is_admin and not participant: raise HTTPException(status_code=403, detail="You are not a participant of this marathon") - # Get all challenges from approved games in this marathon + # Get all approved challenges from approved games in this marathon result = await db.execute( select(Challenge) .join(Game, Challenge.game_id == Game.id) - .options(selectinload(Challenge.game)) + .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) .where( Game.marathon_id == marathon_id, Game.status == GameStatus.APPROVED.value, + Challenge.status == ChallengeStatus.APPROVED.value, ) .order_by(Game.title, Challenge.difficulty, Challenge.created_at) ) challenges = result.scalars().all() - return [ - ChallengeResponse( - id=c.id, - title=c.title, - description=c.description, - type=c.type, - difficulty=c.difficulty, - points=c.points, - estimated_time=c.estimated_time, - proof_type=c.proof_type, - proof_hint=c.proof_hint, - game=GameShort(id=c.game.id, title=c.game.title, cover_url=None), - is_generated=c.is_generated, - created_at=c.created_at, - ) - for c in challenges - ] + return [build_challenge_response(c, c.game) for c in challenges] @router.post("/games/{game_id}/challenges", response_model=ChallengeResponse) @@ -166,25 +168,13 @@ async def create_challenge( proof_type=data.proof_type.value, proof_hint=data.proof_hint, is_generated=False, + status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved ) db.add(challenge) await db.commit() await db.refresh(challenge) - return ChallengeResponse( - id=challenge.id, - title=challenge.title, - description=challenge.description, - type=challenge.type, - difficulty=challenge.difficulty, - points=challenge.points, - estimated_time=challenge.estimated_time, - proof_type=challenge.proof_type, - proof_hint=challenge.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None), - is_generated=challenge.is_generated, - created_at=challenge.created_at, - ) + return build_challenge_response(challenge, game) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) @@ -386,26 +376,12 @@ async def update_challenge( await db.commit() await db.refresh(challenge) - game = challenge.game - return ChallengeResponse( - id=challenge.id, - title=challenge.title, - description=challenge.description, - type=challenge.type, - difficulty=challenge.difficulty, - points=challenge.points, - estimated_time=challenge.estimated_time, - proof_type=challenge.proof_type, - proof_hint=challenge.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None), - is_generated=challenge.is_generated, - created_at=challenge.created_at, - ) + return build_challenge_response(challenge, challenge.game) @router.delete("/challenges/{challenge_id}", response_model=MessageResponse) async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): - """Delete a challenge. Organizers only.""" + """Delete a challenge. Organizers can delete any, participants can delete their own pending.""" challenge = await get_challenge_or_404(db, challenge_id) # Check marathon is in preparing state @@ -414,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS if marathon.status != MarathonStatus.PREPARING.value: raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon") - # Only organizers can delete challenges - await require_organizer(db, current_user, challenge.game.marathon_id) + participant = await get_participant(db, current_user.id, challenge.game.marathon_id) + + # Check permissions + if current_user.is_admin or (participant and participant.is_organizer): + # Organizers can delete any challenge + pass + elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value: + # Participants can delete their own pending challenges + pass + else: + raise HTTPException(status_code=403, detail="You can only delete your own pending challenges") await db.delete(challenge) await db.commit() return MessageResponse(message="Challenge deleted") + + +# ============ Proposed challenges endpoints ============ + +@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse) +async def propose_challenge( + game_id: int, + data: ChallengePropose, + current_user: CurrentUser, + db: DbSession, +): + """Propose a challenge for a game. Participants only, during PREPARING phase.""" + # Get game + result = await db.execute(select(Game).where(Game.id == game_id)) + game = result.scalar_one_or_none() + if not game: + raise HTTPException(status_code=404, detail="Game not found") + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon") + + # Check user is participant + participant = await get_participant(db, current_user.id, game.marathon_id) + if not participant and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + + # Can only propose challenges to approved games + if game.status != GameStatus.APPROVED.value: + raise HTTPException(status_code=400, detail="Can only propose challenges to approved games") + + challenge = Challenge( + game_id=game_id, + title=data.title, + description=data.description, + type=data.type.value, + difficulty=data.difficulty.value, + points=data.points, + estimated_time=data.estimated_time, + proof_type=data.proof_type.value, + proof_hint=data.proof_hint, + is_generated=False, + proposed_by_id=current_user.id, + status=ChallengeStatus.PENDING.value, + ) + db.add(challenge) + await db.commit() + await db.refresh(challenge) + + # Load proposed_by relationship + challenge.proposed_by = current_user + + return build_challenge_response(challenge, game) + + +@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse]) +async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): + """List all pending proposed challenges for a marathon. Organizers only.""" + # Check marathon exists + 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") + + # Only organizers can see all proposed challenges + await require_organizer(db, current_user, marathon_id) + + # Get all pending challenges from approved games + result = await db.execute( + select(Challenge) + .join(Game, Challenge.game_id == Game.id) + .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) + .where( + Game.marathon_id == marathon_id, + Game.status == GameStatus.APPROVED.value, + Challenge.status == ChallengeStatus.PENDING.value, + ) + .order_by(Challenge.created_at.desc()) + ) + challenges = result.scalars().all() + + return [build_challenge_response(c, c.game) for c in challenges] + + +@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse]) +async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): + """List current user's proposed challenges for a marathon.""" + # Check marathon exists + 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") + + # Check user is participant + participant = await get_participant(db, current_user.id, marathon_id) + if not participant and not current_user.is_admin: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + + # Get user's proposed challenges + result = await db.execute( + select(Challenge) + .join(Game, Challenge.game_id == Game.id) + .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by)) + .where( + Game.marathon_id == marathon_id, + Challenge.proposed_by_id == current_user.id, + ) + .order_by(Challenge.created_at.desc()) + ) + challenges = result.scalars().all() + + return [build_challenge_response(c, c.game) for c in challenges] + + +@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse) +async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): + """Approve a proposed challenge. Organizers only.""" + challenge = await get_challenge_or_404(db, challenge_id) + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon") + + # Only organizers can approve + await require_organizer(db, current_user, challenge.game.marathon_id) + + if challenge.status != ChallengeStatus.PENDING.value: + raise HTTPException(status_code=400, detail="Challenge is not pending") + + challenge.status = ChallengeStatus.APPROVED.value + await db.commit() + await db.refresh(challenge) + + # Send Telegram notification to proposer + if challenge.proposed_by_id: + await telegram_notifier.notify_challenge_approved( + db, + challenge.proposed_by_id, + marathon.title, + challenge.game.title, + challenge.title + ) + + return build_challenge_response(challenge, challenge.game) + + +@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse) +async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): + """Reject a proposed challenge. Organizers only.""" + challenge = await get_challenge_or_404(db, challenge_id) + + # Check marathon is in preparing state + result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id)) + marathon = result.scalar_one() + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon") + + # Only organizers can reject + await require_organizer(db, current_user, challenge.game.marathon_id) + + if challenge.status != ChallengeStatus.PENDING.value: + raise HTTPException(status_code=400, detail="Challenge is not pending") + + # Save info for notification before changing status + proposer_id = challenge.proposed_by_id + game_title = challenge.game.title + challenge_title = challenge.title + + challenge.status = ChallengeStatus.REJECTED.value + await db.commit() + await db.refresh(challenge) + + # Send Telegram notification to proposer + if proposer_id: + await telegram_notifier.notify_challenge_rejected( + db, + proposer_id, + marathon.title, + game_title, + challenge_title + ) + + return build_challenge_response(challenge, challenge.game) diff --git a/backend/app/models/challenge.py b/backend/app/models/challenge.py index 462177d..c73c13a 100644 --- a/backend/app/models/challenge.py +++ b/backend/app/models/challenge.py @@ -29,6 +29,12 @@ class ProofType(str, Enum): STEAM = "steam" +class ChallengeStatus(str, Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + class Challenge(Base): __tablename__ = "challenges" @@ -45,8 +51,13 @@ class Challenge(Base): is_generated: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + # Proposed challenges support + proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected + # Relationships game: Mapped["Game"] = relationship("Game", back_populates="challenges") + proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id]) assignments: Mapped[list["Assignment"]] = relationship( "Assignment", back_populates="challenge" diff --git a/backend/app/schemas/challenge.py b/backend/app/schemas/challenge.py index 1a88436..3c068ea 100644 --- a/backend/app/schemas/challenge.py +++ b/backend/app/schemas/challenge.py @@ -1,10 +1,19 @@ from datetime import datetime from pydantic import BaseModel, Field -from app.models.challenge import ChallengeType, Difficulty, ProofType +from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus from app.schemas.game import GameShort +class ProposedByUser(BaseModel): + """Minimal user info for proposed challenges""" + id: int + nickname: str + + class Config: + from_attributes = True + + class ChallengeBase(BaseModel): title: str = Field(..., min_length=1, max_length=100) description: str = Field(..., min_length=1) @@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase): game: GameShort is_generated: bool created_at: datetime + status: str = "approved" + proposed_by: ProposedByUser | None = None class Config: from_attributes = True +class ChallengePropose(ChallengeBase): + """Schema for proposing a challenge by a participant""" + pass + + class ChallengeGenerated(BaseModel): """Schema for GPT-generated challenges""" title: str diff --git a/backend/app/services/telegram_notifier.py b/backend/app/services/telegram_notifier.py index 003e544..6ae2e57 100644 --- a/backend/app/services/telegram_notifier.py +++ b/backend/app/services/telegram_notifier.py @@ -276,6 +276,42 @@ class TelegramNotifier: ) return await self.notify_user(db, user_id, message) + async def notify_challenge_approved( + self, + db: AsyncSession, + user_id: int, + marathon_title: str, + game_title: str, + challenge_title: str + ) -> bool: + """Notify user that their proposed challenge was approved.""" + message = ( + f"✅ Твой челлендж одобрен!\n\n" + f"Марафон: {marathon_title}\n" + f"Игра: {game_title}\n" + f"Задание: {challenge_title}\n\n" + f"Теперь оно доступно для всех участников." + ) + return await self.notify_user(db, user_id, message) + + async def notify_challenge_rejected( + self, + db: AsyncSession, + user_id: int, + marathon_title: str, + game_title: str, + challenge_title: str + ) -> bool: + """Notify user that their proposed challenge was rejected.""" + message = ( + f"❌ Твой челлендж отклонён\n\n" + f"Марафон: {marathon_title}\n" + f"Игра: {game_title}\n" + f"Задание: {challenge_title}\n\n" + f"Ты можешь предложить другой челлендж." + ) + return await self.notify_user(db, user_id, message) + # Global instance telegram_notifier = TelegramNotifier() diff --git a/frontend/src/api/games.ts b/frontend/src/api/games.ts index 236d6fd..7923f86 100644 --- a/frontend/src/api/games.ts +++ b/frontend/src/api/games.ts @@ -79,6 +79,11 @@ export const gamesApi = { await client.delete(`/challenges/${id}`) }, + updateChallenge: async (id: number, data: Partial): Promise => { + const response = await client.patch(`/challenges/${id}`, data) + return response.data + }, + previewChallenges: async (marathonId: number, gameIds?: number[]): Promise => { const data = gameIds?.length ? { game_ids: gameIds } : undefined const response = await client.post(`/marathons/${marathonId}/preview-challenges`, data) @@ -89,4 +94,30 @@ export const gamesApi = { const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges }) return response.data }, + + // Proposed challenges + proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise => { + const response = await client.post(`/games/${gameId}/propose-challenge`, data) + return response.data + }, + + getProposedChallenges: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/proposed-challenges`) + return response.data + }, + + getMyProposedChallenges: async (marathonId: number): Promise => { + const response = await client.get(`/marathons/${marathonId}/my-proposed-challenges`) + return response.data + }, + + approveChallenge: async (id: number): Promise => { + const response = await client.patch(`/challenges/${id}/approve`) + return response.data + }, + + rejectChallenge: async (id: number): Promise => { + const response = await client.patch(`/challenges/${id}/reject`) + return response.data + }, } diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx index da5671a..eb39482 100644 --- a/frontend/src/pages/LobbyPage.tsx +++ b/frontend/src/pages/LobbyPage.tsx @@ -61,6 +61,27 @@ export function LobbyPage() { }) const [isCreatingChallenge, setIsCreatingChallenge] = useState(false) + // Edit challenge + const [editingChallengeId, setEditingChallengeId] = useState(null) + const [editChallenge, setEditChallenge] = useState({ + title: '', + description: '', + type: 'completion', + difficulty: 'medium', + points: 50, + estimated_time: 30, + proof_type: 'screenshot', + proof_hint: '', + }) + const [isUpdatingChallenge, setIsUpdatingChallenge] = useState(false) + + // Proposed challenges + const [proposedChallenges, setProposedChallenges] = useState([]) + const [myProposedChallenges, setMyProposedChallenges] = useState([]) + const [approvingChallengeId, setApprovingChallengeId] = useState(null) + const [isProposingChallenge, setIsProposingChallenge] = useState(false) + const [editingProposedId, setEditingProposedId] = useState(null) + // Start marathon const [isStarting, setIsStarting] = useState(false) @@ -84,6 +105,23 @@ export function LobbyPage() { } catch { setPendingGames([]) } + // Load proposed challenges for organizers + try { + const proposed = await gamesApi.getProposedChallenges(parseInt(id)) + setProposedChallenges(proposed) + } catch { + setProposedChallenges([]) + } + } + + // Load my proposed challenges for all participants + if (marathonData.my_participation) { + try { + const myProposed = await gamesApi.getMyProposedChallenges(parseInt(id)) + setMyProposedChallenges(myProposed) + } catch { + setMyProposedChallenges([]) + } } } catch (error) { console.error('Failed to load data:', error) @@ -249,6 +287,206 @@ export function LobbyPage() { } } + const handleStartEditChallenge = (challenge: Challenge) => { + setEditingChallengeId(challenge.id) + setEditChallenge({ + title: challenge.title, + description: challenge.description, + type: challenge.type, + difficulty: challenge.difficulty, + points: challenge.points, + estimated_time: challenge.estimated_time || 30, + proof_type: challenge.proof_type, + proof_hint: challenge.proof_hint || '', + }) + } + + const handleUpdateChallenge = async (challengeId: number, gameId: number) => { + if (!editChallenge.title.trim() || !editChallenge.description.trim()) { + toast.warning('Заполните название и описание') + return + } + + setIsUpdatingChallenge(true) + try { + await gamesApi.updateChallenge(challengeId, { + title: editChallenge.title.trim(), + description: editChallenge.description.trim(), + type: editChallenge.type, + difficulty: editChallenge.difficulty, + points: editChallenge.points, + estimated_time: editChallenge.estimated_time || undefined, + proof_type: editChallenge.proof_type, + proof_hint: editChallenge.proof_hint.trim() || undefined, + }) + toast.success('Задание обновлено') + setEditingChallengeId(null) + const challenges = await gamesApi.getChallenges(gameId) + setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось обновить задание') + } finally { + setIsUpdatingChallenge(false) + } + } + + const loadProposedChallenges = async () => { + if (!id) return + try { + const proposed = await gamesApi.getProposedChallenges(parseInt(id)) + setProposedChallenges(proposed) + } catch (error) { + console.error('Failed to load proposed challenges:', error) + } + } + + const handleApproveChallenge = async (challengeId: number) => { + setApprovingChallengeId(challengeId) + try { + await gamesApi.approveChallenge(challengeId) + toast.success('Задание одобрено') + await loadProposedChallenges() + // Reload challenges for the game + const challenge = proposedChallenges.find(c => c.id === challengeId) + if (challenge) { + const challenges = await gamesApi.getChallenges(challenge.game.id) + setGameChallenges(prev => ({ ...prev, [challenge.game.id]: challenges })) + } + await loadData() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось одобрить задание') + } finally { + setApprovingChallengeId(null) + } + } + + const handleRejectChallenge = async (challengeId: number) => { + const confirmed = await confirm({ + title: 'Отклонить задание?', + message: 'Задание будет удалено.', + confirmText: 'Отклонить', + cancelText: 'Отмена', + variant: 'danger', + }) + if (!confirmed) return + + setApprovingChallengeId(challengeId) + try { + await gamesApi.rejectChallenge(challengeId) + toast.success('Задание отклонено') + await loadProposedChallenges() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось отклонить задание') + } finally { + setApprovingChallengeId(null) + } + } + + const handleStartEditProposed = (challenge: Challenge) => { + setEditingProposedId(challenge.id) + setEditChallenge({ + title: challenge.title, + description: challenge.description, + type: challenge.type, + difficulty: challenge.difficulty, + points: challenge.points, + estimated_time: challenge.estimated_time || 30, + proof_type: challenge.proof_type, + proof_hint: challenge.proof_hint || '', + }) + } + + const handleUpdateProposedChallenge = async (challengeId: number) => { + if (!editChallenge.title.trim() || !editChallenge.description.trim()) { + toast.warning('Заполните название и описание') + return + } + + setIsUpdatingChallenge(true) + try { + await gamesApi.updateChallenge(challengeId, { + title: editChallenge.title.trim(), + description: editChallenge.description.trim(), + type: editChallenge.type, + difficulty: editChallenge.difficulty, + points: editChallenge.points, + estimated_time: editChallenge.estimated_time || undefined, + proof_type: editChallenge.proof_type, + proof_hint: editChallenge.proof_hint.trim() || undefined, + }) + toast.success('Задание обновлено') + setEditingProposedId(null) + await loadProposedChallenges() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось обновить задание') + } finally { + setIsUpdatingChallenge(false) + } + } + + const handleProposeChallenge = async (gameId: number) => { + if (!newChallenge.title.trim() || !newChallenge.description.trim()) { + toast.warning('Заполните название и описание') + return + } + + setIsProposingChallenge(true) + try { + await gamesApi.proposeChallenge(gameId, { + title: newChallenge.title.trim(), + description: newChallenge.description.trim(), + type: newChallenge.type, + difficulty: newChallenge.difficulty, + points: newChallenge.points, + estimated_time: newChallenge.estimated_time || undefined, + proof_type: newChallenge.proof_type, + proof_hint: newChallenge.proof_hint.trim() || undefined, + }) + toast.success('Задание предложено на модерацию') + setNewChallenge({ + title: '', + description: '', + type: 'completion', + difficulty: 'medium', + points: 50, + estimated_time: 30, + proof_type: 'screenshot', + proof_hint: '', + }) + setAddingChallengeToGameId(null) + await loadData() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось предложить задание') + } finally { + setIsProposingChallenge(false) + } + } + + const handleDeleteMyProposedChallenge = async (challengeId: number) => { + const confirmed = await confirm({ + title: 'Удалить предложение?', + message: 'Предложенное задание будет удалено.', + confirmText: 'Удалить', + cancelText: 'Отмена', + variant: 'danger', + }) + if (!confirmed) return + + try { + await gamesApi.deleteChallenge(challengeId) + toast.success('Предложение удалено') + await loadData() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось удалить предложение') + } + } + const handleGenerateChallenges = async () => { if (!id) return @@ -476,41 +714,156 @@ export function LobbyPage() { ) : ( <> - {gameChallenges[game.id]?.length > 0 ? ( - gameChallenges[game.id].map((challenge) => ( + {(() => { + // For organizers: hide pending challenges (they see them in separate block) + // For regular users: hide their own pending/rejected challenges (they see them in "My proposals") + // but show their own approved challenges in both places + const visibleChallenges = isOrganizer + ? gameChallenges[game.id]?.filter(c => c.status !== 'pending') || [] + : gameChallenges[game.id]?.filter(c => + !(c.proposed_by?.id === user?.id && c.status !== 'approved') + ) || [] + + return visibleChallenges.length > 0 ? ( + visibleChallenges.map((challenge) => (
-
-
- - {challenge.difficulty === 'easy' ? 'Легко' : - challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} - - - +{challenge.points} - - {challenge.is_generated && ( - - ИИ - + {editingChallengeId === challenge.id ? ( + // Edit form +
+ setEditChallenge(prev => ({ ...prev, title: e.target.value }))} + /> +