Compare commits

...

8 Commits

Author SHA1 Message Date
a513dc2207 Filters 2025-12-21 04:13:20 +07:00
6bc35fc0bb http checking 2025-12-21 03:46:37 +07:00
d3adf07c3f Add covers 2025-12-21 03:05:57 +07:00
921917a319 Add covers 2025-12-21 02:52:48 +07:00
9d2dba87b8 Fix wheel 2025-12-21 00:33:25 +07:00
95e2a77335 Fix ban screen 2025-12-20 23:59:13 +07:00
6c824712c9 Fix status--service 2025-12-20 22:41:26 +07:00
5c073705d8 change backup service 2025-12-20 22:30:18 +07:00
21 changed files with 1381 additions and 184 deletions

View File

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

View File

@@ -59,9 +59,15 @@ async def login(request: Request, data: UserLogin, db: DbSession):
# Check if user is banned
if user.is_banned:
# Return full ban info like in deps.py
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been banned",
detail=ban_info,
)
# If admin with Telegram linked, require 2FA

View File

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

View File

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

View File

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

View File

@@ -79,6 +79,8 @@ def create_backup() -> tuple[str, bytes]:
config.DB_NAME,
"--no-owner",
"--no-acl",
"--clean", # Add DROP commands before CREATE
"--if-exists", # Use IF EXISTS with DROP commands
"-F",
"p", # plain SQL format
]

View File

@@ -4,7 +4,8 @@ Restore PostgreSQL database from S3 backup.
Usage:
python restore.py - List available backups
python restore.py <filename> - Restore from specific backup
python restore.py <filename> - Restore from backup (cleans DB first)
python restore.py <filename> --no-clean - Restore without cleaning DB first
"""
import gzip
import os
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
return []
def restore_backup(s3_client, filename: str) -> None:
def clean_database() -> None:
"""Drop and recreate public schema to clean the database."""
print("Cleaning database (dropping and recreating public schema)...")
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
# Drop and recreate public schema
clean_sql = b"""
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO public;
"""
cmd = [
"psql",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
]
result = subprocess.run(
cmd,
env=env,
input=clean_sql,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
if "ERROR" in stderr:
raise Exception(f"Database cleanup failed: {stderr}")
print("Database cleaned successfully!")
def restore_backup(s3_client, filename: str, clean_first: bool = True) -> None:
"""Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
print("Decompressing...")
sql_data = gzip.decompress(compressed_data)
# Clean database before restore if requested
if clean_first:
clean_database()
print(f"Restoring to database {config.DB_NAME}...")
# Build psql command
@@ -124,20 +170,32 @@ def main() -> int:
s3_client = create_s3_client()
if len(sys.argv) < 2:
# Parse arguments
args = sys.argv[1:]
clean_first = True
if "--no-clean" in args:
clean_first = False
args.remove("--no-clean")
if len(args) < 1:
# List available backups
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
print("Add --no-clean to skip database cleanup before restore")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
filename = args[0]
# Confirm restore
print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!")
if clean_first:
print("Database will be CLEANED (all existing data will be DELETED)!")
else:
print("Database will NOT be cleaned (may cause conflicts with existing data)")
print()
confirm = input("Type 'yes' to continue: ")
@@ -147,7 +205,7 @@ def main() -> int:
return 0
try:
restore_backup(s3_client, filename)
restore_backup(s3_client, filename, clean_first=clean_first)
return 0
except Exception as e:
print(f"Restore failed: {e}")

View File

@@ -61,7 +61,6 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() {
const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
const syncUser = useAuthStore((state) => state.syncUser)
// Sync user data with server on app load
@@ -69,8 +68,8 @@ function App() {
syncUser()
}, [syncUser])
// Show banned screen if user is authenticated and banned
if (isAuthenticated && banInfo) {
// Show banned screen if user is banned (either authenticated or during login attempt)
if (banInfo) {
return (
<>
<ToastContainer />

View File

@@ -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<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons')
@@ -32,7 +34,7 @@ export const marathonsApi = {
return response.data
},
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => {
update: async (id: number, data: MarathonUpdate): Promise<Marathon> => {
const response = await client.patch<Marathon>(`/marathons/${id}`, data)
return response.data
},
@@ -78,4 +80,20 @@ export const marathonsApi = {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data
},
uploadCover: async (id: number, file: File): Promise<Marathon> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<Marathon>(`/marathons/${id}/cover`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
deleteCover: async (id: number): Promise<Marathon> => {
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
return response.data
},
}

View File

@@ -10,6 +10,7 @@ interface BanInfo {
interface BannedScreenProps {
banInfo: BanInfo
onLogout?: () => void
}
function formatDate(dateStr: string | null) {
@@ -24,8 +25,9 @@ function formatDate(dateStr: string | null) {
}) + ' (МСК)'
}
export function BannedScreen({ banInfo }: BannedScreenProps) {
const logout = useAuthStore((state) => state.logout)
export function BannedScreen({ banInfo, onLogout }: BannedScreenProps) {
const storeLogout = useAuthStore((state) => state.logout)
const handleLogout = onLogout || storeLogout
const bannedAtFormatted = formatDate(banInfo.banned_at)
const bannedUntilFormatted = formatDate(banInfo.banned_until)
@@ -112,7 +114,7 @@ export function BannedScreen({ banInfo }: BannedScreenProps) {
<NeonButton
variant="secondary"
size="lg"
onClick={logout}
onClick={handleLogout}
icon={<LogOut className="w-5 h-5" />}
>
Выйти из аккаунта

View File

@@ -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<typeof settingsSchema>
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<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<SettingsForm>({
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<HTMLInputElement>) => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={onClose}
/>
{/* Modal */}
<div className="relative glass rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 fade-in duration-200 border border-dark-600 custom-scrollbar">
{/* Header */}
<div className="sticky top-0 z-10 bg-dark-800/95 backdrop-blur-sm border-b border-dark-600 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Настройки марафона</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка марафона
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isUploading || isDeleting}
className="relative w-full h-48 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{displayCover ? (
<img
src={displayCover}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-10 h-10 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{(isUploading || isDeleting) && (
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
</div>
)}
{displayCover && !isUploading && !isDeleting && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-8 h-8 text-neon-500" />
<span className="ml-2 text-white">Изменить</span>
</div>
)}
</button>
{displayCover && !isUploading && !isDeleting && (
<button
type="button"
onClick={handleDeleteCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Title */}
<Input
label="Название"
placeholder="Введите название марафона"
error={errors.title?.message}
{...register('title')}
/>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none w-full"
placeholder="Расскажите о вашем марафоне..."
{...register('description')}
/>
</div>
{/* Start date */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Дата начала
</label>
<input
type="datetime-local"
className="input w-full"
{...register('start_date')}
/>
{errors.start_date && (
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
)}
</div>
{/* Marathon type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип марафона
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('is_public', false, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${!isPublic
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500">
Вход только по коду приглашения
</div>
{!isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('is_public', true, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${isPublic
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500">
Виден всем пользователям
</div>
{isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Game proposal mode */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Кто может предлагать игры
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'all_participants'
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500">
С модерацией организатором
</div>
{gameProposalMode === 'all_participants' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'organizer_only'
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500">
Без модерации
</div>
{gameProposalMode === 'organizer_only' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Auto events toggle */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Автоматические события
</label>
<button
type="button"
onClick={() => setValue('auto_events_enabled', !autoEventsEnabled, { shouldDirty: true })}
className={`
w-full p-4 rounded-xl border-2 transition-all duration-300 text-left flex items-center gap-4
${autoEventsEnabled
? 'border-yellow-500/50 bg-yellow-500/10'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl flex items-center justify-center transition-colors flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500/20' : 'bg-dark-600'}
`}>
<Zap className={`w-5 h-5 ${autoEventsEnabled ? 'text-yellow-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1">
<div className={`font-semibold ${autoEventsEnabled ? 'text-white' : 'text-gray-300'}`}>
{autoEventsEnabled ? 'Включены' : 'Выключены'}
</div>
<div className="text-xs text-gray-500">
Случайные бонусные события во время марафона
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500' : 'bg-dark-600'}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${autoEventsEnabled ? 'left-6' : 'left-1'}
`} />
</div>
</button>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-dark-600">
<NeonButton
type="button"
variant="outline"
className="flex-1"
onClick={onClose}
>
Отмена
</NeonButton>
<NeonButton
type="submit"
className="flex-1"
isLoading={isSubmitting}
disabled={!isDirty}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react'
import { useState, useCallback, useMemo, useEffect } from 'react'
import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react'
@@ -9,27 +9,43 @@ interface SpinWheelProps {
disabled?: boolean
}
const SPIN_DURATION = 5000 // ms
const EXTRA_ROTATIONS = 5
const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
// Цветовая палитра секторов
// Пороги для адаптивного отображения
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
// Цветовая палитра секторов (расширенная для большего количества)
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#ea580c', border: '#f97316' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose
{ bg: '#4f46e5', border: '#6366f1' }, // indigo
{ bg: '#0284c7', border: '#0ea5e9' }, // sky
{ bg: '#9333ea', border: '#a855f7' }, // purple
{ bg: '#16a34a', border: '#22c55e' }, // green
]
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false)
const [rotation, setRotation] = useState(0)
const [displayedGame, setDisplayedGame] = useState<Game | null>(null)
const [spinStartTime, setSpinStartTime] = useState<number | null>(null)
const [startRotation, setStartRotation] = useState(0)
const [targetRotation, setTargetRotation] = useState(0)
// Размеры колеса
const wheelSize = 400
// Определяем режим отображения
const showText = games.length <= TEXT_THRESHOLD
const showLines = games.length <= LINES_THRESHOLD
// Размеры колеса - увеличиваем для большого количества игр
const wheelSize = games.length > 50 ? 450 : games.length > 30 ? 420 : 400
const centerX = wheelSize / 2
const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle)
const newRotation = rotation + finalAngle
setStartRotation(rotation)
setTargetRotation(newRotation)
setSpinStartTime(Date.now())
setRotation(newRotation)
// Ждём окончания анимации
setTimeout(() => {
setIsSpinning(false)
setSpinStartTime(null)
onSpinComplete(resultGame)
}, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
@@ -117,13 +138,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
return text.slice(0, maxLength - 2) + '...'
}
// Функция для вычисления игры под указателем по углу
const getGameAtAngle = useCallback((currentRotation: number) => {
if (games.length === 0) return null
const normalizedRotation = ((currentRotation % 360) + 360) % 360
const angleUnderPointer = (360 - normalizedRotation + 360) % 360
const sectorIndex = Math.floor(angleUnderPointer / sectorAngle) % games.length
return games[sectorIndex] || null
}, [games, sectorAngle])
// Вычисляем игру под указателем (статическое состояние)
const currentGameUnderPointer = useMemo(() => {
return getGameAtAngle(rotation)
}, [rotation, getGameAtAngle])
// Easing функция для имитации инерции - быстрый старт, долгое замедление
// Аппроксимирует CSS cubic-bezier(0.12, 0.9, 0.15, 1)
const easeOutExpo = useCallback((t: number): number => {
// Экспоненциальное замедление - очень быстро в начале, очень медленно в конце
return t === 1 ? 1 : 1 - Math.pow(2, -12 * t)
}, [])
// Отслеживаем позицию во время вращения
useEffect(() => {
if (!isSpinning || spinStartTime === null) {
// Когда не крутится - показываем текущую игру под указателем
if (currentGameUnderPointer) {
setDisplayedGame(currentGameUnderPointer)
}
return
}
const totalDelta = targetRotation - startRotation
const updateDisplayedGame = () => {
const elapsed = Date.now() - spinStartTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
const easedProgress = easeOutExpo(progress)
// Вычисляем текущий угол на основе прогресса анимации
const currentAngle = startRotation + (totalDelta * easedProgress)
const game = getGameAtAngle(currentAngle)
if (game) {
setDisplayedGame(game)
}
}
// Обновляем каждые 30мс для плавности
const interval = setInterval(updateDisplayedGame, 30)
updateDisplayedGame() // Сразу обновляем
return () => clearInterval(interval)
}, [isSpinning, spinStartTime, startRotation, targetRotation, getGameAtAngle, currentGameUnderPointer, easeOutExpo])
// Мемоизируем секторы для производительности
const sectors = useMemo(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength }
})
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
// Инерционное вращение: быстрый старт, долгое плавное замедление
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
}}
>
<defs>
@@ -230,12 +306,13 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
<path
d={path}
fill={color.bg}
stroke={color.border}
strokeWidth="2"
stroke={showLines ? color.border : 'transparent'}
strokeWidth={showLines ? "1" : "0"}
filter="url(#sectorShadow)"
/>
{/* Текст названия игры */}
{/* Текст названия игры - только для небольшого количества */}
{showText && (
<text
x={textPos.x}
y={textPos.y}
@@ -243,7 +320,7 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold"
style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
@@ -252,16 +329,19 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
>
{truncateText(game.title, maxTextLength)}
</text>
)}
{/* Разделительная линия */}
{/* Разделительная линия - только для среднего количества */}
{showLines && (
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
)}
</g>
))}
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
)}
</div>
{/* Название текущей игры (для большого количества) */}
{!showText && (
<div className="glass rounded-xl px-6 py-3 min-w-[280px] text-center">
<p className="text-xs text-gray-500 mb-1">
{games.length} игр в колесе
</p>
<p className={`
font-semibold transition-all duration-100 truncate max-w-[280px]
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-white'}
`}>
{displayedGame?.title || 'Крутите колесо!'}
</p>
</div>
)}
{/* Подсказка */}
<p className={`
text-sm transition-all duration-300

View File

@@ -67,18 +67,28 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
},
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const isIconOnly = icon && !children
const sizeClassesWithText = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const sizeClassesIconOnly = {
sm: 'p-2 text-sm',
md: 'p-2.5 text-base',
lg: 'p-3 text-lg',
}
const sizeClasses = isIconOnly ? sizeClassesIconOnly : sizeClassesWithText
const colors = colorMap[color]
return (
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock, Camera, Trash2 } from 'lucide-react'
import type { GameProposalMode } from '@/types'
import { useToast } from '@/store/toast'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() {
const navigate = useNavigate()
const toast = useToast()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const {
register,
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode')
const handleCoverClick = () => {
fileInputRef.current?.click()
}
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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
}
setCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
const handleRemoveCover = () => {
setCoverFile(null)
if (coverPreview) {
URL.revokeObjectURL(coverPreview)
}
setCoverPreview(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const onSubmit = async (data: CreateForm) => {
setIsLoading(true)
setError(null)
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
})
// Upload cover if selected
if (coverFile) {
try {
await marathonsApi.uploadCover(marathon.id, coverFile)
} catch {
toast.warning('Марафон создан, но не удалось загрузить обложку')
}
}
navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } }
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
</div>
)}
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка (необязательно)
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isLoading}
className="relative w-full h-40 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{coverPreview ? (
<img
src={coverPreview}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-8 h-8 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{coverPreview && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-6 h-6 text-neon-500" />
<span className="ml-2 text-white text-sm">Изменить</span>
</div>
)}
</button>
{coverPreview && (
<button
type="button"
onClick={handleRemoveCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
{/* Basic info */}
<div className="space-y-4">
<Input

View File

@@ -9,8 +9,9 @@ import { useConfirm } from '@/store/confirm'
import { fuzzyFilter } from '@/utils/fuzzySearch'
import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search, Settings
} from 'lucide-react'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
export function LobbyPage() {
const { id } = useParams<{ id: string }>()
@@ -28,9 +29,43 @@ export function LobbyPage() {
const [showAddGame, setShowAddGame] = useState(false)
const [gameTitle, setGameTitle] = useState('')
const [gameUrl, setGameUrl] = useState('')
const [gameUrlError, setGameUrlError] = useState<string | null>(null)
const [gameGenre, setGameGenre] = useState('')
const [isAddingGame, setIsAddingGame] = useState(false)
const validateUrl = (url: string): boolean => {
if (!url.trim()) return true // Empty is ok, will be caught by required check
try {
const parsed = new URL(url.trim())
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false
}
// Check that hostname has at least one dot (domain.tld)
const hostname = parsed.hostname
if (!hostname || !hostname.includes('.')) {
return false
}
// Check that TLD is valid (2-6 letters only, like com, ru, org, online)
const parts = hostname.split('.')
const tld = parts[parts.length - 1].toLowerCase()
if (tld.length < 2 || tld.length > 6 || !/^[a-z]+$/.test(tld)) {
return false
}
return true
} catch {
return false
}
}
const handleGameUrlChange = (value: string) => {
setGameUrl(value)
if (value.trim() && !validateUrl(value)) {
setGameUrlError('Введите корректную ссылку (например: https://store.steampowered.com/...)')
} else {
setGameUrlError(null)
}
}
// Moderation
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
@@ -90,6 +125,17 @@ export function LobbyPage() {
const [searchQuery, setSearchQuery] = useState('')
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
// Games list filters
const [filterProposer, setFilterProposer] = useState<number | 'all'>('all')
const [filterChallenges, setFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
// Generation filters
const [generateFilterProposer, setGenerateFilterProposer] = useState<number | 'all'>('all')
const [generateFilterChallenges, setGenerateFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
// Settings modal
const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
loadData()
}, [id])
@@ -137,7 +183,7 @@ export function LobbyPage() {
}
const handleAddGame = async () => {
if (!id || !gameTitle.trim() || !gameUrl.trim()) return
if (!id || !gameTitle.trim() || !gameUrl.trim() || !validateUrl(gameUrl)) return
setIsAddingGame(true)
try {
@@ -148,6 +194,7 @@ export function LobbyPage() {
})
setGameTitle('')
setGameUrl('')
setGameUrlError(null)
setGameGenre('')
setShowAddGame(false)
await loadData()
@@ -524,10 +571,6 @@ export function LobbyPage() {
)
}
const selectAllGamesForGeneration = () => {
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
}
const clearGameSelection = () => {
setSelectedGamesForGeneration([])
}
@@ -605,6 +648,22 @@ export function LobbyPage() {
const approvedGames = games.filter(g => g.status === 'approved')
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
// Get unique proposers for generation filter (from approved games)
const uniqueProposers = approvedGames.reduce((acc, game) => {
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
acc.push(game.proposed_by)
}
return acc
}, [] as { id: number; nickname: string }[])
// Get unique proposers for games list filter (from all games)
const allGamesProposers = games.reduce((acc, game) => {
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
acc.push(game.proposed_by)
}
return acc
}, [] as { id: number; nickname: string }[])
const getStatusBadge = (status: string) => {
switch (status) {
case 'approved':
@@ -1062,6 +1121,13 @@ export function LobbyPage() {
</div>
{isOrganizer && (
<div className="flex gap-2">
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
<NeonButton
onClick={handleStartMarathon}
isLoading={isStarting}
@@ -1070,6 +1136,7 @@ export function LobbyPage() {
>
Запустить марафон
</NeonButton>
</div>
)}
</div>
@@ -1375,6 +1442,8 @@ export function LobbyPage() {
setShowGenerateSelection(false)
clearGameSelection()
setGenerateSearchQuery('')
setGenerateFilterProposer('all')
setGenerateFilterChallenges('all')
}}
variant="secondary"
size="sm"
@@ -1408,7 +1477,7 @@ export function LobbyPage() {
{/* Game selection */}
{showGenerateSelection && (
<div className="space-y-3">
{/* Search in generation */}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
@@ -1427,12 +1496,63 @@ export function LobbyPage() {
</button>
)}
</div>
{/* Filters */}
<div className="flex gap-2">
<select
value={generateFilterProposer === 'all' ? 'all' : generateFilterProposer}
onChange={(e) => setGenerateFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="input py-2 text-sm flex-1"
>
<option value="all">Все участники</option>
{uniqueProposers.map(u => (
<option key={u.id} value={u.id}>{u.nickname}</option>
))}
</select>
<select
value={generateFilterChallenges}
onChange={(e) => setGenerateFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
className="input py-2 text-sm flex-1"
>
<option value="all">Все игры</option>
<option value="with">С заданиями</option>
<option value="without">Без заданий</option>
</select>
</div>
{(() => {
// Compute filtered games
let filteredGames = approvedGames
// Apply proposer filter
if (generateFilterProposer !== 'all') {
filteredGames = filteredGames.filter(g => g.proposed_by?.id === generateFilterProposer)
}
// Apply challenges filter
if (generateFilterChallenges === 'with') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count > 0
})
} else if (generateFilterChallenges === 'without') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count === 0
})
}
// Apply search filter
if (generateSearchQuery) {
filteredGames = fuzzyFilter(filteredGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
}
return (
<>
<div className="flex items-center justify-between text-sm">
<button
onClick={selectAllGamesForGeneration}
onClick={() => setSelectedGamesForGeneration(filteredGames.map(g => g.id))}
className="text-neon-400 hover:text-neon-300 transition-colors"
>
Выбрать все
Выбрать все ({filteredGames.length})
</button>
<button
onClick={clearGameSelection}
@@ -1442,14 +1562,9 @@ export function LobbyPage() {
</button>
</div>
<div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
{(() => {
const filteredGames = generateSearchQuery
? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
: approvedGames
return filteredGames.length === 0 ? (
{filteredGames.length === 0 ? (
<p className="text-center text-gray-500 py-4 text-sm">
Ничего не найдено по запросу "{generateSearchQuery}"
Ничего не найдено
</p>
) : (
filteredGames.map((game) => {
@@ -1473,7 +1588,12 @@ export function LobbyPage() {
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-white font-medium truncate">{game.title}</p>
{game.proposed_by && (
<span className="text-xs text-gray-500 shrink-0">от {game.proposed_by.nickname}</span>
)}
</div>
<p className="text-xs text-gray-400">
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
</p>
@@ -1481,10 +1601,12 @@ export function LobbyPage() {
</button>
)
})
)}
</div>
</>
)
})()}
</div>
</div>
)}
{generateMessage && (
@@ -1655,8 +1777,9 @@ export function LobbyPage() {
)}
</div>
{/* Search */}
<div className="relative mb-6">
{/* Search and filters */}
<div className="space-y-3 mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
@@ -1674,6 +1797,28 @@ export function LobbyPage() {
</button>
)}
</div>
<div className="flex gap-2">
<select
value={filterProposer === 'all' ? 'all' : filterProposer}
onChange={(e) => setFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="input py-2 text-sm flex-1"
>
<option value="all">Все участники</option>
{allGamesProposers.map(u => (
<option key={u.id} value={u.id}>{u.nickname}</option>
))}
</select>
<select
value={filterChallenges}
onChange={(e) => setFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
className="input py-2 text-sm flex-1"
>
<option value="all">Все игры</option>
<option value="with">С заданиями</option>
<option value="without">Без заданий</option>
</select>
</div>
</div>
{/* Add game form */}
{showAddGame && (
@@ -1684,9 +1829,10 @@ export function LobbyPage() {
onChange={(e) => setGameTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
placeholder="Ссылка для скачивания (https://...)"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
onChange={(e) => handleGameUrlChange(e.target.value)}
error={gameUrlError || undefined}
/>
<Input
placeholder="Жанр (необязательно)"
@@ -1697,11 +1843,11 @@ export function LobbyPage() {
<NeonButton
onClick={handleAddGame}
isLoading={isAddingGame}
disabled={!gameTitle || !gameUrl}
disabled={!gameTitle || !gameUrl || !!gameUrlError}
>
{isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton>
<NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null) }}>
Отмена
</NeonButton>
</div>
@@ -1715,26 +1861,47 @@ export function LobbyPage() {
{/* Games */}
{(() => {
const baseGames = isOrganizer
let filteredGames = isOrganizer
? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
const visibleGames = searchQuery
? fuzzyFilter(baseGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
: baseGames
// Apply proposer filter
if (filterProposer !== 'all') {
filteredGames = filteredGames.filter(g => g.proposed_by?.id === filterProposer)
}
return visibleGames.length === 0 ? (
// Apply challenges filter
if (filterChallenges === 'with') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count > 0
})
} else if (filterChallenges === 'without') {
filteredGames = filteredGames.filter(g => {
const count = gameChallenges[g.id]?.length ?? g.challenges_count
return count === 0
})
}
// Apply search filter
if (searchQuery) {
filteredGames = fuzzyFilter(filteredGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
}
const hasFilters = searchQuery || filterProposer !== 'all' || filterChallenges !== 'all'
return filteredGames.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
{searchQuery ? (
{hasFilters ? (
<Search className="w-8 h-8 text-gray-600" />
) : (
<Gamepad2 className="w-8 h-8 text-gray-600" />
)}
</div>
<p className="text-gray-400">
{searchQuery
? `Ничего не найдено по запросу "${searchQuery}"`
{hasFilters
? 'Ничего не найдено по заданным фильтрам'
: isOrganizer
? 'Пока нет игр. Добавьте игры, чтобы начать!'
: 'Пока нет одобренных игр. Предложите свою!'}
@@ -1742,11 +1909,21 @@ export function LobbyPage() {
</div>
) : (
<div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))}
{filteredGames.map((game) => renderGameCard(game, false))}
</div>
)
})()}
</GlassCard>
{/* Settings Modal */}
{marathon && (
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
)}
</div>
)
}

View File

@@ -54,7 +54,8 @@ export function LoginPage() {
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка входа')
// Error is already set in store by login function
// Ban case is handled separately via banInfo state
}
}

View File

@@ -9,6 +9,7 @@ import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
@@ -35,6 +36,7 @@ export function MarathonPage() {
const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => {
@@ -190,8 +192,22 @@ export function MarathonPage() {
{/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */}
{marathon.cover_url ? (
<>
<img
src={marathon.cover_url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 to-transparent" />
</>
) : (
<>
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
</>
)}
<div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
@@ -227,8 +243,8 @@ export function MarathonPage() {
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
Настройка
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
Игры
</NeonButton>
</Link>
)}
@@ -266,6 +282,15 @@ export function MarathonPage() {
</button>
)}
{marathon.status === 'preparing' && isOrganizer && (
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
)}
{canDelete && (
<NeonButton
variant="ghost"
@@ -533,6 +558,14 @@ export function MarathonPage() {
</div>
)}
</div>
{/* Settings Modal */}
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
</div>
)
}

View File

@@ -233,10 +233,20 @@ export function MarathonsPage() {
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
{/* Cover or Icon */}
{marathon.cover_url ? (
<div className="w-14 h-14 rounded-xl overflow-hidden border border-dark-500 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<img
src={marathon.cover_url}
alt={marathon.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
)}
{/* Info */}
<div>

View File

@@ -60,7 +60,7 @@ export const useAuthStore = create<AuthState>()(
banInfo: null,
login: async (data) => {
set({ isLoading: true, error: null, pending2FA: null })
set({ isLoading: true, error: null, pending2FA: null, banInfo: null })
try {
const response = await authApi.login(data)
@@ -85,9 +85,34 @@ export const useAuthStore = create<AuthState>()(
}
return { requires2FA: false }
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
const error = err as { response?: { status?: number; data?: { detail?: string | BanInfo } } }
// Check if user is banned (403 with ban info)
if (error.response?.status === 403) {
const detail = error.response?.data?.detail
if (typeof detail === 'object' && detail !== null && 'banned_at' in detail) {
set({
error: error.response?.data?.detail || 'Login failed',
banInfo: detail as BanInfo,
isLoading: false,
error: null,
})
throw err
}
}
// Regular error - translate common messages
let errorMessage = 'Ошибка входа'
const detail = error.response?.data?.detail
if (typeof detail === 'string') {
if (detail === 'Incorrect login or password') {
errorMessage = 'Неверный логин или пароль'
} else {
errorMessage = detail
}
}
set({
error: errorMessage,
isLoading: false,
})
throw err

View File

@@ -63,6 +63,7 @@ export interface Marathon {
is_public: boolean
game_proposal_mode: GameProposalMode
auto_events_enabled: boolean
cover_url: string | null
start_date: string | null
end_date: string | null
participants_count: number
@@ -76,6 +77,7 @@ export interface MarathonListItem {
title: string
status: MarathonStatus
is_public: boolean
cover_url: string | null
participants_count: number
start_date: string | null
end_date: string | null
@@ -90,11 +92,21 @@ export interface MarathonCreate {
game_proposal_mode: GameProposalMode
}
export interface MarathonUpdate {
title?: string
description?: string
start_date?: string
is_public?: boolean
game_proposal_mode?: GameProposalMode
auto_events_enabled?: boolean
}
export interface MarathonPublicInfo {
id: number
title: string
description: string | null
status: MarathonStatus
cover_url: string | null
participants_count: number
creator_nickname: string
}

View File

@@ -91,12 +91,13 @@ def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
cursor = conn.cursor()
since = datetime.utcnow() - timedelta(hours=hours)
# Use strftime format to match SQLite CURRENT_TIMESTAMP format (no 'T')
cursor.execute("""
SELECT latency_ms, status, checked_at
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
ORDER BY checked_at ASC
""", (service_name, since.isoformat()))
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
rows = cursor.fetchall()
conn.close()
@@ -123,7 +124,7 @@ def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
FROM metrics
WHERE service_name = ? AND checked_at > ?
""", (service_name, since.isoformat()))
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
row = cursor.fetchone()
conn.close()
@@ -148,7 +149,7 @@ def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
SELECT AVG(latency_ms) as avg_latency
FROM metrics
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
""", (service_name, since.isoformat()))
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
row = cursor.fetchone()
conn.close()
@@ -231,7 +232,7 @@ def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_exp
INSERT OR REPLACE INTO ssl_certificates
(domain, issuer, expires_at, days_until_expiry, checked_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (domain, issuer, expires_at.isoformat(), days_until_expiry))
""", (domain, issuer, expires_at.strftime("%Y-%m-%d %H:%M:%S"), days_until_expiry))
conn.commit()
conn.close()
@@ -254,7 +255,7 @@ def cleanup_old_metrics(hours: int = 24):
conn = get_connection()
cursor = conn.cursor()
cutoff = datetime.utcnow() - timedelta(hours=hours)
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.strftime("%Y-%m-%d %H:%M:%S"),))
deleted = cursor.rowcount
conn.commit()
conn.close()