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 sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
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 (
|
from app.schemas import (
|
||||||
ChallengeCreate,
|
ChallengeCreate,
|
||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
@@ -15,7 +16,9 @@ from app.schemas import (
|
|||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
ChallengesGenerateRequest,
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
|
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||||
from app.services.gpt import gpt_service
|
from app.services.gpt import gpt_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["challenges"])
|
router = APIRouter(tags=["challenges"])
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
|
|||||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(Challenge.id == challenge_id)
|
.where(Challenge.id == challenge_id)
|
||||||
)
|
)
|
||||||
challenge = result.scalar_one_or_none()
|
challenge = result.scalar_one_or_none()
|
||||||
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
|||||||
return 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])
|
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
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
|
# Get game and check access
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == game_id)
|
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:
|
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Game not accessible")
|
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||||
|
|
||||||
result = await db.execute(
|
# Get challenges with proposed_by
|
||||||
select(Challenge)
|
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||||
.where(Challenge.game_id == game_id)
|
|
||||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
# 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()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, game) for c in challenges]
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
@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:
|
if not current_user.is_admin and not participant:
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
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(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.join(Game, Challenge.game_id == Game.id)
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(
|
.where(
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||||
)
|
)
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
@@ -166,25 +168,13 @@ async def create_challenge(
|
|||||||
proof_type=data.proof_type.value,
|
proof_type=data.proof_type.value,
|
||||||
proof_hint=data.proof_hint,
|
proof_hint=data.proof_hint,
|
||||||
is_generated=False,
|
is_generated=False,
|
||||||
|
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
return ChallengeResponse(
|
return build_challenge_response(challenge, game)
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
@@ -386,26 +376,12 @@ async def update_challenge(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
game = challenge.game
|
return build_challenge_response(challenge, 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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
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)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# 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:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||||
|
|
||||||
# Only organizers can delete challenges
|
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||||
await require_organizer(db, current_user, 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.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Challenge deleted")
|
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"
|
STEAM = "steam"
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
class Challenge(Base):
|
||||||
__tablename__ = "challenges"
|
__tablename__ = "challenges"
|
||||||
|
|
||||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
|||||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
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
|
# Relationships
|
||||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||||
|
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment",
|
"Assignment",
|
||||||
back_populates="challenge"
|
back_populates="challenge"
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
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
|
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):
|
class ChallengeBase(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=100)
|
title: str = Field(..., min_length=1, max_length=100)
|
||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
|||||||
game: GameShort
|
game: GameShort
|
||||||
is_generated: bool
|
is_generated: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
status: str = "approved"
|
||||||
|
proposed_by: ProposedByUser | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePropose(ChallengeBase):
|
||||||
|
"""Schema for proposing a challenge by a participant"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChallengeGenerated(BaseModel):
|
class ChallengeGenerated(BaseModel):
|
||||||
"""Schema for GPT-generated challenges"""
|
"""Schema for GPT-generated challenges"""
|
||||||
title: str
|
title: str
|
||||||
|
|||||||
@@ -276,6 +276,42 @@ class TelegramNotifier:
|
|||||||
)
|
)
|
||||||
return await self.notify_user(db, user_id, message)
|
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
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
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> => {
|
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
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 })
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||||
return response.data
|
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)
|
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
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
@@ -84,6 +105,23 @@ export function LobbyPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setPendingGames([])
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', 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 () => {
|
const handleGenerateChallenges = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
@@ -476,41 +714,156 @@ export function LobbyPage() {
|
|||||||
</div>
|
</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
|
<div
|
||||||
key={challenge.id}
|
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">
|
{editingChallengeId === challenge.id ? (
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
// Edit form
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
<div className="space-y-3">
|
||||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
<Input
|
||||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
placeholder="Название задания"
|
||||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
value={editChallenge.title}
|
||||||
}`}>
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
/>
|
||||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
<textarea
|
||||||
</span>
|
placeholder="Описание"
|
||||||
<span className="text-xs text-neon-400 font-semibold">
|
value={editChallenge.description}
|
||||||
+{challenge.points}
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||||
</span>
|
className="input w-full resize-none"
|
||||||
{challenge.is_generated && (
|
rows={2}
|
||||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
/>
|
||||||
<Sparkles className="w-3 h-3" /> ИИ
|
<div className="grid grid-cols-2 gap-2">
|
||||||
</span>
|
<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>
|
</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>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -518,15 +871,16 @@ export function LobbyPage() {
|
|||||||
<p className="text-center text-gray-500 py-4 text-sm">
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
Нет заданий
|
Нет заданий
|
||||||
</p>
|
</p>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Add challenge form */}
|
{/* Add/Propose challenge form */}
|
||||||
{isOrganizer && game.status === 'approved' && (
|
{game.status === 'approved' && (
|
||||||
addingChallengeToGameId === game.id ? (
|
addingChallengeToGameId === game.id ? (
|
||||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
<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">
|
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4 text-neon-400" />
|
<Plus className="w-4 h-4 text-neon-400" />
|
||||||
Новое задание
|
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
|
||||||
</h4>
|
</h4>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Название задания"
|
placeholder="Название задания"
|
||||||
@@ -613,15 +967,27 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<NeonButton
|
{isOrganizer ? (
|
||||||
size="sm"
|
<NeonButton
|
||||||
onClick={() => handleCreateChallenge(game.id)}
|
size="sm"
|
||||||
isLoading={isCreatingChallenge}
|
onClick={() => handleCreateChallenge(game.id)}
|
||||||
disabled={!newChallenge.title || !newChallenge.description}
|
isLoading={isCreatingChallenge}
|
||||||
icon={<Plus className="w-4 h-4" />}
|
disabled={!newChallenge.title || !newChallenge.description}
|
||||||
>
|
icon={<Plus className="w-4 h-4" />}
|
||||||
Добавить
|
>
|
||||||
</NeonButton>
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleProposeChallenge(game.id)}
|
||||||
|
isLoading={isProposingChallenge}
|
||||||
|
disabled={!newChallenge.title || !newChallenge.description}
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Предложить
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
<NeonButton
|
<NeonButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -630,6 +996,11 @@ export function LobbyPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
{!isOrganizer && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Задание будет отправлено на модерацию организаторам
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<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"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
Добавить задание вручную
|
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -721,6 +1092,233 @@ export function LobbyPage() {
|
|||||||
</GlassCard>
|
</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 */}
|
{/* Generate challenges */}
|
||||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||||
<GlassCard className="mb-8">
|
<GlassCard className="mb-8">
|
||||||
|
|||||||
@@ -135,6 +135,13 @@ export type ChallengeType =
|
|||||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||||
export type ProofType = 'screenshot' | 'video' | 'steam'
|
export type ProofType = 'screenshot' | 'video' | 'steam'
|
||||||
|
|
||||||
|
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
export interface ProposedByUser {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Challenge {
|
export interface Challenge {
|
||||||
id: number
|
id: number
|
||||||
game: GameShort
|
game: GameShort
|
||||||
@@ -148,6 +155,8 @@ export interface Challenge {
|
|||||||
proof_hint: string | null
|
proof_hint: string | null
|
||||||
is_generated: boolean
|
is_generated: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
status: ChallengeStatus
|
||||||
|
proposed_by: ProposedByUser | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChallengePreview {
|
export interface ChallengePreview {
|
||||||
|
|||||||
Reference in New Issue
Block a user