stuff
This commit is contained in:
parent
0b64cc5d8b
commit
3b879f80d4
23 changed files with 2115 additions and 94 deletions
109
README.md
109
README.md
|
|
@ -28,11 +28,11 @@
|
|||
|
||||
### Backend
|
||||
- **NestJS** - TypeScript фреймворк
|
||||
- **PostgreSQL** - база данных
|
||||
- **PostgreSQL** - база данных (запускается отдельно в Coolify)
|
||||
- **Prisma ORM** - работа с БД
|
||||
- **Socket.IO** - WebSocket сервер
|
||||
- **JWT** - авторизация
|
||||
- **Docker** - контейнеризация
|
||||
- **ConfigModule** - управление переменными окружения
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
|
|
@ -49,7 +49,6 @@ sto_k_odnomu/
|
|||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # Схема БД
|
||||
│ │ └── seed.ts # Seed данные
|
||||
│ └── docker-compose.yml # Docker конфиг
|
||||
│
|
||||
├── src/ # React Frontend
|
||||
│ ├── pages/ # Страницы
|
||||
|
|
@ -70,22 +69,29 @@ sto_k_odnomu/
|
|||
|
||||
### 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
|
||||
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
|
||||
|
|
@ -157,17 +163,86 @@ Frontend: http://localhost:5173
|
|||
- Демо пользователь
|
||||
- 2 пака вопросов (общие, семейные)
|
||||
|
||||
## 🐳 Docker
|
||||
## ⚙️ Переменные окружения
|
||||
|
||||
```bash
|
||||
# Backend + PostgreSQL
|
||||
cd backend
|
||||
docker-compose up -d
|
||||
Приложение использует переменные окружения напрямую через `@nestjs/config`. Все переменные должны быть настроены в системе или через Coolify:
|
||||
|
||||
# Только PostgreSQL
|
||||
docker-compose up -d postgres
|
||||
### Backend переменные:
|
||||
- `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
|
||||
|
|
|
|||
|
|
@ -7,37 +7,35 @@ NestJS backend для мультиплеерной игры "100 к 1" с WebSoc
|
|||
### Предварительные требования
|
||||
|
||||
- Node.js 18+
|
||||
- PostgreSQL 15+
|
||||
- Docker (опционально, для запуска PostgreSQL в контейнере)
|
||||
- PostgreSQL 15+ (должен быть запущен отдельно, например, в Coolify)
|
||||
|
||||
### Установка
|
||||
|
||||
**Важно:** PostgreSQL должен быть запущен отдельно как отдельное приложение. Все переменные окружения должны быть настроены в системе или через Coolify.
|
||||
|
||||
```bash
|
||||
# 1. Установить зависимости
|
||||
npm install
|
||||
|
||||
# 2. Настроить переменные окружения
|
||||
cp .env.example .env
|
||||
# Отредактировать .env с вашими настройками
|
||||
# Переменные должны быть установлены в системе или через Coolify:
|
||||
# - 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
|
||||
# Вариант A: Использовать Docker Compose
|
||||
docker-compose up -d postgres
|
||||
|
||||
# Вариант B: Использовать локальный PostgreSQL
|
||||
# Создать базу данных вручную:
|
||||
# createdb sto_k_odnomu
|
||||
|
||||
# 4. Выполнить миграции
|
||||
# 3. Выполнить миграции
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 5. Заполнить демо-данными (опционально)
|
||||
# 4. Заполнить демо-данными (опционально)
|
||||
npm run prisma:seed
|
||||
|
||||
# 6. Сгенерировать Prisma Client
|
||||
# 5. Сгенерировать Prisma Client
|
||||
npx prisma generate
|
||||
|
||||
# 7. Запустить backend
|
||||
# 6. Запустить backend
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
|
|
@ -59,9 +57,7 @@ backend/
|
|||
├── prisma/
|
||||
│ ├── schema.prisma # Схема базы данных
|
||||
│ └── seed.ts # Seed скрипт с демо-данными
|
||||
├── Dockerfile # Docker конфигурация
|
||||
├── docker-compose.yml # Docker Compose (PostgreSQL + Backend)
|
||||
└── .env.example # Пример переменных окружения
|
||||
└── Dockerfile # Docker конфигурация
|
||||
```
|
||||
|
||||
## 🗄️ База данных
|
||||
|
|
@ -158,33 +154,66 @@ 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
|
||||
Приложение использует переменные окружения напрямую через `@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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
// This file was generated by Prisma and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
// This file was generated by Prisma
|
||||
// Uses environment variables directly (no dotenv dependency)
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
|
|
@ -7,10 +8,14 @@ import { AuthController } from './auth.controller';
|
|||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET') || 'your-secret-key',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import { RoomsService } from '../rooms/rooms.service';
|
|||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
// Примечание: декоратор выполняется на этапе инициализации,
|
||||
// ConfigModule.forRoot() уже загружает переменные в process.env
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const configService = app.get(ConfigService);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
origin: configService.get<string>('CORS_ORIGIN') || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
|
|
@ -15,7 +17,9 @@ async function bootstrap() {
|
|||
transform: true,
|
||||
}));
|
||||
|
||||
await app.listen(process.env.PORT || 3000);
|
||||
console.log(`Backend running on http://localhost:${process.env.PORT || 3000}`);
|
||||
const port = configService.get<number>('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();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ThemeProvider } from './context/ThemeContext';
|
||||
import Home from './pages/Home';
|
||||
import CreateRoom from './pages/CreateRoom';
|
||||
import JoinRoom from './pages/JoinRoom';
|
||||
|
|
@ -10,6 +11,7 @@ import './App.css';
|
|||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
|
|
@ -21,6 +23,7 @@ function App() {
|
|||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import Players from './Players'
|
|||
import PlayersModal from './PlayersModal'
|
||||
import QuestionsModal from './QuestionsModal'
|
||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
|
||||
import { useVoice } from '../hooks/useVoice'
|
||||
import './Game.css'
|
||||
|
||||
const Game = forwardRef(({
|
||||
|
|
@ -12,6 +13,7 @@ const Game = forwardRef(({
|
|||
onQuestionIndexChange,
|
||||
onQuestionsChange,
|
||||
}, ref) => {
|
||||
const { playEffect } = useVoice();
|
||||
const [players, setPlayers] = useState(() => {
|
||||
const savedPlayers = getCookie('gamePlayers')
|
||||
return savedPlayers || []
|
||||
|
|
@ -192,6 +194,9 @@ const Game = forwardRef(({
|
|||
[currentPlayerId]: (playerScores[currentPlayerId] || 0) + points,
|
||||
})
|
||||
|
||||
// Play correct answer sound
|
||||
playEffect('correct')
|
||||
|
||||
// Переходим к следующему участнику только если это не последний ответ
|
||||
if (!isLastAnswer) {
|
||||
const nextPlayerId = getNextPlayerId()
|
||||
|
|
@ -273,6 +278,11 @@ const Game = forwardRef(({
|
|||
const maxScore = scores.length > 0 ? Math.max(...scores) : 0
|
||||
const winners = players.filter(p => playerScores[p.id] === maxScore)
|
||||
|
||||
// Play victory sound
|
||||
useEffect(() => {
|
||||
playEffect('victory')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="game-over">
|
||||
<div className="game-over-content">
|
||||
|
|
|
|||
388
src/components/HostAdminPanel.css
Normal file
388
src/components/HostAdminPanel.css
Normal 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);
|
||||
}
|
||||
239
src/components/HostAdminPanel.jsx
Normal file
239
src/components/HostAdminPanel.jsx
Normal 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;
|
||||
|
|
@ -2,6 +2,8 @@ import { useState, useRef, useEffect } from 'react'
|
|||
import Game from './Game'
|
||||
import Snowflakes from './Snowflakes'
|
||||
import QuestionsModal from './QuestionsModal'
|
||||
import ThemeSwitcher from './ThemeSwitcher'
|
||||
import VoiceSettings from './VoiceSettings'
|
||||
import { questions as initialQuestions } from '../data/questions'
|
||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
|
||||
import '../App.css'
|
||||
|
|
@ -94,6 +96,8 @@ function LocalGameApp() {
|
|||
<div className="app-content">
|
||||
<div className="app-title-bar">
|
||||
<div className="app-control-buttons">
|
||||
<ThemeSwitcher />
|
||||
<VoiceSettings />
|
||||
<button
|
||||
className="control-button control-button-players"
|
||||
onClick={handleOpenPlayersModal}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@
|
|||
margin-bottom: clamp(10px, 2vh, 15px);
|
||||
}
|
||||
|
||||
.question-text-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.question-nav-button {
|
||||
width: clamp(40px, 6vw, 50px);
|
||||
height: clamp(40px, 6vw, 50px);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Answer from './Answer'
|
||||
import VoicePlayer from './VoicePlayer'
|
||||
import './Question.css'
|
||||
|
||||
const Question = ({
|
||||
|
|
@ -28,7 +29,10 @@ const Question = ({
|
|||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="question-text-wrapper">
|
||||
<h2 className="question-text">{question.text}</h2>
|
||||
<VoicePlayer text={question.text} />
|
||||
</div>
|
||||
{canGoNext && onNextQuestion && (
|
||||
<button
|
||||
className="question-nav-button question-nav-button-next"
|
||||
|
|
|
|||
156
src/components/ThemeSwitcher.css
Normal file
156
src/components/ThemeSwitcher.css
Normal 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;
|
||||
}
|
||||
}
|
||||
49
src/components/ThemeSwitcher.jsx
Normal file
49
src/components/ThemeSwitcher.jsx
Normal 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;
|
||||
68
src/components/VoicePlayer.css
Normal file
68
src/components/VoicePlayer.css
Normal 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;
|
||||
}
|
||||
45
src/components/VoicePlayer.jsx
Normal file
45
src/components/VoicePlayer.jsx
Normal 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;
|
||||
264
src/components/VoiceSettings.css
Normal file
264
src/components/VoiceSettings.css
Normal 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;
|
||||
}
|
||||
}
|
||||
135
src/components/VoiceSettings.jsx
Normal file
135
src/components/VoiceSettings.jsx
Normal 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;
|
||||
65
src/context/ThemeContext.jsx
Normal file
65
src/context/ThemeContext.jsx
Normal 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
228
src/hooks/useVoice.js
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import './styles/themes.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
|
|
|
|||
240
src/styles/themes.css
Normal file
240
src/styles/themes.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue