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
# 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
npm install
### 🌐 Мультиплеер (NEW!)
- **Игровые комнаты** с уникальными кодами
- **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
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
```
## Правила игры
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
2. Coolify автоматически определит Dockerfile
3. Проект будет собран и развёрнут автоматически
## 📊 API Endpoints
### Локальная сборка для проверки
### 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
# Сборка образа
docker build -t sto-k-odnomu .
# Backend + PostgreSQL
cd backend
docker-compose up -d
# Запуск контейнера
docker run -p 8080:80 sto-k-odnomu
# Только PostgreSQL
docker-compose up -d postgres
```
Сайт будет доступен по адресу `http://localhost:8080`
## 📝 Разработка
## Технологии
### Backend
- React 18
- Vite
- CSS3 с анимациями
- Docker + Nginx (для production)
```bash
cd backend
npm run start:dev # Dev режим
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"
},
"dependencies": {
"axios": "^1.13.2",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.4",
"qrcode": "^1.5.4",
"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": {
"@types/react": "^18.2.0",
@ -18,4 +24,3 @@
"vite": "^5.0.0"
}
}

View file

@ -1,168 +1,27 @@
import { useState, useRef, useEffect } from 'react'
import Game from './components/Game'
import Snowflakes from './components/Snowflakes'
import QuestionsModal from './components/QuestionsModal'
import { questions as initialQuestions } from './data/questions'
import { getCookie, setCookie, deleteCookie } from './utils/cookies'
import './App.css'
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Home from './pages/Home';
import CreateRoom from './pages/CreateRoom';
import JoinRoom from './pages/JoinRoom';
import RoomPage from './pages/RoomPage';
import LocalGame from './pages/LocalGame';
import './App.css';
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 (
<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>
)
<AuthProvider>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/create-room" element={<CreateRoom />} />
<Route path="/join-room" element={<JoinRoom />} />
<Route path="/room/:roomCode" element={<RoomPage />} />
<Route path="/local-game" element={<LocalGame />} />
</Routes>
</Router>
</AuthProvider>
);
}
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

View file

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