From 3b879f80d49aa2771303406fc162731e02c3b8e0 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Mon, 5 Jan 2026 00:48:55 +0300 Subject: [PATCH] stuff --- README.md | 109 +++++++-- backend/README.md | 117 +++++---- backend/prisma.config.ts | 5 +- backend/src/auth/auth.module.ts | 11 +- backend/src/game/game.gateway.ts | 2 + backend/src/main.ts | 10 +- src/App.jsx | 25 +- src/components/Game.jsx | 16 +- src/components/HostAdminPanel.css | 388 ++++++++++++++++++++++++++++++ src/components/HostAdminPanel.jsx | 239 ++++++++++++++++++ src/components/LocalGameApp.jsx | 4 + src/components/Question.css | 8 + src/components/Question.jsx | 24 +- src/components/ThemeSwitcher.css | 156 ++++++++++++ src/components/ThemeSwitcher.jsx | 49 ++++ src/components/VoicePlayer.css | 68 ++++++ src/components/VoicePlayer.jsx | 45 ++++ src/components/VoiceSettings.css | 264 ++++++++++++++++++++ src/components/VoiceSettings.jsx | 135 +++++++++++ src/context/ThemeContext.jsx | 65 +++++ src/hooks/useVoice.js | 228 ++++++++++++++++++ src/main.jsx | 1 + src/styles/themes.css | 240 ++++++++++++++++++ 23 files changed, 2115 insertions(+), 94 deletions(-) create mode 100644 src/components/HostAdminPanel.css create mode 100644 src/components/HostAdminPanel.jsx create mode 100644 src/components/ThemeSwitcher.css create mode 100644 src/components/ThemeSwitcher.jsx create mode 100644 src/components/VoicePlayer.css create mode 100644 src/components/VoicePlayer.jsx create mode 100644 src/components/VoiceSettings.css create mode 100644 src/components/VoiceSettings.jsx create mode 100644 src/context/ThemeContext.jsx create mode 100644 src/hooks/useVoice.js create mode 100644 src/styles/themes.css diff --git a/README.md b/README.md index ede1246..5bb23a3 100644 --- a/README.md +++ b/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 diff --git a/backend/README.md b/backend/README.md index 4358ca0..c20ecc5 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/prisma.config.ts b/backend/prisma.config.ts index 8ded1a5..05c7f5b 100644 --- a/backend/prisma.config.ts +++ b/backend/prisma.config.ts @@ -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({ diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 29cefa6..f3dae74 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -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,9 +8,13 @@ import { AuthController } from './auth.controller'; @Module({ imports: [ PassportModule, - JwtModule.register({ - secret: process.env.JWT_SECRET || 'your-secret-key', - signOptions: { expiresIn: '7d' }, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET') || 'your-secret-key', + signOptions: { expiresIn: '7d' }, + }), + inject: [ConfigService], }), ], controllers: [AuthController], diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 59be2f6..c4ca16b 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -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, }, diff --git a/backend/src/main.ts b/backend/src/main.ts index e1bfd18..285ccc6 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -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('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('PORT') || 3000; + const host = configService.get('HOST') || '0.0.0.0'; + await app.listen(port, host); + console.log(`Backend running on http://${host}:${port}`); } bootstrap(); diff --git a/src/App.jsx b/src/App.jsx index a57cbf3..99ee135 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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,17 +11,19 @@ import './App.css'; function App() { return ( - - - - } /> - } /> - } /> - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + + + + ); } diff --git a/src/components/Game.jsx b/src/components/Game.jsx index 43fdfc5..de193fc 100644 --- a/src/components/Game.jsx +++ b/src/components/Game.jsx @@ -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 || [] @@ -183,15 +185,18 @@ const Game = forwardRef(({ if (!currentQuestion) return const isLastAnswer = currentRevealed.length === currentQuestion.answers.length - 1 - + updateRevealedAnswers([...currentRevealed, answerIndex]) - + // Добавляем очки текущему участнику setPlayerScores({ ...playerScores, [currentPlayerId]: (playerScores[currentPlayerId] || 0) + points, }) + // Play correct answer sound + playEffect('correct') + // Переходим к следующему участнику только если это не последний ответ if (!isLastAnswer) { const nextPlayerId = getNextPlayerId() @@ -272,7 +277,12 @@ const Game = forwardRef(({ const scores = Object.values(playerScores) 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 (
diff --git a/src/components/HostAdminPanel.css b/src/components/HostAdminPanel.css new file mode 100644 index 0000000..6ec9d32 --- /dev/null +++ b/src/components/HostAdminPanel.css @@ -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); +} diff --git a/src/components/HostAdminPanel.jsx b/src/components/HostAdminPanel.jsx new file mode 100644 index 0000000..018c9a5 --- /dev/null +++ b/src/components/HostAdminPanel.jsx @@ -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 ( +
+ + + {isExpanded && ( +
+

Панель ведущего

+ + {/* Game Control Section */} +
+

🎮 Управление игрой

+
+ {gameStatus === 'WAITING' && ( + + )} + + {gameStatus === 'PLAYING' && ( + <> + + + + + )} +
+
+ + {/* Answer Control Section */} + {gameStatus === 'PLAYING' && currentQuestion && ( +
+

👁 Управление ответами

+
+ +
+ +
+ {currentQuestion.answers.map((answer, index) => ( + + ))} +
+
+ )} + + {/* Manual Scoring Section */} + {gameStatus === 'PLAYING' && players.length > 0 && ( +
+

➕ Ручное начисление баллов

+ +
+ + +
+ + {selectedPlayer && ( +
+
+ + + + +
+ +
+ setCustomPoints(parseInt(e.target.value) || 0)} + className="points-input" + /> + +
+
+ )} +
+ )} + + {/* Game Info Section */} +
+

📊 Информация

+
+
+ Статус: + + {gameStatus === 'WAITING' ? 'Ожидание' : + gameStatus === 'PLAYING' ? 'Идет игра' : + gameStatus === 'FINISHED' ? 'Завершена' : gameStatus} + +
+
+ Игроков: + {players.length} +
+ {gameStatus === 'PLAYING' && ( +
+ Вопрос: + + {currentQuestionIndex + 1} / {totalQuestions} + +
+ )} +
+
+
+ )} +
+ ); +}; + +export default HostAdminPanel; diff --git a/src/components/LocalGameApp.jsx b/src/components/LocalGameApp.jsx index 8391782..2d4d1b7 100644 --- a/src/components/LocalGameApp.jsx +++ b/src/components/LocalGameApp.jsx @@ -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() {
+ + )} -

{question.text}

+
+

{question.text}

+ +
{canGoNext && onNextQuestion && ( - + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+

Выбрать тему

+
+ {Object.values(themes).map((theme) => ( + + ))} +
+
+ + )} +
+ ); +}; + +export default ThemeSwitcher; diff --git a/src/components/VoicePlayer.css b/src/components/VoicePlayer.css new file mode 100644 index 0000000..3861817 --- /dev/null +++ b/src/components/VoicePlayer.css @@ -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; +} diff --git a/src/components/VoicePlayer.jsx b/src/components/VoicePlayer.jsx new file mode 100644 index 0000000..d0cd406 --- /dev/null +++ b/src/components/VoicePlayer.jsx @@ -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 ( +
+ {children} + {showButton && text && ( + + )} +
+ ); +}; + +export default VoicePlayer; diff --git a/src/components/VoiceSettings.css b/src/components/VoiceSettings.css new file mode 100644 index 0000000..44641cd --- /dev/null +++ b/src/components/VoiceSettings.css @@ -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; + } +} diff --git a/src/components/VoiceSettings.jsx b/src/components/VoiceSettings.jsx new file mode 100644 index 0000000..09bc9c7 --- /dev/null +++ b/src/components/VoiceSettings.jsx @@ -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 ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} /> +
+

Голосовой режим

+ + {/* Enable/Disable */} +
+ +
+ + {isEnabled && ( + <> + {/* Voice Volume */} +
+ + setVolume(parseFloat(e.target.value))} + className="voice-settings-slider" + /> + +
+ + {/* Effects Volume */} +
+ + setEffectsVolume(parseFloat(e.target.value))} + className="voice-settings-slider" + /> +
+ + {/* Test Effects */} +
+ +
+ + + +
+
+ + {/* Info */} +
+ 💡 Голосовой режим озвучивает вопросы, ответы и игровые события +
+ + )} +
+ + )} +
+ ); +}; + +export default VoiceSettings; diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx new file mode 100644 index 0000000..eb96aaf --- /dev/null +++ b/src/context/ThemeContext.jsx @@ -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 {children}; +}; diff --git a/src/hooks/useVoice.js b/src/hooks/useVoice.js new file mode 100644 index 0000000..6722086 --- /dev/null +++ b/src/hooks/useVoice.js @@ -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} 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} 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, + }; +} diff --git a/src/main.jsx b/src/main.jsx index 46457ed..378458f 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( diff --git a/src/styles/themes.css b/src/styles/themes.css new file mode 100644 index 0000000..9f7adc3 --- /dev/null +++ b/src/styles/themes.css @@ -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); + } +}