From a02a0d5d1db2d5838755a1b44c6ae3fd0c79b0da Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 8 Jan 2026 23:59:44 +0300 Subject: [PATCH] schema fix --- backend/src/voice/voice.service.ts | 16 +- src/components/GameManagementModal.css | 309 ++++++++++++++++++ src/components/GameManagementModal.jsx | 429 ++++++++++++++++++++++++- src/pages/GamePage.jsx | 22 +- 4 files changed, 754 insertions(+), 22 deletions(-) diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts index 6db04f2..488a53a 100644 --- a/backend/src/voice/voice.service.ts +++ b/backend/src/voice/voice.service.ts @@ -3,6 +3,18 @@ import { ConfigService } from '@nestjs/config'; import { RoomsService } from '../rooms/rooms.service'; import { TTSContentType } from './dto/tts-request.dto'; +interface Answer { + id: string; + text: string; +} + +interface Question { + id: string; + text?: string; + question?: string; + answers?: Answer[]; +} + @Injectable() export class VoiceService { private readonly logger = new Logger(VoiceService.name); @@ -45,7 +57,7 @@ export class VoiceService { throw new NotFoundException('Questions not found for this room'); } - const question = questions.find((q: any) => q.id === questionId); + const question = (questions as Question[]).find((q) => q.id === questionId); if (!question) { this.logger.error(`Question with id=${questionId} not found in room=${roomId}`); @@ -68,7 +80,7 @@ export class VoiceService { } const answers = question.answers || []; - const answer = answers.find((a: any) => a.id === answerId); + const answer = answers.find((a) => a.id === answerId); if (!answer) { this.logger.error(`Answer with id=${answerId} not found in question=${questionId}`); diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css index 0ee5c42..3bb311d 100644 --- a/src/components/GameManagementModal.css +++ b/src/components/GameManagementModal.css @@ -383,6 +383,307 @@ color: var(--bg-primary, #000000); } +/* Questions tab */ +.questions-tab-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.questions-tab-content h3 { + margin: 0 0 0.5rem 0; +} + +.questions-tab-content h4 { + margin: 0.5rem 0; + font-size: 1rem; +} + +/* Override QuestionsModal styles for embedded version */ +.questions-tab-content .questions-modal-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.questions-tab-content .questions-modal-export-button, +.questions-tab-content .questions-modal-import-button, +.questions-tab-content .questions-modal-pack-import-button { + padding: 0.5rem 1rem; + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + color: var(--text-primary, #ffffff); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; +} + +.questions-tab-content .questions-modal-export-button:hover, +.questions-tab-content .questions-modal-import-button:hover, +.questions-tab-content .questions-modal-pack-import-button:hover { + border-color: var(--accent-primary, #ffd700); + transform: translateY(-2px); +} + +.questions-tab-content .questions-modal-error { + padding: 0.75rem; + background: rgba(255, 0, 0, 0.2); + border: 1px solid rgba(255, 0, 0, 0.5); + border-radius: var(--border-radius-sm, 8px); + color: var(--accent-secondary, #ff6b6b); + margin-bottom: 1rem; +} + +.questions-tab-content .pack-import-section { + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--border-radius-sm, 8px); + margin-bottom: 1rem; +} + +.questions-tab-content .pack-import-select { + width: 100%; + padding: 0.75rem; + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + color: var(--text-primary, #ffffff); + margin-bottom: 1rem; +} + +.questions-tab-content .pack-questions-list { + margin-top: 1rem; +} + +.questions-tab-content .pack-questions-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.questions-tab-content .pack-import-confirm-button { + padding: 0.5rem 1rem; + background: var(--accent-success, #4ecdc4); + border: none; + border-radius: var(--border-radius-sm, 8px); + color: white; + font-weight: 600; + cursor: pointer; +} + +.questions-tab-content .pack-questions-items { + max-height: 200px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.questions-tab-content .pack-question-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.05); + border-radius: var(--border-radius-sm, 8px); +} + +.questions-tab-content .pack-question-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.questions-tab-content .pack-question-info { + font-size: 0.85rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.questions-tab-content .questions-modal-form { + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--border-radius-sm, 8px); + margin-bottom: 1rem; +} + +.questions-tab-content .questions-modal-input { + width: 100%; + padding: 0.75rem; + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + color: var(--text-primary, #ffffff); + font-size: 1rem; + margin-bottom: 1rem; +} + +.questions-tab-content .questions-modal-answers { + margin-bottom: 1rem; +} + +.questions-tab-content .questions-modal-answers-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.questions-tab-content .questions-modal-add-answer-button { + padding: 0.5rem 1rem; + background: var(--accent-primary, #ffd700); + border: none; + border-radius: var(--border-radius-sm, 8px); + color: var(--bg-primary, #000000); + font-weight: 600; + cursor: pointer; +} + +.questions-tab-content .questions-modal-answer-row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; + align-items: center; +} + +.questions-tab-content .questions-modal-answer-input { + flex: 1; + padding: 0.5rem; + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + color: var(--text-primary, #ffffff); +} + +.questions-tab-content .questions-modal-points-input { + width: 80px; + padding: 0.5rem; + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + color: var(--text-primary, #ffffff); + text-align: center; +} + +.questions-tab-content .questions-modal-remove-answer-button { + width: 32px; + height: 32px; + background: rgba(255, 0, 0, 0.2); + border: 1px solid rgba(255, 0, 0, 0.5); + border-radius: 50%; + color: var(--accent-secondary, #ff6b6b); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.questions-tab-content .questions-modal-form-buttons { + display: flex; + gap: 0.75rem; +} + +.questions-tab-content .questions-modal-save-button, +.questions-tab-content .questions-modal-cancel-button { + padding: 0.75rem 1.5rem; + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.questions-tab-content .questions-modal-save-button { + background: var(--accent-success, #4ecdc4); + color: white; + border-color: var(--accent-success, #4ecdc4); +} + +.questions-tab-content .questions-modal-cancel-button { + background: transparent; + color: var(--text-primary, #ffffff); +} + +.questions-tab-content .questions-modal-list { + margin-top: 1rem; +} + +.questions-tab-content .questions-modal-list-title { + margin: 0 0 0.75rem 0; + font-size: 1.1rem; +} + +.questions-tab-content .questions-modal-empty { + text-align: center; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + padding: 2rem; +} + +.questions-tab-content .questions-modal-items { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-height: 300px; + overflow-y: auto; +} + +.questions-tab-content .questions-modal-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); +} + +.questions-tab-content .questions-modal-item-content { + flex: 1; +} + +.questions-tab-content .questions-modal-item-text { + font-weight: 600; + color: var(--text-primary, #ffffff); + margin-bottom: 0.25rem; +} + +.questions-tab-content .questions-modal-item-info { + font-size: 0.85rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.questions-tab-content .questions-modal-item-actions { + display: flex; + gap: 0.5rem; +} + +.questions-tab-content .questions-modal-edit-button, +.questions-tab-content .questions-modal-delete-button { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.questions-tab-content .questions-modal-edit-button { + background: rgba(255, 215, 0, 0.2); + color: var(--accent-primary, #ffd700); +} + +.questions-tab-content .questions-modal-delete-button { + background: rgba(255, 0, 0, 0.2); + color: var(--accent-secondary, #ff6b6b); +} + /* Mobile responsive */ @media (max-width: 768px) { .game-mgmt-tabs { @@ -404,6 +705,14 @@ .question-nav { flex-direction: column; } + + .questions-tab-content .questions-modal-actions { + flex-direction: column; + } + + .questions-tab-content .questions-modal-actions button { + width: 100%; + } } /* Custom Scrollbar */ diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index d82f9b0..2029225 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -1,5 +1,7 @@ import { useState } from 'react' +import { questionsApi } from '../services/api' import './GameManagementModal.css' +import './QuestionsModal.css' const GameManagementModal = ({ isOpen, @@ -10,6 +12,9 @@ const GameManagementModal = ({ currentQuestionIndex, totalQuestions, revealedAnswers, + questions = [], + onUpdateQuestions, + availablePacks = [], onStartGame, onEndGame, onNextQuestion, @@ -21,9 +26,26 @@ const GameManagementModal = ({ onAwardPoints, onPenalty, }) => { - const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring + const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions const [selectedPlayer, setSelectedPlayer] = useState(null) const [customPoints, setCustomPoints] = useState(10) + + // Questions management state + 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('') + const [showPackImport, setShowPackImport] = useState(false) + const [selectedPack, setSelectedPack] = useState(null) + const [packQuestions, setPackQuestions] = useState([]) + const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set()) if (!isOpen) return null @@ -57,6 +79,214 @@ const GameManagementModal = ({ } } + // Questions management handlers + const resetQuestionForm = () => { + setEditingQuestion(null) + setQuestionText('') + setAnswers([ + { text: '', points: 100 }, + { text: '', points: 80 }, + { text: '', points: 60 }, + { text: '', points: 40 }, + { text: '', points: 20 }, + { text: '', points: 10 }, + ]) + setJsonError('') + } + + const handleEditQuestion = (question) => { + setEditingQuestion(question) + setQuestionText(question.text) + setAnswers([...question.answers]) + setJsonError('') + } + + const handleCancelEditQuestion = () => { + resetQuestionForm() + } + + 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 validateQuestionForm = () => { + 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 handleSaveQuestion = () => { + if (!validateQuestionForm()) 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) + resetQuestionForm() + } + + const handleDeleteQuestion = (questionId) => { + if (window.confirm('Вы уверены, что хотите удалить этот вопрос?')) { + const updatedQuestions = questions.filter(q => q.id !== questionId) + onUpdateQuestions(updatedQuestions) + if (editingQuestion && editingQuestion.id === questionId) { + resetQuestionForm() + } + } + } + + 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() + } + + const handleSelectPack = async (packId) => { + if (!packId) { + setPackQuestions([]) + setSelectedPack(null) + return + } + + try { + const response = await questionsApi.getPack(packId) + setPackQuestions(response.data.questions || []) + setSelectedPack(packId) + setSelectedQuestionIndices(new Set()) + } catch (error) { + console.error('Error fetching pack:', error) + setJsonError('Ошибка загрузки пака вопросов') + } + } + + const handleToggleQuestion = (index) => { + const newSelected = new Set(selectedQuestionIndices) + if (newSelected.has(index)) { + newSelected.delete(index) + } else { + newSelected.add(index) + } + setSelectedQuestionIndices(newSelected) + } + + const handleImportSelected = () => { + const indices = Array.from(selectedQuestionIndices) + const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean) + + const copiedQuestions = questionsToImport.map(q => ({ + id: Date.now() + Math.random(), + text: q.text, + answers: q.answers.map(a => ({ text: a.text, points: a.points })), + })) + + const updatedQuestions = [...questions, ...copiedQuestions] + onUpdateQuestions(updatedQuestions) + + setSelectedQuestionIndices(new Set()) + setShowPackImport(false) + setJsonError('') + alert(`Импортировано ${copiedQuestions.length} вопросов`) + } + return (
@@ -94,6 +324,12 @@ const GameManagementModal = ({ > ➕ Очки +
{/* Tab content */} @@ -290,6 +526,197 @@ const GameManagementModal = ({ )}
)} + + {/* QUESTIONS TAB */} + {activeTab === 'questions' && ( +
+

Управление вопросами

+ +
+ + + {availablePacks.length > 0 && ( + + )} +
+ + {jsonError && ( +
{jsonError}
+ )} + + {showPackImport && availablePacks.length > 0 && ( +
+

Импорт вопросов из пака

+ + + {packQuestions.length > 0 && ( +
+
+ Выберите вопросы для импорта: + +
+ +
+ {packQuestions.map((q, idx) => ( +
+ handleToggleQuestion(idx)} + /> +
+ {q.text} + + {q.answers.length} ответов + +
+
+ ))} +
+
+ )} +
+ )} + +
+ 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} ответов +
+
+
+ + +
+
+ ))} +
+ )} +
+
+ )} diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index 4011372..863542d 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -6,7 +6,6 @@ import { questionsApi, roomsApi } from '../services/api'; import QRCode from 'qrcode'; import socketService from '../services/socket'; import Game from '../components/Game'; -import QuestionsModal from '../components/QuestionsModal'; import QRModal from '../components/QRModal'; import GameManagementModal from '../components/GameManagementModal'; import ThemeSwitcher from '../components/ThemeSwitcher'; @@ -36,7 +35,6 @@ const GamePage = () => { const [questionPacks, setQuestionPacks] = useState([]); const [selectedPackId, setSelectedPackId] = useState(''); const [updatingPack, setUpdatingPack] = useState(false); - const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false); const [isGameManagementModalOpen, setIsGameManagementModalOpen] = useState(false); const [isQRModalOpen, setIsQRModalOpen] = useState(false); const [qrCode, setQrCode] = useState(''); @@ -406,13 +404,6 @@ const GamePage = () => { {currentQuestionIndex + 1}/{questions.length} )} - )} @@ -443,16 +434,6 @@ const GamePage = () => { {/* Modals */} {isHost && ( <> - setIsQuestionsModalOpen(false)} - questions={questions} - onUpdateQuestions={handleUpdateRoomQuestions} - isOnlineMode={true} - roomId={room?.id} - availablePacks={questionPacks} - /> - setIsQRModalOpen(false)} @@ -469,6 +450,9 @@ const GamePage = () => { currentQuestionIndex={currentQuestionIndex} totalQuestions={questions.length} revealedAnswers={revealedAnswers} + questions={questions} + onUpdateQuestions={handleUpdateRoomQuestions} + availablePacks={questionPacks} onStartGame={handleStartGame} onEndGame={handleEndGame} onNextQuestion={handleNextQuestion}