Add challenges promotion
This commit is contained in:
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
28
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"✅ <b>Твой челлендж одобрен!</b>\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"❌ <b>Твой челлендж отклонён</b>\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()
|
||||
|
||||
@@ -79,6 +79,11 @@ export const gamesApi = {
|
||||
await client.delete(`/challenges/${id}`)
|
||||
},
|
||||
|
||||
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||
const response = await client.post<ChallengesPreviewResponse>(`/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<Challenge> => {
|
||||
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -61,6 +61,27 @@ export function LobbyPage() {
|
||||
})
|
||||
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
||||
|
||||
// Edit challenge
|
||||
const [editingChallengeId, setEditingChallengeId] = useState<number | null>(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<Challenge[]>([])
|
||||
const [myProposedChallenges, setMyProposedChallenges] = useState<Challenge[]>([])
|
||||
const [approvingChallengeId, setApprovingChallengeId] = useState<number | null>(null)
|
||||
const [isProposingChallenge, setIsProposingChallenge] = useState(false)
|
||||
const [editingProposedId, setEditingProposedId] = useState<number | null>(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() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
{challenge.is_generated && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> ИИ
|
||||
</span>
|
||||
{editingChallengeId === challenge.id ? (
|
||||
// Edit form
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
value={editChallenge.title}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={editChallenge.description}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||
<select
|
||||
value={editChallenge.type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="completion">Прохождение</option>
|
||||
<option value="no_death">Без смертей</option>
|
||||
<option value="speedrun">Спидран</option>
|
||||
<option value="collection">Коллекция</option>
|
||||
<option value="achievement">Достижение</option>
|
||||
<option value="challenge_run">Челлендж-ран</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||
<select
|
||||
value={editChallenge.difficulty}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="easy">Легко</option>
|
||||
<option value="medium">Средне</option>
|
||||
<option value="hard">Сложно</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editChallenge.points}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editChallenge.proof_type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleUpdateChallenge(challenge.id, game.id)}
|
||||
isLoading={isUpdatingChallenge}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingChallengeId(null)}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Display challenge
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
{challenge.status === 'pending' && getStatusBadge('pending')}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
{challenge.is_generated && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> ИИ
|
||||
</span>
|
||||
)}
|
||||
{challenge.proposed_by && (
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> {challenge.proposed_by.nickname}
|
||||
</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 && (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEditChallenge(challenge)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
@@ -518,15 +871,16 @@ export function LobbyPage() {
|
||||
<p className="text-center text-gray-500 py-4 text-sm">
|
||||
Нет заданий
|
||||
</p>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Add challenge form */}
|
||||
{isOrganizer && game.status === 'approved' && (
|
||||
{/* Add/Propose challenge form */}
|
||||
{game.status === 'approved' && (
|
||||
addingChallengeToGameId === game.id ? (
|
||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-neon-400" />
|
||||
Новое задание
|
||||
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
|
||||
</h4>
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
@@ -613,15 +967,27 @@ export function LobbyPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleCreateChallenge(game.id)}
|
||||
isLoading={isCreatingChallenge}
|
||||
disabled={!newChallenge.title || !newChallenge.description}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
{isOrganizer ? (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleCreateChallenge(game.id)}
|
||||
isLoading={isCreatingChallenge}
|
||||
disabled={!newChallenge.title || !newChallenge.description}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Добавить
|
||||
</NeonButton>
|
||||
) : (
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleProposeChallenge(game.id)}
|
||||
isLoading={isProposingChallenge}
|
||||
disabled={!newChallenge.title || !newChallenge.description}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
Предложить
|
||||
</NeonButton>
|
||||
)}
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -630,6 +996,11 @@ export function LobbyPage() {
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
{!isOrganizer && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Задание будет отправлено на модерацию организаторам
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -640,7 +1011,7 @@ export function LobbyPage() {
|
||||
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить задание вручную
|
||||
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
@@ -721,6 +1092,233 @@ export function LobbyPage() {
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Proposed challenges for moderation */}
|
||||
{isOrganizer && proposedChallenges.length > 0 && (
|
||||
<GlassCard className="mb-8 border-accent-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-accent-400">Предложенные задания</h3>
|
||||
<p className="text-sm text-gray-400">{proposedChallenges.length} заданий ожидают</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{proposedChallenges.map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||
>
|
||||
{editingProposedId === challenge.id ? (
|
||||
// Edit form
|
||||
<div className="space-y-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
value={editChallenge.title}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Описание"
|
||||
value={editChallenge.description}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип</label>
|
||||
<select
|
||||
value={editChallenge.type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="completion">Прохождение</option>
|
||||
<option value="no_death">Без смертей</option>
|
||||
<option value="speedrun">Спидран</option>
|
||||
<option value="collection">Коллекция</option>
|
||||
<option value="achievement">Достижение</option>
|
||||
<option value="challenge_run">Челлендж-ран</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||
<select
|
||||
value={editChallenge.difficulty}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="easy">Легко</option>
|
||||
<option value="medium">Средне</option>
|
||||
<option value="hard">Сложно</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editChallenge.points}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editChallenge.proof_type}
|
||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleUpdateProposedChallenge(challenge.id)}
|
||||
isLoading={isUpdatingChallenge}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setEditingProposedId(null)}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
{challenge.proposed_by && (
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Display
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
|
||||
{challenge.proposed_by && (
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEditProposed(challenge)}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApproveChallenge(challenge.id)}
|
||||
disabled={approvingChallengeId === challenge.id}
|
||||
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{approvingChallengeId === challenge.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectChallenge(challenge.id)}
|
||||
disabled={approvingChallengeId === challenge.id}
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* My proposed challenges (for non-organizers) */}
|
||||
{!isOrganizer && myProposedChallenges.length > 0 && (
|
||||
<GlassCard className="mb-8 border-neon-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neon-400">Мои предложения</h3>
|
||||
<p className="text-sm text-gray-400">{myProposedChallenges.length} заданий</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{myProposedChallenges.map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="flex items-start justify-between gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game.title}
|
||||
</span>
|
||||
{getStatusBadge(challenge.status)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{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>
|
||||
</div>
|
||||
{challenge.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleDeleteMyProposedChallenge(challenge.id)}
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Generate challenges */}
|
||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||
<GlassCard className="mb-8">
|
||||
|
||||
@@ -135,6 +135,13 @@ export type ChallengeType =
|
||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||
export type ProofType = 'screenshot' | 'video' | 'steam'
|
||||
|
||||
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
|
||||
|
||||
export interface ProposedByUser {
|
||||
id: number
|
||||
nickname: string
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
id: number
|
||||
game: GameShort
|
||||
@@ -148,6 +155,8 @@ export interface Challenge {
|
||||
proof_hint: string | null
|
||||
is_generated: boolean
|
||||
created_at: string
|
||||
status: ChallengeStatus
|
||||
proposed_by: ProposedByUser | null
|
||||
}
|
||||
|
||||
export interface ChallengePreview {
|
||||
|
||||
Reference in New Issue
Block a user