Add challenges promotion

This commit is contained in:
2025-12-18 23:47:11 +07:00
parent 33f49f4e47
commit 8e634994bd
8 changed files with 1022 additions and 121 deletions

View 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')

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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()

View File

@@ -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
},
} }

View File

@@ -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,14 +714,114 @@ 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"
> >
{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-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <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 ${ <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 === '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' : challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
@@ -500,17 +838,32 @@ export function LobbyPage() {
<Sparkles className="w-3 h-3" /> ИИ <Sparkles className="w-3 h-3" /> ИИ
</span> </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> </div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5> <h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p> <p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div> </div>
{isOrganizer && ( {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 <button
onClick={() => handleDeleteChallenge(challenge.id, game.id)} onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0" className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div>
)}
</div>
)} )}
</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,6 +967,7 @@ export function LobbyPage() {
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{isOrganizer ? (
<NeonButton <NeonButton
size="sm" size="sm"
onClick={() => handleCreateChallenge(game.id)} onClick={() => handleCreateChallenge(game.id)}
@@ -622,6 +977,17 @@ export function LobbyPage() {
> >
Добавить Добавить
</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">

View File

@@ -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 {