initial
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
68
.gitignore
vendored
Normal 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
375
CONCEPT.md
Normal 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
136
README.md
Normal 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
1332
TECHNICAL_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
22
backend/Dockerfile
Normal file
22
backend/Dockerfile
Normal 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
40
backend/alembic.ini
Normal 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
63
backend/alembic/env.py
Normal 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()
|
||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Game Marathon Backend
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API module
|
||||
50
backend/app/api/deps.py
Normal file
50
backend/app/api/deps.py
Normal 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)]
|
||||
13
backend/app/api/v1/__init__.py
Normal file
13
backend/app/api/v1/__init__.py
Normal 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)
|
||||
64
backend/app/api/v1/auth.py
Normal file
64
backend/app/api/v1/auth.py
Normal 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)
|
||||
268
backend/app/api/v1/challenges.py
Normal file
268
backend/app/api/v1/challenges.py
Normal 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")
|
||||
62
backend/app/api/v1/feed.py
Normal file
62
backend/app/api/v1/feed.py
Normal 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
222
backend/app/api/v1/games.py
Normal 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)
|
||||
358
backend/app/api/v1/marathons.py
Normal file
358
backend/app/api/v1/marathons.py
Normal 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
104
backend/app/api/v1/users.py
Normal 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
404
backend/app/api/v1/wheel.py
Normal 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
|
||||
]
|
||||
19
backend/app/core/__init__.py
Normal file
19
backend/app/core/__init__.py
Normal 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",
|
||||
]
|
||||
44
backend/app/core/config.py
Normal file
44
backend/app/core/config.py
Normal 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()
|
||||
29
backend/app/core/database.py
Normal file
29
backend/app/core/database.py
Normal 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()
|
||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal 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
57
backend/app/main.py
Normal 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"}
|
||||
23
backend/app/models/__init__.py
Normal file
23
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
30
backend/app/models/activity.py
Normal file
30
backend/app/models/activity.py
Normal 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")
|
||||
32
backend/app/models/assignment.py
Normal file
32
backend/app/models/assignment.py
Normal 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")
|
||||
53
backend/app/models/challenge.py
Normal file
53
backend/app/models/challenge.py
Normal 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"
|
||||
)
|
||||
27
backend/app/models/game.py
Normal file
27
backend/app/models/game.py
Normal 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"
|
||||
)
|
||||
48
backend/app/models/marathon.py
Normal file
48
backend/app/models/marathon.py
Normal 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"
|
||||
)
|
||||
29
backend/app/models/participant.py
Normal file
29
backend/app/models/participant.py
Normal 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"
|
||||
)
|
||||
33
backend/app/models/user.py
Normal file
33
backend/app/models/user.py
Normal 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"
|
||||
)
|
||||
90
backend/app/schemas/__init__.py
Normal file
90
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
21
backend/app/schemas/activity.py
Normal file
21
backend/app/schemas/activity.py
Normal 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
|
||||
50
backend/app/schemas/assignment.py
Normal file
50
backend/app/schemas/assignment.py
Normal 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
|
||||
53
backend/app/schemas/challenge.py
Normal file
53
backend/app/schemas/challenge.py
Normal 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
|
||||
14
backend/app/schemas/common.py
Normal file
14
backend/app/schemas/common.py
Normal 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
|
||||
40
backend/app/schemas/game.py
Normal file
40
backend/app/schemas/game.py
Normal 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
|
||||
76
backend/app/schemas/marathon.py
Normal file
76
backend/app/schemas/marathon.py
Normal 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
|
||||
54
backend/app/schemas/user.py
Normal file
54
backend/app/schemas/user.py
Normal 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
|
||||
4
backend/app/services/__init__.py
Normal file
4
backend/app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.points import PointsService
|
||||
from app.services.gpt import GPTService
|
||||
|
||||
__all__ = ["PointsService", "GPTService"]
|
||||
96
backend/app/services/gpt.py
Normal file
96
backend/app/services/gpt.py
Normal 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
|
||||
55
backend/app/services/points.py
Normal file
55
backend/app/services/points.py
Normal 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
32
backend/requirements.txt
Normal 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
68
docker-compose.yml
Normal 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
30
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
17
frontend/nginx.conf
Normal 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
42
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
122
frontend/src/App.tsx
Normal file
122
frontend/src/App.tsx
Normal 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
30
frontend/src/api/auth.ts
Normal 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
|
||||
},
|
||||
}
|
||||
34
frontend/src/api/client.ts
Normal file
34
frontend/src/api/client.ts
Normal 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
11
frontend/src/api/feed.ts
Normal 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
70
frontend/src/api/games.ts
Normal 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
|
||||
},
|
||||
}
|
||||
5
frontend/src/api/index.ts
Normal file
5
frontend/src/api/index.ts
Normal 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'
|
||||
64
frontend/src/api/marathons.ts
Normal file
64
frontend/src/api/marathons.ts
Normal 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
41
frontend/src/api/wheel.ts
Normal 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
|
||||
},
|
||||
}
|
||||
77
frontend/src/components/layout/Layout.tsx
Normal file
77
frontend/src/components/layout/Layout.tsx
Normal 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">
|
||||
Игровой Марафон © {new Date().getFullYear()}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
frontend/src/components/ui/Button.tsx
Normal file
40
frontend/src/components/ui/Button.tsx
Normal 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'
|
||||
54
frontend/src/components/ui/Card.tsx
Normal file
54
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/ui/Input.tsx
Normal file
36
frontend/src/components/ui/Input.tsx
Normal 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'
|
||||
3
frontend/src/components/ui/index.ts
Normal file
3
frontend/src/components/ui/index.ts
Normal 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
37
frontend/src/index.css
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
115
frontend/src/pages/CreateMarathonPage.tsx
Normal file
115
frontend/src/pages/CreateMarathonPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
frontend/src/pages/HomePage.tsx
Normal file
113
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
119
frontend/src/pages/LeaderboardPage.tsx
Normal file
119
frontend/src/pages/LeaderboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
265
frontend/src/pages/LobbyPage.tsx
Normal file
265
frontend/src/pages/LobbyPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
frontend/src/pages/LoginPage.tsx
Normal file
84
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
frontend/src/pages/MarathonPage.tsx
Normal file
197
frontend/src/pages/MarathonPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
158
frontend/src/pages/MarathonsPage.tsx
Normal file
158
frontend/src/pages/MarathonsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
frontend/src/pages/PlayPage.tsx
Normal file
315
frontend/src/pages/PlayPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
frontend/src/pages/RegisterPage.tsx
Normal file
115
frontend/src/pages/RegisterPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal 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'
|
||||
90
frontend/src/store/auth.ts
Normal file
90
frontend/src/store/auth.ts
Normal 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
158
frontend/src/types/index.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
47
frontend/tailwind.config.js
Normal file
47
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
29
frontend/vite.config.ts
Normal 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
65
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user