From 7a3576aec0d55162f184779536aa481d5612ddd1 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Sat, 3 Jan 2026 00:12:07 +0700 Subject: [PATCH] a --- backend/app/api/v1/assignments.py | 328 ++++++++++++++++++-- backend/app/api/v1/challenges.py | 2 +- backend/app/api/v1/events.py | 1 + backend/app/api/v1/wheel.py | 96 ++++-- backend/app/models/__init__.py | 3 + backend/app/models/assignment.py | 1 + backend/app/models/assignment_proof.py | 47 +++ backend/app/models/bonus_assignment.py | 6 + backend/app/schemas/assignment.py | 13 + backend/app/schemas/dispute.py | 4 +- backend/app/schemas/game.py | 1 + backend/app/services/storage.py | 2 +- docker-compose.yml | 6 +- frontend/src/api/assignments.ts | 46 ++- frontend/src/api/wheel.ts | 9 +- frontend/src/pages/AssignmentDetailPage.tsx | 261 +++++++++++++++- frontend/src/pages/PlayPage.tsx | 129 +++++--- frontend/src/types/index.ts | 14 +- 18 files changed, 844 insertions(+), 125 deletions(-) create mode 100644 backend/app/models/assignment_proof.py diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index 3ef1651..470b14f 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -101,7 +101,9 @@ async def get_assignment_detail( .options( selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.game), # For playthrough + selectinload(Assignment.proof_files), # Load multiple proof files selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough + selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.proof_files), # Load bonus proof files selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.raised_by), # Bonus disputes selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user), selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user), @@ -176,6 +178,18 @@ async def get_assignment_detail( time_since_bonus_completion = datetime.utcnow() - ba.completed_at bonus_can_dispute = time_since_bonus_completion < timedelta(hours=DISPUTE_WINDOW_HOURS) + # Build bonus proof files list + from app.schemas.assignment import ProofFileResponse + bonus_proof_files = [ + ProofFileResponse( + id=pf.id, + file_type=pf.file_type, + order_index=pf.order_index, + created_at=pf.created_at, + ) + for pf in ba.proof_files + ] + bonus_challenges.append({ "id": ba.id, "challenge": { @@ -189,6 +203,7 @@ async def get_assignment_detail( "status": ba.status, "proof_url": ba.proof_url, "proof_image_url": storage_service.get_url(ba.proof_path, "bonus_proofs") if ba.proof_path else None, + "proof_files": bonus_proof_files, "proof_comment": ba.proof_comment, "points_earned": ba.points_earned, "completed_at": ba.completed_at.isoformat() if ba.completed_at else None, @@ -196,6 +211,18 @@ async def get_assignment_detail( "dispute": build_dispute_response(ba.dispute, current_user.id) if ba.dispute else None, }) + # Build proof files list + from app.schemas.assignment import ProofFileResponse + proof_files_list = [ + ProofFileResponse( + id=pf.id, + file_type=pf.file_type, + order_index=pf.order_index, + created_at=pf.created_at, + ) + for pf in assignment.proof_files + ] + return AssignmentDetailResponse( id=assignment.id, challenge=None, @@ -203,6 +230,7 @@ async def get_assignment_detail( id=game.id, title=game.title, cover_url=storage_service.get_url(game.cover_path, "covers"), + download_url=game.download_url, game_type=game.game_type, ), is_playthrough=True, @@ -216,6 +244,7 @@ async def get_assignment_detail( status=assignment.status, proof_url=assignment.proof_url, proof_image_url=proof_image_url, + proof_files=proof_files_list, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, @@ -230,6 +259,18 @@ async def get_assignment_detail( challenge = assignment.challenge game = challenge.game + # Build proof files list + from app.schemas.assignment import ProofFileResponse + proof_files_list = [ + ProofFileResponse( + id=pf.id, + file_type=pf.file_type, + order_index=pf.order_index, + created_at=pf.created_at, + ) + for pf in assignment.proof_files + ] + return AssignmentDetailResponse( id=assignment.id, challenge=ChallengeResponse( @@ -246,6 +287,7 @@ async def get_assignment_detail( id=game.id, title=game.title, cover_url=storage_service.get_url(game.cover_path, "covers"), + download_url=game.download_url, ), is_generated=challenge.is_generated, created_at=challenge.created_at, @@ -254,6 +296,7 @@ async def get_assignment_detail( status=assignment.status, proof_url=assignment.proof_url, proof_image_url=proof_image_url, + proof_files=proof_files_list, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, @@ -490,6 +533,212 @@ async def get_bonus_proof_media( ) +@router.get("/assignments/{assignment_id}/proof-files/{proof_file_id}/media") +async def get_assignment_proof_file_media( + assignment_id: int, + proof_file_id: int, + request: Request, + current_user: CurrentUser, + db: DbSession, +): + """Stream a specific proof file (image or video) for an assignment""" + from app.models import AssignmentProof, Game + + # Get proof file + result = await db.execute( + select(AssignmentProof) + .options( + selectinload(AssignmentProof.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game), + selectinload(AssignmentProof.assignment).selectinload(Assignment.game), # For playthrough + ) + .where( + AssignmentProof.id == proof_file_id, + AssignmentProof.assignment_id == assignment_id, + ) + ) + proof_file = result.scalar_one_or_none() + + if not proof_file: + raise HTTPException(status_code=404, detail="Proof file not found") + + assignment = proof_file.assignment + + # Get marathon_id based on assignment type + if assignment.is_playthrough: + marathon_id = assignment.game.marathon_id + else: + marathon_id = assignment.challenge.game.marathon_id + + # Check user is participant of the marathon + result = await db.execute( + select(Participant).where( + Participant.user_id == current_user.id, + Participant.marathon_id == marathon_id, + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + + # Get file from storage + file_data = await storage_service.get_file(proof_file.file_path, "proofs") + if not file_data: + raise HTTPException(status_code=404, detail="Proof file not found in storage") + + content, content_type = file_data + file_size = len(content) + + # Check if it's a video and handle Range requests + is_video = content_type.startswith("video/") + + if is_video: + range_header = request.headers.get("range") + + if range_header: + # Parse range header + range_match = range_header.replace("bytes=", "").split("-") + start = int(range_match[0]) if range_match[0] else 0 + end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1 + + chunk = content[start:end + 1] + + return Response( + content=chunk, + status_code=206, + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(len(chunk)), + "Cache-Control": "public, max-age=31536000", + } + ) + + return Response( + content=content, + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Cache-Control": "public, max-age=31536000", + } + ) + + # For images, just return the content + return Response( + content=content, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000", + } + ) + + +@router.get("/assignments/{assignment_id}/bonus/{bonus_id}/proof-files/{proof_file_id}/media") +async def get_bonus_proof_file_media( + assignment_id: int, + bonus_id: int, + proof_file_id: int, + request: Request, + current_user: CurrentUser, + db: DbSession, +): + """Stream a specific proof file (image or video) for a bonus assignment""" + from app.models import BonusAssignmentProof, Game + + # Get proof file + result = await db.execute( + select(BonusAssignmentProof) + .options( + selectinload(BonusAssignmentProof.bonus_assignment) + .selectinload(BonusAssignment.main_assignment) + .selectinload(Assignment.game), # For playthrough + ) + .where( + BonusAssignmentProof.id == proof_file_id, + BonusAssignmentProof.bonus_assignment_id == bonus_id, + ) + ) + proof_file = result.scalar_one_or_none() + + if not proof_file: + raise HTTPException(status_code=404, detail="Proof file not found") + + bonus = proof_file.bonus_assignment + assignment = bonus.main_assignment + + # Check assignment matches + if assignment.id != assignment_id: + raise HTTPException(status_code=404, detail="Proof file not found for this assignment") + + # Get marathon_id (bonus assignments are always for playthrough) + marathon_id = assignment.game.marathon_id + + # Check user is participant of the marathon + result = await db.execute( + select(Participant).where( + Participant.user_id == current_user.id, + Participant.marathon_id == marathon_id, + ) + ) + participant = result.scalar_one_or_none() + if not participant: + raise HTTPException(status_code=403, detail="You are not a participant of this marathon") + + # Get file from storage + file_data = await storage_service.get_file(proof_file.file_path, "bonus_proofs") + if not file_data: + raise HTTPException(status_code=404, detail="Proof file not found in storage") + + content, content_type = file_data + file_size = len(content) + + # Check if it's a video and handle Range requests + is_video = content_type.startswith("video/") + + if is_video: + range_header = request.headers.get("range") + + if range_header: + # Parse range header + range_match = range_header.replace("bytes=", "").split("-") + start = int(range_match[0]) if range_match[0] else 0 + end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1 + + chunk = content[start:end + 1] + + return Response( + content=chunk, + status_code=206, + media_type=content_type, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(len(chunk)), + "Cache-Control": "public, max-age=31536000", + } + ) + + return Response( + content=content, + media_type=content_type, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Cache-Control": "public, max-age=31536000", + } + ) + + # For images, just return the content + return Response( + content=content, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000", + } + ) + + @router.post("/bonus-assignments/{bonus_id}/dispute", response_model=DisputeResponse) async def create_bonus_dispute( bonus_id: int, @@ -891,6 +1140,7 @@ async def get_returned_assignments( id=a.challenge.game.id, title=a.challenge.game.title, cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"), + download_url=a.challenge.game.download_url, ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, @@ -951,6 +1201,7 @@ async def get_bonus_assignments( id=assignment.game.id, title=assignment.game.title, cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None, + download_url=assignment.game.download_url, game_type=assignment.game.game_type, ), is_generated=ba.challenge.is_generated, @@ -974,7 +1225,8 @@ async def complete_bonus_assignment( db: DbSession, proof_url: str | None = Form(None), comment: str | None = Form(None), - proof_file: UploadFile | None = File(None), + proof_file: UploadFile | None = File(None), # Legacy single file support + proof_files: list[UploadFile] = File([]), # Multiple files support ): """Complete a bonus challenge for a playthrough assignment""" from app.core.config import settings @@ -1020,40 +1272,66 @@ async def complete_bonus_assignment( if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value: raise HTTPException(status_code=400, detail="This bonus challenge is already completed") - # Validate proof (need file, URL, or comment) - if not proof_file and not proof_url and not comment: + # Combine legacy single file with new multiple files + all_files = [] + if proof_file: + all_files.append(proof_file) + if proof_files: + all_files.extend(proof_files) + + # Validate proof (need file(s), URL, or comment) + if not all_files and not proof_url and not comment: raise HTTPException( status_code=400, detail="Необходимо прикрепить файл, ссылку или комментарий" ) - # Handle file upload - if proof_file: - contents = await proof_file.read() - if len(contents) > settings.MAX_UPLOAD_SIZE: - raise HTTPException( - status_code=400, - detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + # Handle multiple file uploads + if all_files: + from app.models import BonusAssignmentProof + + for idx, file in enumerate(all_files): + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" + if ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}", + ) + + # Determine file type (image or video) + file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image" + + # Upload file to storage + filename = storage_service.generate_filename(f"bonus_{bonus_id}_{idx}", file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="bonus_proofs", + filename=filename, + content_type=file.content_type or "application/octet-stream", ) - ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" - if ext not in settings.ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", + # Create BonusAssignmentProof record + proof_record = BonusAssignmentProof( + bonus_assignment_id=bonus_id, + file_path=file_path, + file_type=file_type, + order_index=idx ) + db.add(proof_record) - # Upload file to storage - filename = storage_service.generate_filename(bonus_id, proof_file.filename) - file_path = await storage_service.upload_file( - content=contents, - folder="bonus_proofs", - filename=filename, - content_type=proof_file.content_type or "application/octet-stream", - ) + # Legacy: set proof_path on first file for backward compatibility + if idx == 0: + bonus_assignment.proof_path = file_path - bonus_assignment.proof_path = file_path - else: + # Set proof URL if provided + if proof_url: bonus_assignment.proof_url = proof_url # Complete the bonus assignment diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index b85088f..e234f8a 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo 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), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url), is_generated=challenge.is_generated, created_at=challenge.created_at, status=challenge.status, diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index c6fc08e..724f33b 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -937,6 +937,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse: id=game.id, title=game.title, cover_url=storage_service.get_url(game.cover_path, "covers"), + download_url=game.download_url, ), is_generated=challenge.is_generated, created_at=challenge.created_at, diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 88eed1f..b3a636c 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -315,7 +315,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) estimated_time=ch.estimated_time, proof_type=ch.proof_type, proof_hint=ch.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ch.is_generated, created_at=ch.created_at, ) @@ -339,7 +339,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) 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, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), @@ -392,7 +392,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), @@ -406,7 +406,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db return AssignmentResponse( id=assignment.id, challenge=None, - game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, @@ -445,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db 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, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=challenge.is_generated, created_at=challenge.created_at, ), @@ -467,7 +467,8 @@ async def complete_assignment( db: DbSession, proof_url: str | None = Form(None), comment: str | None = Form(None), - proof_file: UploadFile | None = File(None), + proof_file: UploadFile | None = File(None), # Legacy single file support + proof_files: list[UploadFile] = File([]), # Multiple files support ): """Complete a regular assignment with proof (not event assignments)""" # Get assignment with all needed relationships @@ -497,42 +498,68 @@ async def complete_assignment( if assignment.is_event_assignment: raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") - # For playthrough: need either file or URL or comment (proof is flexible) - # For challenges: need either file or URL + # Combine legacy single file with new multiple files + all_files = [] + if proof_file: + all_files.append(proof_file) + if proof_files: + all_files.extend(proof_files) + + # For playthrough: need either file(s) or URL or comment (proof is flexible) + # For challenges: need either file(s) or URL if assignment.is_playthrough: - if not proof_file and not proof_url and not comment: + if not all_files and not proof_url and not comment: raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)") else: - if not proof_file and not proof_url: + if not all_files and not proof_url: raise HTTPException(status_code=400, detail="Proof is required (file or URL)") - # Handle file upload - if proof_file: - contents = await proof_file.read() - if len(contents) > settings.MAX_UPLOAD_SIZE: - raise HTTPException( - status_code=400, - detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + # Handle multiple file uploads + if all_files: + from app.models import AssignmentProof + + for idx, file in enumerate(all_files): + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=400, + detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" + if ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}", + ) + + # Determine file type (image or video) + file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image" + + # Upload file to storage + filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="proofs", + filename=filename, + content_type=file.content_type or "application/octet-stream", ) - ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" - if ext not in settings.ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", + # Create AssignmentProof record + proof_record = AssignmentProof( + assignment_id=assignment_id, + file_path=file_path, + file_type=file_type, + order_index=idx ) + db.add(proof_record) - # Upload file to storage - filename = storage_service.generate_filename(assignment_id, proof_file.filename) - file_path = await storage_service.upload_file( - content=contents, - folder="proofs", - filename=filename, - content_type=proof_file.content_type or "application/octet-stream", - ) + # Legacy: set proof_path on first file for backward compatibility + if idx == 0: + assignment.proof_path = file_path - assignment.proof_path = file_path - else: + # Set proof URL if provided + if proof_url: assignment.proof_url = proof_url assignment.proof_comment = comment @@ -908,7 +935,7 @@ async def get_my_history( estimated_time=ba.challenge.estimated_time, proof_type=ba.challenge.proof_type, proof_hint=ba.challenge.proof_hint, - game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_generated=ba.challenge.is_generated, created_at=ba.challenge.created_at, ), @@ -924,7 +951,7 @@ async def get_my_history( responses.append(AssignmentResponse( id=a.id, challenge=None, - game=GameShort(id=game.id, title=game.title, cover_url=None, game_type=game.game_type), + game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type), is_playthrough=True, playthrough_info=PlaythroughInfo( description=game.playthrough_description, @@ -959,6 +986,7 @@ async def get_my_history( id=a.challenge.game.id, title=a.challenge.game.title, cover_url=None, + download_url=a.challenge.game.download_url, game_type=a.challenge.game.game_type, ), is_generated=a.challenge.is_generated, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6dcf494..b195960 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,6 +5,7 @@ from app.models.game import Game, GameStatus, GameType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.assignment import Assignment, AssignmentStatus from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus +from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof from app.models.activity import Activity, ActivityType from app.models.event import Event, EventType from app.models.swap_request import SwapRequest, SwapRequestStatus @@ -32,6 +33,8 @@ __all__ = [ "AssignmentStatus", "BonusAssignment", "BonusAssignmentStatus", + "AssignmentProof", + "BonusAssignmentProof", "Activity", "ActivityType", "Event", diff --git a/backend/app/models/assignment.py b/backend/app/models/assignment.py index f12b381..66aacff 100644 --- a/backend/app/models/assignment.py +++ b/backend/app/models/assignment.py @@ -42,3 +42,4 @@ class Assignment(Base): event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True) bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan") + proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index") diff --git a/backend/app/models/assignment_proof.py b/backend/app/models/assignment_proof.py new file mode 100644 index 0000000..cf63b67 --- /dev/null +++ b/backend/app/models/assignment_proof.py @@ -0,0 +1,47 @@ +from datetime import datetime +from sqlalchemy import String, ForeignKey, Integer, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +class AssignmentProof(Base): + """Файлы-доказательства для заданий (множественные пруфы)""" + __tablename__ = "assignment_proofs" + + id: Mapped[int] = mapped_column(primary_key=True) + assignment_id: Mapped[int] = mapped_column( + ForeignKey("assignments.id", ondelete="CASCADE"), + index=True + ) + file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище + file_type: Mapped[str] = mapped_column(String(20)) # image или video + order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + assignment: Mapped["Assignment"] = relationship( + "Assignment", + back_populates="proof_files" + ) + + +class BonusAssignmentProof(Base): + """Файлы-доказательства для бонусных заданий (множественные пруфы)""" + __tablename__ = "bonus_assignment_proofs" + + id: Mapped[int] = mapped_column(primary_key=True) + bonus_assignment_id: Mapped[int] = mapped_column( + ForeignKey("bonus_assignments.id", ondelete="CASCADE"), + index=True + ) + file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище + file_type: Mapped[str] = mapped_column(String(20)) # image или video + order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Relationships + bonus_assignment: Mapped["BonusAssignment"] = relationship( + "BonusAssignment", + back_populates="proof_files" + ) diff --git a/backend/app/models/bonus_assignment.py b/backend/app/models/bonus_assignment.py index af4b986..2ccf8a9 100644 --- a/backend/app/models/bonus_assignment.py +++ b/backend/app/models/bonus_assignment.py @@ -46,3 +46,9 @@ class BonusAssignment(Base): back_populates="bonus_assignment", uselist=False, ) + proof_files: Mapped[list["BonusAssignmentProof"]] = relationship( + "BonusAssignmentProof", + back_populates="bonus_assignment", + cascade="all, delete-orphan", + order_by="BonusAssignmentProof.order_index" + ) diff --git a/backend/app/schemas/assignment.py b/backend/app/schemas/assignment.py index 82cd04e..67d892f 100644 --- a/backend/app/schemas/assignment.py +++ b/backend/app/schemas/assignment.py @@ -5,6 +5,17 @@ from app.schemas.game import GameResponse, GameShort, PlaythroughInfo from app.schemas.challenge import ChallengeResponse +class ProofFileResponse(BaseModel): + """Информация о файле-доказательстве""" + id: int + file_type: str # image или video + order_index: int + created_at: datetime + + class Config: + from_attributes = True + + class AssignmentBase(BaseModel): pass @@ -20,6 +31,8 @@ class BonusAssignmentResponse(BaseModel): challenge: ChallengeResponse status: str # pending, completed proof_url: str | None = None + proof_image_url: str | None = None # Legacy, for backward compatibility + proof_files: list[ProofFileResponse] = [] # Multiple uploaded files proof_comment: str | None = None points_earned: int = 0 completed_at: datetime | None = None diff --git a/backend/app/schemas/dispute.py b/backend/app/schemas/dispute.py index 16a505d..2c8387b 100644 --- a/backend/app/schemas/dispute.py +++ b/backend/app/schemas/dispute.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, Field from app.schemas.user import UserPublic from app.schemas.challenge import ChallengeResponse, GameShort +from app.schemas.assignment import ProofFileResponse if TYPE_CHECKING: from app.schemas.game import PlaythroughInfo @@ -75,7 +76,8 @@ class AssignmentDetailResponse(BaseModel): participant: UserPublic status: str proof_url: str | None # External URL (YouTube, etc.) - proof_image_url: str | None # Uploaded file URL + proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility) + proof_files: list[ProofFileResponse] = [] # Multiple uploaded files proof_comment: str | None points_earned: int streak_at_completion: int | None diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py index 9afb5cd..91a4a18 100644 --- a/backend/app/schemas/game.py +++ b/backend/app/schemas/game.py @@ -56,6 +56,7 @@ class GameShort(BaseModel): id: int title: str cover_url: str | None = None + download_url: str game_type: str = "challenges" class Config: diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 1f19a3d..9650dad 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -15,7 +15,7 @@ from app.core.config import settings logger = logging.getLogger(__name__) -StorageFolder = Literal["avatars", "covers", "proofs"] +StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"] class StorageService: diff --git a/docker-compose.yml b/docker-compose.yml index feffa76..235a30a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data ports: - - "5432:5432" + - "5433:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U marathon"] interval: 5s @@ -43,7 +43,7 @@ services: - ./backend/uploads:/app/uploads - ./backend/app:/app/app ports: - - "8000:8000" + - "8002:8000" depends_on: db: condition: service_healthy @@ -57,7 +57,7 @@ services: VITE_API_URL: ${VITE_API_URL:-/api/v1} container_name: marathon-frontend ports: - - "3000:80" + - "3002:80" depends_on: - backend restart: unless-stopped diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts index 262eece..5c1d17d 100644 --- a/frontend/src/api/assignments.ts +++ b/frontend/src/api/assignments.ts @@ -67,18 +67,27 @@ export const assignmentsApi = { completeBonusAssignment: async ( assignmentId: number, bonusId: number, - data: { proof_file?: File; proof_url?: string; comment?: string } + data: { proof_file?: File; proof_files?: File[]; proof_url?: string; comment?: string } ): Promise => { const formData = new FormData() + + // Support both single file (legacy) and multiple files if (data.proof_file) { formData.append('proof_file', data.proof_file) } + if (data.proof_files && data.proof_files.length > 0) { + data.proof_files.forEach(file => { + formData.append('proof_files', file) + }) + } + if (data.proof_url) { formData.append('proof_url', data.proof_url) } if (data.comment) { formData.append('comment', data.comment) } + const response = await client.post( `/assignments/${assignmentId}/bonus/${bonusId}/complete`, formData, @@ -103,4 +112,39 @@ export const assignmentsApi = { type: isVideo ? 'video' : 'image', } }, + + // Get individual proof file media as blob URL (for multiple proofs support) + getProofFileMediaUrl: async ( + assignmentId: number, + proofFileId: number + ): Promise<{ url: string; type: 'image' | 'video' }> => { + const response = await client.get( + `/assignments/${assignmentId}/proof-files/${proofFileId}/media`, + { responseType: 'blob' } + ) + const contentType = response.headers['content-type'] || '' + const isVideo = contentType.startsWith('video/') + return { + url: URL.createObjectURL(response.data), + type: isVideo ? 'video' : 'image', + } + }, + + // Get individual bonus proof file media as blob URL (for multiple proofs support) + getBonusProofFileMediaUrl: async ( + assignmentId: number, + bonusId: number, + proofFileId: number + ): Promise<{ url: string; type: 'image' | 'video' }> => { + const response = await client.get( + `/assignments/${assignmentId}/bonus/${bonusId}/proof-files/${proofFileId}/media`, + { responseType: 'blob' } + ) + const contentType = response.headers['content-type'] || '' + const isVideo = contentType.startsWith('video/') + return { + url: URL.createObjectURL(response.data), + type: isVideo ? 'video' : 'image', + } + }, } diff --git a/frontend/src/api/wheel.ts b/frontend/src/api/wheel.ts index ad7a653..8e047b9 100644 --- a/frontend/src/api/wheel.ts +++ b/frontend/src/api/wheel.ts @@ -14,12 +14,19 @@ export const wheelApi = { complete: async ( assignmentId: number, - data: { proof_url?: string; comment?: string; proof_file?: File } + data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: File[] } ): Promise => { const formData = new FormData() if (data.proof_url) formData.append('proof_url', data.proof_url) if (data.comment) formData.append('comment', data.comment) + + // Support both single file (legacy) and multiple files if (data.proof_file) formData.append('proof_file', data.proof_file) + if (data.proof_files && data.proof_files.length > 0) { + data.proof_files.forEach(file => { + formData.append('proof_files', file) + }) + } const response = await client.post(`/assignments/${assignmentId}/complete`, formData, { headers: { 'Content-Type': 'multipart/form-data' }, diff --git a/frontend/src/pages/AssignmentDetailPage.tsx b/frontend/src/pages/AssignmentDetailPage.tsx index c920b6f..ffb8473 100644 --- a/frontend/src/pages/AssignmentDetailPage.tsx +++ b/frontend/src/pages/AssignmentDetailPage.tsx @@ -8,7 +8,7 @@ import { useToast } from '@/store/toast' import { ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, - Send, Flag, Gamepad2, Zap, Trophy + Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X } from 'lucide-react' export function AssignmentDetailPage() { @@ -23,9 +23,20 @@ export function AssignmentDetailPage() { const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState(null) const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) + // Multiple proof files + const [proofFiles, setProofFiles] = useState>([]) + // Bonus proof media const [bonusProofMedia, setBonusProofMedia] = useState>({}) + // Bonus proof files (multiple) + const [bonusProofFiles, setBonusProofFiles] = useState>>({}) + + // Lightbox state + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxIndex, setLightboxIndex] = useState(0) + const [lightboxItems, setLightboxItems] = useState>([]) + // Dispute creation const [showDisputeForm, setShowDisputeForm] = useState(false) const [disputeReason, setDisputeReason] = useState('') @@ -50,9 +61,20 @@ export function AssignmentDetailPage() { if (proofMediaBlobUrl) { URL.revokeObjectURL(proofMediaBlobUrl) } + proofFiles.forEach(file => { + URL.revokeObjectURL(file.url) + }) Object.values(bonusProofMedia).forEach(media => { URL.revokeObjectURL(media.url) }) + Object.values(bonusProofFiles).forEach(files => { + files.forEach(file => { + URL.revokeObjectURL(file.url) + }) + }) + lightboxItems.forEach(item => { + URL.revokeObjectURL(item.url) + }) } }, [id]) @@ -64,8 +86,20 @@ export function AssignmentDetailPage() { const data = await assignmentsApi.getDetail(parseInt(id)) setAssignment(data) - // Load proof media if exists - if (data.proof_image_url) { + // Load proof files if exists (new multi-file support) + if (data.proof_files && data.proof_files.length > 0) { + const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = [] + for (const proofFile of data.proof_files) { + try { + const { url, type } = await assignmentsApi.getProofFileMediaUrl(parseInt(id), proofFile.id) + files.push({ id: proofFile.id, url, type }) + } catch { + // Ignore error, file just won't show + } + } + setProofFiles(files) + } else if (data.proof_image_url) { + // Legacy: Load single proof media if exists try { const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id)) setProofMediaBlobUrl(url) @@ -75,11 +109,26 @@ export function AssignmentDetailPage() { } } - // Load bonus proof media for playthrough + // Load bonus proof files for playthrough if (data.is_playthrough && data.bonus_challenges) { const bonusMedia: Record = {} + const bonusFiles: Record> = {} + for (const bonus of data.bonus_challenges) { - if (bonus.proof_image_url) { + // New multi-file support + if (bonus.proof_files && bonus.proof_files.length > 0) { + const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = [] + for (const proofFile of bonus.proof_files) { + try { + const { url, type } = await assignmentsApi.getBonusProofFileMediaUrl(parseInt(id), bonus.id, proofFile.id) + files.push({ id: proofFile.id, url, type }) + } catch { + // Ignore error, file just won't show + } + } + bonusFiles[bonus.id] = files + } else if (bonus.proof_image_url) { + // Legacy: single file try { const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id) bonusMedia[bonus.id] = { url, type } @@ -88,7 +137,9 @@ export function AssignmentDetailPage() { } } } + setBonusProofMedia(bonusMedia) + setBonusProofFiles(bonusFiles) } } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } @@ -200,6 +251,24 @@ export function AssignmentDetailPage() { return `${hours}ч ${minutes}м` } + const openLightbox = (items: Array<{ url: string; type: 'image' | 'video' }>, index: number) => { + setLightboxItems(items) + setLightboxIndex(index) + setLightboxOpen(true) + } + + const closeLightbox = () => { + setLightboxOpen(false) + } + + const nextLightboxItem = () => { + setLightboxIndex((prev) => (prev + 1) % lightboxItems.length) + } + + const prevLightboxItem = () => { + setLightboxIndex((prev) => (prev - 1 + lightboxItems.length) % lightboxItems.length) + } + const getStatusConfig = (status: string) => { switch (status) { case 'completed': @@ -332,6 +401,18 @@ export function AssignmentDetailPage() { )} )} + {/* Download link */} + {(assignment.game?.download_url || assignment.challenge?.game.download_url) && ( + + + Скачать игру + + )}
@@ -401,9 +482,44 @@ export function AssignmentDetailPage() { )}

{bonus.challenge.description}

- {bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment) && ( + {bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
- {bonusProofMedia[bonus.id] && ( + {/* Multiple proof files */} + {bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && ( +
+ {bonusProofFiles[bonus.id].map((file, index) => ( +
openLightbox(bonusProofFiles[bonus.id], index)} + > + {file.type === 'video' ? ( +
+
+ ) : ( + {`Proof + )} +
+ ))} +
+ )} + + {/* Legacy: single proof media */} + {(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
{bonusProofMedia[bonus.id].type === 'video' ? (
)} + {bonus.proof_url && (
- {/* Proof media (image or video) */} - {assignment.proof_image_url && ( + {/* Proof files gallery (multiple proofs) */} + {proofFiles.length > 0 && ( +
+
+ {proofFiles.map((file, index) => ( +
openLightbox(proofFiles, index)} + > + {file.type === 'video' ? ( +
+
+ ) : ( + {`Proof + )} +
+ {index + 1}/{proofFiles.length} +
+
+ ))} +
+
+ )} + + {/* Legacy: Single proof media (for backwards compatibility) */} + {proofFiles.length === 0 && assignment.proof_image_url && (
{proofMediaBlobUrl ? ( proofMediaType === 'video' ? ( @@ -557,11 +716,16 @@ export function AssignmentDetailPage() { preload="metadata" /> ) : ( - Proof + ) ) : (
@@ -594,7 +758,7 @@ export function AssignmentDetailPage() {
)} - {!assignment.proof_image_url && !assignment.proof_url && ( + {proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
@@ -810,6 +974,69 @@ export function AssignmentDetailPage() {
)} + + {/* Lightbox modal */} + {lightboxOpen && lightboxItems.length > 0 && ( +
+ + + {lightboxItems.length > 1 && ( + <> + + + + +
+ {lightboxIndex + 1} / {lightboxItems.length} +
+ + )} + +
e.stopPropagation()} + > + {lightboxItems[lightboxIndex].type === 'video' ? ( +
+
+ )}
) } diff --git a/frontend/src/pages/PlayPage.tsx b/frontend/src/pages/PlayPage.tsx index e0af1b1..ee32fa4 100644 --- a/frontend/src/pages/PlayPage.tsx +++ b/frontend/src/pages/PlayPage.tsx @@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { SpinWheel } from '@/components/SpinWheel' import { EventBanner } from '@/components/EventBanner' -import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react' +import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react' import { useToast } from '@/store/toast' import { useConfirm } from '@/store/confirm' @@ -25,7 +25,7 @@ export function PlayPage() { const [activeEvent, setActiveEvent] = useState(null) const [isLoading, setIsLoading] = useState(true) - const [proofFile, setProofFile] = useState(null) + const [proofFiles, setProofFiles] = useState([]) const [proofUrl, setProofUrl] = useState('') const [comment, setComment] = useState('') const [isCompleting, setIsCompleting] = useState(false) @@ -57,7 +57,7 @@ export function PlayPage() { // Bonus challenge completion const [expandedBonusId, setExpandedBonusId] = useState(null) - const [bonusProofFile, setBonusProofFile] = useState(null) + const [bonusProofFiles, setBonusProofFiles] = useState([]) const [bonusProofUrl, setBonusProofUrl] = useState('') const [bonusComment, setBonusComment] = useState('') const [isCompletingBonus, setIsCompletingBonus] = useState(false) @@ -232,12 +232,12 @@ export function PlayPage() { // For playthrough: allow file, URL, or comment // For challenges: require file or URL if (currentAssignment.is_playthrough) { - if (!proofFile && !proofUrl && !comment) { + if (proofFiles.length === 0 && !proofUrl && !comment) { toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)') return } } else { - if (!proofFile && !proofUrl) { + if (proofFiles.length === 0 && !proofUrl) { toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)') return } @@ -246,12 +246,12 @@ export function PlayPage() { setIsCompleting(true) try { const result = await wheelApi.complete(currentAssignment.id, { - proof_file: proofFile || undefined, + proof_files: proofFiles.length > 0 ? proofFiles : undefined, proof_url: proofUrl || undefined, comment: comment || undefined, }) toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`) - setProofFile(null) + setProofFiles([]) setProofUrl('') setComment('') await loadData() @@ -291,7 +291,7 @@ export function PlayPage() { const handleBonusComplete = async (bonusId: number) => { if (!currentAssignment) return - if (!bonusProofFile && !bonusProofUrl && !bonusComment) { + if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) { toast.warning('Прикрепите файл, ссылку или комментарий') return } @@ -302,13 +302,13 @@ export function PlayPage() { currentAssignment.id, bonusId, { - proof_file: bonusProofFile || undefined, + proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined, proof_url: bonusProofUrl || undefined, comment: bonusComment || undefined, } ) toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`) - setBonusProofFile(null) + setBonusProofFiles([]) setBonusProofUrl('') setBonusComment('') setExpandedBonusId(null) @@ -965,11 +965,28 @@ export function PlayPage() {
{currentAssignment.is_playthrough ? ( @@ -1023,7 +1040,7 @@ export function PlayPage() { onClick={() => { if (bonus.status === 'pending') { setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id) - setBonusProofFile(null) + setBonusProofFiles([]) setBonusProofUrl('') setBonusComment('') if (bonusFileInputRef.current) bonusFileInputRef.current.value = '' @@ -1062,24 +1079,40 @@ export function PlayPage() { ref={bonusFileInputRef} type="file" accept="image/*,video/*" + multiple className="hidden" onChange={(e) => { e.stopPropagation() - validateAndSetFile(e.target.files?.[0] || null, setBonusProofFile, bonusFileInputRef) + const files = Array.from(e.target.files || []) + setBonusProofFiles(prev => [...prev, ...files]) + e.target.value = '' }} /> - {bonusProofFile ? ( -
- {bonusProofFile.name} + {bonusProofFiles.length > 0 ? ( +
+ {bonusProofFiles.map((file, index) => ( +
+ {file.name} + +
+ ))}
) : ( @@ -1121,7 +1154,7 @@ export function PlayPage() { handleBonusComplete(bonus.id) }} isLoading={isCompletingBonus} - disabled={!bonusProofFile && !bonusProofUrl && !bonusComment} + disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment} icon={} > Выполнено @@ -1131,7 +1164,7 @@ export function PlayPage() { variant="outline" onClick={(e) => { e.stopPropagation() - setBonusProofFile(null) + setBonusProofFiles([]) setBonusProofUrl('') setBonusComment('') setExpandedBonusId(null) @@ -1202,19 +1235,37 @@ export function PlayPage() { ref={fileInputRef} type="file" accept="image/*,video/*" + multiple className="hidden" - onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)} + onChange={(e) => { + const files = Array.from(e.target.files || []) + setProofFiles(prev => [...prev, ...files]) + // Reset input to allow selecting same files again + e.target.value = '' + }} /> - {proofFile ? ( -
- {proofFile.name} - +
+ ))} + fileInputRef.current?.click()} + icon={} > - - + Добавить ещё файлы +
) : (
@@ -1224,10 +1275,10 @@ export function PlayPage() { onClick={() => fileInputRef.current?.click()} icon={} > - Выбрать файл + Выбрать файлы

- Макс. 15 МБ для изображений, 30 МБ для видео + Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео

)} @@ -1257,8 +1308,8 @@ export function PlayPage() { onClick={handleComplete} isLoading={isCompleting} disabled={currentAssignment.is_playthrough - ? (!proofFile && !proofUrl && !comment) - : (!proofFile && !proofUrl) + ? (proofFiles.length === 0 && !proofUrl && !comment) + : (proofFiles.length === 0 && !proofUrl) } icon={} > diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f421349..1858cf8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -154,6 +154,7 @@ export interface GameShort { id: number title: string cover_url: string | null + download_url?: string game_type?: GameType } @@ -226,7 +227,8 @@ export interface BonusAssignment { challenge: Challenge status: BonusAssignmentStatus proof_url: string | null - proof_image_url: string | null + proof_image_url: string | null // Legacy, for backward compatibility + proof_files?: ProofFile[] // Multiple uploaded files proof_comment: string | null points_earned: number completed_at: string | null @@ -614,6 +616,13 @@ export interface Dispute { resolved_at: string | null } +export interface ProofFile { + id: number + file_type: 'image' | 'video' + order_index: number + created_at: string +} + export interface AssignmentDetail { id: number challenge: Challenge | null // null for playthrough @@ -623,7 +632,8 @@ export interface AssignmentDetail { participant: User status: AssignmentStatus proof_url: string | null - proof_image_url: string | null + proof_image_url: string | null // Legacy, for backward compatibility + proof_files: ProofFile[] // Multiple uploaded files proof_comment: string | null points_earned: number streak_at_completion: number | null