This commit is contained in:
2025-12-14 02:38:35 +07:00
commit 5343a8f2c3
84 changed files with 7406 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Database
DB_PASSWORD=change_me_secure_password
# Backend
SECRET_KEY=change_me_jwt_secret_key_at_least_32_chars
DEBUG=false
# OpenAI API
OPENAI_API_KEY=sk-...
# Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
# Frontend (for build)
VITE_API_URL=/api/v1

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# Environment variables (secrets)
.env
.env.local
.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
.venv/
env/
ENV/
*.egg-info/
dist/
build/
.eggs/
*.egg
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.npm
.pnpm-store/
# Build outputs
frontend/dist/
frontend/build/
*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
*.sublime-*
# OS
.DS_Store
Thumbs.db
Desktop.ini
# Logs
*.log
logs/
# Claude Code local settings
.claude/
# Docker volumes data
postgres_data/
redis_data/
# Misc
*.bak
*.tmp
*.temp

375
CONCEPT.md Normal file
View File

@@ -0,0 +1,375 @@
# Game Marathon — Концепция
## Общее описание
**Game Marathon** — закрытая платформа для проведения игровых марафонов среди друзей (~10 человек), где за месяц нужно набрать максимум очков, выполняя случайные челленджи в случайных играх.
### Ключевые особенности
- Колесо рандома (игра → челлендж)
- Игры добавляют сами участники + ссылки на скачивание
- Челленджи генерирует нейросеть (интегрированное API)
- Пруфы обязательны (скриншоты/видео/Steam ачивки) — загружаются на сервер
- Социальные механики (вызовы, ставки, события)
- Уведомления через Telegram-бота
- Авторизация по логину/паролю
---
## Жизненный цикл марафона
### Фаза 1: Подготовка (3-7 дней до старта)
**Действия участников:**
1. Присоединяются к марафону (по ссылке-приглашению)
2. Добавляют игры (название + ссылка на скачивание + обложка)
3. Когда все готовы — организатор "закрывает" список игр
4. Система (нейросеть) генерирует челленджи для каждой игры
5. Участники могут посмотреть/предложить правки к челленджам
6. Организатор запускает марафон
**Добавление игры включает:**
- Название игры
- Ссылка на скачивание (для платных игр — сторонние ресурсы)
- Обложка (загрузка или URL)
- Жанр (опционально)
### Фаза 2: Генерация челленджей
После закрытия списка игр — автоматический запрос к API нейросети.
**Для каждой игры генерируется 5-7 челленджей:**
- 2 лёгких (15-30 минут)
- 2-3 средних (1-2 часа)
- 1-2 сложных (3+ часов)
**Каждый челлендж содержит:**
- Название
- Описание
- Тип (completion/no-death/speedrun/collection/achievement/etc)
- Примерное время выполнения
- Способ проверки (скриншот/видео/Steam ачивка)
- Количество очков
После генерации участники могут предложить правки или добавить свои челленджи.
### Фаза 3: Активный марафон (1 месяц)
**Процесс выполнения:**
```
1. Крутишь колесо игр
Выпадает случайная игра
2. Крутишь колесо челленджей (для этой игры)
Выпадает случайный челлендж
3. Выбор:
• Принять → идёшь выполнять
• Дроп → штраф, крутишь заново
4. Выполняешь задание
5. Загружаешь пруф (скрин/видео/ссылка на Steam профиль)
6. Получаешь очки + streak продолжается
7. Можешь крутить снова
```
---
## Система очков
### Базовые очки
Зависят от сложности челленджа (определяет нейросеть при генерации):
| Сложность | Очки |
|-----------|------|
| Лёгкий | 30-50 |
| Средний | 60-100 |
| Сложный | 120-200 |
### Streak бонус
Последовательное выполнение заданий без дропов:
| Streak | Бонус |
|--------|-------|
| 1 | +0% |
| 2 | +10% |
| 3 | +20% |
| 4 | +30% |
| 5+ | +40% (максимум) |
### Дроп штрафы
Прогрессивная система штрафов:
| Дроп # | Штраф |
|--------|-------|
| 1 | Бесплатно (streak сбрасывается) |
| 2 | -10 очков |
| 3 | -25 очков |
| 4 | -50 очков |
| 5+ | -50 очков + кулдаун 2 часа |
Штрафы сбрасываются после успешного выполнения задания.
---
## Подтверждение выполнения
### Типы пруфов
- **Скриншот** — загружается на сервер
- **Видео** — загружается на сервер или ссылка (YouTube)
- **Steam Achievement** — ссылка на профиль Steam с ачивкой
### Процесс верификации
1. Участник загружает пруф + опциональный комментарий
2. Пруф виден всем участникам в ленте активности
3. Любой участник может "оспорить" пруф (если считает невалидным)
4. Если нет споров за 24 часа — автоматически засчитывается
5. При споре — голосование участников или решение организатора
---
## Социальные механики
### Лента активности
Отображает в реальном времени:
- Выполненные челленджи (с пруфами)
- Дропы
- Кто крутит колесо и что выпало
- События марафона
- Вызовы между участниками
### Вызов (Challenge)
Когда участник выполнил челлендж, другие могут "вызвать" себя на тот же:
- Вызов заменяет текущее активное задание
- Награда: стандартные очки за челлендж
- Бонус: +30 очков если выполнить быстрее оригинального исполнителя
### Ставка (Bet)
Перед началом выполнения участник может поставить часть своих очков:
- **Выполнил:** награда + ставка x2
- **Дропнул/провалил:** теряет ставку
Варианты ставок: 0 / 25 / 50 / 100 очков
---
## Система событий
Случайные события во время марафона (1-2 раза в неделю). Могут запускаться автоматически или вручную организатором.
| Событие | Описание | Длительность |
|---------|----------|--------------|
| **Золотой час** | Все очки x1.5 | 30-60 минут |
| **Общий враг** | Все получают одинаковое задание, топ-3 = бонус | До выполнения |
| **Двойной риск** | Дропы бесплатны, но очки x0.5 | 2 часа |
| **Джекпот** | Следующему кто крутит — гарантированно сложный челлендж с x3 очками | 1 спин |
| **Обмен** | Можно поменяться заданием с другим участником | 1 час |
| **Реванш** | Можно переделать любой свой проваленный челлендж за 50% очков | 4 часа |
---
## Таблица лидеров
Отображает для каждого участника:
- Место в рейтинге
- Общее количество очков
- Текущий streak
- Количество выполненных челленджей
- Количество дропов
---
## Модель данных
### User (Пользователь)
```
- id
- login
- password_hash
- nickname
- avatar
- telegram_id (для уведомлений)
- created_at
```
### Marathon (Марафон)
```
- id
- title
- description
- organizer_id (User)
- start_date
- end_date
- status: preparing | active | finished
- invite_code
- settings (JSON: события вкл/выкл, etc)
- created_at
```
### Participant (Участник марафона)
```
- id
- user_id
- marathon_id
- total_points
- current_streak
- drop_count (текущий счётчик для штрафов)
- joined_at
```
### Game (Игра)
```
- id
- marathon_id
- title
- cover_image (путь к файлу на сервере)
- download_link
- genre
- added_by (User)
- created_at
```
### Challenge (Челлендж)
```
- id
- game_id
- title
- description
- type: completion | no_death | speedrun | collection | achievement | challenge_run | score_attack | time_trial
- difficulty: easy | medium | hard
- points
- estimated_time (в минутах)
- proof_type: screenshot | video | steam_achievement
- is_generated (boolean — создан AI или вручную)
- created_at
```
### Assignment (Задание — выпавшее участнику)
```
- id
- participant_id
- challenge_id
- status: active | completed | dropped
- proof_file (путь к файлу на сервере)
- proof_url (опционально — ссылка)
- proof_comment
- points_earned
- bet_amount
- started_at
- completed_at
```
### Event (Событие)
```
- id
- marathon_id
- type: golden_hour | common_enemy | double_risk | jackpot | swap | rematch
- start_time
- end_time
- is_active
- data (JSON — доп. данные события)
```
### Dispute (Оспаривание пруфа)
```
- id
- assignment_id
- raised_by (User)
- reason
- status: open | resolved_valid | resolved_invalid
- resolved_by (User — организатор или голосование)
- created_at
- resolved_at
```
---
## Уведомления (Telegram-бот)
### Типы уведомлений
- Марафон скоро начнётся
- Кто-то выполнил челлендж
- Тебя вызвали на челлендж
- Твой пруф оспорен
- Началось событие (Золотой час и т.д.)
- Напоминание: у тебя нет активного задания
- Марафон завершён — итоги
### Настройки
Пользователь может включить/выключить отдельные типы уведомлений.
---
## Технические решения
| Аспект | Решение |
|--------|---------|
| Авторизация | Логин/пароль |
| Хранение пруфов | Загрузка на сервер |
| Уведомления | Telegram-бот |
| Генерация челленджей | Интегрированное API нейросети |
| Формат марафона | 1 месяц (с перспективой других режимов) |
---
## Перспективы развития
### Дополнительные режимы марафонов
- **Sprint** (4-12 часов) — интенсивный формат
- **Daily Challenge** (1 неделя) — по 1-3 задания в день
- **Tournament** — несколько раундов, выбывание
### Дополнительные механики
- Колесо модификаторов (без звука, одной рукой, и т.д.)
- Командные марафоны (2v2, 3v3)
- Глобальная статистика и достижения пользователя
- Рейтинговая система игроков
---
## UI/UX — Основные экраны
1. **Главная** — список марафонов (активные, завершённые)
2. **Авторизация** — вход/регистрация
3. **Создание марафона** — форма с настройками
4. **Лобби марафона** — подготовка, добавление игр
5. **Колесо** — основной геймплей (спин игры → спин челленджа)
6. **Текущее задание** — детали + загрузка пруфа
7. **Лента активности** — действия всех участников
8. **Таблица лидеров** — рейтинг участников
9. **История заданий** — все выполненные/дропнутые
10. **Профиль** — настройки, статистика

136
README.md Normal file
View File

@@ -0,0 +1,136 @@
# Game Marathon
A web application for running gaming marathons with friends. Spin the wheel, complete challenges, earn points!
## Features
- Create private marathons and invite friends
- Add games with download links
- AI-generated challenges using GPT
- Spin the wheel for random game + challenge
- Points system with streak bonuses
- Leaderboard and activity feed
- Proof upload for completed challenges
## Tech Stack
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS, Zustand
- **Backend**: FastAPI, SQLAlchemy, PostgreSQL
- **AI**: OpenAI GPT-4o-mini
- **Infrastructure**: Docker, Nginx
## Quick Start
### Prerequisites
- Docker and Docker Compose
- OpenAI API key
### Setup
1. Clone the repository:
```bash
cd WebApp
```
2. Create `.env` file:
```bash
cp .env.example .env
```
3. Edit `.env` and set your values:
```env
DB_PASSWORD=your_secure_password
SECRET_KEY=your_jwt_secret_at_least_32_characters
OPENAI_API_KEY=sk-your-openai-key
```
4. Start with Docker:
```bash
docker-compose up -d
```
5. Open http://localhost in your browser
### Development Mode
**Backend:**
```bash
cd backend
python -m venv venv
source venv/bin/activate # or venv\Scripts\activate on Windows
pip install -r requirements.txt
uvicorn app.main:app --reload
```
**Frontend:**
```bash
cd frontend
npm install
npm run dev
```
## Project Structure
```
WebApp/
├── backend/ # FastAPI application
│ ├── app/
│ │ ├── api/ # API endpoints
│ │ ├── models/ # SQLAlchemy models
│ │ ├── schemas/ # Pydantic schemas
│ │ ├── services/ # Business logic
│ │ └── core/ # Config, security
│ └── uploads/ # Uploaded files
├── frontend/ # React application
│ └── src/
│ ├── api/ # API client
│ ├── components/# UI components
│ ├── pages/ # Page components
│ ├── store/ # Zustand store
│ └── types/ # TypeScript types
├── bot/ # Telegram bot (coming soon)
├── docker-compose.yml
└── nginx.conf
```
## API Documentation
When backend is running, visit:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## How to Play
1. **Create Marathon** - Set title, description, and duration
2. **Invite Friends** - Share the invite code
3. **Add Games** - Everyone adds their favorite games
4. **Generate Challenges** - AI creates challenges for each game
5. **Start Marathon** - Begin the competition
6. **Spin & Play** - Spin the wheel, get a challenge, complete it
7. **Upload Proof** - Submit screenshot/video as evidence
8. **Earn Points** - Build streaks for bonus points!
## Point System
| Difficulty | Base Points |
|------------|-------------|
| Easy | 30-50 |
| Medium | 60-100 |
| Hard | 120-200 |
**Streak Bonus:**
- 2 in a row: +10%
- 3 in a row: +20%
- 4 in a row: +30%
- 5+ in a row: +40%
**Drop Penalties:**
- 1st drop: Free (streak resets)
- 2nd drop: -10 points
- 3rd drop: -25 points
- 4th+ drop: -50 points
## License
MIT

1332
TECHNICAL_PLAN.md Normal file

File diff suppressed because it is too large Load Diff

22
backend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create upload directories
RUN mkdir -p /app/uploads/avatars /app/uploads/covers /app/uploads/proofs
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

40
backend/alembic.ini Normal file
View File

@@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

63
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,63 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings
from app.core.database import Base
from app.models import * # noqa: F401, F403
config = context.config
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Game Marathon Backend

View File

@@ -0,0 +1 @@
# API module

50
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,50 @@
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models import User
security = HTTPBearer()
async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
token = credentials.credentials
payload = decode_access_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
# Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)]

View File

@@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed
router = APIRouter(prefix="/api/v1")
router.include_router(auth.router)
router.include_router(users.router)
router.include_router(marathons.router)
router.include_router(games.router)
router.include_router(challenges.router)
router.include_router(wheel.router)
router.include_router(feed.router)

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token
from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse)
async def register(data: UserRegister, db: DbSession):
# Check if login already exists
result = await db.execute(select(User).where(User.login == data.login.lower()))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Login already registered",
)
# Create user
user = User(
login=data.login.lower(),
password_hash=get_password_hash(data.password),
nickname=data.nickname,
)
db.add(user)
await db.commit()
await db.refresh(user)
# Generate token
access_token = create_access_token(subject=user.id)
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
)
@router.post("/login", response_model=TokenResponse)
async def login(data: UserLogin, db: DbSession):
# Find user
result = await db.execute(select(User).where(User.login == data.login.lower()))
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect login or password",
)
# Generate token
access_token = create_access_token(subject=user.id)
return TokenResponse(
access_token=access_token,
user=UserPublic.model_validate(user),
)
@router.get("/me", response_model=UserPublic)
async def get_me(current_user: CurrentUser):
return UserPublic.model_validate(current_user)

View File

@@ -0,0 +1,268 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.schemas import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
MessageResponse,
GameShort,
)
from app.services.gpt import GPTService
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
return challenge
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == 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")
return participant
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
await check_participant(db, current_user.id, game.marathon_id)
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at)
)
challenges = result.scalars().all()
return [
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
async def create_challenge(
game_id: int,
data: ChallengeCreate,
current_user: CurrentUser,
db: DbSession,
):
# Get game and check access
result = await db.execute(
select(Game).where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add challenges to active or finished marathon")
await check_participant(db, current_user.id, game.marathon_id)
challenge = Challenge(
game_id=game_id,
title=data.title,
description=data.description,
type=data.type.value,
difficulty=data.difficulty.value,
points=data.points,
estimated_time=data.estimated_time,
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.post("/marathons/{marathon_id}/generate-challenges", response_model=MessageResponse)
async def generate_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Generate challenges for all games in marathon using GPT"""
# Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot generate challenges for active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
# Get all games
result = await db.execute(
select(Game).where(Game.marathon_id == marathon_id)
)
games = result.scalars().all()
if not games:
raise HTTPException(status_code=400, detail="No games in marathon")
generated_count = 0
for game in games:
# Check if game already has challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
for ch_data in challenges_data:
challenge = Challenge(
game_id=game.id,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
difficulty=ch_data.difficulty,
points=ch_data.points,
estimated_time=ch_data.estimated_time,
proof_type=ch_data.proof_type,
proof_hint=ch_data.proof_hint,
is_generated=True,
)
db.add(challenge)
generated_count += 1
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
await db.commit()
return MessageResponse(message=f"Generated {generated_count} challenges")
@router.patch("/challenges/{challenge_id}", response_model=ChallengeResponse)
async def update_challenge(
challenge_id: int,
data: ChallengeUpdate,
current_user: CurrentUser,
db: DbSession,
):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update challenges in active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
if data.title is not None:
challenge.title = data.title
if data.description is not None:
challenge.description = data.description
if data.type is not None:
challenge.type = data.type.value
if data.difficulty is not None:
challenge.difficulty = data.difficulty.value
if data.points is not None:
challenge.points = data.points
if data.estimated_time is not None:
challenge.estimated_time = data.estimated_time
if data.proof_type is not None:
challenge.proof_type = data.proof_type.value
if data.proof_hint is not None:
challenge.proof_hint = data.proof_hint
await db.commit()
await db.refresh(challenge)
game = challenge.game
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
await check_participant(db, current_user.id, challenge.game.marathon_id)
await db.delete(challenge)
await db.commit()
return MessageResponse(message="Challenge deleted")

View File

@@ -0,0 +1,62 @@
from fastapi import APIRouter
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Activity, Participant
from app.schemas import FeedResponse, ActivityResponse, UserPublic
router = APIRouter(tags=["feed"])
@router.get("/marathons/{marathon_id}/feed", response_model=FeedResponse)
async def get_feed(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
limit: int = 20,
offset: int = 0,
):
"""Get activity feed for marathon"""
# Check user is participant
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
if not result.scalar_one_or_none():
return FeedResponse(items=[], total=0, has_more=False)
# Get total count
total = await db.scalar(
select(func.count()).select_from(Activity).where(Activity.marathon_id == marathon_id)
)
# Get activities
result = await db.execute(
select(Activity)
.options(selectinload(Activity.user))
.where(Activity.marathon_id == marathon_id)
.order_by(Activity.created_at.desc())
.limit(limit)
.offset(offset)
)
activities = result.scalars().all()
items = [
ActivityResponse(
id=a.id,
type=a.type,
user=UserPublic.model_validate(a.user),
data=a.data,
created_at=a.created_at,
)
for a in activities
]
return FeedResponse(
items=items,
total=total,
has_more=(offset + limit) < total,
)

222
backend/app/api/v1/games.py Normal file
View File

@@ -0,0 +1,222 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File
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
from app.models import Marathon, MarathonStatus, Game, Challenge, Participant
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
router = APIRouter(tags=["games"])
async def get_game_or_404(db, game_id: int) -> Game:
result = await db.execute(
select(Game)
.options(selectinload(Game.added_by_user))
.where(Game.id == game_id)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
return game
async def check_participant(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == 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")
return participant
@router.get("/marathons/{marathon_id}/games", response_model=list[GameResponse])
async def list_games(marathon_id: int, current_user: CurrentUser, db: DbSession):
await check_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Game, func.count(Challenge.id).label("challenges_count"))
.outerjoin(Challenge)
.options(selectinload(Game.added_by_user))
.where(Game.marathon_id == marathon_id)
.group_by(Game.id)
.order_by(Game.created_at.desc())
)
games = []
for row in result.all():
game = row[0]
games.append(GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=row[1],
created_at=game.created_at,
))
return games
@router.post("/marathons/{marathon_id}/games", response_model=GameResponse)
async def add_game(
marathon_id: int,
data: GameCreate,
current_user: CurrentUser,
db: DbSession,
):
# Check marathon exists and is preparing
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot add games to active or finished marathon")
await check_participant(db, current_user.id, marathon_id)
game = Game(
marathon_id=marathon_id,
title=data.title,
download_url=data.download_url,
genre=data.genre,
added_by_id=current_user.id,
)
db.add(game)
await db.commit()
await db.refresh(game)
return GameResponse(
id=game.id,
title=game.title,
cover_url=None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(current_user),
challenges_count=0,
created_at=game.created_at,
)
@router.get("/games/{game_id}", response_model=GameResponse)
async def get_game(game_id: int, current_user: CurrentUser, db: DbSession):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
challenges_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game_id)
)
return GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=UserPublic.model_validate(game.added_by_user) if game.added_by_user else None,
challenges_count=challenges_count,
created_at=game.created_at,
)
@router.patch("/games/{game_id}", response_model=GameResponse)
async def update_game(
game_id: int,
data: GameUpdate,
current_user: CurrentUser,
db: DbSession,
):
game = await get_game_or_404(db, game_id)
# Check if marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update games in active or finished marathon")
# Only the one who added or organizer can update
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can update it")
if data.title is not None:
game.title = data.title
if data.download_url is not None:
game.download_url = data.download_url
if data.genre is not None:
game.genre = data.genre
await db.commit()
return await get_game(game_id, current_user, db)
@router.delete("/games/{game_id}", response_model=MessageResponse)
async def delete_game(game_id: int, current_user: CurrentUser, db: DbSession):
game = await get_game_or_404(db, game_id)
# Check if marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete games from active or finished marathon")
# Only the one who added or organizer can delete
if game.added_by_id != current_user.id and marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only the one who added the game or organizer can delete it")
await db.delete(game)
await db.commit()
return MessageResponse(message="Game deleted")
@router.post("/games/{game_id}/cover", response_model=GameResponse)
async def upload_cover(
game_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
game = await get_game_or_404(db, game_id)
await check_participant(db, current_user.id, game.marathon_id)
# Validate file
if not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
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)
with open(filepath, "wb") as f:
f.write(contents)
game.cover_path = str(filepath)
await db.commit()
return await get_game(game_id, current_user, db)

View File

@@ -0,0 +1,358 @@
from datetime import timedelta
import secrets
from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import Marathon, Participant, MarathonStatus, Game, Assignment, AssignmentStatus, Activity, ActivityType
from app.schemas import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
JoinMarathon,
ParticipantInfo,
ParticipantWithUser,
LeaderboardEntry,
MessageResponse,
UserPublic,
)
router = APIRouter(prefix="/marathons", tags=["marathons"])
def generate_invite_code() -> str:
return secrets.token_urlsafe(8)
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
result = await db.execute(
select(Marathon)
.options(selectinload(Marathon.organizer))
.where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
return marathon
async def get_participation(db, user_id: int, marathon_id: int) -> Participant | None:
result = await db.execute(
select(Participant).where(
Participant.user_id == user_id,
Participant.marathon_id == marathon_id,
)
)
return result.scalar_one_or_none()
@router.get("", response_model=list[MarathonListItem])
async def list_marathons(current_user: CurrentUser, db: DbSession):
"""Get all marathons where user is participant or organizer"""
result = await db.execute(
select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant)
.where(
(Marathon.organizer_id == current_user.id) |
(Participant.user_id == current_user.id)
)
.group_by(Marathon.id)
.order_by(Marathon.created_at.desc())
)
marathons = []
for row in result.all():
marathon = row[0]
marathons.append(MarathonListItem(
id=marathon.id,
title=marathon.title,
status=marathon.status,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
))
return marathons
@router.post("", response_model=MarathonResponse)
async def create_marathon(
data: MarathonCreate,
current_user: CurrentUser,
db: DbSession,
):
# Strip timezone info for naive datetime columns
start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
end_date = start_date + timedelta(days=data.duration_days)
marathon = Marathon(
title=data.title,
description=data.description,
organizer_id=current_user.id,
invite_code=generate_invite_code(),
start_date=start_date,
end_date=end_date,
)
db.add(marathon)
await db.flush()
# Auto-add organizer as participant
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
)
db.add(participant)
await db.commit()
await db.refresh(marathon)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(current_user),
status=marathon.status,
invite_code=marathon.invite_code,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
games_count=0,
created_at=marathon.created_at,
my_participation=ParticipantInfo.model_validate(participant),
)
@router.get("/{marathon_id}", response_model=MarathonResponse)
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
# Count participants and games
participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
)
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
)
# Get user's participation
participation = await get_participation(db, current_user.id, marathon_id)
return MarathonResponse(
id=marathon.id,
title=marathon.title,
description=marathon.description,
organizer=UserPublic.model_validate(marathon.organizer),
status=marathon.status,
invite_code=marathon.invite_code,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=participants_count,
games_count=games_count,
created_at=marathon.created_at,
my_participation=ParticipantInfo.model_validate(participation) if participation else None,
)
@router.patch("/{marathon_id}", response_model=MarathonResponse)
async def update_marathon(
marathon_id: int,
data: MarathonUpdate,
current_user: CurrentUser,
db: DbSession,
):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can update marathon")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update active or finished marathon")
if data.title is not None:
marathon.title = data.title
if data.description is not None:
marathon.description = data.description
if data.start_date is not None:
# Strip timezone info for naive datetime columns
marathon.start_date = data.start_date.replace(tzinfo=None) if data.start_date.tzinfo else data.start_date
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can delete marathon")
await db.delete(marathon)
await db.commit()
return MessageResponse(message="Marathon deleted")
@router.post("/{marathon_id}/start", response_model=MarathonResponse)
async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can start marathon")
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are games with challenges
games_count = await db.scalar(
select(func.count()).select_from(Game).where(Game.marathon_id == marathon_id)
)
if games_count == 0:
raise HTTPException(status_code=400, detail="Add at least one game before starting")
marathon.status = MarathonStatus.ACTIVE.value
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.START_MARATHON.value,
data={"title": marathon.title},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.post("/{marathon_id}/finish", response_model=MarathonResponse)
async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.organizer_id != current_user.id:
raise HTTPException(status_code=403, detail="Only organizer can finish marathon")
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
marathon.status = MarathonStatus.FINISHED.value
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.FINISH_MARATHON.value,
data={"title": marathon.title},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code)
)
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Invalid invite code")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon has already finished")
# Check if already participant
existing = await get_participation(db, current_user.id, marathon.id)
if existing:
raise HTTPException(status_code=400, detail="Already joined this marathon")
participant = Participant(
user_id=current_user.id,
marathon_id=marathon.id,
)
db.add(participant)
# Log activity
activity = Activity(
marathon_id=marathon.id,
user_id=current_user.id,
type=ActivityType.JOIN.value,
data={"nickname": current_user.nickname},
)
db.add(activity)
await db.commit()
return await get_marathon(marathon.id, current_user, db)
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
await get_marathon_or_404(db, marathon_id)
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.joined_at)
)
participants = result.scalars().all()
return [
ParticipantWithUser(
id=p.id,
total_points=p.total_points,
current_streak=p.current_streak,
drop_count=p.drop_count,
joined_at=p.joined_at,
user=UserPublic.model_validate(p.user),
)
for p in participants
]
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(marathon_id: int, db: DbSession):
await get_marathon_or_404(db, marathon_id)
result = await db.execute(
select(Participant)
.options(selectinload(Participant.user))
.where(Participant.marathon_id == marathon_id)
.order_by(Participant.total_points.desc())
)
participants = result.scalars().all()
leaderboard = []
for rank, p in enumerate(participants, 1):
# Count completed and dropped assignments
completed = await db.scalar(
select(func.count()).select_from(Assignment).where(
Assignment.participant_id == p.id,
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
dropped = await db.scalar(
select(func.count()).select_from(Assignment).where(
Assignment.participant_id == p.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
)
leaderboard.append(LeaderboardEntry(
rank=rank,
user=UserPublic.model_validate(p.user),
total_points=p.total_points,
current_streak=p.current_streak,
completed_count=completed,
dropped_count=dropped,
))
return leaderboard

104
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,104 @@
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
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return UserPublic.model_validate(user)
@router.patch("/me", response_model=UserPublic)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
if data.nickname is not None:
current_user.nickname = data.nickname
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
@router.post("/me/avatar", response_model=UserPublic)
async def upload_avatar(
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
# Validate file
if 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}",
)
# 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)
with open(filepath, "wb") as f:
f.write(contents)
# Update user
current_user.avatar_path = str(filepath)
await db.commit()
await db.refresh(current_user)
return UserPublic.model_validate(current_user)
@router.post("/me/telegram", response_model=MessageResponse)
async def link_telegram(
data: TelegramLink,
current_user: CurrentUser,
db: DbSession,
):
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != current_user.id)
)
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This Telegram account is already linked to another user",
)
current_user.telegram_id = data.telegram_id
current_user.telegram_username = data.telegram_username
await db.commit()
return MessageResponse(message="Telegram account linked successfully")

404
backend/app/api/v1/wheel.py Normal file
View File

@@ -0,0 +1,404 @@
import random
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
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType
)
from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
)
from app.services.points import PointsService
router = APIRouter(tags=["wheel"])
points_service = PointsService()
async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Participant:
result = await db.execute(
select(Participant).where(
Participant.user_id == 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")
return participant
async def get_active_assignment(db, participant_id: int) -> Assignment | None:
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
)
return result.scalar_one_or_none()
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge"""
# Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active")
participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment
active = await get_active_assignment(db, participant.id)
if active:
raise HTTPException(status_code=400, detail="You already have an active assignment")
# Get all games with challenges
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
)
games = [g for g in result.scalars().all() if g.challenges]
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
# Random selection
game = random.choice(games)
challenge = random.choice(game.challenges)
# Create assignment
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
)
db.add(assignment)
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.SPIN.value,
data={
"game": game.title,
"challenge": challenge.title,
},
)
db.add(activity)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count)
return SpinResult(
assignment_id=assignment.id,
game=GameResponse(
id=game.id,
title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None,
download_url=game.download_url,
genre=game.genre,
added_by=None,
challenges_count=len(game.challenges),
created_at=game.created_at,
),
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
can_drop=True,
drop_penalty=drop_penalty,
)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Get current active assignment"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id)
if not assignment:
return None
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
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_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
@router.post("/assignments/{assignment_id}/complete", response_model=CompleteResult)
async def complete_assignment(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an assignment with proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
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)
with open(filepath, "wb") as f:
f.write(contents)
assignment.proof_path = str(filepath)
else:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Calculate points
participant = assignment.participant
challenge = assignment.challenge
total_points, streak_bonus = points_service.calculate_completion_points(
challenge.points, participant.current_streak
)
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.streak_at_completion = participant.current_streak + 1
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success
# Get marathon_id for activity
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
# Log activity
activity = Activity(
marathon_id=full_challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data={
"challenge": challenge.title,
"points": total_points,
"streak": participant.current_streak,
},
)
db.add(activity)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
)
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
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")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
# Calculate penalty
penalty = points_service.calculate_drop_penalty(participant.drop_count)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points = max(0, participant.total_points - penalty)
participant.current_streak = 0
participant.drop_count += 1
# Log activity
activity = Activity(
marathon_id=assignment.challenge.game.marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"challenge": assignment.challenge.title,
"penalty": penalty,
},
)
db.add(activity)
await db.commit()
return DropResult(
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
)
@router.get("/marathons/{marathon_id}/my-history", response_model=list[AssignmentResponse])
async def get_my_history(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
limit: int = 20,
offset: int = 0,
):
"""Get history of user's assignments in marathon"""
participant = await get_participant_or_403(db, current_user.id, marathon_id)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc())
.limit(limit)
.offset(offset)
)
assignments = result.scalars().all()
return [
AssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None
),
is_generated=a.challenge.is_generated,
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_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
)
for a in assignments
]

View File

@@ -0,0 +1,19 @@
from app.core.config import settings
from app.core.database import Base, get_db, engine
from app.core.security import (
verify_password,
get_password_hash,
create_access_token,
decode_access_token,
)
__all__ = [
"settings",
"Base",
"get_db",
"engine",
"verify_password",
"get_password_hash",
"create_access_token",
"decode_access_token",
]

View File

@@ -0,0 +1,44 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# App
APP_NAME: str = "Game Marathon"
DEBUG: bool = False
# Database
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"
# Security
SECRET_KEY: str = "your-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
# OpenAI
OPENAI_API_KEY: str = ""
# Telegram
TELEGRAM_BOT_TOKEN: str = ""
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
@property
def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS
class Config:
env_file = ".env"
extra = "ignore"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,29 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
)
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()

View File

@@ -0,0 +1,37 @@
from datetime import datetime, timedelta
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: int | Any, expires_delta: timedelta | None = None) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except jwt.JWTError:
return None

57
backend/app/main.py Normal file
View File

@@ -0,0 +1,57 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.core.config import settings
from app.core.database import engine, Base
from app.api.v1 import router as api_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create upload directories
upload_dir = Path(settings.UPLOAD_DIR)
(upload_dir / "avatars").mkdir(parents=True, exist_ok=True)
(upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title=settings.APP_NAME,
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for uploads
upload_path = Path(settings.UPLOAD_DIR)
if upload_path.exists():
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
# API routes
app.include_router(api_router)
@app.get("/health")
async def health_check():
return {"status": "ok"}

View File

@@ -0,0 +1,23 @@
from app.models.user import User
from app.models.marathon import Marathon, MarathonStatus
from app.models.participant import Participant
from app.models.game import Game
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType
__all__ = [
"User",
"Marathon",
"MarathonStatus",
"Participant",
"Game",
"Challenge",
"ChallengeType",
"Difficulty",
"ProofType",
"Assignment",
"AssignmentStatus",
"Activity",
"ActivityType",
]

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ActivityType(str, Enum):
JOIN = "join"
SPIN = "spin"
COMPLETE = "complete"
DROP = "drop"
START_MARATHON = "start_marathon"
FINISH_MARATHON = "finish_marathon"
class Activity(Base):
__tablename__ = "activities"
id: Mapped[int] = mapped_column(primary_key=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
type: Mapped[str] = mapped_column(String(30), nullable=False)
data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="activities")
user: Mapped["User"] = relationship("User")

View File

@@ -0,0 +1,32 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AssignmentStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
DROPPED = "dropped"
class Assignment(Base):
__tablename__ = "assignments"
id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
streak_at_completion: Mapped[int | None] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class ChallengeType(str, Enum):
COMPLETION = "completion"
NO_DEATH = "no_death"
SPEEDRUN = "speedrun"
COLLECTION = "collection"
ACHIEVEMENT = "achievement"
CHALLENGE_RUN = "challenge_run"
SCORE_ATTACK = "score_attack"
TIME_TRIAL = "time_trial"
class Difficulty(str, Enum):
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
class ProofType(str, Enum):
SCREENSHOT = "screenshot"
VIDEO = "video"
STEAM = "steam"
class Challenge(Base):
__tablename__ = "challenges"
id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
type: Mapped[str] = mapped_column(String(30), nullable=False)
difficulty: Mapped[str] = mapped_column(String(10), nullable=False)
points: Mapped[int] = mapped_column(Integer, nullable=False)
estimated_time: Mapped[int | None] = mapped_column(Integer, nullable=True) # in minutes
proof_type: Mapped[str] = mapped_column(String(20), nullable=False)
proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="challenge"
)

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Game(Base):
__tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
download_url: Mapped[str] = mapped_column(Text, nullable=False)
genre: Mapped[str | None] = mapped_column(String(50), nullable=True)
added_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
added_by_user: Mapped["User"] = relationship("User", back_populates="added_games")
challenges: Mapped[list["Challenge"]] = relationship(
"Challenge",
back_populates="game",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,48 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class MarathonStatus(str, Enum):
PREPARING = "preparing"
ACTIVE = "active"
FINISHED = "finished"
class Marathon(Base):
__tablename__ = "marathons"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
organizer_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=MarathonStatus.PREPARING.value)
invite_code: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True)
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organizer: Mapped["User"] = relationship(
"User",
back_populates="organized_marathons",
foreign_keys=[organizer_id]
)
participants: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="marathon",
cascade="all, delete-orphan"
)
games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="marathon",
cascade="all, delete-orphan"
)
activities: Mapped[list["Activity"]] = relationship(
"Activity",
back_populates="marathon",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (
UniqueConstraint("user_id", "marathon_id", name="unique_user_marathon"),
)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
marathon_id: Mapped[int] = mapped_column(ForeignKey("marathons.id", ondelete="CASCADE"), index=True)
total_points: Mapped[int] = mapped_column(Integer, default=0)
current_streak: Mapped[int] = mapped_column(Integer, default=0)
drop_count: Mapped[int] = mapped_column(Integer, default=0) # For progressive penalty
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", back_populates="participations")
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="participants")
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="participant",
cascade="all, delete-orphan"
)

View File

@@ -0,0 +1,33 @@
from datetime import datetime
from sqlalchemy import String, BigInteger, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
login: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
nickname: Mapped[str] = mapped_column(String(50), nullable=False)
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
organized_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
back_populates="organizer",
foreign_keys="Marathon.organizer_id"
)
participations: Mapped[list["Participant"]] = relationship(
"Participant",
back_populates="user"
)
added_games: Mapped[list["Game"]] = relationship(
"Game",
back_populates="added_by_user"
)

View File

@@ -0,0 +1,90 @@
from app.schemas.user import (
UserRegister,
UserLogin,
UserUpdate,
UserPublic,
UserWithTelegram,
TokenResponse,
TelegramLink,
)
from app.schemas.marathon import (
MarathonCreate,
MarathonUpdate,
MarathonResponse,
MarathonListItem,
ParticipantInfo,
ParticipantWithUser,
JoinMarathon,
LeaderboardEntry,
)
from app.schemas.game import (
GameCreate,
GameUpdate,
GameResponse,
GameShort,
)
from app.schemas.challenge import (
ChallengeCreate,
ChallengeUpdate,
ChallengeResponse,
ChallengeGenerated,
)
from app.schemas.assignment import (
CompleteAssignment,
AssignmentResponse,
SpinResult,
CompleteResult,
DropResult,
)
from app.schemas.activity import (
ActivityResponse,
FeedResponse,
)
from app.schemas.common import (
MessageResponse,
ErrorResponse,
PaginationParams,
)
__all__ = [
# User
"UserRegister",
"UserLogin",
"UserUpdate",
"UserPublic",
"UserWithTelegram",
"TokenResponse",
"TelegramLink",
# Marathon
"MarathonCreate",
"MarathonUpdate",
"MarathonResponse",
"MarathonListItem",
"ParticipantInfo",
"ParticipantWithUser",
"JoinMarathon",
"LeaderboardEntry",
# Game
"GameCreate",
"GameUpdate",
"GameResponse",
"GameShort",
# Challenge
"ChallengeCreate",
"ChallengeUpdate",
"ChallengeResponse",
"ChallengeGenerated",
# Assignment
"CompleteAssignment",
"AssignmentResponse",
"SpinResult",
"CompleteResult",
"DropResult",
# Activity
"ActivityResponse",
"FeedResponse",
# Common
"MessageResponse",
"ErrorResponse",
"PaginationParams",
]

View File

@@ -0,0 +1,21 @@
from datetime import datetime
from pydantic import BaseModel
from app.schemas.user import UserPublic
class ActivityResponse(BaseModel):
id: int
type: str
user: UserPublic
data: dict | None = None
created_at: datetime
class Config:
from_attributes = True
class FeedResponse(BaseModel):
items: list[ActivityResponse]
total: int
has_more: bool

View File

@@ -0,0 +1,50 @@
from datetime import datetime
from pydantic import BaseModel
from app.schemas.game import GameResponse
from app.schemas.challenge import ChallengeResponse
class AssignmentBase(BaseModel):
pass
class CompleteAssignment(BaseModel):
proof_url: str | None = None
comment: str | None = None
class AssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse
status: str
proof_url: str | None = None
proof_comment: str | None = None
points_earned: int
streak_at_completion: int | None = None
started_at: datetime
completed_at: datetime | None = None
class Config:
from_attributes = True
class SpinResult(BaseModel):
assignment_id: int
game: GameResponse
challenge: ChallengeResponse
can_drop: bool
drop_penalty: int
class CompleteResult(BaseModel):
points_earned: int
streak_bonus: int
total_points: int
new_streak: int
class DropResult(BaseModel):
penalty: int
total_points: int
new_drop_count: int

View File

@@ -0,0 +1,53 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.models.challenge import ChallengeType, Difficulty, ProofType
from app.schemas.game import GameShort
class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1)
type: ChallengeType
difficulty: Difficulty
points: int = Field(..., ge=1, le=500)
estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType
proof_hint: str | None = None
class ChallengeCreate(ChallengeBase):
pass
class ChallengeUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
type: ChallengeType | None = None
difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=500)
estimated_time: int | None = None
proof_type: ProofType | None = None
proof_hint: str | None = None
class ChallengeResponse(ChallengeBase):
id: int
game: GameShort
is_generated: bool
created_at: datetime
class Config:
from_attributes = True
class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges"""
title: str
description: str
type: str
difficulty: str
points: int
estimated_time: int | None = None
proof_type: str
proof_hint: str | None = None

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class MessageResponse(BaseModel):
message: str
class ErrorResponse(BaseModel):
detail: str
class PaginationParams(BaseModel):
limit: int = 20
offset: int = 0

View File

@@ -0,0 +1,40 @@
from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl
from app.schemas.user import UserPublic
class GameBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
download_url: str = Field(..., min_length=1)
genre: str | None = Field(None, max_length=50)
class GameCreate(GameBase):
cover_url: str | None = None
class GameUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
download_url: str | None = None
genre: str | None = None
class GameShort(BaseModel):
id: int
title: str
cover_url: str | None = None
class Config:
from_attributes = True
class GameResponse(GameBase):
id: int
cover_url: str | None = None
added_by: UserPublic | None = None
challenges_count: int = 0
created_at: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,76 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
class MarathonBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str | None = None
class MarathonCreate(MarathonBase):
start_date: datetime
duration_days: int = Field(default=30, ge=1, le=365)
class MarathonUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100)
description: str | None = None
start_date: datetime | None = None
class ParticipantInfo(BaseModel):
id: int
total_points: int
current_streak: int
drop_count: int
joined_at: datetime
class Config:
from_attributes = True
class ParticipantWithUser(ParticipantInfo):
user: UserPublic
class MarathonResponse(MarathonBase):
id: int
organizer: UserPublic
status: str
invite_code: str
start_date: datetime | None
end_date: datetime | None
participants_count: int
games_count: int
created_at: datetime
my_participation: ParticipantInfo | None = None
class Config:
from_attributes = True
class MarathonListItem(BaseModel):
id: int
title: str
status: str
participants_count: int
start_date: datetime | None
end_date: datetime | None
class Config:
from_attributes = True
class JoinMarathon(BaseModel):
invite_code: str
class LeaderboardEntry(BaseModel):
rank: int
user: UserPublic
total_points: int
current_streak: int
completed_count: int
dropped_count: int

View File

@@ -0,0 +1,54 @@
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
import re
class UserBase(BaseModel):
nickname: str = Field(..., min_length=2, max_length=50)
class UserRegister(UserBase):
login: str = Field(..., min_length=3, max_length=50)
password: str = Field(..., min_length=6, max_length=100)
@field_validator("login")
@classmethod
def validate_login(cls, v: str) -> str:
if not re.match(r"^[a-zA-Z0-9_]+$", v):
raise ValueError("Login can only contain letters, numbers, and underscores")
return v.lower()
class UserLogin(BaseModel):
login: str
password: str
class UserUpdate(BaseModel):
nickname: str | None = Field(None, min_length=2, max_length=50)
class UserPublic(UserBase):
id: int
login: str
avatar_url: str | None = None
created_at: datetime
class Config:
from_attributes = True
class UserWithTelegram(UserPublic):
telegram_id: int | None = None
telegram_username: str | None = None
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: UserPublic
class TelegramLink(BaseModel):
telegram_id: int
telegram_username: str | None = None

View File

@@ -0,0 +1,4 @@
from app.services.points import PointsService
from app.services.gpt import GPTService
__all__ = ["PointsService", "GPTService"]

View File

@@ -0,0 +1,96 @@
import json
from openai import AsyncOpenAI
from app.core.config import settings
from app.schemas import ChallengeGenerated
class GPTService:
"""Service for generating challenges using OpenAI GPT"""
def __init__(self):
self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY)
async def generate_challenges(
self,
game_title: str,
game_genre: str | None = None
) -> list[ChallengeGenerated]:
"""
Generate challenges for a game using GPT.
Args:
game_title: Name of the game
game_genre: Optional genre of the game
Returns:
List of generated challenges
"""
genre_text = f" (жанр: {game_genre})" if game_genre else ""
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона.
Требования:
- 2 лёгких челленджа (15-30 минут игры)
- 2 средних челленджа (1-2 часа игры)
- 2 сложных челленджа (3+ часов или высокая сложность)
Для каждого челленджа укажи:
- title: короткое название на русском (до 50 символов)
- description: что нужно сделать на русском (1-2 предложения)
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
- difficulty: easy/medium/hard
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
- estimated_time: примерное время в минутах
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей.
Пример формата:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
response = await self.client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.7,
max_tokens=2000,
)
content = response.choices[0].message.content
data = json.loads(content)
challenges = []
for ch in data.get("challenges", []):
# Validate and normalize type
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points
points = ch.get("points", 50)
if not isinstance(points, int) or points < 1:
points = 50
challenges.append(ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
difficulty=difficulty,
points=points,
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
return challenges

View File

@@ -0,0 +1,55 @@
class PointsService:
"""Service for calculating points and penalties"""
STREAK_MULTIPLIERS = {
0: 0.0,
1: 0.0,
2: 0.1,
3: 0.2,
4: 0.3,
}
MAX_STREAK_MULTIPLIER = 0.4
DROP_PENALTIES = {
0: 0, # First drop is free
1: 10,
2: 25,
}
MAX_DROP_PENALTY = 50
def calculate_completion_points(
self,
base_points: int,
current_streak: int
) -> tuple[int, int]:
"""
Calculate points earned for completing a challenge.
Args:
base_points: Base points for the challenge
current_streak: Current streak before this completion
Returns:
Tuple of (total_points, streak_bonus)
"""
multiplier = self.STREAK_MULTIPLIERS.get(
current_streak,
self.MAX_STREAK_MULTIPLIER
)
bonus = int(base_points * multiplier)
return base_points + bonus, bonus
def calculate_drop_penalty(self, consecutive_drops: int) -> int:
"""
Calculate penalty for dropping a challenge.
Args:
consecutive_drops: Number of drops since last completion
Returns:
Penalty points to subtract
"""
return self.DROP_PENALTIES.get(
consecutive_drops,
self.MAX_DROP_PENALTY
)

32
backend/requirements.txt Normal file
View File

@@ -0,0 +1,32 @@
# FastAPI
fastapi==0.109.0
uvicorn[standard]==0.27.0
python-multipart==0.0.6
# Database
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
alembic==1.13.1
# Auth
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.0.1
# Validation
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0
# OpenAI
openai==1.12.0
# Telegram notifications
httpx==0.26.0
# File handling
aiofiles==23.2.1
python-magic==0.4.27
# Utils
python-dotenv==1.0.0

68
docker-compose.yml Normal file
View File

@@ -0,0 +1,68 @@
services:
db:
image: postgres:15-alpine
container_name: marathon-db
environment:
POSTGRES_USER: marathon
POSTGRES_PASSWORD: ${DB_PASSWORD:-marathon}
POSTGRES_DB: marathon
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: marathon-backend
environment:
DATABASE_URL: postgresql+asyncpg://marathon:${DB_PASSWORD:-marathon}@db:5432/marathon
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
DEBUG: ${DEBUG:-false}
volumes:
- ./backend/uploads:/app/uploads
- ./backend/app:/app/app
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend
ports:
- "3000:80"
depends_on:
- backend
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: marathon-nginx
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./backend/uploads:/app/uploads:ro
depends_on:
- frontend
- backend
restart: unless-stopped
volumes:
postgres_data:

30
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM node:20-alpine as build
WORKDIR /app
# Install dependencies
COPY package.json ./
RUN npm install
# Copy source
COPY . .
# Build argument for API URL
ARG VITE_API_URL=/api/v1
ENV VITE_API_URL=$VITE_API_URL
# Build
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Игровой Марафон</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "game-marathon-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"axios": "^1.6.2",
"zustand": "^4.4.7",
"react-hook-form": "^7.49.2",
"zod": "^3.22.4",
"@hookform/resolvers": "^3.3.2",
"framer-motion": "^10.16.16",
"date-fns": "^3.0.6",
"lucide-react": "^0.303.0",
"clsx": "^2.0.0",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

122
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
// Layout
import { Layout } from '@/components/layout/Layout'
// Pages
import { HomePage } from '@/pages/HomePage'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { MarathonsPage } from '@/pages/MarathonsPage'
import { CreateMarathonPage } from '@/pages/CreateMarathonPage'
import { MarathonPage } from '@/pages/MarathonPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
// Public route wrapper (redirect if authenticated)
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (isAuthenticated) {
return <Navigate to="/marathons" replace />
}
return <>{children}</>
}
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route
path="login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route
path="marathons"
element={
<ProtectedRoute>
<MarathonsPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/create"
element={
<ProtectedRoute>
<CreateMarathonPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id"
element={
<ProtectedRoute>
<MarathonPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/lobby"
element={
<ProtectedRoute>
<LobbyPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/play"
element={
<ProtectedRoute>
<PlayPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/leaderboard"
element={
<ProtectedRoute>
<LeaderboardPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
)
}
export default App

30
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
import client from './client'
import type { TokenResponse, User } from '@/types'
export interface RegisterData {
login: string
password: string
nickname: string
}
export interface LoginData {
login: string
password: string
}
export const authApi = {
register: async (data: RegisterData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/register', data)
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/login', data)
return response.data
},
me: async (): Promise<User> => {
const response = await client.get<User>('/auth/me')
return response.data
},
}

View File

@@ -0,0 +1,34 @@
import axios, { AxiosError } from 'axios'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
const client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor to handle errors
client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ detail: string }>) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default client

11
frontend/src/api/feed.ts Normal file
View File

@@ -0,0 +1,11 @@
import client from './client'
import type { FeedResponse } from '@/types'
export const feedApi = {
get: async (marathonId: number, limit = 20, offset = 0): Promise<FeedResponse> => {
const response = await client.get<FeedResponse>(`/marathons/${marathonId}/feed`, {
params: { limit, offset },
})
return response.data
},
}

70
frontend/src/api/games.ts Normal file
View File

@@ -0,0 +1,70 @@
import client from './client'
import type { Game, Challenge } from '@/types'
export interface CreateGameData {
title: string
download_url: string
genre?: string
cover_url?: string
}
export interface CreateChallengeData {
title: string
description: string
type: string
difficulty: string
points: number
estimated_time?: number
proof_type: string
proof_hint?: string
}
export const gamesApi = {
list: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`)
return response.data
},
get: async (id: number): Promise<Game> => {
const response = await client.get<Game>(`/games/${id}`)
return response.data
},
create: async (marathonId: number, data: CreateGameData): Promise<Game> => {
const response = await client.post<Game>(`/marathons/${marathonId}/games`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await client.delete(`/games/${id}`)
},
uploadCover: async (id: number, file: File): Promise<Game> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<Game>(`/games/${id}/cover`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
// Challenges
getChallenges: async (gameId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/games/${gameId}/challenges`)
return response.data
},
createChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
const response = await client.post<Challenge>(`/games/${gameId}/challenges`, data)
return response.data
},
deleteChallenge: async (id: number): Promise<void> => {
await client.delete(`/challenges/${id}`)
},
generateChallenges: async (marathonId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`)
return response.data
},
}

View File

@@ -0,0 +1,5 @@
export { authApi } from './auth'
export { marathonsApi } from './marathons'
export { gamesApi } from './games'
export { wheelApi } from './wheel'
export { feedApi } from './feed'

View File

@@ -0,0 +1,64 @@
import client from './client'
import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantInfo, User } from '@/types'
export interface CreateMarathonData {
title: string
description?: string
start_date: string
duration_days?: number
}
export interface ParticipantWithUser extends ParticipantInfo {
user: User
}
export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons')
return response.data
},
get: async (id: number): Promise<Marathon> => {
const response = await client.get<Marathon>(`/marathons/${id}`)
return response.data
},
create: async (data: CreateMarathonData): Promise<Marathon> => {
const response = await client.post<Marathon>('/marathons', data)
return response.data
},
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => {
const response = await client.patch<Marathon>(`/marathons/${id}`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await client.delete(`/marathons/${id}`)
},
start: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/start`)
return response.data
},
finish: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/finish`)
return response.data
},
join: async (inviteCode: string): Promise<Marathon> => {
const response = await client.post<Marathon>('/marathons/join', { invite_code: inviteCode })
return response.data
},
getParticipants: async (id: number): Promise<ParticipantWithUser[]> => {
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
return response.data
},
getLeaderboard: async (id: number): Promise<LeaderboardEntry[]> => {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data
},
}

41
frontend/src/api/wheel.ts Normal file
View File

@@ -0,0 +1,41 @@
import client from './client'
import type { SpinResult, Assignment, CompleteResult, DropResult } from '@/types'
export const wheelApi = {
spin: async (marathonId: number): Promise<SpinResult> => {
const response = await client.post<SpinResult>(`/marathons/${marathonId}/spin`)
return response.data
},
getCurrentAssignment: async (marathonId: number): Promise<Assignment | null> => {
const response = await client.get<Assignment | null>(`/marathons/${marathonId}/current-assignment`)
return response.data
},
complete: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment)
if (data.proof_file) formData.append('proof_file', data.proof_file)
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
drop: async (assignmentId: number): Promise<DropResult> => {
const response = await client.post<DropResult>(`/assignments/${assignmentId}/drop`)
return response.data
},
getHistory: async (marathonId: number, limit = 20, offset = 0): Promise<Assignment[]> => {
const response = await client.get<Assignment[]>(`/marathons/${marathonId}/my-history`, {
params: { limit, offset },
})
return response.data
},
}

View File

@@ -0,0 +1,77 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
<Gamepad2 className="w-8 h-8 text-primary-500" />
<span>Игровой Марафон</span>
</Link>
<nav className="flex items-center gap-4">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
<div className="flex items-center gap-2 text-gray-300">
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</div>
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors"
title="Выйти"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</>
) : (
<>
<Link to="/login" className="text-gray-300 hover:text-white transition-colors">
Войти
</Link>
<Link to="/register" className="btn btn-primary">
Регистрация
</Link>
</>
)}
</nav>
</div>
</header>
{/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8">
<Outlet />
</main>
{/* Footer */}
<footer className="bg-gray-800 border-t border-gray-700 py-4">
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
Игровой Марафон &copy; {new Date().getFullYear()}
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
)}
{...props}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{children}
</button>
)
}
)
Button.displayName = 'Button'

View File

@@ -0,0 +1,54 @@
import { type ReactNode } from 'react'
import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
}
export function Card({ children, className }: CardProps) {
return (
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}>
{children}
</div>
)
}
interface CardHeaderProps {
children: ReactNode
className?: string
}
export function CardHeader({ children, className }: CardHeaderProps) {
return (
<div className={clsx('mb-4', className)}>
{children}
</div>
)
}
interface CardTitleProps {
children: ReactNode
className?: string
}
export function CardTitle({ children, className }: CardTitleProps) {
return (
<h3 className={clsx('text-xl font-bold text-white', className)}>
{children}
</h3>
)
}
interface CardContentProps {
children: ReactNode
className?: string
}
export function CardContent({ children, className }: CardContentProps) {
return (
<div className={clsx('text-gray-300', className)}>
{children}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={clsx(
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'transition-colors',
error ? 'border-red-500' : 'border-gray-700',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
)
}
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,3 @@
export { Button } from './Button'
export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card'

37
frontend/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-900 text-gray-100 min-h-screen;
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white;
}
.input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-gray-800 rounded-xl p-6 shadow-lg;
}
.link {
@apply text-primary-400 hover:text-primary-300 transition-colors;
}
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { useNavigate } 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 { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
description: z.string().optional(),
start_date: z.string().min(1, 'Дата начала обязательна'),
duration_days: z.number().min(1).max(365).default(30),
})
type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateForm>({
resolver: zodResolver(createSchema),
defaultValues: {
duration_days: 30,
},
})
const onSubmit = async (data: CreateForm) => {
setIsLoading(true)
setError(null)
try {
const marathon = await marathonsApi.create({
...data,
start_date: new Date(data.start_date).toISOString(),
})
navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } }
setError(apiError.response?.data?.detail || 'Не удалось создать марафон')
} finally {
setIsLoading(false)
}
}
return (
<div className="max-w-lg mx-auto">
<Card>
<CardHeader>
<CardTitle>Создать марафон</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{error}
</div>
)}
<Input
label="Название"
placeholder="Введите название марафона"
error={errors.title?.message}
{...register('title')}
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none"
placeholder="Введите описание"
{...register('description')}
/>
</div>
<Input
label="Дата начала"
type="datetime-local"
error={errors.start_date?.message}
{...register('start_date')}
/>
<Input
label="Длительность (дней)"
type="number"
error={errors.duration_days?.message}
{...register('duration_days', { valueAsNumber: true })}
/>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/marathons')}
>
Отмена
</Button>
<Button type="submit" className="flex-1" isLoading={isLoading}>
Создать
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,113 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react'
export function HomePage() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return (
<div className="max-w-4xl mx-auto text-center">
{/* Hero */}
<div className="py-12">
<div className="flex justify-center mb-6">
<Gamepad2 className="w-20 h-20 text-primary-500" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Игровой Марафон
</h1>
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом!
</p>
<div className="flex gap-4 justify-center">
{isAuthenticated ? (
<Link to="/marathons">
<Button size="lg">К марафонам</Button>
</Link>
) : (
<>
<Link to="/register">
<Button size="lg">Начать</Button>
</Link>
<Link to="/login">
<Button size="lg" variant="secondary">Войти</Button>
</Link>
</>
)}
</div>
</div>
{/* Features */}
<div className="grid md:grid-cols-3 gap-8 py-12">
<div className="card text-center">
<div className="flex justify-center mb-4">
<Sparkles className="w-12 h-12 text-yellow-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3>
<p className="text-gray-400">
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
</p>
</div>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Users className="w-12 h-12 text-green-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3>
<p className="text-gray-400">
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры.
</p>
</div>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Trophy className="w-12 h-12 text-primary-500" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3>
<p className="text-gray-400">
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей!
</p>
</div>
</div>
{/* How it works */}
<div className="py-12">
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2>
<div className="grid md:grid-cols-4 gap-6 text-left">
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Добавьте игры</h4>
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4>
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p>
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Победите!</h4>
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react'
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
const user = useAuthStore((state) => state.user)
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadLeaderboard()
}, [id])
const loadLeaderboard = async () => {
if (!id) return
try {
const data = await marathonsApi.getLeaderboard(parseInt(id))
setLeaderboard(data)
} catch (error) {
console.error('Failed to load leaderboard:', error)
} finally {
setIsLoading(false)
}
}
const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return <Trophy className="w-6 h-6 text-yellow-500" />
case 2:
return <Trophy className="w-6 h-6 text-gray-400" />
case 3:
return <Trophy className="w-6 h-6 text-amber-700" />
default:
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span>
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-4 mb-8">
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Рейтинг
</CardTitle>
</CardHeader>
<CardContent>
{leaderboard.length === 0 ? (
<p className="text-center text-gray-400 py-8">Пока нет участников</p>
) : (
<div className="space-y-2">
{leaderboard.map((entry) => (
<div
key={entry.user.id}
className={`flex items-center gap-4 p-4 rounded-lg ${
entry.user.id === user?.id
? 'bg-primary-500/20 border border-primary-500/50'
: 'bg-gray-900'
}`}
>
<div className="flex items-center justify-center w-8">
{getRankIcon(entry.rank)}
</div>
<div className="flex-1">
<div className="font-medium text-white">
{entry.user.nickname}
{entry.user.id === user?.id && (
<span className="ml-2 text-xs text-primary-400">(Вы)</span>
)}
</div>
<div className="text-sm text-gray-400">
{entry.completed_count} выполнено, {entry.dropped_count} пропущено
</div>
</div>
{entry.current_streak > 0 && (
<div className="flex items-center gap-1 text-yellow-500">
<Flame className="w-4 h-4" />
<span className="text-sm">{entry.current_streak}</span>
</div>
)}
<div className="text-right">
<div className="text-xl font-bold text-primary-400">
{entry.total_points}
</div>
<div className="text-xs text-gray-500">очков</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,265 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game } from '@/types'
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Plus, Trash2, Sparkles, Play, Loader2, Gamepad2 } from 'lucide-react'
export function LobbyPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [games, setGames] = useState<Game[]>([])
const [isLoading, setIsLoading] = useState(true)
// Add game form
const [showAddGame, setShowAddGame] = useState(false)
const [gameTitle, setGameTitle] = useState('')
const [gameUrl, setGameUrl] = useState('')
const [gameGenre, setGameGenre] = useState('')
const [isAddingGame, setIsAddingGame] = useState(false)
// Generate challenges
const [isGenerating, setIsGenerating] = useState(false)
const [generateMessage, setGenerateMessage] = useState<string | null>(null)
// Start marathon
const [isStarting, setIsStarting] = useState(false)
useEffect(() => {
loadData()
}, [id])
const loadData = async () => {
if (!id) return
try {
const [marathonData, gamesData] = await Promise.all([
marathonsApi.get(parseInt(id)),
gamesApi.list(parseInt(id)),
])
setMarathon(marathonData)
setGames(gamesData)
} catch (error) {
console.error('Failed to load data:', error)
navigate('/marathons')
} finally {
setIsLoading(false)
}
}
const handleAddGame = async () => {
if (!id || !gameTitle.trim() || !gameUrl.trim()) return
setIsAddingGame(true)
try {
await gamesApi.create(parseInt(id), {
title: gameTitle.trim(),
download_url: gameUrl.trim(),
genre: gameGenre.trim() || undefined,
})
setGameTitle('')
setGameUrl('')
setGameGenre('')
setShowAddGame(false)
await loadData()
} catch (error) {
console.error('Failed to add game:', error)
} finally {
setIsAddingGame(false)
}
}
const handleDeleteGame = async (gameId: number) => {
if (!confirm('Удалить эту игру?')) return
try {
await gamesApi.delete(gameId)
await loadData()
} catch (error) {
console.error('Failed to delete game:', error)
}
}
const handleGenerateChallenges = async () => {
if (!id) return
setIsGenerating(true)
setGenerateMessage(null)
try {
const result = await gamesApi.generateChallenges(parseInt(id))
setGenerateMessage(result.message)
await loadData()
} catch (error) {
console.error('Failed to generate challenges:', error)
setGenerateMessage('Не удалось сгенерировать задания')
} finally {
setIsGenerating(false)
}
}
const handleStartMarathon = async () => {
if (!id || !confirm('Начать марафон? После этого нельзя будет добавить новые игры.')) return
setIsStarting(true)
try {
await marathonsApi.start(parseInt(id))
navigate(`/marathons/${id}/play`)
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось запустить марафон')
} finally {
setIsStarting(false)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
const isOrganizer = user?.id === marathon.organizer.id
const totalChallenges = games.reduce((sum, g) => sum + g.challenges_count, 0)
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
<p className="text-gray-400">Настройка - Добавьте игры и сгенерируйте задания</p>
</div>
{isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={games.length === 0}>
<Play className="w-4 h-4 mr-2" />
Запустить марафон
</Button>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-2 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{games.length}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Gamepad2 className="w-4 h-4" />
Игр
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
</div>
</CardContent>
</Card>
</div>
{/* Generate challenges button */}
{games.length > 0 && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для всех игр без заданий
</p>
</div>
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
<Sparkles className="w-4 h-4 mr-2" />
Сгенерировать
</Button>
</div>
{generateMessage && (
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
)}
</CardContent>
</Card>
)}
{/* Games list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Игры</CardTitle>
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
<Plus className="w-4 h-4 mr-1" />
Добавить игру
</Button>
</CardHeader>
<CardContent>
{/* Add game form */}
{showAddGame && (
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3">
<Input
placeholder="Название игры"
value={gameTitle}
onChange={(e) => setGameTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
/>
<Input
placeholder="Жанр (необязательно)"
value={gameGenre}
onChange={(e) => setGameGenre(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
Добавить
</Button>
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
Отмена
</Button>
</div>
</div>
)}
{/* Games */}
{games.length === 0 ? (
<p className="text-center text-gray-400 py-8">
Пока нет игр. Добавьте игры, чтобы начать!
</p>
) : (
<div className="space-y-3">
{games.map((game) => (
<div
key={game.id}
className="flex items-center justify-between p-4 bg-gray-900 rounded-lg"
>
<div>
<h4 className="font-medium text-white">{game.title}</h4>
<div className="text-sm text-gray-400">
{game.genre && <span className="mr-3">{game.genre}</span>}
<span>{game.challenges_count} заданий</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteGame(game.id)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
})
type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const navigate = useNavigate()
const { login, isLoading, error, clearError } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
})
const onSubmit = async (data: LoginForm) => {
setSubmitError(null)
clearError()
try {
await login(data)
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка входа')
}
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Вход</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
</div>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
{...register('login')}
/>
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Войти
</Button>
<p className="text-center text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link to="/register" className="link">
Зарегистрироваться
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { Marathon } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
loadMarathon()
}, [id])
const loadMarathon = async () => {
if (!id) return
try {
const data = await marathonsApi.get(parseInt(id))
setMarathon(data)
} catch (error) {
console.error('Failed to load marathon:', error)
navigate('/marathons')
} finally {
setIsLoading(false)
}
}
const copyInviteCode = () => {
if (marathon) {
navigator.clipboard.writeText(marathon.invite_code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
const isOrganizer = user?.id === marathon.organizer.id
const isParticipant = !!marathon.my_participation
return (
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">{marathon.title}</h1>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
)}
</div>
<div className="flex gap-2">
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Settings className="w-4 h-4 mr-2" />
Настройка
</Button>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<Button>
<Play className="w-4 h-4 mr-2" />
Играть
</Button>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary">
<Trophy className="w-4 h-4 mr-2" />
Рейтинг
</Button>
</Link>
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Users className="w-4 h-4" />
Участников
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
<div className="text-sm text-gray-400">Игр</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Дата начала
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
</div>
{/* Invite code */}
{marathon.status !== 'finished' && (
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Код приглашения</h3>
<div className="flex items-center gap-3">
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono">
{marathon.invite_code}
</code>
<Button variant="secondary" onClick={copyInviteCode}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
</div>
<p className="text-sm text-gray-500 mt-2">
Поделитесь этим кодом с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
)}
{/* My stats */}
{marathon.my_participation && (
<Card>
<CardContent>
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary-500">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-500">
{marathon.my_participation.current_streak}
</div>
<div className="text-sm text-gray-400">Серия</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">
{marathon.my_participation.drop_count}
</div>
<div className="text-sm text-gray-400">Пропусков</div>
</div>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { Plus, Users, Calendar, Loader2 } from 'lucide-react'
import { format } from 'date-fns'
export function MarathonsPage() {
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [joinCode, setJoinCode] = useState('')
const [joinError, setJoinError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
useEffect(() => {
loadMarathons()
}, [])
const loadMarathons = async () => {
try {
const data = await marathonsApi.list()
setMarathons(data)
} catch (error) {
console.error('Failed to load marathons:', error)
} finally {
setIsLoading(false)
}
}
const handleJoin = async () => {
if (!joinCode.trim()) return
setJoinError(null)
setIsJoining(true)
try {
await marathonsApi.join(joinCode.trim())
setJoinCode('')
await loadMarathons()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setJoinError(error.response?.data?.detail || 'Не удалось присоединиться')
} finally {
setIsJoining(false)
}
}
const getStatusColor = (status: string) => {
switch (status) {
case 'preparing':
return 'bg-yellow-500/20 text-yellow-500'
case 'active':
return 'bg-green-500/20 text-green-500'
case 'finished':
return 'bg-gray-500/20 text-gray-400'
default:
return 'bg-gray-500/20 text-gray-400'
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'preparing':
return 'Подготовка'
case 'active':
return 'Активен'
case 'finished':
return 'Завершён'
default:
return status
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1>
<Link to="/marathons/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Создать марафон
</Button>
</Link>
</div>
{/* Join marathon */}
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3>
<div className="flex gap-3">
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Введите код приглашения"
className="input flex-1"
/>
<Button onClick={handleJoin} isLoading={isJoining}>
Присоединиться
</Button>
</div>
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>}
</CardContent>
</Card>
{/* Marathon list */}
{marathons.length === 0 ? (
<Card>
<CardContent className="text-center py-8">
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p>
<Link to="/marathons/create">
<Button>Создать первый марафон</Button>
</Link>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{marathons.map((marathon) => (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer">
<CardContent className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-white mb-1">
{marathon.title}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{marathon.participants_count} участников
</span>
{marathon.start_date && (
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'MMM d, yyyy')}
</span>
)}
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}>
{getStatusText(marathon.status)}
</span>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,315 @@
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { marathonsApi, wheelApi } from '@/api'
import type { Marathon, Assignment, SpinResult } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { Loader2, Upload, X } from 'lucide-react'
export function PlayPage() {
const { id } = useParams<{ id: string }>()
const [marathon, setMarathon] = useState<Marathon | null>(null)
const [currentAssignment, setCurrentAssignment] = useState<Assignment | null>(null)
const [spinResult, setSpinResult] = useState<SpinResult | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Spin state
const [isSpinning, setIsSpinning] = useState(false)
// Complete state
const [proofFile, setProofFile] = useState<File | null>(null)
const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
// Drop state
const [isDropping, setIsDropping] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadData()
}, [id])
const loadData = async () => {
if (!id) return
try {
const [marathonData, assignment] = await Promise.all([
marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setIsLoading(false)
}
}
const handleSpin = async () => {
if (!id) return
setIsSpinning(true)
setSpinResult(null)
try {
const result = await wheelApi.spin(parseInt(id))
setSpinResult(result)
// Reload to get assignment
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось крутить')
} finally {
setIsSpinning(false)
}
}
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
alert('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
setIsCompleting(true)
try {
const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined,
proof_url: proofUrl || undefined,
comment: comment || undefined,
})
alert(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
// Reset form
setProofFile(null)
setProofUrl('')
setComment('')
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось выполнить')
} finally {
setIsCompleting(false)
}
}
const handleDrop = async () => {
if (!currentAssignment) return
const penalty = spinResult?.drop_penalty || 0
if (!confirm(`Пропустить это задание? Вы потеряете ${penalty} очков.`)) return
setIsDropping(true)
try {
const result = await wheelApi.drop(currentAssignment.id)
alert(`Пропущено. Штраф: -${result.penalty} очков`)
setSpinResult(null)
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось пропустить')
} finally {
setIsDropping(false)
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (!marathon) {
return <div>Марафон не найден</div>
}
const participation = marathon.my_participation
return (
<div className="max-w-2xl mx-auto">
{/* Header stats */}
<div className="grid grid-cols-3 gap-4 mb-8">
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-primary-500">
{participation?.total_points || 0}
</div>
<div className="text-xs text-gray-400">Очков</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-yellow-500">
{participation?.current_streak || 0}
</div>
<div className="text-xs text-gray-400">Серия</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-3">
<div className="text-xl font-bold text-gray-400">
{participation?.drop_count || 0}
</div>
<div className="text-xs text-gray-400">Пропусков</div>
</CardContent>
</Card>
</div>
{/* No active assignment - show spin */}
{!currentAssignment && (
<Card className="text-center">
<CardContent className="py-12">
<h2 className="text-2xl font-bold text-white mb-4">Крутите колесо!</h2>
<p className="text-gray-400 mb-8">
Получите случайную игру и задание для выполнения
</p>
<Button size="lg" onClick={handleSpin} isLoading={isSpinning}>
{isSpinning ? 'Крутим...' : 'КРУТИТЬ'}
</Button>
</CardContent>
</Card>
)}
{/* Active assignment */}
{currentAssignment && (
<Card>
<CardContent>
<div className="text-center mb-6">
<span className="px-3 py-1 bg-primary-500/20 text-primary-400 rounded-full text-sm">
Активное задание
</span>
</div>
{/* Game */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Игра</h3>
<p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title}
</p>
</div>
{/* Challenge */}
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-400 mb-1">Задание</h3>
<p className="text-xl font-bold text-white mb-2">
{currentAssignment.challenge.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge.description}
</p>
</div>
{/* Points */}
<div className="flex items-center gap-4 mb-6 text-sm">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full">
+{currentAssignment.challenge.points} очков
</span>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full">
{currentAssignment.challenge.difficulty}
</span>
{currentAssignment.challenge.estimated_time && (
<span className="text-gray-400">
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{/* Proof hint */}
{currentAssignment.challenge.proof_hint && (
<div className="mb-6 p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400">
<strong>Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p>
</div>
)}
{/* Proof upload */}
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({currentAssignment.challenge.proof_type})
</label>
{/* File upload */}
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
className="hidden"
onChange={(e) => setProofFile(e.target.files?.[0] || null)}
/>
{proofFile ? (
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
<span className="text-white flex-1 truncate">{proofFile.name}</span>
<Button
variant="ghost"
size="sm"
onClick={() => setProofFile(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
) : (
<Button
variant="secondary"
className="w-full"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Выбрать файл
</Button>
)}
</div>
<div className="text-center text-gray-500">или</div>
{/* URL input */}
<input
type="text"
className="input"
placeholder="Вставьте ссылку (YouTube, профиль Steam и т.д.)"
value={proofUrl}
onChange={(e) => setProofUrl(e.target.value)}
/>
{/* Comment */}
<textarea
className="input min-h-[80px] resize-none"
placeholder="Комментарий (необязательно)"
value={comment}
onChange={(e) => setComment(e.target.value)}
/>
</div>
{/* Actions */}
<div className="flex gap-3">
<Button
className="flex-1"
onClick={handleComplete}
isLoading={isCompleting}
disabled={!proofFile && !proofUrl}
>
Выполнено
</Button>
<Button
variant="danger"
onClick={handleDrop}
isLoading={isDropping}
>
Пропустить (-{spinResult?.drop_penalty || 0})
</Button>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
const registerSchema = z.object({
login: z
.string()
.min(3, 'Логин должен быть не менее 3 символов')
.max(50, 'Логин должен быть не более 50 символов')
.regex(/^[a-zA-Z0-9_]+$/, 'Логин может содержать только буквы, цифры и подчёркивания'),
nickname: z
.string()
.min(2, 'Никнейм должен быть не менее 2 символов')
.max(50, 'Никнейм должен быть не более 50 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Пароли не совпадают',
path: ['confirmPassword'],
})
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterPage() {
const navigate = useNavigate()
const { register: registerUser, isLoading, error, clearError } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
})
const onSubmit = async (data: RegisterForm) => {
setSubmitError(null)
clearError()
try {
await registerUser({
login: data.login,
password: data.password,
nickname: data.nickname,
})
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка регистрации')
}
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Регистрация</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
</div>
)}
<Input
label="Логин"
placeholder="Придумайте логин"
error={errors.login?.message}
{...register('login')}
/>
<Input
label="Никнейм"
placeholder="Придумайте никнейм"
error={errors.nickname?.message}
{...register('nickname')}
/>
<Input
label="Пароль"
type="password"
placeholder="Придумайте пароль"
error={errors.password?.message}
{...register('password')}
/>
<Input
label="Подтвердите пароль"
type="password"
placeholder="Повторите пароль"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Зарегистрироваться
</Button>
<p className="text-center text-gray-400 text-sm">
Уже есть аккаунт?{' '}
<Link to="/login" className="link">
Войти
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export { HomePage } from './HomePage'
export { LoginPage } from './LoginPage'
export { RegisterPage } from './RegisterPage'
export { MarathonsPage } from './MarathonsPage'
export { CreateMarathonPage } from './CreateMarathonPage'
export { MarathonPage } from './MarathonPage'
export { LobbyPage } from './LobbyPage'
export { PlayPage } from './PlayPage'
export { LeaderboardPage } from './LeaderboardPage'

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
login: (data: LoginData) => Promise<void>
register: (data: RegisterData) => Promise<void>
logout: () => void
clearError: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (data) => {
set({ isLoading: true, error: null })
try {
const response = await authApi.login(data)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Login failed',
isLoading: false,
})
throw err
}
},
register: async (data) => {
set({ isLoading: true, error: null })
try {
const response = await authApi.register(data)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Registration failed',
isLoading: false,
})
throw err
}
},
logout: () => {
localStorage.removeItem('token')
set({
user: null,
token: null,
isAuthenticated: false,
})
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
)

158
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,158 @@
// User types
export interface User {
id: number
login: string
nickname: string
avatar_url: string | null
created_at: string
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
// Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished'
export interface ParticipantInfo {
id: number
total_points: number
current_streak: number
drop_count: number
joined_at: string
}
export interface Marathon {
id: number
title: string
description: string | null
organizer: User
status: MarathonStatus
invite_code: string
start_date: string | null
end_date: string | null
participants_count: number
games_count: number
created_at: string
my_participation: ParticipantInfo | null
}
export interface MarathonListItem {
id: number
title: string
status: MarathonStatus
participants_count: number
start_date: string | null
end_date: string | null
}
export interface LeaderboardEntry {
rank: number
user: User
total_points: number
current_streak: number
completed_count: number
dropped_count: number
}
// Game types
export interface Game {
id: number
title: string
cover_url: string | null
download_url: string
genre: string | null
added_by: User | null
challenges_count: number
created_at: string
}
export interface GameShort {
id: number
title: string
cover_url: string | null
}
// Challenge types
export type ChallengeType =
| 'completion'
| 'no_death'
| 'speedrun'
| 'collection'
| 'achievement'
| 'challenge_run'
| 'score_attack'
| 'time_trial'
export type Difficulty = 'easy' | 'medium' | 'hard'
export type ProofType = 'screenshot' | 'video' | 'steam'
export interface Challenge {
id: number
game: GameShort
title: string
description: string
type: ChallengeType
difficulty: Difficulty
points: number
estimated_time: number | null
proof_type: ProofType
proof_hint: string | null
is_generated: boolean
created_at: string
}
// Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
export interface Assignment {
id: number
challenge: Challenge
status: AssignmentStatus
proof_url: string | null
proof_comment: string | null
points_earned: number
streak_at_completion: number | null
started_at: string
completed_at: string | null
}
export interface SpinResult {
assignment_id: number
game: Game
challenge: Challenge
can_drop: boolean
drop_penalty: number
}
export interface CompleteResult {
points_earned: number
streak_bonus: number
total_points: number
new_streak: number
}
export interface DropResult {
penalty: number
total_points: number
new_drop_count: number
}
// Activity types
export type ActivityType = 'join' | 'spin' | 'complete' | 'drop' | 'start_marathon' | 'finish_marathon'
export interface Activity {
id: number
type: ActivityType
user: User
data: Record<string, unknown> | null
created_at: string
}
export interface FeedResponse {
items: Activity[]
total: number
has_more: boolean
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,47 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
},
animation: {
'spin-slow': 'spin 3s linear infinite',
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
'fade-in': 'fade-in 0.3s ease-out',
'slide-up': 'slide-up 0.3s ease-out',
},
keyframes: {
'wheel-spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(var(--wheel-rotation, 1800deg))' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'slide-up': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

29
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})

65
nginx.conf Normal file
View File

@@ -0,0 +1,65 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# File upload limit (15 MB)
client_max_body_size 15M;
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:80;
}
server {
listen 80;
server_name localhost;
# Frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API
location /api {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeout for file uploads
proxy_read_timeout 300;
proxy_connect_timeout 300;
proxy_send_timeout 300;
}
# Static files (uploads)
location /uploads {
alias /app/uploads;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Health check
location /health {
proxy_pass http://backend;
}
}
}