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 ( +
+
+
+

Управление участниками

+ +
+ +
+ setNewPlayerName(e.target.value)} + placeholder="Введите имя участника" + className="players-modal-input" + maxLength={30} + autoFocus + /> + +
+ +
+ {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" + /> + +
+
+ Ответы: + +
+ + {answers.map((answer, index) => ( +
+ handleAnswerChange(index, 'text', e.target.value)} + placeholder={`Ответ ${index + 1}`} + className="questions-modal-answer-input" + /> + handleAnswerChange(index, 'points', e.target.value)} + className="questions-modal-points-input" + min="0" + /> + {answers.length > 1 && ( + + )} +
+ ))} +
+ +
+ + {editingQuestion && ( + + )} +
+
+ +
+

+ Вопросы ({questions.length}) +

+ {questions.length === 0 ? ( +

Нет вопросов. Добавьте вопросы для игры.

+ ) : ( +
+ {questions.map((question) => ( +
+
+
{question.text}
+
+ {question.answers.length} ответов +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+ ) +} + +export default QuestionsModal +