This commit is contained in:
2026-01-03 00:12:07 +07:00
parent d295ff2aff
commit 7a3576aec0
18 changed files with 844 additions and 125 deletions

View File

@@ -101,7 +101,9 @@ async def get_assignment_detail(
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough 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.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.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.comments).selectinload(DisputeComment.user),
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.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 time_since_bonus_completion = datetime.utcnow() - ba.completed_at
bonus_can_dispute = time_since_bonus_completion < timedelta(hours=DISPUTE_WINDOW_HOURS) 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({ bonus_challenges.append({
"id": ba.id, "id": ba.id,
"challenge": { "challenge": {
@@ -189,6 +203,7 @@ async def get_assignment_detail(
"status": ba.status, "status": ba.status,
"proof_url": ba.proof_url, "proof_url": ba.proof_url,
"proof_image_url": storage_service.get_url(ba.proof_path, "bonus_proofs") if ba.proof_path else None, "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, "proof_comment": ba.proof_comment,
"points_earned": ba.points_earned, "points_earned": ba.points_earned,
"completed_at": ba.completed_at.isoformat() if ba.completed_at else None, "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, "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( return AssignmentDetailResponse(
id=assignment.id, id=assignment.id,
challenge=None, challenge=None,
@@ -203,6 +230,7 @@ async def get_assignment_detail(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
game_type=game.game_type, game_type=game.game_type,
), ),
is_playthrough=True, is_playthrough=True,
@@ -216,6 +244,7 @@ async def get_assignment_detail(
status=assignment.status, status=assignment.status,
proof_url=assignment.proof_url, proof_url=assignment.proof_url,
proof_image_url=proof_image_url, proof_image_url=proof_image_url,
proof_files=proof_files_list,
proof_comment=assignment.proof_comment, proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned, points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
@@ -230,6 +259,18 @@ async def get_assignment_detail(
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game 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( return AssignmentDetailResponse(
id=assignment.id, id=assignment.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
@@ -246,6 +287,7 @@ async def get_assignment_detail(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
@@ -254,6 +296,7 @@ async def get_assignment_detail(
status=assignment.status, status=assignment.status,
proof_url=assignment.proof_url, proof_url=assignment.proof_url,
proof_image_url=proof_image_url, proof_image_url=proof_image_url,
proof_files=proof_files_list,
proof_comment=assignment.proof_comment, proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned, points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion, 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) @router.post("/bonus-assignments/{bonus_id}/dispute", response_model=DisputeResponse)
async def create_bonus_dispute( async def create_bonus_dispute(
bonus_id: int, bonus_id: int,
@@ -891,6 +1140,7 @@ async def get_returned_assignments(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"), 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, is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
@@ -951,6 +1201,7 @@ async def get_bonus_assignments(
id=assignment.game.id, id=assignment.game.id,
title=assignment.game.title, title=assignment.game.title,
cover_url=storage_service.get_url(assignment.game.cover_path, "covers") if hasattr(assignment.game, 'cover_path') else None, 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, game_type=assignment.game.game_type,
), ),
is_generated=ba.challenge.is_generated, is_generated=ba.challenge.is_generated,
@@ -974,7 +1225,8 @@ async def complete_bonus_assignment(
db: DbSession, db: DbSession,
proof_url: str | None = Form(None), proof_url: str | None = Form(None),
comment: 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""" """Complete a bonus challenge for a playthrough assignment"""
from app.core.config import settings from app.core.config import settings
@@ -1020,40 +1272,66 @@ async def complete_bonus_assignment(
if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value: if bonus_assignment.status == BonusAssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="This bonus challenge is already completed") raise HTTPException(status_code=400, detail="This bonus challenge is already completed")
# Validate proof (need file, URL, or comment) # Combine legacy single file with new multiple files
if not proof_file and not proof_url and not comment: 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( raise HTTPException(
status_code=400, status_code=400,
detail="Необходимо прикрепить файл, ссылку или комментарий" detail="Необходимо прикрепить файл, ссылку или комментарий"
) )
# Handle file upload # Handle multiple file uploads
if proof_file: if all_files:
contents = await proof_file.read() from app.models import BonusAssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE: if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
) )
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS: if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", 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 # Upload file to storage
filename = storage_service.generate_filename(bonus_id, proof_file.filename) filename = storage_service.generate_filename(f"bonus_{bonus_id}_{idx}", file.filename)
file_path = await storage_service.upload_file( file_path = await storage_service.upload_file(
content=contents, content=contents,
folder="bonus_proofs", folder="bonus_proofs",
filename=filename, filename=filename,
content_type=proof_file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
) )
# 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)
# 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 bonus_assignment.proof_url = proof_url
# Complete the bonus assignment # Complete the bonus assignment

View File

@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, 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, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
status=challenge.status, status=challenge.status,

View File

@@ -937,6 +937,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,

View File

@@ -315,7 +315,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
estimated_time=ch.estimated_time, estimated_time=ch.estimated_time,
proof_type=ch.proof_type, proof_type=ch.proof_type,
proof_hint=ch.proof_hint, 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, is_generated=ch.is_generated,
created_at=ch.created_at, 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, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, 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, is_generated=challenge.is_generated,
created_at=challenge.created_at, 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, estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type, proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint, 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, is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at, created_at=ba.challenge.created_at,
), ),
@@ -406,7 +406,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
return AssignmentResponse( return AssignmentResponse(
id=assignment.id, id=assignment.id,
challenge=None, 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, is_playthrough=True,
playthrough_info=PlaythroughInfo( playthrough_info=PlaythroughInfo(
description=game.playthrough_description, 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, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, 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, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
@@ -467,7 +467,8 @@ async def complete_assignment(
db: DbSession, db: DbSession,
proof_url: str | None = Form(None), proof_url: str | None = Form(None),
comment: 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)""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment with all needed relationships # Get assignment with all needed relationships
@@ -497,42 +498,68 @@ async def complete_assignment(
if assignment.is_event_assignment: if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") 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) # Combine legacy single file with new multiple files
# For challenges: need either file or URL 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 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)") raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
else: 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)") raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload # Handle multiple file uploads
if proof_file: if all_files:
contents = await proof_file.read() from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE: if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
) )
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg" ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS: if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", 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 # Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename) filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename)
file_path = await storage_service.upload_file( file_path = await storage_service.upload_file(
content=contents, content=contents,
folder="proofs", folder="proofs",
filename=filename, filename=filename,
content_type=proof_file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
) )
# 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)
# 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_url = proof_url
assignment.proof_comment = comment assignment.proof_comment = comment
@@ -908,7 +935,7 @@ async def get_my_history(
estimated_time=ba.challenge.estimated_time, estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type, proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint, 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, is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at, created_at=ba.challenge.created_at,
), ),
@@ -924,7 +951,7 @@ async def get_my_history(
responses.append(AssignmentResponse( responses.append(AssignmentResponse(
id=a.id, id=a.id,
challenge=None, 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, is_playthrough=True,
playthrough_info=PlaythroughInfo( playthrough_info=PlaythroughInfo(
description=game.playthrough_description, description=game.playthrough_description,
@@ -959,6 +986,7 @@ async def get_my_history(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=None, cover_url=None,
download_url=a.challenge.game.download_url,
game_type=a.challenge.game.game_type, game_type=a.challenge.game.game_type,
), ),
is_generated=a.challenge.is_generated, is_generated=a.challenge.is_generated,

View File

@@ -5,6 +5,7 @@ from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus from app.models.assignment import Assignment, AssignmentStatus
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus 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.activity import Activity, ActivityType
from app.models.event import Event, EventType from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus from app.models.swap_request import SwapRequest, SwapRequestStatus
@@ -32,6 +33,8 @@ __all__ = [
"AssignmentStatus", "AssignmentStatus",
"BonusAssignment", "BonusAssignment",
"BonusAssignmentStatus", "BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity", "Activity",
"ActivityType", "ActivityType",
"Event", "Event",

View File

@@ -42,3 +42,4 @@ class Assignment(Base):
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") 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) 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") 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")

View File

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

View File

@@ -46,3 +46,9 @@ class BonusAssignment(Base):
back_populates="bonus_assignment", back_populates="bonus_assignment",
uselist=False, uselist=False,
) )
proof_files: Mapped[list["BonusAssignmentProof"]] = relationship(
"BonusAssignmentProof",
back_populates="bonus_assignment",
cascade="all, delete-orphan",
order_by="BonusAssignmentProof.order_index"
)

View File

@@ -5,6 +5,17 @@ from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
from app.schemas.challenge import ChallengeResponse 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): class AssignmentBase(BaseModel):
pass pass
@@ -20,6 +31,8 @@ class BonusAssignmentResponse(BaseModel):
challenge: ChallengeResponse challenge: ChallengeResponse
status: str # pending, completed status: str # pending, completed
proof_url: str | None = None 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 proof_comment: str | None = None
points_earned: int = 0 points_earned: int = 0
completed_at: datetime | None = None completed_at: datetime | None = None

View File

@@ -4,6 +4,7 @@ from pydantic import BaseModel, Field
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse, GameShort from app.schemas.challenge import ChallengeResponse, GameShort
from app.schemas.assignment import ProofFileResponse
if TYPE_CHECKING: if TYPE_CHECKING:
from app.schemas.game import PlaythroughInfo from app.schemas.game import PlaythroughInfo
@@ -75,7 +76,8 @@ class AssignmentDetailResponse(BaseModel):
participant: UserPublic participant: UserPublic
status: str status: str
proof_url: str | None # External URL (YouTube, etc.) 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 proof_comment: str | None
points_earned: int points_earned: int
streak_at_completion: int | None streak_at_completion: int | None

View File

@@ -56,6 +56,7 @@ class GameShort(BaseModel):
id: int id: int
title: str title: str
cover_url: str | None = None cover_url: str | None = None
download_url: str
game_type: str = "challenges" game_type: str = "challenges"
class Config: class Config:

View File

@@ -15,7 +15,7 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"] StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
class StorageService: class StorageService:

View File

@@ -9,7 +9,7 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"] test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s interval: 5s
@@ -43,7 +43,7 @@ services:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
- ./backend/app:/app/app - ./backend/app:/app/app
ports: ports:
- "8000:8000" - "8002:8000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -57,7 +57,7 @@ services:
VITE_API_URL: ${VITE_API_URL:-/api/v1} VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend container_name: marathon-frontend
ports: ports:
- "3000:80" - "3002:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped

View File

@@ -67,18 +67,27 @@ export const assignmentsApi = {
completeBonusAssignment: async ( completeBonusAssignment: async (
assignmentId: number, assignmentId: number,
bonusId: 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<BonusCompleteResult> => { ): Promise<BonusCompleteResult> => {
const formData = new FormData() const formData = new FormData()
// Support both single file (legacy) and multiple files
if (data.proof_file) { if (data.proof_file) {
formData.append('proof_file', 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) { if (data.proof_url) {
formData.append('proof_url', data.proof_url) formData.append('proof_url', data.proof_url)
} }
if (data.comment) { if (data.comment) {
formData.append('comment', data.comment) formData.append('comment', data.comment)
} }
const response = await client.post<BonusCompleteResult>( const response = await client.post<BonusCompleteResult>(
`/assignments/${assignmentId}/bonus/${bonusId}/complete`, `/assignments/${assignmentId}/bonus/${bonusId}/complete`,
formData, formData,
@@ -103,4 +112,39 @@ export const assignmentsApi = {
type: isVideo ? 'video' : 'image', 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',
}
},
} }

View File

@@ -14,12 +14,19 @@ export const wheelApi = {
complete: async ( complete: async (
assignmentId: number, assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File } data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: File[] }
): Promise<CompleteResult> => { ): Promise<CompleteResult> => {
const formData = new FormData() const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url) if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment) 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_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<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, { const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
import { import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
} from 'lucide-react' } from 'lucide-react'
export function AssignmentDetailPage() { export function AssignmentDetailPage() {
@@ -23,9 +23,20 @@ export function AssignmentDetailPage() {
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null) const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Multiple proof files
const [proofFiles, setProofFiles] = useState<Array<{ id: number; url: string; type: 'image' | 'video' }>>([])
// Bonus proof media // Bonus proof media
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({}) const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
// Bonus proof files (multiple)
const [bonusProofFiles, setBonusProofFiles] = useState<Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>>>({})
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const [lightboxItems, setLightboxItems] = useState<Array<{ url: string; type: 'image' | 'video' }>>([])
// Dispute creation // Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false) const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('') const [disputeReason, setDisputeReason] = useState('')
@@ -50,9 +61,20 @@ export function AssignmentDetailPage() {
if (proofMediaBlobUrl) { if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl) URL.revokeObjectURL(proofMediaBlobUrl)
} }
proofFiles.forEach(file => {
URL.revokeObjectURL(file.url)
})
Object.values(bonusProofMedia).forEach(media => { Object.values(bonusProofMedia).forEach(media => {
URL.revokeObjectURL(media.url) URL.revokeObjectURL(media.url)
}) })
Object.values(bonusProofFiles).forEach(files => {
files.forEach(file => {
URL.revokeObjectURL(file.url)
})
})
lightboxItems.forEach(item => {
URL.revokeObjectURL(item.url)
})
} }
}, [id]) }, [id])
@@ -64,8 +86,20 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id)) const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data) setAssignment(data)
// Load proof media if exists // Load proof files if exists (new multi-file support)
if (data.proof_image_url) { 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 { try {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id)) const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url) 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) { if (data.is_playthrough && data.bonus_challenges) {
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {} const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
const bonusFiles: Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>> = {}
for (const bonus of data.bonus_challenges) { 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 { try {
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id) const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
bonusMedia[bonus.id] = { url, type } bonusMedia[bonus.id] = { url, type }
@@ -88,7 +137,9 @@ export function AssignmentDetailPage() {
} }
} }
} }
setBonusProofMedia(bonusMedia) setBonusProofMedia(bonusMedia)
setBonusProofFiles(bonusFiles)
} }
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
@@ -200,6 +251,24 @@ export function AssignmentDetailPage() {
return `${hours}ч ${minutes}м` 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) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@@ -332,6 +401,18 @@ export function AssignmentDetailPage() {
)} )}
</> </>
)} )}
{/* Download link */}
{(assignment.game?.download_url || assignment.challenge?.game.download_url) && (
<a
href={assignment.is_playthrough ? assignment.game?.download_url : assignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div> </div>
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1"> <div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
@@ -401,9 +482,44 @@ export function AssignmentDetailPage() {
)} )}
</div> </div>
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p> <p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
{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]) && (
<div className="mt-2 text-xs space-y-2"> <div className="mt-2 text-xs space-y-2">
{bonusProofMedia[bonus.id] && ( {/* Multiple proof files */}
{bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
<div className="flex gap-2 flex-wrap">
{bonusProofFiles[bonus.id].map((file, index) => (
<div
key={file.id}
className="relative rounded-lg overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all w-24 h-24"
onClick={() => openLightbox(bonusProofFiles[bonus.id], index)}
>
{file.type === 'video' ? (
<div className="relative w-full h-full">
<video
src={file.url}
className="w-full h-full object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="w-6 h-6 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-4 border-l-white border-y-3 border-y-transparent ml-0.5"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-full object-cover bg-dark-900"
/>
)}
</div>
))}
</div>
)}
{/* Legacy: single proof media */}
{(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs"> <div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
{bonusProofMedia[bonus.id].type === 'video' ? ( {bonusProofMedia[bonus.id].type === 'video' ? (
<video <video
@@ -413,16 +529,20 @@ export function AssignmentDetailPage() {
preload="metadata" preload="metadata"
/> />
) : ( ) : (
<a href={bonusProofMedia[bonus.id].url} target="_blank" rel="noopener noreferrer"> <button
onClick={() => openLightbox([bonusProofMedia[bonus.id]], 0)}
className="w-full"
>
<img <img
src={bonusProofMedia[bonus.id].url} src={bonusProofMedia[bonus.id].url}
alt="Proof" alt="Proof"
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity" className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
/> />
</a> </button>
)} )}
</div> </div>
)} )}
{bonus.proof_url && ( {bonus.proof_url && (
<a <a
href={bonus.proof_url} href={bonus.proof_url}
@@ -545,8 +665,47 @@ export function AssignmentDetailPage() {
</div> </div>
</div> </div>
{/* Proof media (image or video) */} {/* Proof files gallery (multiple proofs) */}
{assignment.proof_image_url && ( {proofFiles.length > 0 && (
<div className="mb-4">
<div className="grid grid-cols-2 gap-3">
{proofFiles.map((file, index) => (
<div
key={file.id}
className="relative rounded-xl overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all group"
onClick={() => openLightbox(proofFiles, index)}
>
{file.type === 'video' ? (
<div className="relative">
<video
src={file.url}
className="w-full h-48 object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 group-hover:bg-black/30 transition-all">
<div className="w-12 h-12 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-8 border-l-white border-y-6 border-y-transparent ml-1"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-48 object-cover bg-dark-900 group-hover:opacity-90 transition-opacity"
/>
)}
<div className="absolute top-2 right-2 px-2 py-1 bg-dark-900/80 rounded text-xs text-gray-300">
{index + 1}/{proofFiles.length}
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy: Single proof media (for backwards compatibility) */}
{proofFiles.length === 0 && assignment.proof_image_url && (
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600"> <div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? ( {proofMediaBlobUrl ? (
proofMediaType === 'video' ? ( proofMediaType === 'video' ? (
@@ -557,11 +716,16 @@ export function AssignmentDetailPage() {
preload="metadata" preload="metadata"
/> />
) : ( ) : (
<button
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
className="w-full"
>
<img <img
src={proofMediaBlobUrl} src={proofMediaBlobUrl}
alt="Proof" alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900" className="w-full max-h-96 object-contain bg-dark-900 hover:opacity-90 transition-opacity"
/> />
</button>
) )
) : ( ) : (
<div className="w-full h-48 bg-dark-900 flex items-center justify-center"> <div className="w-full h-48 bg-dark-900 flex items-center justify-center">
@@ -594,7 +758,7 @@ export function AssignmentDetailPage() {
</div> </div>
)} )}
{!assignment.proof_image_url && !assignment.proof_url && ( {proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Image className="w-6 h-6 text-gray-600" /> <Image className="w-6 h-6 text-gray-600" />
@@ -810,6 +974,69 @@ export function AssignmentDetailPage() {
</div> </div>
</GlassCard> </GlassCard>
)} )}
{/* Lightbox modal */}
{lightboxOpen && lightboxItems.length > 0 && (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeLightbox}
>
<button
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={closeLightbox}
>
<X className="w-6 h-6" />
</button>
{lightboxItems.length > 1 && (
<>
<button
className="absolute left-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
prevLightboxItem()
}}
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
className="absolute right-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
nextLightboxItem()
}}
>
<ChevronRight className="w-8 h-8" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-dark-700/80 rounded-full text-white text-sm z-10">
{lightboxIndex + 1} / {lightboxItems.length}
</div>
</>
)}
<div
className="max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
{lightboxItems[lightboxIndex].type === 'video' ? (
<video
src={lightboxItems[lightboxIndex].url}
controls
autoPlay
className="max-w-full max-h-full"
/>
) : (
<img
src={lightboxItems[lightboxIndex].url}
alt="Proof"
className="max-w-full max-h-full object-contain"
/>
)}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' 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 { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
@@ -25,7 +25,7 @@ export function PlayPage() {
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [proofFile, setProofFile] = useState<File | null>(null) const [proofFiles, setProofFiles] = useState<File[]>([])
const [proofUrl, setProofUrl] = useState('') const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false) const [isCompleting, setIsCompleting] = useState(false)
@@ -57,7 +57,7 @@ export function PlayPage() {
// Bonus challenge completion // Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null) const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFile, setBonusProofFile] = useState<File | null>(null) const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
const [bonusProofUrl, setBonusProofUrl] = useState('') const [bonusProofUrl, setBonusProofUrl] = useState('')
const [bonusComment, setBonusComment] = useState('') const [bonusComment, setBonusComment] = useState('')
const [isCompletingBonus, setIsCompletingBonus] = useState(false) const [isCompletingBonus, setIsCompletingBonus] = useState(false)
@@ -232,12 +232,12 @@ export function PlayPage() {
// For playthrough: allow file, URL, or comment // For playthrough: allow file, URL, or comment
// For challenges: require file or URL // For challenges: require file or URL
if (currentAssignment.is_playthrough) { if (currentAssignment.is_playthrough) {
if (!proofFile && !proofUrl && !comment) { if (proofFiles.length === 0 && !proofUrl && !comment) {
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)') toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
return return
} }
} else { } else {
if (!proofFile && !proofUrl) { if (proofFiles.length === 0 && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)') toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return return
} }
@@ -246,12 +246,12 @@ export function PlayPage() {
setIsCompleting(true) setIsCompleting(true)
try { try {
const result = await wheelApi.complete(currentAssignment.id, { const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined, proof_files: proofFiles.length > 0 ? proofFiles : undefined,
proof_url: proofUrl || undefined, proof_url: proofUrl || undefined,
comment: comment || undefined, comment: comment || undefined,
}) })
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`) toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
setProofFile(null) setProofFiles([])
setProofUrl('') setProofUrl('')
setComment('') setComment('')
await loadData() await loadData()
@@ -291,7 +291,7 @@ export function PlayPage() {
const handleBonusComplete = async (bonusId: number) => { const handleBonusComplete = async (bonusId: number) => {
if (!currentAssignment) return if (!currentAssignment) return
if (!bonusProofFile && !bonusProofUrl && !bonusComment) { if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
toast.warning('Прикрепите файл, ссылку или комментарий') toast.warning('Прикрепите файл, ссылку или комментарий')
return return
} }
@@ -302,13 +302,13 @@ export function PlayPage() {
currentAssignment.id, currentAssignment.id,
bonusId, bonusId,
{ {
proof_file: bonusProofFile || undefined, proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
proof_url: bonusProofUrl || undefined, proof_url: bonusProofUrl || undefined,
comment: bonusComment || undefined, comment: bonusComment || undefined,
} }
) )
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`) toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
setBonusProofFile(null) setBonusProofFiles([])
setBonusProofUrl('') setBonusProofUrl('')
setBonusComment('') setBonusComment('')
setExpandedBonusId(null) setExpandedBonusId(null)
@@ -965,11 +965,28 @@ export function PlayPage() {
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p> <p className="text-gray-400 text-sm mb-1">Игра</p>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{currentAssignment.is_playthrough {currentAssignment.is_playthrough
? currentAssignment.game?.title ? currentAssignment.game?.title
: currentAssignment.challenge?.game.title} : currentAssignment.challenge?.game.title}
</p> </p>
{(currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url) && (
<a
href={currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div>
</div> </div>
{currentAssignment.is_playthrough ? ( {currentAssignment.is_playthrough ? (
@@ -1023,7 +1040,7 @@ export function PlayPage() {
onClick={() => { onClick={() => {
if (bonus.status === 'pending') { if (bonus.status === 'pending') {
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id) setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
setBonusProofFile(null) setBonusProofFiles([])
setBonusProofUrl('') setBonusProofUrl('')
setBonusComment('') setBonusComment('')
if (bonusFileInputRef.current) bonusFileInputRef.current.value = '' if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
@@ -1062,26 +1079,42 @@ export function PlayPage() {
ref={bonusFileInputRef} ref={bonusFileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
multiple
className="hidden" className="hidden"
onChange={(e) => { onChange={(e) => {
e.stopPropagation() 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 ? ( {bonusProofFiles.length > 0 ? (
<div className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600"> <div className="space-y-2">
<span className="text-white text-sm flex-1 truncate">{bonusProofFile.name}</span> {bonusProofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
<span className="text-white text-sm flex-1 truncate">{file.name}</span>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setBonusProofFile(null) setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}} }}
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors" className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
</div> </div>
))}
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-neon-500/30 rounded-lg text-neon-400 hover:border-neon-500/50 hover:bg-neon-500/5 transition-all text-sm flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Добавить еще файл
</button>
</div>
) : ( ) : (
<button <button
onClick={(e) => { onClick={(e) => {
@@ -1121,7 +1154,7 @@ export function PlayPage() {
handleBonusComplete(bonus.id) handleBonusComplete(bonus.id)
}} }}
isLoading={isCompletingBonus} isLoading={isCompletingBonus}
disabled={!bonusProofFile && !bonusProofUrl && !bonusComment} disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment}
icon={<Check className="w-3 h-3" />} icon={<Check className="w-3 h-3" />}
> >
Выполнено Выполнено
@@ -1131,7 +1164,7 @@ export function PlayPage() {
variant="outline" variant="outline"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setBonusProofFile(null) setBonusProofFiles([])
setBonusProofUrl('') setBonusProofUrl('')
setBonusComment('') setBonusComment('')
setExpandedBonusId(null) setExpandedBonusId(null)
@@ -1202,20 +1235,38 @@ export function PlayPage() {
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
multiple
className="hidden" 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 ? ( {proofFiles.length > 0 ? (
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="space-y-2">
<span className="text-white flex-1 truncate">{proofFile.name}</span> {proofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<span className="text-white flex-1 truncate">{file.name}</span>
<button <button
onClick={() => setProofFile(null)} onClick={() => setProofFiles(proofFiles.filter((_, i) => i !== index))}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors" className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
))}
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Добавить ещё файлы
</NeonButton>
</div>
) : ( ) : (
<div> <div>
<NeonButton <NeonButton
@@ -1224,10 +1275,10 @@ export function PlayPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />} icon={<Upload className="w-4 h-4" />}
> >
Выбрать файл Выбрать файлы
</NeonButton> </NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center"> <p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
</p> </p>
</div> </div>
)} )}
@@ -1257,8 +1308,8 @@ export function PlayPage() {
onClick={handleComplete} onClick={handleComplete}
isLoading={isCompleting} isLoading={isCompleting}
disabled={currentAssignment.is_playthrough disabled={currentAssignment.is_playthrough
? (!proofFile && !proofUrl && !comment) ? (proofFiles.length === 0 && !proofUrl && !comment)
: (!proofFile && !proofUrl) : (proofFiles.length === 0 && !proofUrl)
} }
icon={<Check className="w-4 h-4" />} icon={<Check className="w-4 h-4" />}
> >

View File

@@ -154,6 +154,7 @@ export interface GameShort {
id: number id: number
title: string title: string
cover_url: string | null cover_url: string | null
download_url?: string
game_type?: GameType game_type?: GameType
} }
@@ -226,7 +227,8 @@ export interface BonusAssignment {
challenge: Challenge challenge: Challenge
status: BonusAssignmentStatus status: BonusAssignmentStatus
proof_url: string | null 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 proof_comment: string | null
points_earned: number points_earned: number
completed_at: string | null completed_at: string | null
@@ -614,6 +616,13 @@ export interface Dispute {
resolved_at: string | null resolved_at: string | null
} }
export interface ProofFile {
id: number
file_type: 'image' | 'video'
order_index: number
created_at: string
}
export interface AssignmentDetail { export interface AssignmentDetail {
id: number id: number
challenge: Challenge | null // null for playthrough challenge: Challenge | null // null for playthrough
@@ -623,7 +632,8 @@ export interface AssignmentDetail {
participant: User participant: User
status: AssignmentStatus status: AssignmentStatus
proof_url: string | null 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 proof_comment: string | null
points_earned: number points_earned: number
streak_at_completion: number | null streak_at_completion: number | null