backend and stuff

This commit is contained in:
Dmitry 2026-01-03 17:07:04 +03:00
parent 876010ef8f
commit 0b64cc5d8b
58 changed files with 14630 additions and 235 deletions

View file

@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(npm install:*)",
"Bash(npx prisma init)",
"Bash(npx prisma generate:*)",
"Bash(npm run build:*)",
"Bash(docker-compose up:*)"
]
}
}

11
.gitignore vendored
View file

@ -82,3 +82,14 @@ Thumbs.db
* backup.* * backup.*
*.backup *.backup
# Backend specific
backend/dist
backend/node_modules
backend/.env
backend/.env.local
backend/prisma/migrations
backend/coverage
# Frontend build backup
src/App.jsx.backup

241
README.md
View file

@ -1,62 +1,231 @@
# 100 к 1 - Новогодняя версия # 100 к 1 - Multiplayer Game
Игра "100 к 1" в новогодней тематике на React. Идеально подходит для семейного застолья! Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера и локальной игры.
## Установка ## 🎮 Возможности
```bash ### 🌐 Мультиплеер (NEW!)
npm install - **Игровые комнаты** с уникальными кодами
- **QR-коды** для быстрого присоединения
- **Real-time синхронизация** через WebSocket
- **Роли**: Ведущий, Игрок, Зритель
- **Статистика игр** с историей
### 🏠 Локальная игра
- Оригинальная версия для одного устройства
- Управление участниками
- Редактирование вопросов
- Автосохранение прогресса
## 🛠 Технологический стек
### Frontend
- **React 18.2** + **Vite 5.0**
- **React Router v6** - маршрутизация
- **Socket.IO Client** - WebSocket
- **Axios** - HTTP клиент
- **QRCode** - генерация QR-кодов
### Backend
- **NestJS** - TypeScript фреймворк
- **PostgreSQL** - база данных
- **Prisma ORM** - работа с БД
- **Socket.IO** - WebSocket сервер
- **JWT** - авторизация
- **Docker** - контейнеризация
## 📁 Структура проекта
```
sto_k_odnomu/
├── backend/ # NestJS Backend
│ ├── src/
│ │ ├── auth/ # Авторизация (JWT, анонимные)
│ │ ├── rooms/ # Модуль комнат
│ │ ├── questions/ # Паки вопросов
│ │ ├── game/ # WebSocket игра
│ │ ├── stats/ # Статистика
│ │ └── prisma/ # Prisma сервис
│ ├── prisma/
│ │ ├── schema.prisma # Схема БД
│ │ └── seed.ts # Seed данные
│ └── docker-compose.yml # Docker конфиг
├── src/ # React Frontend
│ ├── pages/ # Страницы
│ │ ├── Home.jsx # Главная
│ │ ├── CreateRoom.jsx # Создание комнаты
│ │ ├── JoinRoom.jsx # Присоединение
│ │ ├── RoomPage.jsx # Лобби комнаты
│ │ └── LocalGame.jsx # Локальная игра
│ ├── services/ # API & WebSocket
│ ├── context/ # React Context
│ ├── hooks/ # Custom hooks
│ └── components/ # Компоненты игры
└── PLAN.md # Детальный план разработки
``` ```
## Запуск ## 🚀 Быстрый старт
### 1. Backend
```bash ```bash
cd backend
# Установить зависимости
npm install
# Настроить .env
cp .env.example .env
# Запустить PostgreSQL (Docker)
docker-compose up -d postgres
# Выполнить миграции
npx prisma migrate dev --name init
# Заполнить демо-данными
npm run prisma:seed
# Запустить backend
npm run start:dev
```
Backend: http://localhost:3000
### 2. Frontend
```bash
# В корне проекта
npm install
npm run dev npm run dev
``` ```
## Правила игры Frontend: http://localhost:5173
1. На экране появляется вопрос ## 🎯 Как играть
2. Нужно угадать самые популярные ответы
3. Кликайте на тайлы с ответами, чтобы открыть их
4. Чем популярнее ответ, тем больше очков он приносит (100, 80, 60, 40, 20, 10)
5. После открытия всех ответов на вопрос, игра переходит к следующему
6. Всего 10 вопросов на новогоднюю тематику
7. В конце игры показывается итоговый счёт
## Адаптация под ТВ ### Мультиплеер
Игра оптимизирована для отображения на больших экранах (ТВ): 1. **Главная страница** → Выберите действие
- Крупные шрифты для лучшей читаемости 2. **Создать комнату**:
- Увеличенные элементы интерфейса - Выберите пак вопросов
- Чёткая видимость с расстояния - Настройте параметры
- Поделитесь кодом/QR с игроками
3. **Присоединиться**:
- Введите 6-значный код комнаты
- Или отсканируйте QR-код
4. **Начать игру** (ведущий)
5. Игроки открывают ответы в реальном времени
## Развёртывание через Coolify ### Локальная игра
Проект настроен для развёртывания через Coolify: 1. Главная → **Локальная игра**
2. Добавьте участников (👥)
3. Играйте на одном устройстве
1. Подключите репозиторий в Coolify ## 📊 API Endpoints
2. Coolify автоматически определит Dockerfile
3. Проект будет собран и развёрнут автоматически
### Локальная сборка для проверки ### REST API
Для проверки Docker образа локально: - **Auth**: `/auth/anonymous`, `/auth/register`, `/auth/login`
- **Rooms**: `/rooms` (POST, GET), `/rooms/:code`, `/rooms/:id/join`
- **Questions**: `/questions/packs` (CRUD)
- **Stats**: `/stats/game-history/:userId`, `/stats/user/:userId`
### WebSocket Events
**Client → Server:**
- `joinRoom`, `startGame`, `revealAnswer`, `updateScore`, `nextQuestion`, `endGame`
**Server → Client:**
- `roomUpdate`, `gameStarted`, `answerRevealed`, `scoreUpdated`, `questionChanged`, `gameEnded`
## 🗄️ База данных
### Модели (Prisma)
- **User** - пользователи (анонимные/зарегистрированные)
- **Room** - игровые комнаты
- **Participant** - участники (HOST/PLAYER/SPECTATOR)
- **QuestionPack** - паки вопросов
- **GameHistory** - история игр
### Seed данные
- Демо пользователь
- 2 пака вопросов (общие, семейные)
## 🐳 Docker
```bash ```bash
# Сборка образа # Backend + PostgreSQL
docker build -t sto-k-odnomu . cd backend
docker-compose up -d
# Запуск контейнера # Только PostgreSQL
docker run -p 8080:80 sto-k-odnomu docker-compose up -d postgres
``` ```
Сайт будет доступен по адресу `http://localhost:8080` ## 📝 Разработка
## Технологии ### Backend
- React 18 ```bash
- Vite cd backend
- CSS3 с анимациями npm run start:dev # Dev режим
- Docker + Nginx (для production) npm run build # Сборка
npm run test # Тесты
npx prisma studio # DB GUI
```
### Frontend
```bash
npm run dev # Dev сервер
npm run build # Сборка
npm run preview # Preview build
```
## 🔐 Безопасность
- JWT токены для авторизации
- CORS настройка
- Валидация данных (class-validator)
- PostgreSQL для надёжного хранения
## 📄 Документация
- [Backend README](backend/README.md) - детальная документация backend
- [PLAN.md](PLAN.md) - полный план разработки
- [API Documentation](backend/README.md#-api-endpoints) - REST и WebSocket API
## 🎨 Особенности
- ❄️ Новогодняя анимация снежинок
- 🎨 Адаптивный дизайн
- 💾 Автосохранение прогресса
- 🔄 Real-time синхронизация
- 📱 QR-коды для присоединения
- 📊 Статистика и история игр
## 🚧 Roadmap
- [x] Backend инфраструктура
- [x] Frontend интеграция
- [x] WebSocket real-time
- [x] Игровые комнаты
- [x] QR-коды
- [ ] Таймер ответов
- [ ] Экспорт статистики (PDF)
- [ ] Публичные комнаты
- [ ] Рейтинг игроков
## 📜 Лицензия
Private project
---
**Сделано с ❤️ для семейных праздников**

4
backend/.env.example Normal file
View file

@ -0,0 +1,4 @@
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
JWT_SECRET="change-me"
PORT=3000
CORS_ORIGIN="http://localhost:5173"

5
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

4
backend/.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

18
backend/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma/
RUN npm ci --production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
EXPOSE 3000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]

209
backend/README.md Normal file
View file

@ -0,0 +1,209 @@
# Backend - "100 к 1" Multiplayer Game
NestJS backend для мультиплеерной игры "100 к 1" с WebSocket поддержкой.
## 🚀 Быстрый старт
### Предварительные требования
- Node.js 18+
- PostgreSQL 15+
- Docker (опционально, для запуска PostgreSQL в контейнере)
### Установка
```bash
# 1. Установить зависимости
npm install
# 2. Настроить переменные окружения
cp .env.example .env
# Отредактировать .env с вашими настройками
# 3. Запустить PostgreSQL
# Вариант A: Использовать Docker Compose
docker-compose up -d postgres
# Вариант B: Использовать локальный PostgreSQL
# Создать базу данных вручную:
# createdb sto_k_odnomu
# 4. Выполнить миграции
npx prisma migrate dev --name init
# 5. Заполнить демо-данными (опционально)
npm run prisma:seed
# 6. Сгенерировать Prisma Client
npx prisma generate
# 7. Запустить backend
npm run start:dev
```
Backend запустится на http://localhost:3000
## 📁 Структура проекта
```
backend/
├── src/
│ ├── auth/ # Модуль авторизации (JWT, анонимные пользователи)
│ ├── rooms/ # Модуль комнат (создание, присоединение)
│ ├── questions/ # Модуль паков вопросов (CRUD)
│ ├── game/ # WebSocket модуль (real-time игра)
│ ├── stats/ # Модуль статистики (история игр)
│ ├── prisma/ # Prisma сервис
│ ├── app.module.ts # Главный модуль
│ └── main.ts # Точка входа
├── prisma/
│ ├── schema.prisma # Схема базы данных
│ └── seed.ts # Seed скрипт с демо-данными
├── Dockerfile # Docker конфигурация
├── docker-compose.yml # Docker Compose (PostgreSQL + Backend)
└── .env.example # Пример переменных окружения
```
## 🗄️ База данных
### Модели
- **User** - пользователи (анонимные или зарегистрированные)
- **Room** - игровые комнаты
- **Participant** - участники комнат (игроки, ведущие, зрители)
- **QuestionPack** - паки вопросов
- **GameHistory** - история завершённых игр
### Миграции
```bash
# Создать новую миграцию
npx prisma migrate dev --name migration_name
# Применить миграции на production
npx prisma migrate deploy
# Сбросить базу данных (ВНИМАНИЕ: удалит все данные!)
npx prisma migrate reset
```
### Seed данные
Seed скрипт создаёт:
- Демо пользователя
- 2 пака вопросов (общие и семейные)
```bash
npm run prisma:seed
```
## 🔌 API Endpoints
### Auth
- POST /auth/anonymous - создать анонимного пользователя
- POST /auth/register - регистрация
- POST /auth/login - вход
### Rooms
- POST /rooms - создать комнату
- GET /rooms/:code - получить комнату по коду
- POST /rooms/:roomId/join - присоединиться к комнате
### Questions
- POST /questions/packs - создать пак вопросов
- GET /questions/packs - получить все паки
- GET /questions/packs/:id - получить пак по ID
- PUT /questions/packs/:id - обновить пак
- DELETE /questions/packs/:id - удалить пак
### Stats
- POST /stats/game-history - сохранить историю игры
- GET /stats/game-history/:userId - получить историю пользователя
- GET /stats/user/:userId - получить статистику пользователя
## 🌐 WebSocket Events
### Client → Server
- joinRoom - присоединиться к комнате
- startGame - начать игру
- revealAnswer - открыть ответ
- updateScore - обновить счёт
- nextQuestion - следующий вопрос
- endGame - завершить игру
### Server → Client
- roomUpdate - обновление комнаты
- gameStarted - игра началась
- answerRevealed - ответ открыт
- scoreUpdated - счёт обновлён
- questionChanged - вопрос изменён
- gameEnded - игра завершена
## 🔧 NPM Scripts
```bash
npm run start # Запуск в production режиме
npm run start:dev # Запуск в dev режиме с hot reload
npm run start:debug # Запуск в debug режиме
npm run build # Сборка для production
npm run lint # Проверка ESLint
npm run format # Форматирование кода (Prettier)
npm run test # Запуск тестов
npm run test:watch # Запуск тестов в watch режиме
npm run test:cov # Запуск тестов с coverage
npm run prisma:generate # Генерация Prisma Client
npm run prisma:migrate # Создание и применение миграции
npm run prisma:seed # Заполнение БД демо-данными
```
## 🐳 Docker
### Запуск с Docker Compose
```bash
# Запустить всё (PostgreSQL + Backend)
docker-compose up -d
# Остановить
docker-compose down
# Просмотр логов
docker-compose logs -f backend
# Пересобрать и запустить
docker-compose up -d --build
```
### Запуск только PostgreSQL
```bash
docker-compose up -d postgres
```
## 🔐 Переменные окружения
Создайте файл .env на основе .env.example
## 📝 Prisma Studio
```bash
npx prisma studio
```
Откроется на http://localhost:5555
## 🚀 Production Deployment
```bash
# 1. Собрать проект
npm run build
# 2. Применить миграции
npx prisma migrate deploy
# 3. Запустить
npm run start:prod
```

View file

@ -0,0 +1,30 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: sto_k_odnomu
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/sto_k_odnomu
JWT_SECRET: your-secret-key-change-in-production
PORT: 3000
depends_on:
- postgres
volumes:
- ./src:/app/src
command: npm run start:dev
volumes:
postgres_data:

35
backend/eslint.config.mjs Normal file
View file

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11033
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

93
backend/package.json Normal file
View file

@ -0,0 +1,93 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:seed": "ts-node prisma/seed.ts"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.11",
"@nestjs/websockets": "^11.1.11",
"@prisma/client": "^6.19.1",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"nanoid": "^5.1.6",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

16
backend/prisma.config.ts Normal file
View file

@ -0,0 +1,16 @@
// This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});

View file

@ -0,0 +1,125 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String? @unique
name String?
createdAt DateTime @default(now())
// Связи
hostedRooms Room[] @relation("HostedRooms")
participants Participant[]
questionPacks QuestionPack[]
// Статистика
gamesPlayed Int @default(0)
gamesWon Int @default(0)
totalPoints Int @default(0)
}
model Room {
id String @id @default(uuid())
code String @unique // 6-символьный код
status RoomStatus @default(WAITING)
hostId String
createdAt DateTime @default(now())
expiresAt DateTime
// Настройки
maxPlayers Int @default(10)
allowSpectators Boolean @default(true)
timerEnabled Boolean @default(false)
timerDuration Int @default(30)
questionPackId String
autoAdvance Boolean @default(false)
// Состояние игры
currentQuestionIndex Int @default(0)
revealedAnswers Json @default("{}")
currentPlayerId String?
isGameOver Boolean @default(false)
// Метрики
totalQuestions Int @default(0)
answeredQuestions Int @default(0)
startedAt DateTime?
finishedAt DateTime?
// Связи
host User @relation("HostedRooms", fields: [hostId], references: [id])
participants Participant[]
questionPack QuestionPack @relation(fields: [questionPackId], references: [id])
gameHistory GameHistory?
}
enum RoomStatus {
WAITING
PLAYING
FINISHED
}
model Participant {
id String @id @default(uuid())
userId String
roomId String
name String
role ParticipantRole
score Int @default(0)
joinedAt DateTime @default(now())
isActive Boolean @default(true)
user User @relation(fields: [userId], references: [id])
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@unique([userId, roomId])
}
enum ParticipantRole {
HOST
PLAYER
SPECTATOR
}
model QuestionPack {
id String @id @default(uuid())
name String
description String
category String
isPublic Boolean @default(false)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
questions Json // Массив вопросов с ответами
questionCount Int @default(0)
timesUsed Int @default(0)
rating Float @default(0)
creator User @relation(fields: [createdBy], references: [id])
rooms Room[]
}
model GameHistory {
id String @id @default(uuid())
roomId String @unique
roomCode String
questionPackId String
startedAt DateTime
finishedAt DateTime
players Json // { userId: { name, score, rank } }
statistics Json // Детальная статистика игры
timeline Json // История событий игры
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
}

191
backend/prisma/seed.ts Normal file
View file

@ -0,0 +1,191 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Starting seed...');
// Create demo user
const demoUser = await prisma.user.upsert({
where: { email: 'demo@100k1.ru' },
update: {},
create: {
email: 'demo@100k1.ru',
name: 'Демо пользователь',
},
});
console.log('Demo user created:', demoUser);
// Demo questions data
const demoQuestions = [
{
text: 'Что дед мороз делает летом?',
answers: [
{ text: 'Отдыхает', points: 100 },
{ text: 'Готовит подарки', points: 80 },
{ text: 'Спит', points: 60 },
{ text: 'Путешествует', points: 40 },
{ text: 'Загорает', points: 20 },
{ text: 'Работает', points: 10 },
],
},
{
text: 'Что намазывают на хлеб?',
answers: [
{ text: 'Масло', points: 100 },
{ text: 'Икру', points: 80 },
{ text: 'Варенье', points: 60 },
{ text: 'Паштет', points: 40 },
{ text: 'Майонез', points: 20 },
{ text: 'Горчицу', points: 10 },
],
},
{
text: 'Кто работает в новый год?',
answers: [
{ text: 'Дед Мороз', points: 100 },
{ text: 'Снегурочка', points: 80 },
{ text: 'Врач', points: 60 },
{ text: 'Полицейский', points: 40 },
{ text: 'Таксист', points: 20 },
{ text: 'Продавец', points: 10 },
],
},
{
text: 'Почему лошадь не курит?',
answers: [
{ text: 'Боится умереть', points: 100 },
{ text: 'Неудобно (копыта мешают)', points: 80 },
{ text: 'Не хочет', points: 60 },
{ text: 'Не продают', points: 40 },
],
},
{
text: 'Какая самая "лошадиная" фамилия?',
answers: [
{ text: 'Конев', points: 100 },
{ text: 'Жеребцов', points: 80 },
{ text: 'Кобылин', points: 60 },
{ text: 'Табунов', points: 40 },
{ text: 'Лошадкин', points: 20 },
],
},
{
text: 'Что носят на голове?',
answers: [
{ text: 'Шапка', points: 100 },
{ text: 'Шляпа', points: 80 },
{ text: 'Кепка', points: 60 },
{ text: 'Корона', points: 40 },
{ text: 'Платок', points: 20 },
{ text: 'Панама', points: 10 },
],
},
{
text: 'Что можно найти в холодильнике?',
answers: [
{ text: 'Еда', points: 100 },
{ text: 'Молоко', points: 80 },
{ text: 'Колбаса', points: 60 },
{ text: 'Масло', points: 40 },
{ text: 'Лёд', points: 20 },
{ text: 'Свет', points: 10 },
],
},
{
text: 'Где можно встретить новый год?',
answers: [
{ text: 'Дома', points: 100 },
{ text: 'На улице', points: 80 },
{ text: 'В кафе', points: 60 },
{ text: 'У друзей', points: 40 },
{ text: 'На работе', points: 20 },
{ text: 'В самолёте', points: 10 },
],
},
];
// Create question pack
const questionPack = await prisma.questionPack.upsert({
where: { id: 'demo-pack-1' },
update: {},
create: {
id: 'demo-pack-1',
name: 'Демо пак вопросов',
description: 'Базовый набор вопросов для игры "100 к 1"',
category: 'Общие',
isPublic: true,
createdBy: demoUser.id,
questions: demoQuestions,
questionCount: demoQuestions.length,
rating: 5.0,
},
});
console.log('Question pack created:', questionPack);
// Create family questions pack
const familyQuestions = [
{
text: 'Что мама говорит чаще всего?',
answers: [
{ text: 'Убери', points: 100 },
{ text: 'Поешь', points: 80 },
{ text: 'Спать', points: 60 },
{ text: 'Я люблю тебя', points: 40 },
{ text: 'Делай уроки', points: 20 },
],
},
{
text: 'Что папа делает на выходных?',
answers: [
{ text: 'Отдыхает', points: 100 },
{ text: 'Чинит что-то', points: 80 },
{ text: 'Смотрит телевизор', points: 60 },
{ text: 'Спит', points: 40 },
{ text: 'Работает', points: 20 },
],
},
{
text: 'Что бабушка любит дарить внукам?',
answers: [
{ text: 'Деньги', points: 100 },
{ text: 'Еду', points: 80 },
{ text: 'Одежду', points: 60 },
{ text: 'Игрушки', points: 40 },
{ text: 'Конфеты', points: 20 },
],
},
];
const familyPack = await prisma.questionPack.upsert({
where: { id: 'family-pack-1' },
update: {},
create: {
id: 'family-pack-1',
name: 'Семейные вопросы',
description: 'Вопросы для семейной игры',
category: 'Семья',
isPublic: true,
createdBy: demoUser.id,
questions: familyQuestions,
questionCount: familyQuestions.length,
rating: 4.8,
},
});
console.log('Family pack created:', familyPack);
console.log('Seed completed successfully!');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error('Seed error:', e);
await prisma.$disconnect();
process.exit(1);
});

View file

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View file

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

25
backend/src/app.module.ts Normal file
View file

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { RoomsModule } from './rooms/rooms.module';
import { QuestionsModule } from './questions/questions.module';
import { GameModule } from './game/game.module';
import { StatsModule } from './stats/stats.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
RoomsModule,
QuestionsModule,
GameModule,
StatsModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View file

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View file

@ -0,0 +1,22 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('anonymous')
async createAnonymous(@Body('name') name?: string) {
return this.authService.createAnonymousUser(name);
}
@Post('register')
async register(@Body() dto: { email: string; password: string; name: string }) {
return this.authService.register(dto.email, dto.password, dto.name);
}
@Post('login')
async login(@Body() dto: { email: string; password: string }) {
return this.authService.login(dto.email, dto.password);
}
}

View file

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key',
signOptions: { expiresIn: '7d' },
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

View file

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
) {}
async createAnonymousUser(name?: string) {
const user = await this.prisma.user.create({
data: { name: name || 'Гость' },
});
const token = this.jwtService.sign({ sub: user.id, type: 'anonymous' });
return { user, token };
}
async register(email: string, password: string, name: string) {
const user = await this.prisma.user.create({
data: {
email,
name,
},
});
const token = this.jwtService.sign({ sub: user.id, type: 'registered' });
return { user, token };
}
async login(email: string, password: string) {
const user = await this.prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new Error('User not found');
}
const token = this.jwtService.sign({ sub: user.id, type: 'registered' });
return { user, token };
}
async validateUser(userId: string) {
return this.prisma.user.findUnique({ where: { id: userId } });
}
}

View file

@ -0,0 +1,68 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { RoomsService } from '../rooms/rooms.service';
@WebSocketGateway({
cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
},
})
export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server: Server;
constructor(private roomsService: RoomsService) {}
handleConnection(client: Socket) {
console.log(`Client connected: ${client.id}`);
}
handleDisconnect(client: Socket) {
console.log(`Client disconnected: ${client.id}`);
}
@SubscribeMessage('joinRoom')
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
client.join(payload.roomCode);
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('roomUpdate', room);
}
@SubscribeMessage('startGame')
async handleStartGame(client: Socket, payload: { roomId: string; roomCode: string }) {
await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING');
const room = await this.roomsService.getRoomByCode(payload.roomCode);
if (room) {
this.server.to(room.code).emit('gameStarted', room);
}
}
@SubscribeMessage('revealAnswer')
handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number }) {
this.server.to(payload.roomCode).emit('answerRevealed', payload);
}
@SubscribeMessage('updateScore')
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string }) {
await this.roomsService.updateParticipantScore(payload.participantId, payload.score);
this.server.to(payload.roomCode).emit('scoreUpdated', payload);
}
@SubscribeMessage('nextQuestion')
handleNextQuestion(client: Socket, payload: { roomCode: string }) {
this.server.to(payload.roomCode).emit('questionChanged', payload);
}
@SubscribeMessage('endGame')
async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string }) {
await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED');
this.server.to(payload.roomCode).emit('gameEnded', payload);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { GameGateway } from './game.gateway';
import { RoomsModule } from '../rooms/rooms.module';
@Module({
imports: [RoomsModule],
providers: [GameGateway],
})
export class GameModule {}

21
backend/src/main.ts Normal file
View file

@ -0,0 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}));
await app.listen(process.env.PORT || 3000);
console.log(`Backend running on http://localhost:${process.env.PORT || 3000}`);
}
bootstrap();

View file

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View file

@ -0,0 +1,13 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View file

@ -0,0 +1,32 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { QuestionsService } from './questions.service';
@Controller('questions')
export class QuestionsController {
constructor(private questionsService: QuestionsService) {}
@Post('packs')
async createPack(@Body() dto: { createdBy: string; name: string; description: string; category: string; questions: any; isPublic?: boolean }) {
return this.questionsService.createQuestionPack(dto.createdBy, dto);
}
@Get('packs')
async getPacks(@Query('userId') userId?: string) {
return this.questionsService.getQuestionPacks(userId);
}
@Get('packs/:id')
async getPack(@Param('id') id: string) {
return this.questionsService.getQuestionPackById(id);
}
@Put('packs/:id')
async updatePack(@Param('id') id: string, @Body() data: any) {
return this.questionsService.updateQuestionPack(id, data);
}
@Delete('packs/:id')
async deletePack(@Param('id') id: string) {
return this.questionsService.deleteQuestionPack(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { QuestionsService } from './questions.service';
import { QuestionsController } from './questions.controller';
@Module({
controllers: [QuestionsController],
providers: [QuestionsService],
exports: [QuestionsService],
})
export class QuestionsModule {}

View file

@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class QuestionsService {
constructor(private prisma: PrismaService) {}
async createQuestionPack(createdBy: string, data: { name: string; description: string; category: string; questions: any; isPublic?: boolean }) {
return this.prisma.questionPack.create({
data: {
...data,
createdBy,
questionCount: Array.isArray(data.questions) ? data.questions.length : 0,
},
});
}
async getQuestionPacks(userId?: string) {
return this.prisma.questionPack.findMany({
where: {
OR: [
{ isPublic: true },
{ createdBy: userId },
],
},
include: {
creator: true,
},
});
}
async getQuestionPackById(id: string) {
return this.prisma.questionPack.findUnique({
where: { id },
include: {
creator: true,
},
});
}
async updateQuestionPack(id: string, data: any) {
return this.prisma.questionPack.update({
where: { id },
data,
});
}
async deleteQuestionPack(id: string) {
return this.prisma.questionPack.delete({
where: { id },
});
}
}

View file

@ -0,0 +1,25 @@
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
import { RoomsService } from './rooms.service';
@Controller('rooms')
export class RoomsController {
constructor(private roomsService: RoomsService) {}
@Post()
async createRoom(@Body() dto: { hostId: string; questionPackId: string; settings?: any }) {
return this.roomsService.createRoom(dto.hostId, dto.questionPackId, dto.settings);
}
@Get(':code')
async getRoom(@Param('code') code: string) {
return this.roomsService.getRoomByCode(code);
}
@Post(':roomId/join')
async joinRoom(
@Param('roomId') roomId: string,
@Body() dto: { userId: string; name: string; role: 'PLAYER' | 'SPECTATOR' }
) {
return this.roomsService.joinRoom(roomId, dto.userId, dto.name, dto.role);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
@Module({
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],
})
export class RoomsModule {}

View file

@ -0,0 +1,78 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@Injectable()
export class RoomsService {
constructor(private prisma: PrismaService) {}
async createRoom(hostId: string, questionPackId: string, settings?: any) {
const code = nanoid();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
const room = await this.prisma.room.create({
data: {
code,
hostId,
questionPackId,
expiresAt,
...settings,
},
include: {
host: true,
questionPack: true,
},
});
await this.prisma.participant.create({
data: {
userId: hostId,
roomId: room.id,
name: room.host.name || 'Host',
role: 'HOST',
},
});
return room;
}
async getRoomByCode(code: string) {
return this.prisma.room.findUnique({
where: { code },
include: {
host: true,
participants: {
include: { user: true },
},
questionPack: true,
},
});
}
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
return this.prisma.participant.create({
data: {
userId,
roomId,
name,
role,
},
});
}
async updateRoomStatus(roomId: string, status: 'WAITING' | 'PLAYING' | 'FINISHED') {
return this.prisma.room.update({
where: { id: roomId },
data: { status },
});
}
async updateParticipantScore(participantId: string, score: number) {
return this.prisma.participant.update({
where: { id: participantId },
data: { score },
});
}
}

View file

@ -0,0 +1,22 @@
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { StatsService } from './stats.service';
@Controller('stats')
export class StatsController {
constructor(private statsService: StatsService) {}
@Post('game-history')
async createHistory(@Body() data: any) {
return this.statsService.createGameHistory(data);
}
@Get('game-history/:userId')
async getHistory(@Param('userId') userId: string) {
return this.statsService.getGameHistory(userId);
}
@Get('user/:userId')
async getUserStats(@Param('userId') userId: string) {
return this.statsService.getUserStats(userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { StatsService } from './stats.service';
import { StatsController } from './stats.controller';
@Module({
controllers: [StatsController],
providers: [StatsService],
exports: [StatsService],
})
export class StatsModule {}

View file

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class StatsService {
constructor(private prisma: PrismaService) {}
async createGameHistory(data: {
roomId: string;
roomCode: string;
questionPackId: string;
startedAt: Date;
finishedAt: Date;
players: any;
statistics: any;
timeline: any;
}) {
return this.prisma.gameHistory.create({
data,
});
}
async getGameHistory(userId: string) {
const allHistory = await this.prisma.gameHistory.findMany({
orderBy: {
finishedAt: 'desc',
},
});
return allHistory.filter((history) => {
const players = history.players as any;
return players && players[userId];
});
}
async getUserStats(userId: string) {
return this.prisma.user.findUnique({
where: { id: userId },
select: {
gamesPlayed: true,
gamesWon: true,
totalPoints: true,
},
});
}
}

View file

@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View file

@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View file

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View file

@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}

View file

@ -1,5 +1,23 @@
Кто дольше всех собирается за стол? Кто дольше всех собирается за стол?
Катя, Надя, Надя, Вика, Миша, Лера, Бабуля, Андрей, Надя, Надя
Кто больше всех ест на Новый год? Кто больше всех ест на Новый год?
Что важнее всего в новогоднюю ночь? Егор, Егор, Миша, Миша, Миша, Лера, Бабуля, Вика, Миша, Миша
Кто лучше всех говорит тосты? Кто лучше всех говорит тосты?
Миша, Бабуля, Егор, Миша, Андрей, ИИ, Надя, Миша, Андрей, Миша
Что важнее всего в новогоднюю ночь?
Весело встретить вместе, ценить моменты проведённые с близкими, понимать, что ты не одинок, Миша, Доесть еду, Семья, Близкие, компания, компания, Оливье
Где мы встретим следующий новый год? Где мы встретим следующий новый год?
тут, тут, незнаю, в рф, тут, В собственном жк, тут, тут, в ресторане, тут
мама
егор
яна
катя
миша
лера
бабуля
вика
дима
андрей

956
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.4",
"qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"react-router-dom": "^7.11.0",
"socket.io-client": "^4.8.3"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
@ -18,4 +24,3 @@
"vite": "^5.0.0" "vite": "^5.0.0"
} }
} }

View file

@ -1,168 +1,27 @@
import { useState, useRef, useEffect } from 'react' import React from 'react';
import Game from './components/Game' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Snowflakes from './components/Snowflakes' import { AuthProvider } from './context/AuthContext';
import QuestionsModal from './components/QuestionsModal' import Home from './pages/Home';
import { questions as initialQuestions } from './data/questions' import CreateRoom from './pages/CreateRoom';
import { getCookie, setCookie, deleteCookie } from './utils/cookies' import JoinRoom from './pages/JoinRoom';
import './App.css' import RoomPage from './pages/RoomPage';
import LocalGame from './pages/LocalGame';
import './App.css';
function App() { function App() {
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
const [questions, setQuestions] = useState(() => {
const savedQuestions = getCookie('gameQuestions')
return savedQuestions || initialQuestions
})
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(() => {
const savedIndex = getCookie('gameQuestionIndex')
return savedIndex !== null ? savedIndex : 0
})
const [areAllRevealed, setAreAllRevealed] = useState(false)
const gameRef = useRef(null)
const currentQuestion = questions[currentQuestionIndex]
// Сохраняем вопросы в cookies при изменении
useEffect(() => {
if (questions.length > 0) {
setCookie('gameQuestions', questions)
}
}, [questions])
// Сохраняем индекс вопроса в cookies при изменении
useEffect(() => {
setCookie('gameQuestionIndex', currentQuestionIndex)
}, [currentQuestionIndex])
// Обновляем состояние открытых ответов при смене вопроса или изменении состояния
useEffect(() => {
const checkRevealedState = () => {
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
} else {
setAreAllRevealed(false)
}
}
checkRevealedState()
// Проверяем состояние периодически для обновления кнопки
const interval = setInterval(checkRevealedState, 200)
return () => clearInterval(interval)
}, [currentQuestionIndex, questions])
const handleUpdateQuestions = (updatedQuestions) => {
setQuestions(updatedQuestions)
// Если текущий вопрос был удален, сбрасываем индекс
if (currentQuestionIndex >= updatedQuestions.length) {
setCurrentQuestionIndex(0)
}
}
const handleOpenPlayersModal = () => {
if (gameRef.current) {
gameRef.current.openPlayersModal()
}
}
const handleNewGame = () => {
if (window.confirm('Начать новую игру? Текущий прогресс будет потерян.')) {
deleteCookie('gameQuestions')
deleteCookie('gameQuestionIndex')
deleteCookie('gamePlayers')
deleteCookie('gamePlayerScores')
deleteCookie('gameCurrentPlayerId')
deleteCookie('gameRevealedAnswers')
deleteCookie('gameOver')
setQuestions(initialQuestions)
setCurrentQuestionIndex(0)
if (gameRef.current) {
gameRef.current.newGame()
}
}
}
const handleShowAll = () => {
if (gameRef.current && gameRef.current.showAllAnswers) {
gameRef.current.showAllAnswers()
// Обновляем состояние после изменения
setTimeout(() => {
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
}
}, 100)
}
}
return ( return (
<div className="app"> <AuthProvider>
<Snowflakes /> <Router>
<div className="app-content"> <Routes>
<div className="app-title-bar"> <Route path="/" element={<Home />} />
<div className="app-control-buttons"> <Route path="/create-room" element={<CreateRoom />} />
<button <Route path="/join-room" element={<JoinRoom />} />
className="control-button control-button-players" <Route path="/room/:roomCode" element={<RoomPage />} />
onClick={handleOpenPlayersModal} <Route path="/local-game" element={<LocalGame />} />
title="Управление участниками" </Routes>
> </Router>
👥 </AuthProvider>
</button> );
<button
className="control-button control-button-questions"
onClick={() => setIsQuestionsModalOpen(true)}
title="Управление вопросами"
>
</button>
<button
className="control-button control-button-new-game"
onClick={handleNewGame}
title="Новая игра"
>
🎮
</button>
</div>
<h1 className="app-title">
<span className="title-number">100</span>
<span className="title-to">к</span>
<span className="title-number">1</span>
</h1>
{questions.length > 0 && currentQuestion && (
<div className="question-counter-wrapper">
<div className="question-counter">
{currentQuestionIndex + 1}/{questions.length}
</div>
<button
className="show-all-button-top"
onClick={handleShowAll}
title={areAllRevealed ? "Скрыть все ответы" : "Показать все ответы"}
>
{areAllRevealed ? "Скрыть все" : "Показать все"}
</button>
</div>
)}
</div>
<QuestionsModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
questions={questions}
onUpdateQuestions={handleUpdateQuestions}
/>
<Game
ref={gameRef}
questions={questions}
currentQuestionIndex={currentQuestionIndex}
onQuestionIndexChange={setCurrentQuestionIndex}
onQuestionsChange={setQuestions}
/>
</div>
</div>
)
} }
export default App export default App;

View file

@ -0,0 +1,161 @@
import { useState, useRef, useEffect } from 'react'
import Game from './Game'
import Snowflakes from './Snowflakes'
import QuestionsModal from './QuestionsModal'
import { questions as initialQuestions } from '../data/questions'
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
import '../App.css'
function LocalGameApp() {
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
const [questions, setQuestions] = useState(() => {
const savedQuestions = getCookie('gameQuestions')
return savedQuestions || initialQuestions
})
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(() => {
const savedIndex = getCookie('gameQuestionIndex')
return savedIndex !== null ? savedIndex : 0
})
const [areAllRevealed, setAreAllRevealed] = useState(false)
const gameRef = useRef(null)
const currentQuestion = questions[currentQuestionIndex]
useEffect(() => {
if (questions.length > 0) {
setCookie('gameQuestions', questions)
}
}, [questions])
useEffect(() => {
setCookie('gameQuestionIndex', currentQuestionIndex)
}, [currentQuestionIndex])
useEffect(() => {
const checkRevealedState = () => {
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
} else {
setAreAllRevealed(false)
}
}
checkRevealedState()
const interval = setInterval(checkRevealedState, 200)
return () => clearInterval(interval)
}, [currentQuestionIndex, questions])
const handleUpdateQuestions = (updatedQuestions) => {
setQuestions(updatedQuestions)
if (currentQuestionIndex >= updatedQuestions.length) {
setCurrentQuestionIndex(0)
}
}
const handleOpenPlayersModal = () => {
if (gameRef.current) {
gameRef.current.openPlayersModal()
}
}
const handleNewGame = () => {
if (window.confirm('Начать новую игру? Текущий прогресс будет потерян.')) {
deleteCookie('gameQuestions')
deleteCookie('gameQuestionIndex')
deleteCookie('gamePlayers')
deleteCookie('gamePlayerScores')
deleteCookie('gameCurrentPlayerId')
deleteCookie('gameRevealedAnswers')
deleteCookie('gameOver')
setQuestions(initialQuestions)
setCurrentQuestionIndex(0)
if (gameRef.current) {
gameRef.current.newGame()
}
}
}
const handleShowAll = () => {
if (gameRef.current && gameRef.current.showAllAnswers) {
gameRef.current.showAllAnswers()
setTimeout(() => {
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
}
}, 100)
}
}
return (
<div className="app">
<Snowflakes />
<div className="app-content">
<div className="app-title-bar">
<div className="app-control-buttons">
<button
className="control-button control-button-players"
onClick={handleOpenPlayersModal}
title="Управление участниками"
>
👥
</button>
<button
className="control-button control-button-questions"
onClick={() => setIsQuestionsModalOpen(true)}
title="Управление вопросами"
>
</button>
<button
className="control-button control-button-new-game"
onClick={handleNewGame}
title="Новая игра"
>
🎮
</button>
</div>
<h1 className="app-title">
<span className="title-number">100</span>
<span className="title-to">к</span>
<span className="title-number">1</span>
</h1>
{questions.length > 0 && currentQuestion && (
<div className="question-counter-wrapper">
<div className="question-counter">
{currentQuestionIndex + 1}/{questions.length}
</div>
<button
className="show-all-button-top"
onClick={handleShowAll}
title={areAllRevealed ? "Скрыть все ответы" : "Показать все ответы"}
>
{areAllRevealed ? "Скрыть все" : "Показать все"}
</button>
</div>
)}
</div>
<QuestionsModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
questions={questions}
onUpdateQuestions={handleUpdateQuestions}
/>
<Game
ref={gameRef}
questions={questions}
currentQuestionIndex={currentQuestionIndex}
onQuestionIndexChange={setCurrentQuestionIndex}
onQuestionsChange={setQuestions}
/>
</div>
</div>
)
}
export default LocalGameApp

View file

@ -72,3 +72,4 @@ const PlayersModal = ({ isOpen, onClose, players, onAddPlayer, onRemovePlayer })
export default PlayersModal export default PlayersModal

View file

@ -330,3 +330,4 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
export default QuestionsModal export default QuestionsModal

104
src/context/AuthContext.jsx Normal file
View file

@ -0,0 +1,104 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi } from '../services/api';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const storedUser = localStorage.getItem('user');
const storedToken = localStorage.getItem('token');
if (storedUser && storedToken) {
setUser(JSON.parse(storedUser));
setToken(storedToken);
}
setLoading(false);
}, []);
const loginAnonymous = async (name) => {
try {
const response = await authApi.createAnonymous(name);
const { user: newUser, token: newToken } = response.data;
setUser(newUser);
setToken(newToken);
localStorage.setItem('user', JSON.stringify(newUser));
localStorage.setItem('token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
console.error('Anonymous login error:', error);
throw error;
}
};
const register = async (email, password, name) => {
try {
const response = await authApi.register(email, password, name);
const { user: newUser, token: newToken } = response.data;
setUser(newUser);
setToken(newToken);
localStorage.setItem('user', JSON.stringify(newUser));
localStorage.setItem('token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
console.error('Registration error:', error);
throw error;
}
};
const login = async (email, password) => {
try {
const response = await authApi.login(email, password);
const { user: newUser, token: newToken } = response.data;
setUser(newUser);
setToken(newToken);
localStorage.setItem('user', JSON.stringify(newUser));
localStorage.setItem('token', newToken);
return { user: newUser, token: newToken };
} catch (error) {
console.error('Login error:', error);
throw error;
}
};
const logout = () => {
setUser(null);
setToken(null);
localStorage.removeItem('user');
localStorage.removeItem('token');
};
const value = {
user,
token,
loading,
loginAnonymous,
register,
login,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View file

@ -19,7 +19,7 @@ export const questions = [
{ text: 'Икру', points: 80 }, { text: 'Икру', points: 80 },
{ text: 'Варенье', points: 60 }, { text: 'Варенье', points: 60 },
{ text: 'Паштет', points: 40 }, { text: 'Паштет', points: 40 },
{ text: 'Плавленный сыр', points: 20 }, { text: 'Майонез', points: 20 },
{ text: 'Горчицу', points: 10 }, { text: 'Горчицу', points: 10 },
], ],
}, },
@ -41,10 +41,8 @@ export const questions = [
answers: [ answers: [
{ text: 'Боится умереть', points: 100 }, { text: 'Боится умереть', points: 100 },
{ text: 'Неудобно (копыта мешают)', points: 80 }, { text: 'Неудобно (копыта мешают)', points: 80 },
{ text: 'Не хочет, бросила', points: 60 }, { text: 'Не хочет', points: 60 },
{ text: 'Ведёт здоровый образ жизни (вредно)', points: 40 }, { text: 'Не продают', points: 40 },
{ text: 'Болеет', points: 20 },
{ text: 'Не предлагают', points: 10 },
], ],
}, },
{ {
@ -63,24 +61,23 @@ export const questions = [
id: 10, id: 10,
text: 'Кто больше всех ест на Новый год?', text: 'Кто больше всех ест на Новый год?',
answers: [ answers: [
{ text: 'Дети', points: 100 }, { text: 'Миша', points: 100 },
{ text: 'Мужчины', points: 80 }, { text: 'Егор', points: 40 },
{ text: 'Все', points: 60 }, { text: 'Лера', points: 40 },
{ text: 'Женщины', points: 40 }, { text: 'Бабуля', points: 40 },
{ text: 'Подростки', points: 20 }, { text: 'Вика', points: 40 },
{ text: 'Бабушки', points: 10 },
], ],
}, },
{ {
id: 13, id: 13,
text: 'Кто лучше всех говорит тосты?', text: 'Кто лучше всех говорит тосты?',
answers: [ answers: [
{ text: 'Дедушка', points: 100 }, { text: 'Миша', points: 100 },
{ text: 'Папа', points: 80 }, { text: 'Андрей', points: 80 },
{ text: 'Дядя', points: 60 }, { text: 'Егор', points: 60 },
{ text: 'Муж', points: 40 }, { text: 'Бабуля', points: 40 },
{ text: 'Друзья', points: 20 }, { text: 'Надя', points: 20 },
{ text: 'Все', points: 10 }, { text: 'ИИ', points: 10 },
], ],
}, },
{ {
@ -123,24 +120,21 @@ export const questions = [
id: 8, id: 8,
text: 'Кто дольше всех собирается за стол?', text: 'Кто дольше всех собирается за стол?',
answers: [ answers: [
{ text: 'Женщины', points: 100 }, { text: 'Надя', points: 100 },
{ text: 'Дети', points: 80 }, { text: 'Вика', points: 40 },
{ text: 'Мужчины', points: 60 }, { text: 'Лера', points: 40 },
{ text: 'Бабушки', points: 40 }, { text: 'Бабуля', points: 40 },
{ text: 'Подростки', points: 20 }, { text: 'Катя', points: 40 },
{ text: 'Дедушки', points: 10 }, { text: 'Миша', points: 40 },
{ text: 'Андрей', points: 40 },
], ],
}, },
{ {
id: 24, id: 24,
text: 'Где мы встретим следующий новый год?', text: 'Где мы встретим следующий новый год?',
answers: [ answers: [
{ text: 'Дома', points: 100 }, { text: 'Тут', points: 50 },
{ text: 'В лесу', points: 80 }, { text: 'Не тут', points: 50 },
{ text: 'С друзьями', points: 60 },
{ text: 'На улице', points: 40 },
{ text: 'На природе', points: 20 },
{ text: 'В бане', points: 10 },
], ],
}, },
{ {
@ -203,6 +197,17 @@ export const questions = [
{ text: 'Свечи', points: 10 }, { text: 'Свечи', points: 10 },
], ],
}, },
{
id: 333,
text: 'Что важнее всего в новогоднюю ночь?',
answers: [
{ text: 'Семя', points: 100 },
{ text: 'Компания', points: 80 },
{ text: 'Оливье', points: 60 },
{ text: 'Доесть еду', points: 40 },
{ text: 'Миша', points: 20 },
],
},
{ {
id: 4, id: 4,
text: 'Чем обычно заканчивается новогодняя ночь?', text: 'Чем обычно заканчивается новогодняя ночь?',

152
src/hooks/useRoom.js Normal file
View file

@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react';
import { roomsApi } from '../services/api';
import socketService from '../services/socket';
export const useRoom = (roomCode) => {
const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [participants, setParticipants] = useState([]);
useEffect(() => {
if (!roomCode) {
setLoading(false);
return;
}
const fetchRoom = async () => {
try {
setLoading(true);
const response = await roomsApi.getByCode(roomCode);
setRoom(response.data);
setParticipants(response.data.participants || []);
setError(null);
} catch (err) {
setError(err.message);
console.error('Error fetching room:', err);
} finally {
setLoading(false);
}
};
fetchRoom();
// Connect to WebSocket
socketService.connect();
// Listen for room updates
const handleRoomUpdate = (updatedRoom) => {
setRoom(updatedRoom);
setParticipants(updatedRoom.participants || []);
};
const handleGameStarted = (updatedRoom) => {
setRoom(updatedRoom);
};
const handleAnswerRevealed = (data) => {
console.log('Answer revealed:', data);
};
const handleScoreUpdated = (data) => {
console.log('Score updated:', data);
setParticipants((prev) =>
prev.map((p) =>
p.id === data.participantId ? { ...p, score: data.score } : p
)
);
};
const handleQuestionChanged = (data) => {
console.log('Question changed:', data);
};
const handleGameEnded = (data) => {
console.log('Game ended:', data);
if (room) {
setRoom({ ...room, status: 'FINISHED' });
}
};
socketService.on('roomUpdate', handleRoomUpdate);
socketService.on('gameStarted', handleGameStarted);
socketService.on('answerRevealed', handleAnswerRevealed);
socketService.on('scoreUpdated', handleScoreUpdated);
socketService.on('questionChanged', handleQuestionChanged);
socketService.on('gameEnded', handleGameEnded);
return () => {
socketService.off('roomUpdate', handleRoomUpdate);
socketService.off('gameStarted', handleGameStarted);
socketService.off('answerRevealed', handleAnswerRevealed);
socketService.off('scoreUpdated', handleScoreUpdated);
socketService.off('questionChanged', handleQuestionChanged);
socketService.off('gameEnded', handleGameEnded);
};
}, [roomCode]);
const createRoom = useCallback(async (hostId, questionPackId, settings = {}) => {
try {
const response = await roomsApi.create(hostId, questionPackId, settings);
setRoom(response.data);
return response.data;
} catch (err) {
setError(err.message);
throw err;
}
}, []);
const joinRoom = useCallback(async (roomId, userId, name, role = 'PLAYER') => {
try {
const response = await roomsApi.join(roomId, userId, name, role);
return response.data;
} catch (err) {
setError(err.message);
throw err;
}
}, []);
const startGame = useCallback(() => {
if (room) {
socketService.startGame(room.id, room.code);
}
}, [room]);
const revealAnswer = useCallback((answerIndex) => {
if (room) {
socketService.revealAnswer(room.code, answerIndex);
}
}, [room]);
const updateScore = useCallback((participantId, score) => {
if (room) {
socketService.updateScore(participantId, score, room.code);
}
}, [room]);
const nextQuestion = useCallback(() => {
if (room) {
socketService.nextQuestion(room.code);
}
}, [room]);
const endGame = useCallback(() => {
if (room) {
socketService.endGame(room.id, room.code);
}
}, [room]);
return {
room,
participants,
loading,
error,
createRoom,
joinRoom,
startGame,
revealAnswer,
updateScore,
nextQuestion,
endGame,
};
};

147
src/pages/CreateRoom.jsx Normal file
View file

@ -0,0 +1,147 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom';
import { questionsApi } from '../services/api';
const CreateRoom = () => {
const navigate = useNavigate();
const { user } = useAuth();
const { createRoom, loading: roomLoading } = useRoom();
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [settings, setSettings] = useState({
maxPlayers: 10,
allowSpectators: true,
timerEnabled: false,
timerDuration: 30,
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPacks = async () => {
try {
const response = await questionsApi.getPacks(user?.id);
setQuestionPacks(response.data);
if (response.data.length > 0) {
setSelectedPackId(response.data[0].id);
}
} catch (error) {
console.error('Error fetching question packs:', error);
} finally {
setLoading(false);
}
};
fetchPacks();
}, [user]);
const handleCreateRoom = async () => {
if (!user || !selectedPackId) {
alert('Выберите пак вопросов');
return;
}
try {
const room = await createRoom(user.id, selectedPackId, settings);
navigate(`/room/${room.code}`);
} catch (error) {
console.error('Error creating room:', error);
alert('Ошибка создания комнаты');
}
};
if (loading) {
return <div className="loading">Загрузка...</div>;
}
return (
<div className="create-room-page">
<div className="create-room-container">
<h1>Создать комнату</h1>
<div className="form-group">
<label>Выберите пак вопросов:</label>
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
>
{questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
</div>
<div className="form-group">
<label>Максимум игроков:</label>
<input
type="number"
min="2"
max="20"
value={settings.maxPlayers}
onChange={(e) =>
setSettings({ ...settings, maxPlayers: parseInt(e.target.value) })
}
/>
</div>
<div className="form-group checkbox">
<label>
<input
type="checkbox"
checked={settings.allowSpectators}
onChange={(e) =>
setSettings({ ...settings, allowSpectators: e.target.checked })
}
/>
Разрешить зрителей
</label>
</div>
<div className="form-group checkbox">
<label>
<input
type="checkbox"
checked={settings.timerEnabled}
onChange={(e) =>
setSettings({ ...settings, timerEnabled: e.target.checked })
}
/>
Включить таймер
</label>
</div>
{settings.timerEnabled && (
<div className="form-group">
<label>Время на ответ (сек):</label>
<input
type="number"
min="10"
max="120"
value={settings.timerDuration}
onChange={(e) =>
setSettings({ ...settings, timerDuration: parseInt(e.target.value) })
}
/>
</div>
)}
<div className="button-group">
<button
onClick={handleCreateRoom}
disabled={roomLoading || !selectedPackId}
className="primary"
>
{roomLoading ? 'Создание...' : 'Создать комнату'}
</button>
<button onClick={() => navigate('/')}>Назад</button>
</div>
</div>
</div>
);
};
export default CreateRoom;

69
src/pages/Home.jsx Normal file
View file

@ -0,0 +1,69 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const Home = () => {
const navigate = useNavigate();
const { user, loginAnonymous, isAuthenticated } = useAuth();
useEffect(() => {
const initAuth = async () => {
if (!isAuthenticated) {
try {
await loginAnonymous('Гость');
} catch (error) {
console.error('Auto login failed:', error);
}
}
};
initAuth();
}, [isAuthenticated, loginAnonymous]);
const handleCreateRoom = () => {
navigate('/create-room');
};
const handleJoinRoom = () => {
navigate('/join-room');
};
const handleLocalGame = () => {
navigate('/local-game');
};
return (
<div className="home-page">
<div className="home-container">
<h1>100 к 1</h1>
<p className="welcome-text">
{user ? `Привет, ${user.name}!` : 'Добро пожаловать!'}
</p>
<div className="menu-buttons">
<button onClick={handleCreateRoom} className="menu-button primary">
Создать комнату
</button>
<button onClick={handleJoinRoom} className="menu-button">
Присоединиться к комнате
</button>
<button onClick={handleLocalGame} className="menu-button">
Локальная игра
</button>
</div>
{user && (
<div className="user-stats">
<p>Игр сыграно: {user.gamesPlayed || 0}</p>
<p>Побед: {user.gamesWon || 0}</p>
<p>Очков: {user.totalPoints || 0}</p>
</div>
)}
</div>
</div>
);
};
export default Home;

55
src/pages/JoinRoom.jsx Normal file
View file

@ -0,0 +1,55 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
const JoinRoom = () => {
const navigate = useNavigate();
const [roomCode, setRoomCode] = useState('');
const handleJoin = () => {
if (roomCode.trim().length === 6) {
navigate(`/room/${roomCode.toUpperCase()}`);
} else {
alert('Введите 6-символьный код комнаты');
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter') {
handleJoin();
}
};
return (
<div className="join-room-page">
<div className="join-room-container">
<h1>Присоединиться к комнате</h1>
<div className="form-group">
<label>Код комнаты:</label>
<input
type="text"
value={roomCode}
onChange={(e) => setRoomCode(e.target.value.toUpperCase())}
onKeyPress={handleKeyPress}
placeholder="Введите код"
maxLength={6}
autoFocus
/>
</div>
<div className="button-group">
<button
onClick={handleJoin}
disabled={roomCode.trim().length !== 6}
className="primary"
>
Присоединиться
</button>
<button onClick={() => navigate('/')}>Назад</button>
</div>
</div>
</div>
);
};
export default JoinRoom;

8
src/pages/LocalGame.jsx Normal file
View file

@ -0,0 +1,8 @@
import React from 'react';
import LocalGameApp from '../components/LocalGameApp';
const LocalGame = () => {
return <LocalGameApp />;
};
export default LocalGame;

127
src/pages/RoomPage.jsx Normal file
View file

@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom';
import QRCode from 'qrcode';
const RoomPage = () => {
const { roomCode } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const { room, participants, loading, error, joinRoom, startGame } = useRoom(roomCode);
const [qrCode, setQrCode] = useState('');
const [joined, setJoined] = useState(false);
useEffect(() => {
const generateQR = async () => {
try {
const url = `${window.location.origin}/join-room?code=${roomCode}`;
const qr = await QRCode.toDataURL(url);
setQrCode(qr);
} catch (err) {
console.error('QR generation error:', err);
}
};
if (roomCode) {
generateQR();
}
}, [roomCode]);
useEffect(() => {
const handleJoin = async () => {
if (room && user && !joined) {
const isParticipant = participants.some((p) => p.userId === user.id);
if (!isParticipant) {
try {
await joinRoom(room.id, user.id, user.name || 'Гость', 'PLAYER');
setJoined(true);
} catch (error) {
console.error('Join error:', error);
}
} else {
setJoined(true);
}
}
};
handleJoin();
}, [room, user, participants, joined, joinRoom]);
const handleStartGame = () => {
startGame();
navigate(`/game/${roomCode}`);
};
if (loading) {
return <div className="loading">Загрузка комнаты...</div>;
}
if (error) {
return (
<div className="error-page">
<h1>Ошибка</h1>
<p>{error}</p>
<button onClick={() => navigate('/')}>На главную</button>
</div>
);
}
if (!room) {
return (
<div className="error-page">
<h1>Комната не найдена</h1>
<button onClick={() => navigate('/')}>На главную</button>
</div>
);
}
const isHost = user && room.hostId === user.id;
return (
<div className="room-page">
<div className="room-container">
<h1>Комната: {room.code}</h1>
<div className="room-info">
<p>Статус: {room.status === 'WAITING' ? 'Ожидание игроков' : room.status}</p>
<p>Игроков: {participants.length}/{room.maxPlayers}</p>
</div>
{qrCode && (
<div className="qr-code">
<h3>QR-код для присоединения:</h3>
<img src={qrCode} alt="QR Code" />
</div>
)}
<div className="participants-list">
<h3>Участники:</h3>
<ul>
{participants.map((participant) => (
<li key={participant.id}>
{participant.name} {participant.role === 'HOST' && '(Ведущий)'}
{participant.role === 'SPECTATOR' && '(Зритель)'}
</li>
))}
</ul>
</div>
<div className="button-group">
{isHost && room.status === 'WAITING' && (
<button
onClick={handleStartGame}
disabled={participants.length < 2}
className="primary"
>
Начать игру
</button>
)}
<button onClick={() => navigate('/')}>Покинуть комнату</button>
</div>
</div>
</div>
);
};
export default RoomPage;

42
src/services/api.js Normal file
View file

@ -0,0 +1,42 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const api = axios.create({
baseURL: API_URL,
withCredentials: true,
});
// Auth endpoints
export const authApi = {
createAnonymous: (name) => api.post('/auth/anonymous', { name }),
register: (email, password, name) => api.post('/auth/register', { email, password, name }),
login: (email, password) => api.post('/auth/login', { email, password }),
};
// Rooms endpoints
export const roomsApi = {
create: (hostId, questionPackId, settings) =>
api.post('/rooms', { hostId, questionPackId, settings }),
getByCode: (code) => api.get(`/rooms/${code}`),
join: (roomId, userId, name, role) =>
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
};
// Questions endpoints
export const questionsApi = {
createPack: (data) => api.post('/questions/packs', data),
getPacks: (userId) => api.get('/questions/packs', { params: { userId } }),
getPack: (id) => api.get(`/questions/packs/${id}`),
updatePack: (id, data) => api.put(`/questions/packs/${id}`, data),
deletePack: (id) => api.delete(`/questions/packs/${id}`),
};
// Stats endpoints
export const statsApi = {
createHistory: (data) => api.post('/stats/game-history', data),
getHistory: (userId) => api.get(`/stats/game-history/${userId}`),
getUserStats: (userId) => api.get(`/stats/user/${userId}`),
};
export default api;

102
src/services/socket.js Normal file
View file

@ -0,0 +1,102 @@
import { io } from 'socket.io-client';
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000';
class SocketService {
constructor() {
this.socket = null;
this.listeners = new Map();
}
connect() {
if (this.socket?.connected) {
return this.socket;
}
this.socket = io(WS_URL, {
withCredentials: true,
transports: ['websocket', 'polling'],
});
this.socket.on('connect', () => {
console.log('WebSocket connected:', this.socket.id);
});
this.socket.on('disconnect', () => {
console.log('WebSocket disconnected');
});
this.socket.on('error', (error) => {
console.error('WebSocket error:', error);
});
return this.socket;
}
disconnect() {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
on(event, callback) {
if (!this.socket) {
this.connect();
}
this.socket.on(event, callback);
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
off(event, callback) {
if (this.socket) {
this.socket.off(event, callback);
}
if (this.listeners.has(event)) {
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
emit(event, data) {
if (!this.socket) {
this.connect();
}
this.socket.emit(event, data);
}
// Game-specific methods
joinRoom(roomCode, userId) {
this.emit('joinRoom', { roomCode, userId });
}
startGame(roomId, roomCode) {
this.emit('startGame', { roomId, roomCode });
}
revealAnswer(roomCode, answerIndex) {
this.emit('revealAnswer', { roomCode, answerIndex });
}
updateScore(participantId, score, roomCode) {
this.emit('updateScore', { participantId, score, roomCode });
}
nextQuestion(roomCode) {
this.emit('nextQuestion', { roomCode });
}
endGame(roomId, roomCode) {
this.emit('endGame', { roomId, roomCode });
}
}
export default new SocketService();