diff --git a/.env.example b/.env.example index 6873734..2371084 100644 --- a/.env.example +++ b/.env.example @@ -11,5 +11,14 @@ OPENAI_API_KEY=sk-... # Telegram Bot TELEGRAM_BOT_TOKEN=123456:ABC-DEF... +# S3 Storage - FirstVDS (set S3_ENABLED=true to use) +S3_ENABLED=false +S3_BUCKET_NAME=your-bucket-name +S3_REGION=ru-1 +S3_ACCESS_KEY_ID=your-access-key-id +S3_SECRET_ACCESS_KEY=your-secret-access-key +S3_ENDPOINT_URL=https://s3.firstvds.ru +S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru + # Frontend (for build) VITE_API_URL=/api/v1 diff --git a/backend/app/api/v1/assignments.py b/backend/app/api/v1/assignments.py index f35cd3b..78ab490 100644 --- a/backend/app/api/v1/assignments.py +++ b/backend/app/api/v1/assignments.py @@ -3,6 +3,7 @@ Assignment details and dispute system endpoints. """ from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException +from fastapi.responses import Response from sqlalchemy import select from sqlalchemy.orm import selectinload @@ -17,6 +18,7 @@ from app.schemas import ( MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse, ) from app.schemas.user import UserPublic +from app.services.storage import storage_service router = APIRouter(tags=["assignments"]) @@ -133,10 +135,7 @@ async def get_assignment_detail( can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS) # Build proof URLs - proof_image_url = None - if assignment.proof_path: - # Extract filename from path - proof_image_url = f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" + proof_image_url = storage_service.get_url(assignment.proof_path, "proofs") return AssignmentDetailResponse( id=assignment.id, @@ -153,7 +152,7 @@ async def get_assignment_detail( game=GameShort( id=game.id, title=game.title, - cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + cover_url=storage_service.get_url(game.cover_path, "covers"), ), is_generated=challenge.is_generated, created_at=challenge.created_at, @@ -172,6 +171,58 @@ async def get_assignment_detail( ) +@router.get("/assignments/{assignment_id}/proof-image") +async def get_assignment_proof_image( + assignment_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Stream the proof image for an assignment""" + # Get assignment + result = await db.execute( + select(Assignment) + .options( + selectinload(Assignment.challenge).selectinload(Challenge.game), + ) + .where(Assignment.id == assignment_id) + ) + assignment = result.scalar_one_or_none() + + if not assignment: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Check user is participant of the marathon + marathon_id = assignment.challenge.game.marathon_id + 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") + + # Check if proof exists + if not assignment.proof_path: + raise HTTPException(status_code=404, detail="No proof image for this assignment") + + # Get file from storage + file_data = await storage_service.get_file(assignment.proof_path, "proofs") + if not file_data: + raise HTTPException(status_code=404, detail="Proof image not found in storage") + + content, content_type = file_data + + return Response( + content=content, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=31536000", + } + ) + + @router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse) async def create_dispute( assignment_id: int, @@ -421,7 +472,7 @@ async def get_returned_assignments( game=GameShort( id=a.challenge.game.id, title=a.challenge.game.title, - cover_url=f"/uploads/covers/{a.challenge.game.cover_path.split('/')[-1]}" if a.challenge.game.cover_path else None, + cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"), ), is_generated=a.challenge.is_generated, created_at=a.challenge.created_at, diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py index 09ad2f4..c6fc08e 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -11,8 +11,6 @@ from app.models import ( SwapRequest as SwapRequestModel, SwapRequestStatus, User, ) from fastapi import UploadFile, File, Form -from pathlib import Path -import uuid from app.schemas import ( EventCreate, EventResponse, ActiveEventResponse, EventEffects, @@ -24,6 +22,7 @@ from app.core.config import settings from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.user import UserPublic from app.services.events import event_service +from app.services.storage import storage_service router = APIRouter(tags=["events"]) @@ -937,13 +936,13 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse: game=GameShort( id=game.id, title=game.title, - cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + cover_url=storage_service.get_url(game.cover_path, "covers"), ), is_generated=challenge.is_generated, created_at=challenge.created_at, ), status=assignment.status, - proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, + proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, @@ -1065,14 +1064,16 @@ async def complete_event_assignment( detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", ) - filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" - filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename - filepath.parent.mkdir(parents=True, exist_ok=True) + # Upload file to storage + filename = storage_service.generate_filename(assignment_id, proof_file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="proofs", + filename=filename, + content_type=proof_file.content_type or "application/octet-stream", + ) - with open(filepath, "wb") as f: - f.write(contents) - - assignment.proof_path = str(filepath) + assignment.proof_path = file_path else: assignment.proof_url = proof_url diff --git a/backend/app/api/v1/games.py b/backend/app/api/v1/games.py index 96461fb..85a3b1b 100644 --- a/backend/app/api/v1/games.py +++ b/backend/app/api/v1/games.py @@ -1,8 +1,6 @@ from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query from sqlalchemy import select, func from sqlalchemy.orm import selectinload -import uuid -from pathlib import Path from app.api.deps import ( DbSession, CurrentUser, @@ -11,6 +9,7 @@ from app.api.deps import ( from app.core.config import settings from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic +from app.services.storage import storage_service router = APIRouter(tags=["games"]) @@ -35,7 +34,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse: return GameResponse( id=game.id, title=game.title, - cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + cover_url=storage_service.get_url(game.cover_path, "covers"), download_url=game.download_url, genre=game.genre, status=game.status, @@ -354,15 +353,20 @@ async def upload_cover( detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", ) - # Save file - filename = f"{game_id}_{uuid.uuid4().hex}.{ext}" - filepath = Path(settings.UPLOAD_DIR) / "covers" / filename - filepath.parent.mkdir(parents=True, exist_ok=True) + # Delete old cover if exists + if game.cover_path: + await storage_service.delete_file(game.cover_path) - with open(filepath, "wb") as f: - f.write(contents) + # Upload file + filename = storage_service.generate_filename(game_id, file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="covers", + filename=filename, + content_type=file.content_type or "image/jpeg", + ) - game.cover_path = str(filepath) + game.cover_path = file_path await db.commit() return await get_game(game_id, current_user, db) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 4785443..4622876 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,12 +1,11 @@ from fastapi import APIRouter, HTTPException, status, UploadFile, File from sqlalchemy import select -import uuid -from pathlib import Path from app.api.deps import DbSession, CurrentUser from app.core.config import settings from app.models import User from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse +from app.services.storage import storage_service router = APIRouter(prefix="/users", tags=["users"]) @@ -64,16 +63,21 @@ async def upload_avatar( detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", ) - # Save file - filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}" - filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename - filepath.parent.mkdir(parents=True, exist_ok=True) + # Delete old avatar if exists + if current_user.avatar_path: + await storage_service.delete_file(current_user.avatar_path) - with open(filepath, "wb") as f: - f.write(contents) + # Upload file + filename = storage_service.generate_filename(current_user.id, file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="avatars", + filename=filename, + content_type=file.content_type or "image/jpeg", + ) # Update user - current_user.avatar_path = str(filepath) + current_user.avatar_path = file_path await db.commit() await db.refresh(current_user) diff --git a/backend/app/api/v1/wheel.py b/backend/app/api/v1/wheel.py index 7b209c4..e449fe9 100644 --- a/backend/app/api/v1/wheel.py +++ b/backend/app/api/v1/wheel.py @@ -3,8 +3,6 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Form from sqlalchemy import select, func from sqlalchemy.orm import selectinload -import uuid -from pathlib import Path from app.api.deps import DbSession, CurrentUser from app.core.config import settings @@ -19,6 +17,7 @@ from app.schemas import ( ) from app.services.points import PointsService from app.services.events import event_service +from app.services.storage import storage_service router = APIRouter(tags=["wheel"]) @@ -195,7 +194,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession) game=GameResponse( id=game.id, title=game.title, - cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, + cover_url=storage_service.get_url(game.cover_path, "covers"), download_url=game.download_url, genre=game.genre, added_by=None, @@ -250,7 +249,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db created_at=challenge.created_at, ), status=assignment.status, - proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, + proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url, proof_comment=assignment.proof_comment, points_earned=assignment.points_earned, streak_at_completion=assignment.streak_at_completion, @@ -313,14 +312,16 @@ async def complete_assignment( detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", ) - filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" - filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename - filepath.parent.mkdir(parents=True, exist_ok=True) + # Upload file to storage + filename = storage_service.generate_filename(assignment_id, proof_file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="proofs", + filename=filename, + content_type=proof_file.content_type or "application/octet-stream", + ) - with open(filepath, "wb") as f: - f.write(contents) - - assignment.proof_path = str(filepath) + assignment.proof_path = file_path else: assignment.proof_url = proof_url @@ -571,7 +572,7 @@ async def get_my_history( created_at=a.challenge.created_at, ), status=a.status, - proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url, + proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url, proof_comment=a.proof_comment, points_earned=a.points_earned, streak_at_completion=a.streak_at_completion, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index da43761..e079a13 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -27,6 +27,15 @@ class Settings(BaseSettings): ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"} + # S3 Storage (FirstVDS) + S3_ENABLED: bool = False + S3_BUCKET_NAME: str = "" + S3_REGION: str = "ru-1" + S3_ACCESS_KEY_ID: str = "" + S3_SECRET_ACCESS_KEY: str = "" + S3_ENDPOINT_URL: str = "" + S3_PUBLIC_URL: str = "" + @property def ALLOWED_EXTENSIONS(self) -> set: return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 73aeb52..32cb4b1 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -52,5 +52,7 @@ class User(Base): @property def avatar_url(self) -> str | None: if self.avatar_path: - return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}" + # Lazy import to avoid circular dependency + from app.services.storage import storage_service + return storage_service.get_url(self.avatar_path, "avatars") return None diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py new file mode 100644 index 0000000..1f19a3d --- /dev/null +++ b/backend/app/services/storage.py @@ -0,0 +1,269 @@ +""" +Storage service for file uploads. +Supports both local filesystem and S3-compatible storage (FirstVDS). +""" +import logging +import uuid +from pathlib import Path +from typing import Literal + +import boto3 +from botocore.exceptions import ClientError, BotoCoreError +from botocore.config import Config + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +StorageFolder = Literal["avatars", "covers", "proofs"] + + +class StorageService: + """Unified storage service with S3 and local filesystem support.""" + + def __init__(self): + self._s3_client = None + + @property + def s3_client(self): + """Lazy initialization of S3 client.""" + if self._s3_client is None and settings.S3_ENABLED: + logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}") + try: + # Use signature_version=s3v4 for S3-compatible storage + self._s3_client = boto3.client( + "s3", + endpoint_url=settings.S3_ENDPOINT_URL, + aws_access_key_id=settings.S3_ACCESS_KEY_ID, + aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY, + region_name=settings.S3_REGION or "us-east-1", + config=Config(signature_version="s3v4"), + ) + logger.info("S3 client initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize S3 client: {e}") + self._s3_client = None + return self._s3_client + + def generate_filename(self, prefix: str | int, original_filename: str | None) -> str: + """Generate unique filename with prefix.""" + ext = "jpg" + if original_filename and "." in original_filename: + ext = original_filename.rsplit(".", 1)[-1].lower() + return f"{prefix}_{uuid.uuid4().hex}.{ext}" + + async def upload_file( + self, + content: bytes, + folder: StorageFolder, + filename: str, + content_type: str = "application/octet-stream", + ) -> str: + """ + Upload file to storage. + + Returns: + Path/key to the uploaded file (relative path for local, S3 key for S3) + """ + if settings.S3_ENABLED: + try: + return await self._upload_to_s3(content, folder, filename, content_type) + except Exception as e: + logger.error(f"S3 upload failed, falling back to local: {e}") + return await self._upload_to_local(content, folder, filename) + else: + return await self._upload_to_local(content, folder, filename) + + async def _upload_to_s3( + self, + content: bytes, + folder: StorageFolder, + filename: str, + content_type: str, + ) -> str: + """Upload file to S3.""" + key = f"{folder}/{filename}" + + if not self.s3_client: + raise RuntimeError("S3 client not initialized") + + try: + logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}") + self.s3_client.put_object( + Bucket=settings.S3_BUCKET_NAME, + Key=key, + Body=content, + ContentType=content_type, + ) + logger.info(f"Successfully uploaded to S3: {key}") + return key + except (ClientError, BotoCoreError) as e: + logger.error(f"S3 upload error: {e}") + raise RuntimeError(f"Failed to upload to S3: {e}") + + async def _upload_to_local( + self, + content: bytes, + folder: StorageFolder, + filename: str, + ) -> str: + """Upload file to local filesystem.""" + filepath = Path(settings.UPLOAD_DIR) / folder / filename + filepath.parent.mkdir(parents=True, exist_ok=True) + + with open(filepath, "wb") as f: + f.write(content) + + return str(filepath) + + def get_url(self, path: str | None, folder: StorageFolder) -> str | None: + """ + Get public URL for a file. + + Args: + path: File path/key (can be full path or just filename) + folder: Storage folder (avatars, covers, proofs) + + Returns: + Public URL or None if path is None + """ + if not path: + return None + + # Extract filename from path + filename = path.split("/")[-1] + + if settings.S3_ENABLED: + # S3 URL + return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}" + else: + # Local URL + return f"/uploads/{folder}/{filename}" + + async def delete_file(self, path: str | None) -> bool: + """ + Delete file from storage. + + Args: + path: File path/key + + Returns: + True if deleted, False otherwise + """ + if not path: + return False + + if settings.S3_ENABLED: + return await self._delete_from_s3(path) + else: + return await self._delete_from_local(path) + + async def _delete_from_s3(self, key: str) -> bool: + """Delete file from S3.""" + try: + self.s3_client.delete_object( + Bucket=settings.S3_BUCKET_NAME, + Key=key, + ) + return True + except ClientError: + return False + + async def _delete_from_local(self, path: str) -> bool: + """Delete file from local filesystem.""" + try: + filepath = Path(path) + if filepath.exists(): + filepath.unlink() + return True + return False + except Exception: + return False + + async def get_file( + self, + path: str, + folder: StorageFolder, + ) -> tuple[bytes, str] | None: + """ + Get file content from storage. + + Args: + path: File path/key (can be full path or just filename) + folder: Storage folder + + Returns: + Tuple of (content bytes, content_type) or None if not found + """ + if not path: + return None + + # Extract filename from path + filename = path.split("/")[-1] + + if settings.S3_ENABLED: + return await self._get_from_s3(folder, filename) + else: + return await self._get_from_local(folder, filename) + + async def _get_from_s3( + self, + folder: StorageFolder, + filename: str, + ) -> tuple[bytes, str] | None: + """Get file from S3.""" + key = f"{folder}/{filename}" + + if not self.s3_client: + logger.error("S3 client not initialized") + return None + + try: + response = self.s3_client.get_object( + Bucket=settings.S3_BUCKET_NAME, + Key=key, + ) + content = response["Body"].read() + content_type = response.get("ContentType", "application/octet-stream") + return content, content_type + except ClientError as e: + logger.error(f"S3 get error for {key}: {e}") + return None + + async def _get_from_local( + self, + folder: StorageFolder, + filename: str, + ) -> tuple[bytes, str] | None: + """Get file from local filesystem.""" + filepath = Path(settings.UPLOAD_DIR) / folder / filename + + if not filepath.exists(): + return None + + try: + with open(filepath, "rb") as f: + content = f.read() + + # Determine content type from extension + ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + content_types = { + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "png": "image/png", + "gif": "image/gif", + "webp": "image/webp", + "mp4": "video/mp4", + "webm": "video/webm", + "mov": "video/quicktime", + } + content_type = content_types.get(ext, "application/octet-stream") + + return content, content_type + except Exception as e: + logger.error(f"Local get error for {filepath}: {e}") + return None + + +# Singleton instance +storage_service = StorageService() diff --git a/backend/requirements.txt b/backend/requirements.txt index 8522e9a..ecc4760 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -28,5 +28,8 @@ httpx==0.26.0 aiofiles==23.2.1 python-magic==0.4.27 +# S3 Storage +boto3==1.34.0 + # Utils python-dotenv==1.0.0 diff --git a/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg b/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg deleted file mode 100644 index e811e8e..0000000 Binary files a/backend/uploads/proofs/74_a2655aa0a2134b1ba4d859e34a836916.jpg and /dev/null differ diff --git a/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png b/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png deleted file mode 100644 index a2c204a..0000000 Binary files a/backend/uploads/proofs/78_f1afea81917e4ce69a0ddd84260385a4.png and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index eb34d13..30cc66b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,14 @@ services: OPENAI_API_KEY: ${OPENAI_API_KEY} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} DEBUG: ${DEBUG:-false} + # S3 Storage + S3_ENABLED: ${S3_ENABLED:-false} + S3_BUCKET_NAME: ${S3_BUCKET_NAME:-} + S3_REGION: ${S3_REGION:-ru-1} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-} + S3_PUBLIC_URL: ${S3_PUBLIC_URL:-} volumes: - ./backend/uploads:/app/uploads - ./backend/app:/app/app diff --git a/frontend/src/api/assignments.ts b/frontend/src/api/assignments.ts index 2922fde..d72dfc8 100644 --- a/frontend/src/api/assignments.ts +++ b/frontend/src/api/assignments.ts @@ -31,4 +31,12 @@ export const assignmentsApi = { const response = await client.get(`/marathons/${marathonId}/returned-assignments`) return response.data }, + + // Get proof image as blob URL + getProofImageUrl: async (assignmentId: number): Promise => { + const response = await client.get(`/assignments/${assignmentId}/proof-image`, { + responseType: 'blob', + }) + return URL.createObjectURL(response.data) + }, } diff --git a/frontend/src/pages/AssignmentDetailPage.tsx b/frontend/src/pages/AssignmentDetailPage.tsx index a982d8e..178b9c6 100644 --- a/frontend/src/pages/AssignmentDetailPage.tsx +++ b/frontend/src/pages/AssignmentDetailPage.tsx @@ -10,8 +10,6 @@ import { Send, Flag } from 'lucide-react' -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' - export function AssignmentDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() @@ -20,6 +18,7 @@ export function AssignmentDetailPage() { const [assignment, setAssignment] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [proofImageBlobUrl, setProofImageBlobUrl] = useState(null) // Dispute creation const [showDisputeForm, setShowDisputeForm] = useState(false) @@ -35,6 +34,12 @@ export function AssignmentDetailPage() { useEffect(() => { loadAssignment() + return () => { + // Cleanup blob URL on unmount + if (proofImageBlobUrl) { + URL.revokeObjectURL(proofImageBlobUrl) + } + } }, [id]) const loadAssignment = async () => { @@ -44,6 +49,16 @@ export function AssignmentDetailPage() { try { const data = await assignmentsApi.getDetail(parseInt(id)) setAssignment(data) + + // Load proof image if exists + if (data.proof_image_url) { + try { + const blobUrl = await assignmentsApi.getProofImageUrl(parseInt(id)) + setProofImageBlobUrl(blobUrl) + } catch { + // Ignore error, image just won't show + } + } } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } setError(error.response?.data?.detail || 'Не удалось загрузить данные') @@ -237,11 +252,17 @@ export function AssignmentDetailPage() { {/* Proof image */} {assignment.proof_image_url && (
- Proof + {proofImageBlobUrl ? ( + Proof + ) : ( +
+ +
+ )}
)}