diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index a6cbe75..ab32d6a 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,6 +1,7 @@ import { PrismaClient } from '@prisma/client'; import * as fs from 'fs'; import * as path from 'path'; +import { ensureQuestionIds } from '../src/utils/question-utils'; const prisma = new PrismaClient(); @@ -109,6 +110,7 @@ async function main() { ]; // Create question pack + const demoQuestionsWithIds = ensureQuestionIds(demoQuestions); const questionPack = await prisma.questionPack.upsert({ where: { id: 'demo-pack-1' }, update: {}, @@ -119,8 +121,8 @@ async function main() { category: 'Общие', isPublic: true, createdBy: demoUser.id, - questions: demoQuestions, - questionCount: demoQuestions.length, + questions: demoQuestionsWithIds as any, + questionCount: demoQuestionsWithIds.length, rating: 5.0, }, }); @@ -161,6 +163,7 @@ async function main() { }, ]; + const familyQuestionsWithIds = ensureQuestionIds(familyQuestions); const familyPack = await prisma.questionPack.upsert({ where: { id: 'family-pack-1' }, update: {}, @@ -171,8 +174,8 @@ async function main() { category: 'Семья', isPublic: true, createdBy: demoUser.id, - questions: familyQuestions, - questionCount: familyQuestions.length, + questions: familyQuestionsWithIds as any, + questionCount: familyQuestionsWithIds.length, rating: 4.8, }, }); @@ -195,6 +198,9 @@ async function main() { answers: q.answers, })); + // Add UUID to questions + const defaultQuestionsWithIds = ensureQuestionIds(defaultQuestions); + // Create default question pack const defaultPack = await prisma.questionPack.upsert({ where: { id: 'default-pack-1' }, @@ -206,8 +212,8 @@ async function main() { category: 'Новый год', isPublic: true, createdBy: demoUser.id, - questions: defaultQuestions, - questionCount: defaultQuestions.length, + questions: defaultQuestionsWithIds as any, + questionCount: defaultQuestionsWithIds.length, rating: 5.0, }, }); diff --git a/backend/src/admin/packs/admin-packs.service.ts b/backend/src/admin/packs/admin-packs.service.ts index 6a0fd39..484d19f 100644 --- a/backend/src/admin/packs/admin-packs.service.ts +++ b/backend/src/admin/packs/admin-packs.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { PackFiltersDto } from './dto/pack-filters.dto'; import { CreatePackDto } from './dto/create-pack.dto'; import { UpdatePackDto } from './dto/update-pack.dto'; +import { ensureQuestionIds } from '../../utils/question-utils'; @Injectable() export class AdminPacksService { @@ -90,13 +91,14 @@ export class AdminPacksService { async create(createPackDto: CreatePackDto, createdBy: string) { const { questions, ...data } = createPackDto; + const questionsWithIds = ensureQuestionIds(questions as any); return this.prisma.questionPack.create({ data: { ...data, createdBy, - questions: questions as any, - questionCount: questions.length, + questions: questionsWithIds as any, + questionCount: questionsWithIds.length, }, include: { creator: { @@ -123,8 +125,9 @@ export class AdminPacksService { const updateData: any = { ...data }; if (questions) { - updateData.questions = questions; - updateData.questionCount = questions.length; + const questionsWithIds = ensureQuestionIds(questions as any); + updateData.questions = questionsWithIds; + updateData.questionCount = questionsWithIds.length; } return this.prisma.questionPack.update({ diff --git a/backend/src/admin/packs/dto/create-pack.dto.ts b/backend/src/admin/packs/dto/create-pack.dto.ts index f8ef166..5dfab9c 100644 --- a/backend/src/admin/packs/dto/create-pack.dto.ts +++ b/backend/src/admin/packs/dto/create-pack.dto.ts @@ -2,6 +2,10 @@ import { IsString, IsBoolean, IsArray, IsOptional, ValidateNested, IsNumber } fr import { Type } from 'class-transformer'; class AnswerDto { + @IsOptional() + @IsString() + id?: string; + @IsString() text: string; @@ -10,6 +14,10 @@ class AnswerDto { } class QuestionDto { + @IsOptional() + @IsString() + id?: string; + @IsString() question: string; diff --git a/backend/src/admin/packs/dto/update-pack.dto.ts b/backend/src/admin/packs/dto/update-pack.dto.ts index 4482bd5..84fe6fc 100644 --- a/backend/src/admin/packs/dto/update-pack.dto.ts +++ b/backend/src/admin/packs/dto/update-pack.dto.ts @@ -2,6 +2,10 @@ import { IsString, IsBoolean, IsArray, IsOptional, ValidateNested, IsNumber } fr import { Type } from 'class-transformer'; class AnswerDto { + @IsOptional() + @IsString() + id?: string; + @IsString() text: string; @@ -10,6 +14,10 @@ class AnswerDto { } class QuestionDto { + @IsOptional() + @IsString() + id?: string; + @IsString() question: string; diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 2d35f86..a19c66f 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -74,7 +74,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On } @SubscribeMessage('revealAnswer') - async handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string }) { + async handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string; questionIndex?: number }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can reveal answers' }); @@ -84,6 +84,39 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On this.server.to(payload.roomCode).emit('answerRevealed', payload); } + @SubscribeMessage('hideAnswer') + async handleHideAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string; questionIndex?: number }) { + const isHost = await this.isHost(payload.roomId, payload.userId); + if (!isHost) { + client.emit('error', { message: 'Only the host can hide answers' }); + return; + } + + this.server.to(payload.roomCode).emit('answerHidden', payload); + } + + @SubscribeMessage('showAllAnswers') + async handleShowAllAnswers(client: Socket, payload: { roomCode: string; userId: string; roomId: string; questionIndex?: number }) { + const isHost = await this.isHost(payload.roomId, payload.userId); + if (!isHost) { + client.emit('error', { message: 'Only the host can show all answers' }); + return; + } + + this.server.to(payload.roomCode).emit('allAnswersShown', payload); + } + + @SubscribeMessage('hideAllAnswers') + async handleHideAllAnswers(client: Socket, payload: { roomCode: string; userId: string; roomId: string; questionIndex?: number }) { + const isHost = await this.isHost(payload.roomId, payload.userId); + if (!isHost) { + client.emit('error', { message: 'Only the host can hide all answers' }); + return; + } + + this.server.to(payload.roomCode).emit('allAnswersHidden', payload); + } + @SubscribeMessage('updateScore') async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string; userId: string; roomId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); @@ -104,7 +137,50 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On return; } - this.server.to(payload.roomCode).emit('questionChanged', payload); + const room = await this.prisma.room.findUnique({ + where: { id: payload.roomId }, + select: { currentQuestionIndex: true }, + }); + + if (room) { + const newIndex = (room.currentQuestionIndex || 0) + 1; + await this.prisma.room.update({ + where: { id: payload.roomId }, + data: { currentQuestionIndex: newIndex }, + }); + + this.server.to(payload.roomCode).emit('questionChanged', { + ...payload, + questionIndex: newIndex, + }); + } + } + + @SubscribeMessage('previousQuestion') + async handlePreviousQuestion(client: Socket, payload: { roomCode: string; userId: string; roomId: string }) { + const isHost = await this.isHost(payload.roomId, payload.userId); + if (!isHost) { + client.emit('error', { message: 'Only the host can change questions' }); + return; + } + + const room = await this.prisma.room.findUnique({ + where: { id: payload.roomId }, + select: { currentQuestionIndex: true }, + }); + + if (room && room.currentQuestionIndex > 0) { + const newIndex = room.currentQuestionIndex - 1; + await this.prisma.room.update({ + where: { id: payload.roomId }, + data: { currentQuestionIndex: newIndex }, + }); + + this.server.to(payload.roomCode).emit('questionChanged', { + ...payload, + questionIndex: newIndex, + }); + } } @SubscribeMessage('endGame') diff --git a/backend/src/room-pack/room-pack.service.ts b/backend/src/room-pack/room-pack.service.ts index f97be57..9220f96 100644 --- a/backend/src/room-pack/room-pack.service.ts +++ b/backend/src/room-pack/room-pack.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { ensureQuestionIds } from '../utils/question-utils'; @Injectable() export class RoomPackService { @@ -25,8 +26,11 @@ export class RoomPackService { }); if (sourcePack && sourcePack.questions) { - questions = sourcePack.questions; - questionCount = sourcePack.questionCount; + const sourceQuestions = Array.isArray(sourcePack.questions) + ? (sourcePack.questions as any[]) + : []; + questions = ensureQuestionIds(sourceQuestions); + questionCount = questions.length; } } @@ -56,12 +60,14 @@ export class RoomPackService { * Update room pack questions */ async updateQuestions(roomId: string, questions: any[]) { - const questionCount = Array.isArray(questions) ? questions.length : 0; + const questionsArray = Array.isArray(questions) ? questions : []; + const questionsWithIds = ensureQuestionIds(questionsArray); + const questionCount = questionsWithIds.length; return this.prisma.roomPack.update({ where: { roomId }, data: { - questions, + questions: questionsWithIds as any, questionCount, updatedAt: new Date(), }, @@ -96,9 +102,16 @@ export class RoomPackService { const questionsToImport = questionIndices .map(idx => sourceQuestions[idx]) .filter(Boolean) - .map(q => ({ ...q })); // Deep copy + .map(q => ({ ...q })); // Deep copy - удаляем ID чтобы создать новые - const updatedQuestions = [...existingQuestions, ...questionsToImport]; + // Удаляем ID чтобы ensureQuestionIds создал новые + const questionsToImportWithoutIds = questionsToImport.map(q => ({ + ...q, + id: undefined, + answers: q.answers?.map((a: any) => ({ ...a, id: undefined })) || [], + })); + + const updatedQuestions = [...existingQuestions, ...questionsToImportWithoutIds]; return this.updateQuestions(roomId, updatedQuestions); } diff --git a/backend/src/utils/question-utils.ts b/backend/src/utils/question-utils.ts new file mode 100644 index 0000000..cbb5baf --- /dev/null +++ b/backend/src/utils/question-utils.ts @@ -0,0 +1,39 @@ +import { randomUUID } from 'crypto'; + +interface Answer { + id?: string; + text: string; + points: number; +} + +interface Question { + id?: string; + text?: string; + question?: string; // Поддержка обоих вариантов названия поля + answers: Answer[]; +} + +/** + * Добавляет UUID к вопросам и ответам, если их нет + * @param questions - Массив вопросов + * @returns Массив вопросов с добавленными UUID + */ +export function ensureQuestionIds(questions: Question[]): Question[] { + return questions.map((question) => { + const questionId = question.id || randomUUID(); + const questionText = question.text || question.question || ''; + + const answersWithIds = question.answers.map((answer) => ({ + ...answer, + id: answer.id || randomUUID(), + })); + + return { + ...question, + id: questionId, + text: questionText, + question: questionText, // Сохраняем оба поля для совместимости + answers: answersWithIds, + }; + }); +} diff --git a/backend/src/voice/dto/tts-request.dto.ts b/backend/src/voice/dto/tts-request.dto.ts new file mode 100644 index 0000000..e6378b5 --- /dev/null +++ b/backend/src/voice/dto/tts-request.dto.ts @@ -0,0 +1,25 @@ +import { IsString, IsEnum, IsOptional, ValidateIf } from 'class-validator'; + +export enum TTSContentType { + QUESTION = 'question', + ANSWER = 'answer', +} + +export class TTSRequestDto { + @IsString() + roomId: string; + + @IsString() + questionId: string; + + @IsEnum(TTSContentType) + contentType: TTSContentType; + + @ValidateIf((o) => o.contentType === TTSContentType.ANSWER) + @IsString() + answerId?: string; + + @IsOptional() + @IsString() + voice?: string; +} diff --git a/backend/src/voice/voice.controller.ts b/backend/src/voice/voice.controller.ts index f42d382..c982a75 100644 --- a/backend/src/voice/voice.controller.ts +++ b/backend/src/voice/voice.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import type { Response } from 'express'; import { VoiceService } from './voice.service'; +import { TTSRequestDto } from './dto/tts-request.dto'; @Controller('voice') export class VoiceController { @@ -22,23 +23,41 @@ export class VoiceController { @Post('tts') async generateTTS( - @Body() body: { text: string; voice?: string }, + @Body() body: TTSRequestDto, @Res() res: Response, ) { this.logger.log('POST /voice/tts - Request received'); - const { text, voice } = body; - this.logger.debug(`Request body: text="${text?.substring(0, 50)}...", voice=${voice}`); + const { roomId, questionId, contentType, answerId, voice } = body; + this.logger.debug( + `Request body: roomId=${roomId}, questionId=${questionId}, contentType=${contentType}, answerId=${answerId}, voice=${voice}`, + ); - if (!text) { - this.logger.warn('POST /voice/tts - Text is missing'); + if (!roomId || !questionId || !contentType) { + this.logger.warn('POST /voice/tts - Required fields are missing'); return res.status(HttpStatus.BAD_REQUEST).json({ - error: 'Text is required', + error: 'roomId, questionId, and contentType are required', + }); + } + + if (contentType === 'answer' && !answerId) { + this.logger.warn('POST /voice/tts - answerId is required for answer contentType'); + return res.status(HttpStatus.BAD_REQUEST).json({ + error: 'answerId is required when contentType is answer', }); } try { + // Get text from question/answer using IDs + const text = await this.voiceService.getQuestionText( + roomId, + questionId, + contentType, + answerId, + ); + + // Generate TTS from text const audioBuffer = await this.voiceService.generateTTS(text, voice); - + this.logger.log(`POST /voice/tts - Success, sending ${audioBuffer.length} bytes`); res.setHeader('Content-Type', 'audio/mpeg'); res.setHeader('Content-Length', audioBuffer.length.toString()); diff --git a/backend/src/voice/voice.module.ts b/backend/src/voice/voice.module.ts index a233ee8..2763dfb 100644 --- a/backend/src/voice/voice.module.ts +++ b/backend/src/voice/voice.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { VoiceService } from './voice.service'; import { VoiceController } from './voice.controller'; +import { RoomsModule } from '../rooms/rooms.module'; @Module({ + imports: [RoomsModule], controllers: [VoiceController], providers: [VoiceService], exports: [VoiceService], diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts index 8259d4f..6db04f2 100644 --- a/backend/src/voice/voice.service.ts +++ b/backend/src/voice/voice.service.ts @@ -1,12 +1,17 @@ -import { Injectable, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { Injectable, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { RoomsService } from '../rooms/rooms.service'; +import { TTSContentType } from './dto/tts-request.dto'; @Injectable() export class VoiceService { private readonly logger = new Logger(VoiceService.name); private readonly voiceServiceUrl: string; - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + private roomsService: RoomsService, + ) { this.logger.log('Initializing VoiceService...'); var voiceServiceHost = this.configService.get('VOICE_SERVICE_HOST'); @@ -23,6 +28,64 @@ export class VoiceService { this.logger.log(`VoiceService initialized with URL: ${this.voiceServiceUrl}`); } + async getQuestionText( + roomId: string, + questionId: string, + contentType: TTSContentType, + answerId?: string, + ): Promise { + this.logger.log( + `Getting question text for roomId=${roomId}, questionId=${questionId}, contentType=${contentType}, answerId=${answerId}`, + ); + + const questions = await this.roomsService.getEffectiveQuestions(roomId); + + if (!questions || !Array.isArray(questions)) { + this.logger.error(`Questions not found for roomId=${roomId}`); + throw new NotFoundException('Questions not found for this room'); + } + + const question = questions.find((q: any) => q.id === questionId); + + if (!question) { + this.logger.error(`Question with id=${questionId} not found in room=${roomId}`); + throw new NotFoundException('Question not found'); + } + + if (contentType === TTSContentType.QUESTION) { + const questionText = question.text || question.question; + if (!questionText) { + this.logger.error(`Question text is empty for questionId=${questionId}`); + throw new NotFoundException('Question text is empty'); + } + return questionText; + } + + if (contentType === TTSContentType.ANSWER) { + if (!answerId) { + this.logger.error(`answerId is required for contentType=answer`); + throw new HttpException('answerId is required when contentType is answer', HttpStatus.BAD_REQUEST); + } + + const answers = question.answers || []; + const answer = answers.find((a: any) => a.id === answerId); + + if (!answer) { + this.logger.error(`Answer with id=${answerId} not found in question=${questionId}`); + throw new NotFoundException('Answer not found'); + } + + if (!answer.text) { + this.logger.error(`Answer text is empty for answerId=${answerId}`); + throw new NotFoundException('Answer text is empty'); + } + + return answer.text; + } + + throw new HttpException('Invalid contentType', HttpStatus.BAD_REQUEST); + } + async generateTTS(text: string, voice?: string): Promise { this.logger.log(`Generating TTS for text: "${text.substring(0, 50)}..." (voice parameter ignored)`); try { diff --git a/src/components/Answer.jsx b/src/components/Answer.jsx index 6a37752..a73a050 100644 --- a/src/components/Answer.jsx +++ b/src/components/Answer.jsx @@ -1,6 +1,7 @@ +import VoicePlayer from './VoicePlayer' import './Answer.css' -const Answer = ({ answer, index, onClick, isRevealed }) => { +const Answer = ({ answer, index, onClick, isRevealed, roomId, questionId }) => { const getAnswerClass = () => { if (!isRevealed) return 'answer-hidden' return 'answer-revealed' @@ -31,7 +32,7 @@ const Answer = ({ answer, index, onClick, isRevealed }) => { } > {isRevealed ? ( - <> +
{answer.text} { > {answer.points} - + {roomId && questionId && answer.id && ( + + )} +
) : ( { const { playEffect } = useVoice(); @@ -409,6 +410,7 @@ const Game = forwardRef(({ onNextQuestion={handleNextQuestion} canGoPrevious={currentQuestionIndex > 0} canGoNext={currentQuestionIndex < questions.length - 1} + roomId={roomId} /> ) : (
diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css new file mode 100644 index 0000000..0ee5c42 --- /dev/null +++ b/src/components/GameManagementModal.css @@ -0,0 +1,427 @@ +/* Modal backdrop */ +.game-mgmt-modal-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +/* Modal content */ +.game-mgmt-modal-content { + background: var(--bg-card, #1a1a1a); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-md, 12px); + width: 100%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +/* Header */ +.game-mgmt-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem; + border-bottom: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); +} + +.game-mgmt-modal-header h2 { + margin: 0; + font-size: 1.5rem; + color: var(--accent-primary, #ffd700); +} + +.game-mgmt-close { + background: transparent; + border: none; + font-size: 2rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + cursor: pointer; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} + +.game-mgmt-close:hover { + background: rgba(255, 0, 0, 0.2); + color: var(--accent-secondary, #ff6b6b); +} + +/* Tabs */ +.game-mgmt-tabs { + display: flex; + border-bottom: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + background: rgba(0, 0, 0, 0.2); +} + +.tab { + flex: 1; + padding: 1rem; + background: transparent; + border: none; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + border-bottom: 3px solid transparent; +} + +.tab:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary, #ffffff); +} + +.tab.active { + color: var(--accent-primary, #ffd700); + border-bottom-color: var(--accent-primary, #ffd700); + background: rgba(255, 215, 0, 0.1); +} + +.tab:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +/* Tab content */ +.game-mgmt-body { + padding: 1.5rem; + overflow-y: auto; + max-height: calc(90vh - 200px); +} + +.tab-content h3 { + margin: 0 0 1rem 0; + color: var(--text-primary, #ffffff); +} + +.empty-message { + text-align: center; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); + padding: 2rem; +} + +/* Players tab */ +.players-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.player-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); +} + +.player-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.player-name { + font-weight: 600; + color: var(--text-primary, #ffffff); +} + +.player-role { + font-size: 0.85rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.player-score { + font-size: 1.1rem; + font-weight: bold; + color: var(--accent-primary, #ffd700); +} + +/* Game controls tab */ +.game-status { + padding: 1rem; + background: rgba(255, 255, 255, 0.05); + border-radius: var(--border-radius-sm, 8px); + margin-bottom: 1rem; +} + +.game-controls { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.question-nav { + display: flex; + align-items: center; + gap: 1rem; + justify-content: space-between; +} + +.question-indicator { + font-weight: 600; + color: var(--text-primary, #ffffff); +} + +.game-info { + display: flex; + gap: 2rem; + margin-top: 1rem; + padding: 1rem; + background: rgba(0, 0, 0, 0.2); + border-radius: var(--border-radius-sm, 8px); +} + +.info-item { + display: flex; + gap: 0.5rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.info-item strong { + color: var(--accent-primary, #ffd700); +} + +/* Buttons */ +.mgmt-button { + padding: 0.75rem 1.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); + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.mgmt-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: var(--accent-primary, #ffd700); +} + +.mgmt-button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.start-button { + background: var(--accent-success, #4ecdc4); + border-color: var(--accent-success, #4ecdc4); + color: white; + width: 100%; + font-size: 1.1rem; +} + +.end-button { + background: var(--accent-secondary, #ff6b6b); + border-color: var(--accent-secondary, #ff6b6b); + color: white; +} + +.toggle-all-button { + background: var(--accent-primary, #ffd700); + border-color: var(--accent-primary, #ffd700); + color: var(--bg-primary, #000000); + width: 100%; + margin-bottom: 1rem; +} + +/* Answers grid */ +.answers-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 0.75rem; +} + +.answer-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: var(--border-radius-sm, 8px); + cursor: pointer; + transition: all 0.2s; + text-align: left; +} + +.answer-button.revealed { + background: var(--accent-success, #4ecdc4); + border-color: var(--accent-success, #4ecdc4); + color: white; +} + +.answer-button.hidden { + opacity: 0.6; +} + +.answer-button:hover { + transform: translateX(4px); +} + +.answer-num { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + font-weight: bold; + flex-shrink: 0; +} + +.answer-txt { + flex: 1; +} + +.answer-pts { + font-weight: bold; + color: var(--accent-primary, #ffd700); + flex-shrink: 0; +} + +.answer-button.revealed .answer-pts { + color: white; +} + +/* Scoring tab */ +.player-selector { + margin-bottom: 1.5rem; +} + +.player-selector label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-secondary, rgba(255, 255, 255, 0.6)); +} + +.player-selector 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); + font-size: 1rem; + cursor: pointer; +} + +.player-selector select:focus { + outline: none; + border-color: var(--accent-primary, #ffd700); +} + +.scoring-section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.quick-points { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +.points-button { + background: var(--accent-success, #4ecdc4); + border-color: var(--accent-success, #4ecdc4); + color: white; +} + +.penalty-button { + background: var(--accent-secondary, #ff6b6b); + border-color: var(--accent-secondary, #ff6b6b); + color: white; +} + +.custom-points { + display: flex; + gap: 0.75rem; +} + +.custom-points input { + flex: 1; + 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; + text-align: center; +} + +.custom-points input:focus { + outline: none; + border-color: var(--accent-primary, #ffd700); +} + +.custom-button { + flex: 2; + background: var(--accent-primary, #ffd700); + border-color: var(--accent-primary, #ffd700); + color: var(--bg-primary, #000000); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .game-mgmt-tabs { + font-size: 0.85rem; + } + + .tab { + padding: 0.75rem 0.5rem; + } + + .answers-grid { + grid-template-columns: 1fr; + } + + .quick-points { + grid-template-columns: repeat(2, 1fr); + } + + .question-nav { + flex-direction: column; + } +} + +/* Custom Scrollbar */ +.game-mgmt-body::-webkit-scrollbar { + width: 8px; +} + +.game-mgmt-body::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; +} + +.game-mgmt-body::-webkit-scrollbar-thumb { + background: var(--accent-primary, #ffd700); + border-radius: 4px; +} + +.game-mgmt-body::-webkit-scrollbar-thumb:hover { + background: var(--accent-secondary, #ff6b6b); +} + diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx new file mode 100644 index 0000000..d82f9b0 --- /dev/null +++ b/src/components/GameManagementModal.jsx @@ -0,0 +1,300 @@ +import { useState } from 'react' +import './GameManagementModal.css' + +const GameManagementModal = ({ + isOpen, + onClose, + room, + participants, + currentQuestion, + currentQuestionIndex, + totalQuestions, + revealedAnswers, + onStartGame, + onEndGame, + onNextQuestion, + onPreviousQuestion, + onRevealAnswer, + onHideAnswer, + onShowAllAnswers, + onHideAllAnswers, + onAwardPoints, + onPenalty, +}) => { + const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring + const [selectedPlayer, setSelectedPlayer] = useState(null) + const [customPoints, setCustomPoints] = useState(10) + + if (!isOpen) return null + + const gameStatus = room?.status || 'WAITING' + const areAllAnswersRevealed = currentQuestion + ? revealedAnswers.length === currentQuestion.answers.length + : false + + // Handlers + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget) onClose() + } + + 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 ( +
+
+ {/* Header with title and close button */} +
+

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

+ +
+ + {/* Tabs navigation */} +
+ + + + +
+ + {/* Tab content */} +
+ {/* PLAYERS TAB */} + {activeTab === 'players' && ( +
+

Участники ({participants.length})

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

Нет участников

+ ) : ( + participants.map((participant) => ( +
+
+ {participant.name} + + {participant.role === 'HOST' && '👑 Ведущий'} + {participant.role === 'SPECTATOR' && '👀 Зритель'} + +
+
+ {participant.score || 0} очков +
+
+ )) + )} +
+
+ )} + + {/* GAME CONTROLS TAB */} + {activeTab === 'game' && ( +
+

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

+ +
+ Статус: + {gameStatus === 'WAITING' && ' Ожидание'} + {gameStatus === 'PLAYING' && ' Идет игра'} + {gameStatus === 'FINISHED' && ' Завершена'} +
+ + {gameStatus === 'WAITING' && ( + + )} + + {gameStatus === 'PLAYING' && ( +
+
+ + + Вопрос {currentQuestionIndex + 1} / {totalQuestions} + + +
+ +
+ )} + +
+
+ Игроков: {participants.length} +
+ {gameStatus === 'PLAYING' && totalQuestions > 0 && ( +
+ Вопросов: {totalQuestions} +
+ )} +
+
+ )} + + {/* ANSWERS CONTROL TAB */} + {activeTab === 'answers' && currentQuestion && ( +
+

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

+ + + +
+ {currentQuestion.answers.map((answer, index) => ( + + ))} +
+
+ )} + + {/* SCORING TAB */} + {activeTab === 'scoring' && ( +
+

Начисление очков

+ +
+ + +
+ + {selectedPlayer && ( +
+
+ + + + +
+ +
+ setCustomPoints(parseInt(e.target.value) || 0)} + /> + +
+
+ )} +
+ )} +
+
+
+ ) +} + +export default GameManagementModal + diff --git a/src/components/Question.jsx b/src/components/Question.jsx index 77e73e7..e5fa8c9 100644 --- a/src/components/Question.jsx +++ b/src/components/Question.jsx @@ -12,6 +12,7 @@ const Question = ({ onNextQuestion, canGoPrevious, canGoNext, + roomId, }) => { const allAnswersRevealed = question.answers.every((_, index) => revealedAnswers.includes(index)) const hasUnrevealedAnswers = revealedAnswers.length < question.answers.length @@ -31,7 +32,13 @@ const Question = ({ )}

{question.text}

- + {roomId && question.id && ( + + )}
{canGoNext && onNextQuestion && (
diff --git a/src/components/VoicePlayer.jsx b/src/components/VoicePlayer.jsx index d0cd406..073f3b9 100644 --- a/src/components/VoicePlayer.jsx +++ b/src/components/VoicePlayer.jsx @@ -2,33 +2,49 @@ import React from 'react'; import { useVoice } from '../hooks/useVoice'; import './VoicePlayer.css'; -const VoicePlayer = ({ text, autoPlay = false, showButton = true, children }) => { +const VoicePlayer = ({ + roomId, + questionId, + contentType, + answerId, + autoPlay = false, + showButton = true, + children +}) => { const { isEnabled, isPlaying, currentText, speak, stop } = useVoice(); - const isPlayingThis = isPlaying && currentText === text; + // Create unique identifier for this speech request + const speechId = roomId && questionId && contentType + ? `${roomId}:${questionId}:${contentType}:${answerId || ''}` + : null; + + const isPlayingThis = isPlaying && currentText === speechId; const handleClick = () => { if (isPlayingThis) { stop(); - } else { - speak(text); + } else if (roomId && questionId && contentType) { + speak({ roomId, questionId, contentType, answerId }); } }; React.useEffect(() => { - if (autoPlay && isEnabled && text) { - speak(text); + if (autoPlay && isEnabled && roomId && questionId && contentType) { + speak({ roomId, questionId, contentType, answerId }); } - }, [autoPlay, isEnabled, text]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoPlay, isEnabled, roomId, questionId, contentType, answerId]); if (!isEnabled || !showButton) { return children || null; } + const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId); + return (
{children} - {showButton && text && ( + {showButton && canPlay && (