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