a
This commit is contained in:
@@ -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
|
||||||
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(
|
for idx, file in enumerate(all_files):
|
||||||
status_code=400,
|
contents = await file.read()
|
||||||
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
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"
|
# Create BonusAssignmentProof record
|
||||||
if ext not in settings.ALLOWED_EXTENSIONS:
|
proof_record = BonusAssignmentProof(
|
||||||
raise HTTPException(
|
bonus_assignment_id=bonus_id,
|
||||||
status_code=400,
|
file_path=file_path,
|
||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
file_type=file_type,
|
||||||
|
order_index=idx
|
||||||
)
|
)
|
||||||
|
db.add(proof_record)
|
||||||
|
|
||||||
# Upload file to storage
|
# Legacy: set proof_path on first file for backward compatibility
|
||||||
filename = storage_service.generate_filename(bonus_id, proof_file.filename)
|
if idx == 0:
|
||||||
file_path = await storage_service.upload_file(
|
bonus_assignment.proof_path = file_path
|
||||||
content=contents,
|
|
||||||
folder="bonus_proofs",
|
|
||||||
filename=filename,
|
|
||||||
content_type=proof_file.content_type or "application/octet-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
bonus_assignment.proof_path = file_path
|
# Set proof URL if provided
|
||||||
else:
|
if proof_url:
|
||||||
bonus_assignment.proof_url = proof_url
|
bonus_assignment.proof_url = proof_url
|
||||||
|
|
||||||
# Complete the bonus assignment
|
# Complete the bonus assignment
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(
|
for idx, file in enumerate(all_files):
|
||||||
status_code=400,
|
contents = await file.read()
|
||||||
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
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"
|
# Create AssignmentProof record
|
||||||
if ext not in settings.ALLOWED_EXTENSIONS:
|
proof_record = AssignmentProof(
|
||||||
raise HTTPException(
|
assignment_id=assignment_id,
|
||||||
status_code=400,
|
file_path=file_path,
|
||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
file_type=file_type,
|
||||||
|
order_index=idx
|
||||||
)
|
)
|
||||||
|
db.add(proof_record)
|
||||||
|
|
||||||
# Upload file to storage
|
# Legacy: set proof_path on first file for backward compatibility
|
||||||
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
if idx == 0:
|
||||||
file_path = await storage_service.upload_file(
|
assignment.proof_path = file_path
|
||||||
content=contents,
|
|
||||||
folder="proofs",
|
|
||||||
filename=filename,
|
|
||||||
content_type=proof_file.content_type or "application/octet-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
assignment.proof_path = file_path
|
# Set proof URL if provided
|
||||||
else:
|
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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
47
backend/app/models/assignment_proof.py
Normal file
47
backend/app/models/assignment_proof.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<button
|
||||||
src={proofMediaBlobUrl}
|
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
|
||||||
alt="Proof"
|
className="w-full"
|
||||||
className="w-full max-h-96 object-contain bg-dark-900"
|
>
|
||||||
/>
|
<img
|
||||||
|
src={proofMediaBlobUrl}
|
||||||
|
alt="Proof"
|
||||||
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
<p className="text-xl font-bold text-white">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
{currentAssignment.is_playthrough
|
<p className="text-xl font-bold text-white">
|
||||||
? currentAssignment.game?.title
|
{currentAssignment.is_playthrough
|
||||||
: currentAssignment.challenge?.game.title}
|
? currentAssignment.game?.title
|
||||||
</p>
|
: currentAssignment.challenge?.game.title}
|
||||||
|
</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,24 +1079,40 @@ 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
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
|
||||||
|
}}
|
||||||
|
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setBonusProofFile(null)
|
bonusFileInputRef.current?.click()
|
||||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
|
||||||
}}
|
}}
|
||||||
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<Upload className="w-4 h-4" />
|
||||||
|
Добавить еще файл
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -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,19 +1235,37 @@ 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) => (
|
||||||
<button
|
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
onClick={() => setProofFile(null)}
|
<span className="text-white flex-1 truncate">{file.name}</span>
|
||||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
icon={<Upload className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
Добавить ещё файлы
|
||||||
</button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -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" />}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user