diff --git a/backend/alembic/versions/019_add_marathon_cover.py b/backend/alembic/versions/019_add_marathon_cover.py new file mode 100644 index 0000000..fe74ce1 --- /dev/null +++ b/backend/alembic/versions/019_add_marathon_cover.py @@ -0,0 +1,36 @@ +"""Add marathon cover_url field + +Revision ID: 019_add_marathon_cover +Revises: 018_seed_static_content +Create Date: 2024-12-21 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = '019_add_marathon_cover' +down_revision: Union[str, None] = '018_seed_static_content' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + inspector = inspect(bind) + columns = [col['name'] for col in inspector.get_columns(table_name)] + return column_name in columns + + +def upgrade() -> None: + if not column_exists('marathons', 'cover_url'): + op.add_column('marathons', sa.Column('cover_url', sa.String(500), nullable=True)) + + +def downgrade() -> None: + if column_exists('marathons', 'cover_url'): + op.drop_column('marathons', 'cover_url') diff --git a/backend/app/api/v1/marathons.py b/backend/app/api/v1/marathons.py index faead45..f93186c 100644 --- a/backend/app/api/v1/marathons.py +++ b/backend/app/api/v1/marathons.py @@ -1,7 +1,7 @@ from datetime import timedelta import secrets import string -from fastapi import APIRouter, HTTPException, status, Depends +from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy import select, func from sqlalchemy.orm import selectinload @@ -11,7 +11,9 @@ from app.api.deps import ( require_participant, require_organizer, require_creator, get_participant, ) +from app.core.config import settings from app.core.security import decode_access_token +from app.services.storage import storage_service # Optional auth for endpoints that need it conditionally optional_auth = HTTPBearer(auto_error=False) @@ -62,6 +64,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession): title=marathon.title, description=marathon.description, status=marathon.status, + cover_url=marathon.cover_url, participants_count=participants_count, creator_nickname=marathon.creator.nickname, ) @@ -128,6 +131,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession): title=marathon.title, status=marathon.status, is_public=marathon.is_public, + cover_url=marathon.cover_url, participants_count=row[1], start_date=marathon.start_date, end_date=marathon.end_date, @@ -180,6 +184,7 @@ async def create_marathon( is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, + cover_url=marathon.cover_url, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=1, @@ -226,6 +231,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio is_public=marathon.is_public, game_proposal_mode=marathon.game_proposal_mode, auto_events_enabled=marathon.auto_events_enabled, + cover_url=marathon.cover_url, start_date=marathon.start_date, end_date=marathon.end_date, participants_count=participants_count, @@ -591,3 +597,109 @@ async def get_leaderboard( )) return leaderboard + + +@router.get("/{marathon_id}/cover") +async def get_marathon_cover(marathon_id: int, db: DbSession): + """Get marathon cover image""" + marathon = await get_marathon_or_404(db, marathon_id) + + if not marathon.cover_path: + raise HTTPException(status_code=404, detail="Marathon has no cover") + + file_data = await storage_service.get_file(marathon.cover_path, "covers") + if not file_data: + raise HTTPException(status_code=404, detail="Cover not found in storage") + + content, content_type = file_data + + return Response( + content=content, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=3600", + } + ) + + +@router.post("/{marathon_id}/cover", response_model=MarathonResponse) +async def upload_marathon_cover( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, + file: UploadFile = File(...), +): + """Upload marathon cover image (organizers only, preparing status)""" + await require_organizer(db, current_user, marathon_id) + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon") + + # Validate file + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File must be an image", + ) + + contents = await file.read() + if len(contents) > settings.MAX_UPLOAD_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", + ) + + # Get file extension + ext = file.filename.split(".")[-1].lower() if file.filename else "jpg" + if ext not in settings.ALLOWED_IMAGE_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", + ) + + # Delete old cover if exists + if marathon.cover_path: + await storage_service.delete_file(marathon.cover_path) + + # Upload file + filename = storage_service.generate_filename(marathon_id, file.filename) + file_path = await storage_service.upload_file( + content=contents, + folder="covers", + filename=filename, + content_type=file.content_type or "image/jpeg", + ) + + # Update marathon with cover path and URL + marathon.cover_path = file_path + marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover" + await db.commit() + + return await get_marathon(marathon_id, current_user, db) + + +@router.delete("/{marathon_id}/cover", response_model=MarathonResponse) +async def delete_marathon_cover( + marathon_id: int, + current_user: CurrentUser, + db: DbSession, +): + """Delete marathon cover image (organizers only, preparing status)""" + await require_organizer(db, current_user, marathon_id) + marathon = await get_marathon_or_404(db, marathon_id) + + if marathon.status != MarathonStatus.PREPARING.value: + raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon") + + if not marathon.cover_path: + raise HTTPException(status_code=400, detail="Marathon has no cover") + + # Delete file from storage + await storage_service.delete_file(marathon.cover_path) + + marathon.cover_path = None + marathon.cover_url = None + await db.commit() + + return await get_marathon(marathon_id, current_user, db) diff --git a/backend/app/models/marathon.py b/backend/app/models/marathon.py index 8174798..61841a5 100644 --- a/backend/app/models/marathon.py +++ b/backend/app/models/marathon.py @@ -31,6 +31,8 @@ class Marathon(Base): start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True) + cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True) + cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) # Relationships diff --git a/backend/app/schemas/marathon.py b/backend/app/schemas/marathon.py index 75b9acd..8dce928 100644 --- a/backend/app/schemas/marathon.py +++ b/backend/app/schemas/marathon.py @@ -49,6 +49,7 @@ class MarathonResponse(MarathonBase): is_public: bool game_proposal_mode: str auto_events_enabled: bool + cover_url: str | None start_date: datetime | None end_date: datetime | None participants_count: int @@ -69,6 +70,7 @@ class MarathonListItem(BaseModel): title: str status: str is_public: bool + cover_url: str | None participants_count: int start_date: datetime | None end_date: datetime | None @@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel): title: str description: str | None status: str + cover_url: str | None participants_count: int creator_nickname: str diff --git a/frontend/src/api/marathons.ts b/frontend/src/api/marathons.ts index 6fe3906..2ed3c50 100644 --- a/frontend/src/api/marathons.ts +++ b/frontend/src/api/marathons.ts @@ -1,5 +1,5 @@ import client from './client' -import type { Marathon, MarathonListItem, MarathonPublicInfo, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types' +import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types' export interface CreateMarathonData { title: string @@ -10,6 +10,8 @@ export interface CreateMarathonData { game_proposal_mode?: GameProposalMode } +export type { MarathonUpdate } + export const marathonsApi = { list: async (): Promise => { const response = await client.get('/marathons') @@ -32,7 +34,7 @@ export const marathonsApi = { return response.data }, - update: async (id: number, data: Partial): Promise => { + update: async (id: number, data: MarathonUpdate): Promise => { const response = await client.patch(`/marathons/${id}`, data) return response.data }, @@ -78,4 +80,20 @@ export const marathonsApi = { const response = await client.get(`/marathons/${id}/leaderboard`) return response.data }, + + uploadCover: async (id: number, file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + const response = await client.post(`/marathons/${id}/cover`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data + }, + + deleteCover: async (id: number): Promise => { + const response = await client.delete(`/marathons/${id}/cover`) + return response.data + }, } diff --git a/frontend/src/components/MarathonSettingsModal.tsx b/frontend/src/components/MarathonSettingsModal.tsx new file mode 100644 index 0000000..c99da17 --- /dev/null +++ b/frontend/src/components/MarathonSettingsModal.tsx @@ -0,0 +1,501 @@ +import { useState, useRef, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { marathonsApi } from '@/api' +import type { Marathon, GameProposalMode } from '@/types' +import { NeonButton, Input } from '@/components/ui' +import { useToast } from '@/store/toast' +import { + X, Camera, Trash2, Loader2, Save, Globe, Lock, Users, UserCog, Sparkles, Zap +} from 'lucide-react' + +const settingsSchema = z.object({ + title: z.string().min(1, 'Название обязательно').max(100, 'Максимум 100 символов'), + description: z.string().optional(), + start_date: z.string().min(1, 'Дата начала обязательна'), + is_public: z.boolean(), + game_proposal_mode: z.enum(['all_participants', 'organizer_only']), + auto_events_enabled: z.boolean(), +}) + +type SettingsForm = z.infer + +interface MarathonSettingsModalProps { + marathon: Marathon + isOpen: boolean + onClose: () => void + onUpdate: (marathon: Marathon) => void +} + +export function MarathonSettingsModal({ + marathon, + isOpen, + onClose, + onUpdate, +}: MarathonSettingsModalProps) { + const toast = useToast() + const fileInputRef = useRef(null) + + const [isUploading, setIsUploading] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [coverPreview, setCoverPreview] = useState(null) + + const { + register, + handleSubmit, + watch, + setValue, + reset, + formState: { errors, isSubmitting, isDirty }, + } = useForm({ + resolver: zodResolver(settingsSchema), + defaultValues: { + title: marathon.title, + description: marathon.description || '', + start_date: marathon.start_date + ? new Date(marathon.start_date).toISOString().slice(0, 16) + : '', + is_public: marathon.is_public, + game_proposal_mode: marathon.game_proposal_mode as GameProposalMode, + auto_events_enabled: marathon.auto_events_enabled, + }, + }) + + const isPublic = watch('is_public') + const gameProposalMode = watch('game_proposal_mode') + const autoEventsEnabled = watch('auto_events_enabled') + + // Reset form when marathon changes + useEffect(() => { + reset({ + title: marathon.title, + description: marathon.description || '', + start_date: marathon.start_date + ? new Date(marathon.start_date).toISOString().slice(0, 16) + : '', + is_public: marathon.is_public, + game_proposal_mode: marathon.game_proposal_mode as GameProposalMode, + auto_events_enabled: marathon.auto_events_enabled, + }) + setCoverPreview(null) + }, [marathon, reset]) + + // Handle escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + // Prevent body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden' + } else { + document.body.style.overflow = '' + } + return () => { + document.body.style.overflow = '' + } + }, [isOpen]) + + const onSubmit = async (data: SettingsForm) => { + try { + const updated = await marathonsApi.update(marathon.id, { + title: data.title, + description: data.description || undefined, + start_date: new Date(data.start_date).toISOString(), + is_public: data.is_public, + game_proposal_mode: data.game_proposal_mode, + auto_events_enabled: data.auto_events_enabled, + }) + onUpdate(updated) + toast.success('Настройки сохранены') + onClose() + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось сохранить настройки') + } + } + + const handleCoverClick = () => { + fileInputRef.current?.click() + } + + const handleCoverChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + if (!file.type.startsWith('image/')) { + toast.error('Файл должен быть изображением') + return + } + if (file.size > 5 * 1024 * 1024) { + toast.error('Максимальный размер файла 5 МБ') + return + } + + // Show preview immediately + const previewUrl = URL.createObjectURL(file) + setCoverPreview(previewUrl) + + setIsUploading(true) + try { + const updated = await marathonsApi.uploadCover(marathon.id, file) + onUpdate(updated) + toast.success('Обложка загружена') + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось загрузить обложку') + setCoverPreview(null) + } finally { + setIsUploading(false) + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + } + + const handleDeleteCover = async () => { + setIsDeleting(true) + try { + const updated = await marathonsApi.deleteCover(marathon.id) + onUpdate(updated) + setCoverPreview(null) + toast.success('Обложка удалена') + } catch (err: unknown) { + const error = err as { response?: { data?: { detail?: string } } } + toast.error(error.response?.data?.detail || 'Не удалось удалить обложку') + } finally { + setIsDeleting(false) + } + } + + if (!isOpen) return null + + const displayCover = coverPreview || marathon.cover_url + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

Настройки марафона

+ +
+ +
+ {/* Cover Image */} +
+ +
+ + {displayCover && !isUploading && !isDeleting && ( + + )} +
+ +
+ +
+ {/* Title */} + + + {/* Description */} +
+ +