This commit is contained in:
Dmitry 2026-01-05 00:48:55 +03:00
parent 0b64cc5d8b
commit 3b879f80d4
23 changed files with 2115 additions and 94 deletions

109
README.md
View file

@ -28,11 +28,11 @@
### Backend ### Backend
- **NestJS** - TypeScript фреймворк - **NestJS** - TypeScript фреймворк
- **PostgreSQL** - база данных - **PostgreSQL** - база данных (запускается отдельно в Coolify)
- **Prisma ORM** - работа с БД - **Prisma ORM** - работа с БД
- **Socket.IO** - WebSocket сервер - **Socket.IO** - WebSocket сервер
- **JWT** - авторизация - **JWT** - авторизация
- **Docker** - контейнеризация - **ConfigModule** - управление переменными окружения
## 📁 Структура проекта ## 📁 Структура проекта
@ -49,7 +49,6 @@ sto_k_odnomu/
│ ├── prisma/ │ ├── prisma/
│ │ ├── schema.prisma # Схема БД │ │ ├── schema.prisma # Схема БД
│ │ └── seed.ts # Seed данные │ │ └── seed.ts # Seed данные
│ └── docker-compose.yml # Docker конфиг
├── src/ # React Frontend ├── src/ # React Frontend
│ ├── pages/ # Страницы │ ├── pages/ # Страницы
@ -70,22 +69,29 @@ sto_k_odnomu/
### 1. Backend ### 1. Backend
**Требования:**
- PostgreSQL должен быть запущен отдельно (например, в Coolify)
- Переменные окружения должны быть настроены в системе или через Coolify
**Переменные окружения:**
- `DATABASE_URL` - строка подключения к PostgreSQL
- `JWT_SECRET` - секретный ключ для JWT токенов
- `PORT` - порт для backend (по умолчанию 3000)
- `HOST` - хост для backend (по умолчанию 0.0.0.0)
- `CORS_ORIGIN` - домен frontend приложения, с которого разрешены запросы к backend (по умолчанию http://localhost:5173)
**Важно:** `CORS_ORIGIN` - это домен, где работает frontend, а не backend. Например, если frontend на `https://example.com`, а backend на `https://api.example.com`, то `CORS_ORIGIN` должен быть `https://example.com`.
```bash ```bash
cd backend cd backend
# Установить зависимости # Установить зависимости
npm install npm install
# Настроить .env
cp .env.example .env
# Запустить PostgreSQL (Docker)
docker-compose up -d postgres
# Выполнить миграции # Выполнить миграции
npx prisma migrate dev --name init npx prisma migrate dev --name init
# Заполнить демо-данными # Заполнить демо-данными (опционально)
npm run prisma:seed npm run prisma:seed
# Запустить backend # Запустить backend
@ -157,17 +163,86 @@ Frontend: http://localhost:5173
- Демо пользователь - Демо пользователь
- 2 пака вопросов (общие, семейные) - 2 пака вопросов (общие, семейные)
## 🐳 Docker ## ⚙️ Переменные окружения
```bash Приложение использует переменные окружения напрямую через `@nestjs/config`. Все переменные должны быть настроены в системе или через Coolify:
# Backend + PostgreSQL
cd backend
docker-compose up -d
# Только PostgreSQL ### Backend переменные:
docker-compose up -d postgres - `DATABASE_URL` - строка подключения к PostgreSQL (например: `postgresql://user:password@host:5432/dbname`)
- `JWT_SECRET` - секретный ключ для JWT токенов (см. ниже как сгенерировать)
- `PORT` - порт для backend (по умолчанию 3000)
- `HOST` - хост для backend (по умолчанию 0.0.0.0)
- `CORS_ORIGIN` - **домен frontend приложения**, с которого разрешены запросы к backend (по умолчанию http://localhost:5173)
**Важно:** `CORS_ORIGIN` - это домен, где работает frontend, а не backend. Это настройка безопасности, которая говорит backend, с каких доменов принимать запросы. Например:
- Frontend: `https://example.com`
- Backend: `https://api.example.com`
- `CORS_ORIGIN` должен быть: `https://example.com`
### Frontend переменные:
- `VITE_API_URL` - URL backend API (по умолчанию http://localhost:3000)
- `VITE_WS_URL` - URL WebSocket сервера (по умолчанию http://localhost:3000)
**Почему префикс `VITE_`?**
Vite (инструмент сборки) требует префикс `VITE_` для переменных окружения, которые должны быть доступны в клиентском коде. Это сделано для безопасности — только переменные с этим префиксом встраиваются в собранный JavaScript код.
**Как это работает:**
- В коде используется `import.meta.env.VITE_API_URL` (см. `src/services/api.js`)
- Vite заменяет эти значения на этапе сборки
- Без префикса `VITE_` переменная не будет доступна в браузере
**Пример использования:**
```javascript
// В коде frontend
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
``` ```
**Примечание:** PostgreSQL должен быть запущен отдельно как отдельное приложение в Coolify.
### 🔑 Как сгенерировать JWT_SECRET?
`JWT_SECRET` - это секретный ключ для подписи и проверки JWT токенов. Он должен быть:
- **Случайным** и криптографически стойким
- **Длинным** (минимум 32 символа, рекомендуется 64+)
- **Уникальным** для каждого приложения
- **Секретным** - никогда не коммитьте в Git!
#### Способы генерации:
**1. Используя Node.js:**
```bash
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
```
**2. Используя OpenSSL:**
```bash
openssl rand -hex 64
```
**3. Используя Python:**
```bash
python3 -c "import secrets; print(secrets.token_hex(64))"
```
**4. Онлайн генераторы:**
- Можно использовать, но не рекомендуется для production
- Пример: https://generate-secret.vercel.app/64
#### Пример сгенерированного ключа:
```
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2g3h4i5j6k7l8m9n0o1p2q3r4s5t6u7v8w9x0y1z2
```
#### Где использовать:
1. **В Coolify**: Добавьте переменную окружения `JWT_SECRET` в настройках приложения
2. **Локально для разработки**: Можно использовать `.env` файл (но не коммитить его!)
**⚠️ ВАЖНО:**
- Используйте **разные** `JWT_SECRET` для development и production
- Если измените `JWT_SECRET`, все существующие токены станут недействительными
- Храните секрет в безопасности - это критически важно для безопасности приложения
## 📝 Разработка ## 📝 Разработка
### Backend ### Backend

View file

@ -7,37 +7,35 @@ NestJS backend для мультиплеерной игры "100 к 1" с WebSoc
### Предварительные требования ### Предварительные требования
- Node.js 18+ - Node.js 18+
- PostgreSQL 15+ - PostgreSQL 15+ (должен быть запущен отдельно, например, в Coolify)
- Docker (опционально, для запуска PostgreSQL в контейнере)
### Установка ### Установка
**Важно:** PostgreSQL должен быть запущен отдельно как отдельное приложение. Все переменные окружения должны быть настроены в системе или через Coolify.
```bash ```bash
# 1. Установить зависимости # 1. Установить зависимости
npm install npm install
# 2. Настроить переменные окружения # 2. Настроить переменные окружения
cp .env.example .env # Переменные должны быть установлены в системе или через Coolify:
# Отредактировать .env с вашими настройками # - DATABASE_URL - строка подключения к PostgreSQL
# - JWT_SECRET - секретный ключ для JWT токенов
# - PORT - порт для backend (по умолчанию 3000)
# - HOST - хост для backend (по умолчанию 0.0.0.0)
# - CORS_ORIGIN - домен frontend приложения, с которого разрешены запросы (по умолчанию http://localhost:5173)
# ВАЖНО: CORS_ORIGIN - это домен frontend, а не backend!
# 3. Запустить PostgreSQL # 3. Выполнить миграции
# Вариант A: Использовать Docker Compose
docker-compose up -d postgres
# Вариант B: Использовать локальный PostgreSQL
# Создать базу данных вручную:
# createdb sto_k_odnomu
# 4. Выполнить миграции
npx prisma migrate dev --name init npx prisma migrate dev --name init
# 5. Заполнить демо-данными (опционально) # 4. Заполнить демо-данными (опционально)
npm run prisma:seed npm run prisma:seed
# 6. Сгенерировать Prisma Client # 5. Сгенерировать Prisma Client
npx prisma generate npx prisma generate
# 7. Запустить backend # 6. Запустить backend
npm run start:dev npm run start:dev
``` ```
@ -59,9 +57,7 @@ backend/
├── prisma/ ├── prisma/
│ ├── schema.prisma # Схема базы данных │ ├── schema.prisma # Схема базы данных
│ └── seed.ts # Seed скрипт с демо-данными │ └── seed.ts # Seed скрипт с демо-данными
├── Dockerfile # Docker конфигурация └── Dockerfile # Docker конфигурация
├── docker-compose.yml # Docker Compose (PostgreSQL + Backend)
└── .env.example # Пример переменных окружения
``` ```
## 🗄️ База данных ## 🗄️ База данных
@ -158,33 +154,66 @@ npm run prisma:migrate # Создание и применение миграц
npm run prisma:seed # Заполнение БД демо-данными 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 Приложение использует переменные окружения напрямую через `@nestjs/config`. Все переменные должны быть настроены в системе или через Coolify:
- `DATABASE_URL` - строка подключения к PostgreSQL (например: `postgresql://user:password@host:5432/dbname`)
- `JWT_SECRET` - секретный ключ для JWT токенов (см. ниже как сгенерировать)
- `PORT` - порт для backend (по умолчанию 3000)
- `HOST` - хост для backend (по умолчанию 0.0.0.0)
- `CORS_ORIGIN` - **домен frontend приложения**, с которого разрешены запросы к backend (по умолчанию http://localhost:5173)
### Что такое CORS_ORIGIN?
`CORS_ORIGIN` - это настройка безопасности CORS (Cross-Origin Resource Sharing). Она указывает backend, с каких доменов разрешено принимать HTTP и WebSocket запросы.
**Важно:** `CORS_ORIGIN` - это домен, где работает **frontend**, а не backend!
**Пример:**
- Frontend работает на: `https://example.com`
- Backend работает на: `https://api.example.com`
- `CORS_ORIGIN` должен быть: `https://example.com` (домен frontend)
Это защищает backend от запросов с неавторизованных доменов.
### 🔑 Как сгенерировать JWT_SECRET?
`JWT_SECRET` - это секретный ключ для подписи и проверки JWT токенов. Он используется для создания и проверки токенов авторизации.
**Требования к JWT_SECRET:**
- Должен быть случайным и криптографически стойким
- Минимум 32 символа, рекомендуется 64+ символа
- Уникальный для каждого приложения
- **Никогда не коммитьте в Git!**
**Способы генерации:**
1. **Node.js:**
```bash
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
```
2. **OpenSSL:**
```bash
openssl rand -hex 64
```
3. **Python:**
```bash
python3 -c "import secrets; print(secrets.token_hex(64))"
```
**Где использовать:**
- В Coolify: Добавьте переменную окружения `JWT_SECRET` в настройках приложения
- Локально: Можно использовать `.env` файл (но не коммитить его в Git!)
**⚠️ ВАЖНО:**
- Используйте **разные** `JWT_SECRET` для development и production
- Если измените `JWT_SECRET`, все существующие токены станут недействительными
- Храните секрет в безопасности - это критически важно для безопасности приложения
**Примечание:** PostgreSQL должен быть запущен отдельно как отдельное приложение в Coolify. Файлы `.env` не используются - все переменные берутся из переменных окружения системы.
## 📝 Prisma Studio ## 📝 Prisma Studio

View file

@ -1,7 +1,6 @@
// This file was generated by Prisma and assumes you have installed the following: // This file was generated by Prisma
// npm install --save-dev prisma dotenv // Uses environment variables directly (no dotenv dependency)
import "dotenv/config";
import { defineConfig, env } from "prisma/config"; import { defineConfig, env } from "prisma/config";
export default defineConfig({ export default defineConfig({

View file

@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -7,10 +8,14 @@ import { AuthController } from './auth.controller';
@Module({ @Module({
imports: [ imports: [
PassportModule, PassportModule,
JwtModule.register({ JwtModule.registerAsync({
secret: process.env.JWT_SECRET || 'your-secret-key', imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
signOptions: { expiresIn: '7d' }, signOptions: { expiresIn: '7d' },
}), }),
inject: [ConfigService],
}),
], ],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService], providers: [AuthService],

View file

@ -10,6 +10,8 @@ import { RoomsService } from '../rooms/rooms.service';
@WebSocketGateway({ @WebSocketGateway({
cors: { cors: {
// Примечание: декоратор выполняется на этапе инициализации,
// ConfigModule.forRoot() уже загружает переменные в process.env
origin: process.env.CORS_ORIGIN || 'http://localhost:5173', origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
credentials: true, credentials: true,
}, },

View file

@ -1,12 +1,14 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
app.enableCors({ app.enableCors({
origin: process.env.CORS_ORIGIN || 'http://localhost:5173', origin: configService.get<string>('CORS_ORIGIN') || 'http://localhost:5173',
credentials: true, credentials: true,
}); });
@ -15,7 +17,9 @@ async function bootstrap() {
transform: true, transform: true,
})); }));
await app.listen(process.env.PORT || 3000); const port = configService.get<number>('PORT') || 3000;
console.log(`Backend running on http://localhost:${process.env.PORT || 3000}`); const host = configService.get<string>('HOST') || '0.0.0.0';
await app.listen(port, host);
console.log(`Backend running on http://${host}:${port}`);
} }
bootstrap(); bootstrap();

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import Home from './pages/Home'; import Home from './pages/Home';
import CreateRoom from './pages/CreateRoom'; import CreateRoom from './pages/CreateRoom';
import JoinRoom from './pages/JoinRoom'; import JoinRoom from './pages/JoinRoom';
@ -10,6 +11,7 @@ import './App.css';
function App() { function App() {
return ( return (
<ThemeProvider>
<AuthProvider> <AuthProvider>
<Router> <Router>
<Routes> <Routes>
@ -21,6 +23,7 @@ function App() {
</Routes> </Routes>
</Router> </Router>
</AuthProvider> </AuthProvider>
</ThemeProvider>
); );
} }

View file

@ -4,6 +4,7 @@ import Players from './Players'
import PlayersModal from './PlayersModal' import PlayersModal from './PlayersModal'
import QuestionsModal from './QuestionsModal' import QuestionsModal from './QuestionsModal'
import { getCookie, setCookie, deleteCookie } from '../utils/cookies' import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
import { useVoice } from '../hooks/useVoice'
import './Game.css' import './Game.css'
const Game = forwardRef(({ const Game = forwardRef(({
@ -12,6 +13,7 @@ const Game = forwardRef(({
onQuestionIndexChange, onQuestionIndexChange,
onQuestionsChange, onQuestionsChange,
}, ref) => { }, ref) => {
const { playEffect } = useVoice();
const [players, setPlayers] = useState(() => { const [players, setPlayers] = useState(() => {
const savedPlayers = getCookie('gamePlayers') const savedPlayers = getCookie('gamePlayers')
return savedPlayers || [] return savedPlayers || []
@ -192,6 +194,9 @@ const Game = forwardRef(({
[currentPlayerId]: (playerScores[currentPlayerId] || 0) + points, [currentPlayerId]: (playerScores[currentPlayerId] || 0) + points,
}) })
// Play correct answer sound
playEffect('correct')
// Переходим к следующему участнику только если это не последний ответ // Переходим к следующему участнику только если это не последний ответ
if (!isLastAnswer) { if (!isLastAnswer) {
const nextPlayerId = getNextPlayerId() const nextPlayerId = getNextPlayerId()
@ -273,6 +278,11 @@ const Game = forwardRef(({
const maxScore = scores.length > 0 ? Math.max(...scores) : 0 const maxScore = scores.length > 0 ? Math.max(...scores) : 0
const winners = players.filter(p => playerScores[p.id] === maxScore) const winners = players.filter(p => playerScores[p.id] === maxScore)
// Play victory sound
useEffect(() => {
playEffect('victory')
}, [])
return ( return (
<div className="game-over"> <div className="game-over">
<div className="game-over-content"> <div className="game-over-content">

View file

@ -0,0 +1,388 @@
.host-admin-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
transition: all 0.3s ease;
}
.admin-toggle-button {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
border: 2px solid var(--border-color);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
padding: 0.5rem 1.5rem;
color: var(--text-primary);
font-weight: bold;
cursor: pointer;
transition: all var(--animation-speed) ease;
box-shadow: var(--shadow-sm);
font-size: 1rem;
}
.admin-toggle-button:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
box-shadow: var(--shadow-md);
}
.admin-panel-content {
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
border-top: 2px solid var(--border-color);
padding: 1.5rem;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
}
.admin-panel-title {
margin: 0 0 1.5rem 0;
font-size: 1.5rem;
color: var(--accent-primary);
text-align: center;
font-weight: bold;
}
.admin-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-section-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text-primary);
font-weight: bold;
}
.admin-button-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.admin-button-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.admin-button {
background: var(--bg-card);
color: var(--text-primary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0.75rem 1rem;
font-weight: bold;
font-size: 0.95rem;
cursor: pointer;
transition: all var(--animation-speed) ease;
white-space: nowrap;
}
.admin-button:hover:not(:disabled) {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.admin-button:active:not(:disabled) {
transform: translateY(0);
}
.admin-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.admin-button-start {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
}
.admin-button-start:hover:not(:disabled) {
background: var(--accent-success);
box-shadow: 0 4px 15px var(--accent-success);
}
.admin-button-end {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
color: #ffffff;
}
.admin-button-end:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-secondary);
}
.admin-button-next,
.admin-button-prev {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.admin-button-next:hover:not(:disabled),
.admin-button-prev:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-primary);
}
.admin-button-toggle {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
width: 100%;
}
.admin-button-toggle:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-primary);
}
/* Answers Control */
.answers-control-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin-top: 1rem;
}
.answer-control-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--animation-speed) ease;
text-align: left;
}
.answer-control-button.revealed {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
}
.answer-control-button.hidden {
opacity: 0.6;
}
.answer-control-button:hover {
transform: translateX(4px);
box-shadow: var(--shadow-md);
}
.answer-number {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
font-weight: bold;
font-size: 0.85rem;
flex-shrink: 0;
}
.answer-text {
flex: 1;
font-size: 0.9rem;
}
.answer-points {
font-weight: bold;
color: var(--accent-primary);
font-size: 0.95rem;
flex-shrink: 0;
}
.answer-control-button.revealed .answer-points {
color: #ffffff;
}
/* Scoring Controls */
.player-selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.player-selector label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.player-select {
padding: 0.75rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 1rem;
cursor: pointer;
transition: all var(--animation-speed) ease;
}
.player-select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.2);
}
.scoring-controls {
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-points {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.admin-button-small {
padding: 0.5rem;
font-size: 0.9rem;
}
.admin-button-success {
background: var(--accent-success);
border-color: var(--accent-success);
color: #ffffff;
}
.admin-button-success:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-success);
}
.admin-button-danger {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
color: #ffffff;
}
.admin-button-danger:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-secondary);
}
.custom-points {
display: flex;
gap: 0.5rem;
align-items: center;
}
.points-input {
flex: 1;
padding: 0.5rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 1rem;
text-align: center;
}
.points-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(255, 215, 0, 0.2);
}
.admin-button-custom {
flex: 2;
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.admin-button-custom:hover:not(:disabled) {
box-shadow: 0 4px 15px var(--accent-primary);
}
/* Info Section */
.admin-section-info {
background: rgba(0, 0, 0, 0.2);
}
.admin-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.admin-info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.admin-info-label {
font-size: 0.85rem;
color: var(--text-secondary);
opacity: 0.8;
}
.admin-info-value {
font-size: 1.1rem;
font-weight: bold;
color: var(--accent-primary);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.admin-panel-content {
max-height: 60vh;
}
.admin-button-grid {
grid-template-columns: 1fr;
}
.answers-control-grid {
grid-template-columns: 1fr;
}
.quick-points {
grid-template-columns: repeat(2, 1fr);
}
.admin-info-grid {
grid-template-columns: 1fr;
}
}
/* Custom Scrollbar */
.admin-panel-content::-webkit-scrollbar {
width: 8px;
}
.admin-panel-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.admin-panel-content::-webkit-scrollbar-thumb {
background: var(--accent-primary);
border-radius: 4px;
}
.admin-panel-content::-webkit-scrollbar-thumb:hover {
background: var(--accent-secondary);
}

View file

@ -0,0 +1,239 @@
import React, { useState } from 'react';
import './HostAdminPanel.css';
const HostAdminPanel = ({
gameStatus = 'WAITING',
currentQuestion,
currentQuestionIndex,
totalQuestions,
players = [],
onStartGame,
onNextQuestion,
onPreviousQuestion,
onRevealAnswer,
onHideAnswer,
onShowAllAnswers,
onHideAllAnswers,
onAwardPoints,
onPenalty,
onEndGame,
revealedAnswers = [],
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState(null);
const [customPoints, setCustomPoints] = useState(10);
const areAllAnswersRevealed = currentQuestion
? revealedAnswers.length === currentQuestion.answers.length
: false;
const handleRevealAnswer = (index) => {
if (revealedAnswers.includes(index)) {
onHideAnswer(index);
} else {
onRevealAnswer(index);
}
};
const handleAwardPoints = (points) => {
if (selectedPlayer) {
onAwardPoints(selectedPlayer, points);
}
};
const handlePenalty = () => {
if (selectedPlayer) {
onPenalty(selectedPlayer);
}
};
return (
<div className={`host-admin-panel ${isExpanded ? 'expanded' : ''}`}>
<button
className="admin-toggle-button"
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? 'Свернуть панель' : 'Развернуть панель ведущего'}
>
🎛 {isExpanded ? '▼' : '▲'}
</button>
{isExpanded && (
<div className="admin-panel-content">
<h3 className="admin-panel-title">Панель ведущего</h3>
{/* Game Control Section */}
<div className="admin-section">
<h4 className="admin-section-title">🎮 Управление игрой</h4>
<div className="admin-button-grid">
{gameStatus === 'WAITING' && (
<button
className="admin-button admin-button-start"
onClick={onStartGame}
disabled={players.length < 2}
>
Начать игру
</button>
)}
{gameStatus === 'PLAYING' && (
<>
<button
className="admin-button admin-button-prev"
onClick={onPreviousQuestion}
disabled={currentQuestionIndex === 0}
>
Предыдущий вопрос
</button>
<button
className="admin-button admin-button-next"
onClick={onNextQuestion}
disabled={currentQuestionIndex >= totalQuestions - 1}
>
Следующий вопрос
</button>
<button
className="admin-button admin-button-end"
onClick={onEndGame}
>
Завершить игру
</button>
</>
)}
</div>
</div>
{/* Answer Control Section */}
{gameStatus === 'PLAYING' && currentQuestion && (
<div className="admin-section">
<h4 className="admin-section-title">👁 Управление ответами</h4>
<div className="admin-button-row">
<button
className="admin-button admin-button-toggle"
onClick={areAllAnswersRevealed ? onHideAllAnswers : onShowAllAnswers}
>
{areAllAnswersRevealed ? '🙈 Скрыть все' : '👁 Показать все'}
</button>
</div>
<div className="answers-control-grid">
{currentQuestion.answers.map((answer, index) => (
<button
key={index}
className={`answer-control-button ${
revealedAnswers.includes(index) ? 'revealed' : 'hidden'
}`}
onClick={() => handleRevealAnswer(index)}
>
<span className="answer-number">{index + 1}</span>
<span className="answer-text">{answer.text}</span>
<span className="answer-points">{answer.points}</span>
</button>
))}
</div>
</div>
)}
{/* Manual Scoring Section */}
{gameStatus === 'PLAYING' && players.length > 0 && (
<div className="admin-section">
<h4 className="admin-section-title"> Ручное начисление баллов</h4>
<div className="player-selector">
<label>Выбрать игрока:</label>
<select
value={selectedPlayer || ''}
onChange={(e) => setSelectedPlayer(e.target.value || null)}
className="player-select"
>
<option value="">-- Выберите игрока --</option>
{players.map((player) => (
<option key={player.id} value={player.id}>
{player.name}
</option>
))}
</select>
</div>
{selectedPlayer && (
<div className="scoring-controls">
<div className="quick-points">
<button
className="admin-button admin-button-small admin-button-success"
onClick={() => handleAwardPoints(5)}
>
+5
</button>
<button
className="admin-button admin-button-small admin-button-success"
onClick={() => handleAwardPoints(10)}
>
+10
</button>
<button
className="admin-button admin-button-small admin-button-success"
onClick={() => handleAwardPoints(20)}
>
+20
</button>
<button
className="admin-button admin-button-small admin-button-danger"
onClick={handlePenalty}
>
Промах
</button>
</div>
<div className="custom-points">
<input
type="number"
min="1"
max="100"
value={customPoints}
onChange={(e) => setCustomPoints(parseInt(e.target.value) || 0)}
className="points-input"
/>
<button
className="admin-button admin-button-small admin-button-custom"
onClick={() => handleAwardPoints(customPoints)}
>
Начислить {customPoints}
</button>
</div>
</div>
)}
</div>
)}
{/* Game Info Section */}
<div className="admin-section admin-section-info">
<h4 className="admin-section-title">📊 Информация</h4>
<div className="admin-info-grid">
<div className="admin-info-item">
<span className="admin-info-label">Статус:</span>
<span className="admin-info-value">
{gameStatus === 'WAITING' ? 'Ожидание' :
gameStatus === 'PLAYING' ? 'Идет игра' :
gameStatus === 'FINISHED' ? 'Завершена' : gameStatus}
</span>
</div>
<div className="admin-info-item">
<span className="admin-info-label">Игроков:</span>
<span className="admin-info-value">{players.length}</span>
</div>
{gameStatus === 'PLAYING' && (
<div className="admin-info-item">
<span className="admin-info-label">Вопрос:</span>
<span className="admin-info-value">
{currentQuestionIndex + 1} / {totalQuestions}
</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default HostAdminPanel;

View file

@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react'
import Game from './Game' import Game from './Game'
import Snowflakes from './Snowflakes' import Snowflakes from './Snowflakes'
import QuestionsModal from './QuestionsModal' import QuestionsModal from './QuestionsModal'
import ThemeSwitcher from './ThemeSwitcher'
import VoiceSettings from './VoiceSettings'
import { questions as initialQuestions } from '../data/questions' import { questions as initialQuestions } from '../data/questions'
import { getCookie, setCookie, deleteCookie } from '../utils/cookies' import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
import '../App.css' import '../App.css'
@ -94,6 +96,8 @@ function LocalGameApp() {
<div className="app-content"> <div className="app-content">
<div className="app-title-bar"> <div className="app-title-bar">
<div className="app-control-buttons"> <div className="app-control-buttons">
<ThemeSwitcher />
<VoiceSettings />
<button <button
className="control-button control-button-players" className="control-button control-button-players"
onClick={handleOpenPlayersModal} onClick={handleOpenPlayersModal}

View file

@ -27,6 +27,14 @@
margin-bottom: clamp(10px, 2vh, 15px); margin-bottom: clamp(10px, 2vh, 15px);
} }
.question-text-wrapper {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
justify-content: center;
}
.question-nav-button { .question-nav-button {
width: clamp(40px, 6vw, 50px); width: clamp(40px, 6vw, 50px);
height: clamp(40px, 6vw, 50px); height: clamp(40px, 6vw, 50px);

View file

@ -1,4 +1,5 @@
import Answer from './Answer' import Answer from './Answer'
import VoicePlayer from './VoicePlayer'
import './Question.css' import './Question.css'
const Question = ({ const Question = ({
@ -28,7 +29,10 @@ const Question = ({
</button> </button>
)} )}
<div className="question-text-wrapper">
<h2 className="question-text">{question.text}</h2> <h2 className="question-text">{question.text}</h2>
<VoicePlayer text={question.text} />
</div>
{canGoNext && onNextQuestion && ( {canGoNext && onNextQuestion && (
<button <button
className="question-nav-button question-nav-button-next" className="question-nav-button question-nav-button-next"

View file

@ -0,0 +1,156 @@
.theme-switcher {
position: relative;
}
.theme-switcher-button {
width: clamp(30px, 4vw, 40px);
height: clamp(30px, 4vw, 40px);
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(1rem, 2vw, 1.3rem);
transition: all var(--animation-speed) ease;
box-shadow: var(--shadow-sm);
padding: 0;
}
.theme-switcher-button:hover {
transform: translateY(-2px) scale(1.1);
box-shadow: var(--shadow-md);
border-color: var(--border-glow);
background: var(--bg-card-hover);
}
.theme-switcher-button:active {
transform: translateY(0) scale(1);
}
.theme-switcher-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.2s ease;
}
.theme-switcher-menu {
position: absolute;
top: calc(100% + 10px);
left: 0;
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
border: 2px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 1.5rem;
min-width: 300px;
max-width: 400px;
box-shadow: var(--shadow-lg);
z-index: 1000;
animation: slideIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.theme-switcher-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
color: var(--text-primary);
text-align: center;
font-weight: bold;
}
.theme-switcher-grid {
display: grid;
gap: 0.75rem;
}
.theme-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
padding: 1rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--animation-speed) ease;
text-align: left;
}
.theme-option:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
transform: translateX(4px);
}
.theme-option.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: var(--bg-primary);
}
.theme-option.active .theme-option-icon {
transform: scale(1.2);
}
.theme-option-icon {
font-size: 1.5rem;
transition: transform var(--animation-speed) ease;
}
.theme-option-name {
font-size: 1rem;
font-weight: bold;
color: var(--text-primary);
}
.theme-option.active .theme-option-name,
.theme-option.active .theme-option-description {
color: inherit;
}
.theme-option-description {
font-size: 0.85rem;
color: var(--text-secondary);
opacity: 0.8;
line-height: 1.3;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.theme-switcher-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 280px;
max-width: 90vw;
}
}

View file

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { useTheme } from '../context/ThemeContext';
import './ThemeSwitcher.css';
const ThemeSwitcher = () => {
const { currentTheme, themes, changeTheme } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const handleThemeChange = (themeId) => {
changeTheme(themeId);
setIsOpen(false);
};
return (
<div className="theme-switcher">
<button
className="theme-switcher-button control-button"
onClick={() => setIsOpen(!isOpen)}
title="Сменить тему"
>
{themes[currentTheme].icon}
</button>
{isOpen && (
<>
<div className="theme-switcher-overlay" onClick={() => setIsOpen(false)} />
<div className="theme-switcher-menu">
<h3 className="theme-switcher-title">Выбрать тему</h3>
<div className="theme-switcher-grid">
{Object.values(themes).map((theme) => (
<button
key={theme.id}
className={`theme-option ${currentTheme === theme.id ? 'active' : ''}`}
onClick={() => handleThemeChange(theme.id)}
>
<span className="theme-option-icon">{theme.icon}</span>
<span className="theme-option-name">{theme.name}</span>
<span className="theme-option-description">{theme.description}</span>
</button>
))}
</div>
</div>
</>
)}
</div>
);
};
export default ThemeSwitcher;

View file

@ -0,0 +1,68 @@
.voice-player {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.voice-player-button {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: all var(--animation-speed) ease;
box-shadow: var(--shadow-sm);
padding: 0;
}
.voice-player-button:hover:not(:disabled) {
transform: scale(1.1);
box-shadow: var(--shadow-md);
border-color: var(--accent-primary);
background: var(--bg-card-hover);
}
.voice-player-button:active:not(:disabled) {
transform: scale(0.95);
}
.voice-player-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.voice-player-button.playing {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: var(--shadow-sm);
}
50% {
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
}
/* Inline mode */
.voice-player-inline {
display: inline-flex;
gap: 0.25rem;
}
.voice-player-inline .voice-player-button {
width: 24px;
height: 24px;
font-size: 0.9rem;
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { useVoice } from '../hooks/useVoice';
import './VoicePlayer.css';
const VoicePlayer = ({ text, autoPlay = false, showButton = true, children }) => {
const { isEnabled, isPlaying, currentText, speak, stop } = useVoice();
const isPlayingThis = isPlaying && currentText === text;
const handleClick = () => {
if (isPlayingThis) {
stop();
} else {
speak(text);
}
};
React.useEffect(() => {
if (autoPlay && isEnabled && text) {
speak(text);
}
}, [autoPlay, isEnabled, text]);
if (!isEnabled || !showButton) {
return children || null;
}
return (
<div className="voice-player">
{children}
{showButton && text && (
<button
className={`voice-player-button ${isPlayingThis ? 'playing' : ''}`}
onClick={handleClick}
title={isPlayingThis ? 'Остановить' : 'Озвучить'}
disabled={!isEnabled}
>
{isPlayingThis ? '⏹' : '🔊'}
</button>
)}
</div>
);
};
export default VoicePlayer;

View file

@ -0,0 +1,264 @@
.voice-settings {
position: relative;
}
.voice-settings-button {
width: clamp(30px, 4vw, 40px);
height: clamp(30px, 4vw, 40px);
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(1rem, 2vw, 1.3rem);
transition: all var(--animation-speed) ease;
box-shadow: var(--shadow-sm);
padding: 0;
}
.voice-settings-button:hover {
transform: translateY(-2px) scale(1.1);
box-shadow: var(--shadow-md);
border-color: var(--border-glow);
background: var(--bg-card-hover);
}
.voice-settings-button:active {
transform: translateY(0) scale(1);
}
.voice-settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.2s ease;
}
.voice-settings-menu {
position: absolute;
top: calc(100% + 10px);
left: 0;
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
border: 2px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 1.5rem;
min-width: 320px;
max-width: 400px;
box-shadow: var(--shadow-lg);
z-index: 1000;
animation: slideIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.voice-settings-title {
margin: 0 0 1.5rem 0;
font-size: 1.3rem;
color: var(--text-primary);
text-align: center;
font-weight: bold;
}
.voice-settings-section {
margin-bottom: 1.5rem;
}
.voice-settings-toggle {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
padding: 0.75rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
transition: all var(--animation-speed) ease;
}
.voice-settings-toggle:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
}
.voice-settings-toggle input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.voice-settings-toggle-label {
font-size: 1rem;
font-weight: bold;
color: var(--text-primary);
flex: 1;
}
.voice-settings-label {
display: block;
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.voice-settings-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--bg-card);
outline: none;
cursor: pointer;
appearance: none;
}
.voice-settings-slider::-webkit-slider-thumb {
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
transition: all var(--animation-speed) ease;
}
.voice-settings-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 10px var(--accent-primary);
}
.voice-settings-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent-primary);
cursor: pointer;
border: none;
transition: all var(--animation-speed) ease;
}
.voice-settings-slider::-moz-range-thumb:hover {
transform: scale(1.2);
box-shadow: 0 0 10px var(--accent-primary);
}
.voice-settings-test-button {
margin-top: 0.75rem;
width: 100%;
padding: 0.75rem;
background: var(--accent-primary);
color: var(--bg-primary);
border: 2px solid var(--accent-primary);
border-radius: var(--border-radius-sm);
font-weight: bold;
cursor: pointer;
transition: all var(--animation-speed) ease;
}
.voice-settings-test-button:hover {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.voice-settings-test-button:active {
transform: translateY(0);
}
.voice-settings-effects-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
margin-top: 0.5rem;
}
.voice-settings-effect-button {
padding: 0.75rem;
background: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-weight: bold;
cursor: pointer;
transition: all var(--animation-speed) ease;
text-align: center;
}
.voice-settings-effect-button:hover {
transform: translateX(4px);
box-shadow: var(--shadow-md);
}
.voice-settings-effect-button.effect-correct {
border-color: var(--accent-success);
}
.voice-settings-effect-button.effect-correct:hover {
background: var(--accent-success);
color: #ffffff;
}
.voice-settings-effect-button.effect-error {
border-color: var(--accent-secondary);
}
.voice-settings-effect-button.effect-error:hover {
background: var(--accent-secondary);
color: #ffffff;
}
.voice-settings-effect-button.effect-victory {
border-color: var(--accent-primary);
}
.voice-settings-effect-button.effect-victory:hover {
background: var(--accent-primary);
color: var(--bg-primary);
}
.voice-settings-info {
padding: 0.75rem;
background: rgba(0, 0, 0, 0.2);
border-radius: var(--border-radius-sm);
font-size: 0.85rem;
color: var(--text-secondary);
line-height: 1.4;
text-align: center;
}
/* Mobile adjustments */
@media (max-width: 768px) {
.voice-settings-menu {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 280px;
max-width: 90vw;
}
}

View file

@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { useVoice } from '../hooks/useVoice';
import './VoiceSettings.css';
const VoiceSettings = () => {
const {
isEnabled,
volume,
effectsVolume,
setIsEnabled,
setVolume,
setEffectsVolume,
speak,
playEffect,
} = useVoice();
const [isOpen, setIsOpen] = useState(false);
const handleTestVoice = () => {
speak('Привет! Это тестовое сообщение голосового режима.');
};
const handleTestEffect = (effectType) => {
playEffect(effectType);
};
return (
<div className="voice-settings">
<button
className="voice-settings-button control-button"
onClick={() => setIsOpen(!isOpen)}
title="Настройки голоса"
>
🎤
</button>
{isOpen && (
<>
<div className="voice-settings-overlay" onClick={() => setIsOpen(false)} />
<div className="voice-settings-menu">
<h3 className="voice-settings-title">Голосовой режим</h3>
{/* Enable/Disable */}
<div className="voice-settings-section">
<label className="voice-settings-toggle">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
<span className="voice-settings-toggle-label">
{isEnabled ? 'Включен' : 'Выключен'}
</span>
</label>
</div>
{isEnabled && (
<>
{/* Voice Volume */}
<div className="voice-settings-section">
<label className="voice-settings-label">
Громкость голоса: {Math.round(volume * 100)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="voice-settings-slider"
/>
<button
className="voice-settings-test-button"
onClick={handleTestVoice}
>
Проверить голос
</button>
</div>
{/* Effects Volume */}
<div className="voice-settings-section">
<label className="voice-settings-label">
Громкость эффектов: {Math.round(effectsVolume * 100)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.1"
value={effectsVolume}
onChange={(e) => setEffectsVolume(parseFloat(e.target.value))}
className="voice-settings-slider"
/>
</div>
{/* Test Effects */}
<div className="voice-settings-section">
<label className="voice-settings-label">Тестовые звуки:</label>
<div className="voice-settings-effects-grid">
<button
className="voice-settings-effect-button effect-correct"
onClick={() => handleTestEffect('correct')}
>
Правильно
</button>
<button
className="voice-settings-effect-button effect-error"
onClick={() => handleTestEffect('error')}
>
Промах
</button>
<button
className="voice-settings-effect-button effect-victory"
onClick={() => handleTestEffect('victory')}
>
🏆 Победа
</button>
</div>
</div>
{/* Info */}
<div className="voice-settings-info">
💡 Голосовой режим озвучивает вопросы, ответы и игровые события
</div>
</>
)}
</div>
</>
)}
</div>
);
};
export default VoiceSettings;

View file

@ -0,0 +1,65 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext();
export const themes = {
'new-year': {
id: 'new-year',
name: 'Новый год',
icon: '🎄',
description: 'Праздничная новогодняя тема с золотым свечением',
},
family: {
id: 'family',
name: 'Семейная',
icon: '🏠',
description: 'Светлая и уютная тема для семейной игры',
},
party: {
id: 'party',
name: 'Вечеринка',
icon: '🎉',
description: 'Яркая энергичная тема для шумных компаний',
},
dark: {
id: 'dark',
name: 'Темная',
icon: '🌙',
description: 'Контрастная тема для ТВ и проектора',
},
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
export const ThemeProvider = ({ children }) => {
const [currentTheme, setCurrentTheme] = useState(() => {
const saved = localStorage.getItem('app-theme');
return saved && themes[saved] ? saved : 'new-year';
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme);
localStorage.setItem('app-theme', currentTheme);
}, [currentTheme]);
const changeTheme = (themeId) => {
if (themes[themeId]) {
setCurrentTheme(themeId);
}
};
const value = {
currentTheme,
currentThemeData: themes[currentTheme],
themes,
changeTheme,
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};

228
src/hooks/useVoice.js Normal file
View file

@ -0,0 +1,228 @@
import { useState, useEffect, useRef, useCallback } from 'react';
const VOICE_SERVICE_URL = import.meta.env.VITE_VOICE_SERVICE_URL || 'http://localhost:3001';
/**
* Hook for voice generation and playback
*/
export function useVoice() {
const [isEnabled, setIsEnabled] = useState(() => {
const saved = localStorage.getItem('voice-enabled');
return saved ? JSON.parse(saved) : false;
});
const [volume, setVolume] = useState(() => {
const saved = localStorage.getItem('voice-volume');
return saved ? parseFloat(saved) : 0.8;
});
const [effectsVolume, setEffectsVolume] = useState(() => {
const saved = localStorage.getItem('effects-volume');
return saved ? parseFloat(saved) : 0.6;
});
const [isPlaying, setIsPlaying] = useState(false);
const [currentText, setCurrentText] = useState(null);
const audioRef = useRef(null);
const audioCache = useRef(new Map());
// Save settings to localStorage
useEffect(() => {
localStorage.setItem('voice-enabled', JSON.stringify(isEnabled));
}, [isEnabled]);
useEffect(() => {
localStorage.setItem('voice-volume', volume.toString());
}, [volume]);
useEffect(() => {
localStorage.setItem('effects-volume', effectsVolume.toString());
}, [effectsVolume]);
/**
* Generate speech from text
* @param {string} text - Text to speak
* @param {Object} options - Options
* @returns {Promise<string>} Audio URL
*/
const generateSpeech = useCallback(async (text, options = {}) => {
const { voice = 'sarah', cache = true } = options;
// Check cache first
const cacheKey = `${text}_${voice}`;
if (cache && audioCache.current.has(cacheKey)) {
return audioCache.current.get(cacheKey);
}
try {
const response = await fetch(`${VOICE_SERVICE_URL}/api/voice/tts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, voice }),
});
if (!response.ok) {
throw new Error(`Voice service error: ${response.status}`);
}
// Create blob URL from response
const blob = await response.blob();
const audioUrl = URL.createObjectURL(blob);
// Cache the URL
if (cache) {
audioCache.current.set(cacheKey, audioUrl);
}
return audioUrl;
} catch (error) {
console.error('Failed to generate speech:', error);
throw error;
}
}, []);
/**
* Speak text
* @param {string} text - Text to speak
* @param {Object} options - Options
*/
const speak = useCallback(async (text, options = {}) => {
if (!isEnabled) return;
if (!text) return;
try {
setCurrentText(text);
setIsPlaying(true);
const audioUrl = await generateSpeech(text, options);
// Create or reuse audio element
if (!audioRef.current) {
audioRef.current = new Audio();
}
const audio = audioRef.current;
audio.src = audioUrl;
audio.volume = volume;
audio.onended = () => {
setIsPlaying(false);
setCurrentText(null);
};
audio.onerror = () => {
console.error('Audio playback error');
setIsPlaying(false);
setCurrentText(null);
};
await audio.play();
} catch (error) {
console.error('Failed to speak:', error);
setIsPlaying(false);
setCurrentText(null);
}
}, [isEnabled, volume, generateSpeech]);
/**
* Stop current speech
*/
const stop = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
setIsPlaying(false);
setCurrentText(null);
}
}, []);
/**
* Play sound effect
* @param {string} effectType - Type of effect (correct/error/victory)
*/
const playEffect = useCallback(async (effectType) => {
if (!isEnabled) return;
try {
const audio = new Audio(`${VOICE_SERVICE_URL}/api/voice/effects/${effectType}`);
audio.volume = effectsVolume;
await audio.play();
} catch (error) {
console.error(`Failed to play effect ${effectType}:`, error);
}
}, [isEnabled, effectsVolume]);
/**
* Preload speech for text
* @param {string} text - Text to preload
* @param {Object} options - Options
*/
const preload = useCallback(async (text, options = {}) => {
if (!isEnabled) return;
if (!text) return;
try {
await generateSpeech(text, { ...options, cache: true });
} catch (error) {
console.error('Failed to preload speech:', error);
}
}, [isEnabled, generateSpeech]);
/**
* Preload multiple texts
* @param {Array<string>} texts - Texts to preload
*/
const preloadBatch = useCallback(async (texts) => {
if (!isEnabled) return;
if (!texts || texts.length === 0) return;
try {
const promises = texts.map((text) => preload(text));
await Promise.all(promises);
} catch (error) {
console.error('Failed to preload batch:', error);
}
}, [isEnabled, preload]);
/**
* Clear audio cache
*/
const clearCache = useCallback(() => {
// Revoke blob URLs to free memory
audioCache.current.forEach((url) => {
URL.revokeObjectURL(url);
});
audioCache.current.clear();
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
stop();
clearCache();
};
}, [stop, clearCache]);
return {
// State
isEnabled,
isPlaying,
currentText,
volume,
effectsVolume,
// Actions
setIsEnabled,
setVolume,
setEffectsVolume,
speak,
stop,
playEffect,
preload,
preloadBatch,
clearCache,
};
}

View file

@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './index.css' import './index.css'
import './styles/themes.css'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>

240
src/styles/themes.css Normal file
View file

@ -0,0 +1,240 @@
/* Theme System - Base Variables */
:root {
/* Default Theme Variables */
--bg-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--bg-overlay: rgba(0, 0, 0, 0.3);
--bg-card: rgba(255, 255, 255, 0.1);
--bg-card-hover: rgba(255, 255, 255, 0.15);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.9);
--text-glow: rgba(255, 215, 0, 0.8);
--accent-primary: #ffd700;
--accent-secondary: #ff6b6b;
--accent-success: #4ecdc4;
--border-color: rgba(255, 255, 255, 0.3);
--border-glow: rgba(255, 215, 0, 0.5);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 15px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
--blur-amount: 10px;
--border-radius-sm: 12px;
--border-radius-md: 15px;
--border-radius-lg: 20px;
/* Animation variables */
--animation-speed: 0.3s;
}
/* New Year Theme */
[data-theme="new-year"] {
--bg-primary: linear-gradient(135deg, #1a4d7a 0%, #2b0d4f 50%, #4a1942 100%);
--bg-overlay: rgba(10, 10, 30, 0.4);
--bg-card: rgba(255, 255, 255, 0.12);
--bg-card-hover: rgba(255, 255, 255, 0.18);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.95);
--text-glow: rgba(255, 215, 0, 1);
--accent-primary: #ffd700;
--accent-secondary: #ff6b6b;
--accent-success: #4ecdc4;
--border-color: rgba(255, 215, 0, 0.4);
--border-glow: rgba(255, 215, 0, 0.6);
--shadow-sm: 0 2px 15px rgba(255, 215, 0, 0.2);
--shadow-md: 0 4px 20px rgba(255, 215, 0, 0.3);
--shadow-lg: 0 8px 35px rgba(255, 215, 0, 0.4);
}
/* Family Theme */
[data-theme="family"] {
--bg-primary: linear-gradient(135deg, #56ccf2 0%, #2f80ed 50%, #b2fefa 100%);
--bg-overlay: rgba(255, 255, 255, 0.1);
--bg-card: rgba(255, 255, 255, 0.25);
--bg-card-hover: rgba(255, 255, 255, 0.35);
--text-primary: #2d3748;
--text-secondary: #4a5568;
--text-glow: rgba(47, 128, 237, 0.8);
--accent-primary: #2f80ed;
--accent-secondary: #eb5757;
--accent-success: #27ae60;
--border-color: rgba(47, 128, 237, 0.3);
--border-glow: rgba(47, 128, 237, 0.5);
--shadow-sm: 0 2px 10px rgba(47, 128, 237, 0.15);
--shadow-md: 0 4px 15px rgba(47, 128, 237, 0.2);
--shadow-lg: 0 8px 30px rgba(47, 128, 237, 0.25);
}
/* Party Theme */
[data-theme="party"] {
--bg-primary: linear-gradient(135deg, #f093fb 0%, #f5576c 50%, #4facfe 100%);
--bg-overlay: rgba(0, 0, 0, 0.2);
--bg-card: rgba(255, 255, 255, 0.15);
--bg-card-hover: rgba(255, 255, 255, 0.25);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.95);
--text-glow: rgba(255, 87, 108, 1);
--accent-primary: #f5576c;
--accent-secondary: #f093fb;
--accent-success: #4facfe;
--border-color: rgba(255, 87, 108, 0.5);
--border-glow: rgba(255, 87, 108, 0.7);
--shadow-sm: 0 2px 15px rgba(245, 87, 108, 0.3);
--shadow-md: 0 4px 20px rgba(245, 87, 108, 0.4);
--shadow-lg: 0 8px 35px rgba(245, 87, 108, 0.5);
--animation-speed: 0.2s;
}
/* Dark Theme (TV/Projector optimized) */
[data-theme="dark"] {
--bg-primary: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%);
--bg-overlay: rgba(0, 0, 0, 0.7);
--bg-card: rgba(40, 40, 40, 0.8);
--bg-card-hover: rgba(60, 60, 60, 0.9);
--text-primary: #e0e0e0;
--text-secondary: #b0b0b0;
--text-glow: rgba(100, 255, 218, 0.8);
--accent-primary: #64ffda;
--accent-secondary: #ff5370;
--accent-success: #c3e88d;
--border-color: rgba(100, 255, 218, 0.3);
--border-glow: rgba(100, 255, 218, 0.5);
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 15px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.7);
--blur-amount: 5px;
}
/* Apply theme to body background */
body {
background: var(--bg-primary);
color: var(--text-primary);
transition: background var(--animation-speed) ease, color var(--animation-speed) ease;
}
/* Global theme-aware utilities */
.themed-card {
background: var(--bg-card);
backdrop-filter: blur(var(--blur-amount));
border: 2px solid var(--border-color);
border-radius: var(--border-radius-md);
box-shadow: var(--shadow-md);
transition: all var(--animation-speed) ease;
}
.themed-card:hover {
background: var(--bg-card-hover);
border-color: var(--border-glow);
box-shadow: var(--shadow-lg);
}
.themed-button {
background: var(--bg-card);
color: var(--text-primary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 0.75rem 1.5rem;
font-weight: bold;
cursor: pointer;
transition: all var(--animation-speed) ease;
box-shadow: var(--shadow-sm);
}
.themed-button:hover {
background: var(--bg-card-hover);
border-color: var(--accent-primary);
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.themed-button:active {
transform: translateY(0);
}
.themed-button-primary {
background: var(--accent-primary);
color: var(--bg-primary);
border-color: var(--accent-primary);
}
.themed-button-primary:hover {
background: var(--accent-secondary);
border-color: var(--accent-secondary);
box-shadow: 0 4px 20px var(--accent-primary);
}
.text-glow {
text-shadow:
0 0 10px var(--text-glow),
0 0 20px var(--text-glow),
0 0 30px var(--text-glow);
animation: textGlow 2s ease-in-out infinite alternate;
}
@keyframes textGlow {
from {
text-shadow:
0 0 10px var(--text-glow),
0 0 20px var(--text-glow);
}
to {
text-shadow:
0 0 20px var(--text-glow),
0 0 30px var(--text-glow),
0 0 40px var(--text-glow);
}
}
/* Theme-specific animations */
[data-theme="party"] .themed-card {
animation: partyPulse 2s ease-in-out infinite;
}
@keyframes partyPulse {
0%, 100% {
border-color: var(--accent-primary);
}
50% {
border-color: var(--accent-secondary);
}
}
[data-theme="new-year"] .text-glow {
animation: newYearSparkle 1.5s ease-in-out infinite;
}
@keyframes newYearSparkle {
0%, 100% {
text-shadow:
0 0 10px var(--text-glow),
0 0 20px var(--text-glow);
}
50% {
text-shadow:
0 0 30px var(--text-glow),
0 0 40px var(--text-glow),
0 0 50px var(--text-glow);
}
}