diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..907aca8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,39 @@
+# Dependencies
+node_modules
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+package-lock.json
+
+# Build output
+dist
+
+# IDE
+.vscode
+.idea
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Git
+.git
+.gitignore
+
+# Documentation and development files
+*.md
+!README.md
+*.py
+
+# Environment
+.env
+.env.local
+.env.*.local
+
+# Testing
+coverage
+.nyc_output
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..74bfffe
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,33 @@
+# Multi-stage build для статического сайта
+# Stage 1: Build
+FROM node:18-alpine AS builder
+
+WORKDIR /app
+
+# Копируем файлы зависимостей
+COPY package*.json ./
+
+# Устанавливаем зависимости
+RUN npm ci
+
+# Копируем исходный код
+COPY . .
+
+# Собираем приложение
+RUN npm run build
+
+# Stage 2: Serve
+FROM nginx:alpine
+
+# Копируем собранные файлы из builder stage
+COPY --from=builder /app/dist /usr/share/nginx/html
+
+# Копируем конфигурацию nginx
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+
+# Открываем порт 80
+EXPOSE 80
+
+# Запускаем nginx
+CMD ["nginx", "-g", "daemon off;"]
+
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..130a2c3
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,35 @@
+server {
+ listen 80;
+ server_name _;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+
+ # SPA routing support - все запросы направляются на index.html
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Кэширование статических ресурсов
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # Не кэшируем HTML
+ location ~* \.html$ {
+ expires -1;
+ add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
+ }
+}
+
diff --git a/src/components/Players.css b/src/components/Players.css
new file mode 100644
index 0000000..2ece3c3
--- /dev/null
+++ b/src/components/Players.css
@@ -0,0 +1,89 @@
+.players-container {
+ margin-bottom: 20px;
+}
+
+.players-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ justify-content: center;
+}
+
+.player-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 20px;
+ background: rgba(255, 255, 255, 0.1);
+ backdrop-filter: blur(10px);
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-radius: 25px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ min-width: 120px;
+}
+
+.player-item:hover {
+ background: rgba(255, 255, 255, 0.15);
+ border-color: rgba(255, 215, 0, 0.4);
+ transform: translateY(-2px);
+}
+
+.player-item.player-active {
+ background: rgba(255, 215, 0, 0.25);
+ border-color: #ffd700;
+ box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
+ transform: translateY(-2px);
+}
+
+.player-name {
+ color: #fff;
+ font-size: 1rem;
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.player-item.player-active .player-name {
+ color: #ffd700;
+ font-weight: 600;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
+}
+
+.player-score {
+ color: rgba(255, 255, 255, 0.8);
+ font-size: 1.1rem;
+ font-weight: bold;
+ min-width: 40px;
+ text-align: right;
+}
+
+.player-item.player-active .player-score {
+ color: #ffd700;
+ font-size: 1.2rem;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
+}
+
+@media (max-width: 768px) {
+ .players-list {
+ gap: 8px;
+ }
+
+ .player-item {
+ padding: 8px 16px;
+ min-width: 100px;
+ }
+
+ .player-name {
+ font-size: 0.9rem;
+ }
+
+ .player-score {
+ font-size: 1rem;
+ min-width: 35px;
+ }
+
+ .player-item.player-active .player-score {
+ font-size: 1.1rem;
+ }
+}
+
diff --git a/src/components/Players.jsx b/src/components/Players.jsx
new file mode 100644
index 0000000..98559ee
--- /dev/null
+++ b/src/components/Players.jsx
@@ -0,0 +1,30 @@
+import './Players.css'
+
+const Players = ({ players, currentPlayerId, playerScores, onSelectPlayer }) => {
+ if (players.length === 0) return null
+
+ return (
+
+
+ {players.map((player) => {
+ const isActive = currentPlayerId === player.id
+ const score = playerScores[player.id] || 0
+
+ return (
+
onSelectPlayer(player.id)}
+ >
+ {player.name}
+ {score}
+
+ )
+ })}
+
+
+ )
+}
+
+export default Players
+
diff --git a/src/components/PlayersModal.css b/src/components/PlayersModal.css
new file mode 100644
index 0000000..bf374b1
--- /dev/null
+++ b/src/components/PlayersModal.css
@@ -0,0 +1,211 @@
+.players-modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(5px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ padding: 20px;
+}
+
+.players-modal-content {
+ background: rgba(20, 20, 30, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 20px;
+ padding: 30px;
+ max-width: 500px;
+ width: 100%;
+ max-height: 80vh;
+ overflow-y: auto;
+ border: 2px solid rgba(255, 215, 0, 0.3);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ position: relative;
+}
+
+.players-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 25px;
+}
+
+.players-modal-title {
+ color: #ffd700;
+ font-size: 2rem;
+ margin: 0;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
+}
+
+.players-modal-close {
+ background: rgba(255, 107, 107, 0.2);
+ color: #ff6b6b;
+ border: 2px solid rgba(255, 107, 107, 0.5);
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ font-size: 2rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ flex-shrink: 0;
+}
+
+.players-modal-close:hover {
+ background: rgba(255, 107, 107, 0.4);
+ border-color: #ff6b6b;
+ transform: scale(1.1);
+}
+
+.players-modal-add-form {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 25px;
+}
+
+.players-modal-input {
+ flex: 1;
+ padding: 15px 20px;
+ border: 2px solid rgba(255, 215, 0, 0.3);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.1);
+ color: #fff;
+ font-size: 1rem;
+ outline: none;
+ transition: all 0.3s ease;
+}
+
+.players-modal-input::placeholder {
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.players-modal-input:focus {
+ border-color: #ffd700;
+ background: rgba(255, 255, 255, 0.15);
+ box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
+}
+
+.players-modal-add-button {
+ padding: 15px 30px;
+ background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
+ color: white;
+ border: none;
+ border-radius: 12px;
+ font-size: 1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ white-space: nowrap;
+}
+
+.players-modal-add-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 15px rgba(78, 205, 196, 0.4);
+}
+
+.players-modal-add-button:active {
+ transform: translateY(0);
+}
+
+.players-modal-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.players-modal-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 15px 20px;
+ background: rgba(255, 255, 255, 0.05);
+ border: 2px solid rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ transition: all 0.3s ease;
+}
+
+.players-modal-item:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(255, 215, 0, 0.3);
+}
+
+.players-modal-item-name {
+ color: #fff;
+ font-size: 1.2rem;
+ font-weight: 500;
+ flex: 1;
+}
+
+.players-modal-remove-button {
+ background: rgba(255, 107, 107, 0.3);
+ color: #ff6b6b;
+ border: 2px solid rgba(255, 107, 107, 0.5);
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ font-size: 1.5rem;
+ line-height: 1;
+ cursor: pointer;
+ margin-left: 10px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ flex-shrink: 0;
+}
+
+.players-modal-remove-button:hover {
+ background: rgba(255, 107, 107, 0.5);
+ border-color: #ff6b6b;
+ transform: scale(1.1);
+}
+
+.players-modal-empty {
+ color: rgba(255, 255, 255, 0.6);
+ text-align: center;
+ padding: 40px 20px;
+ font-size: 1.1rem;
+}
+
+@media (max-width: 768px) {
+ .players-modal-content {
+ padding: 20px;
+ max-height: 90vh;
+ }
+
+ .players-modal-title {
+ font-size: 1.5rem;
+ }
+
+ .players-modal-close {
+ width: 35px;
+ height: 35px;
+ font-size: 1.5rem;
+ }
+
+ .players-modal-add-form {
+ flex-direction: column;
+ }
+
+ .players-modal-input {
+ font-size: 0.9rem;
+ padding: 12px 15px;
+ }
+
+ .players-modal-add-button {
+ font-size: 0.9rem;
+ padding: 12px 20px;
+ }
+
+ .players-modal-item-name {
+ font-size: 1rem;
+ }
+}
+
diff --git a/src/components/PlayersModal.jsx b/src/components/PlayersModal.jsx
new file mode 100644
index 0000000..b8b62f5
--- /dev/null
+++ b/src/components/PlayersModal.jsx
@@ -0,0 +1,74 @@
+import { useState } from 'react'
+import './PlayersModal.css'
+
+const PlayersModal = ({ isOpen, onClose, players, onAddPlayer, onRemovePlayer }) => {
+ const [newPlayerName, setNewPlayerName] = useState('')
+
+ if (!isOpen) return null
+
+ const handleAddPlayer = (e) => {
+ e.preventDefault()
+ if (newPlayerName.trim()) {
+ onAddPlayer(newPlayerName.trim())
+ setNewPlayerName('')
+ }
+ }
+
+ const handleBackdropClick = (e) => {
+ if (e.target === e.currentTarget) {
+ onClose()
+ }
+ }
+
+ return (
+
+
+
+
Управление участниками
+
+
+
+
+
+
+ {players.length === 0 ? (
+
Нет участников. Добавьте участников для начала игры.
+ ) : (
+ players.map((player) => (
+
+ {player.name}
+ {players.length > 1 && (
+
+ )}
+
+ ))
+ )}
+
+
+
+ )
+}
+
+export default PlayersModal
+
diff --git a/src/components/QuestionsModal.jsx b/src/components/QuestionsModal.jsx
new file mode 100644
index 0000000..0543ad5
--- /dev/null
+++ b/src/components/QuestionsModal.jsx
@@ -0,0 +1,332 @@
+import { useState } from 'react'
+import './QuestionsModal.css'
+
+const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
+ const [editingQuestion, setEditingQuestion] = useState(null)
+ const [questionText, setQuestionText] = useState('')
+ const [answers, setAnswers] = useState([
+ { text: '', points: 100 },
+ { text: '', points: 80 },
+ { text: '', points: 60 },
+ { text: '', points: 40 },
+ { text: '', points: 20 },
+ { text: '', points: 10 },
+ ])
+ const [jsonError, setJsonError] = useState('')
+
+ if (!isOpen) return null
+
+ const resetForm = () => {
+ setEditingQuestion(null)
+ setQuestionText('')
+ setAnswers([
+ { text: '', points: 100 },
+ { text: '', points: 80 },
+ { text: '', points: 60 },
+ { text: '', points: 40 },
+ { text: '', points: 20 },
+ { text: '', points: 10 },
+ ])
+ setJsonError('')
+ }
+
+ const handleBackdropClick = (e) => {
+ if (e.target === e.currentTarget) {
+ onClose()
+ resetForm()
+ }
+ }
+
+ const handleClose = () => {
+ onClose()
+ resetForm()
+ }
+
+ const handleEdit = (question) => {
+ setEditingQuestion(question)
+ setQuestionText(question.text)
+ setAnswers([...question.answers])
+ setJsonError('')
+ }
+
+ const handleCancelEdit = () => {
+ resetForm()
+ }
+
+ const handleAnswerChange = (index, field, value) => {
+ const updatedAnswers = [...answers]
+ if (field === 'text') {
+ updatedAnswers[index].text = value
+ } else if (field === 'points') {
+ updatedAnswers[index].points = parseInt(value) || 0
+ }
+ setAnswers(updatedAnswers)
+ }
+
+ const handleAddAnswer = () => {
+ const minPoints = Math.min(...answers.map(a => a.points))
+ setAnswers([...answers, { text: '', points: Math.max(0, minPoints - 10) }])
+ }
+
+ const handleRemoveAnswer = (index) => {
+ if (answers.length > 1) {
+ setAnswers(answers.filter((_, i) => i !== index))
+ }
+ }
+
+ const validateForm = () => {
+ if (!questionText.trim()) {
+ setJsonError('Введите текст вопроса')
+ return false
+ }
+ if (answers.length === 0) {
+ setJsonError('Добавьте хотя бы один ответ')
+ return false
+ }
+ const hasEmptyAnswers = answers.some(a => !a.text.trim())
+ if (hasEmptyAnswers) {
+ setJsonError('Заполните все ответы')
+ return false
+ }
+ return true
+ }
+
+ const handleSave = () => {
+ if (!validateForm()) return
+
+ const questionData = {
+ id: editingQuestion ? editingQuestion.id : Date.now(),
+ text: questionText.trim(),
+ answers: answers
+ .filter(a => a.text.trim())
+ .map(a => ({
+ text: a.text.trim(),
+ points: a.points,
+ })),
+ }
+
+ let updatedQuestions
+ if (editingQuestion) {
+ updatedQuestions = questions.map(q =>
+ q.id === editingQuestion.id ? questionData : q
+ )
+ } else {
+ updatedQuestions = [...questions, questionData]
+ }
+
+ onUpdateQuestions(updatedQuestions)
+ resetForm()
+ }
+
+ const handleDelete = (questionId) => {
+ if (window.confirm('Вы уверены, что хотите удалить этот вопрос?')) {
+ const updatedQuestions = questions.filter(q => q.id !== questionId)
+ onUpdateQuestions(updatedQuestions)
+ if (editingQuestion && editingQuestion.id === questionId) {
+ resetForm()
+ }
+ }
+ }
+
+ const handleExportJson = () => {
+ try {
+ const jsonString = JSON.stringify(questions, null, 2)
+ const blob = new Blob([jsonString], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = 'questions.json'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+ setJsonError('')
+ } catch (error) {
+ setJsonError('Ошибка при экспорте: ' + error.message)
+ }
+ }
+
+ const handleImportJson = () => {
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = '.json'
+ input.onchange = (e) => {
+ const file = e.target.files[0]
+ if (!file) return
+
+ const reader = new FileReader()
+ reader.onload = (event) => {
+ try {
+ const jsonContent = JSON.parse(event.target.result)
+
+ if (!Array.isArray(jsonContent)) {
+ setJsonError('JSON должен содержать массив вопросов')
+ return
+ }
+
+ // Валидация структуры
+ const isValid = jsonContent.every(q =>
+ q.id &&
+ typeof q.text === 'string' &&
+ Array.isArray(q.answers) &&
+ q.answers.every(a => a.text && typeof a.points === 'number')
+ )
+
+ if (!isValid) {
+ setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
+ return
+ }
+
+ onUpdateQuestions(jsonContent)
+ setJsonError('')
+ alert(`Успешно импортировано ${jsonContent.length} вопросов`)
+ } catch (error) {
+ setJsonError('Ошибка при импорте: ' + error.message)
+ }
+ }
+ reader.readAsText(file)
+ }
+ input.click()
+ }
+
+ return (
+
+
+
+
Управление вопросами
+
+
+
+
+
+
+
+
+ {jsonError && (
+
{jsonError}
+ )}
+
+
+
setQuestionText(e.target.value)}
+ placeholder="Введите текст вопроса"
+ className="questions-modal-input"
+ />
+
+
+
+
+
+ {editingQuestion && (
+
+ )}
+
+
+
+
+
+ Вопросы ({questions.length})
+
+ {questions.length === 0 ? (
+
Нет вопросов. Добавьте вопросы для игры.
+ ) : (
+
+ {questions.map((question) => (
+
+
+
{question.text}
+
+ {question.answers.length} ответов
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ )
+}
+
+export default QuestionsModal
+