tts and buttons

This commit is contained in:
Dmitry 2026-01-08 23:14:58 +03:00
parent 7d986ce528
commit 49fcad7f1d
20 changed files with 1502 additions and 85 deletions

View file

@ -1,6 +1,7 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ensureQuestionIds } from '../src/utils/question-utils';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -109,6 +110,7 @@ async function main() {
]; ];
// Create question pack // Create question pack
const demoQuestionsWithIds = ensureQuestionIds(demoQuestions);
const questionPack = await prisma.questionPack.upsert({ const questionPack = await prisma.questionPack.upsert({
where: { id: 'demo-pack-1' }, where: { id: 'demo-pack-1' },
update: {}, update: {},
@ -119,8 +121,8 @@ async function main() {
category: 'Общие', category: 'Общие',
isPublic: true, isPublic: true,
createdBy: demoUser.id, createdBy: demoUser.id,
questions: demoQuestions, questions: demoQuestionsWithIds as any,
questionCount: demoQuestions.length, questionCount: demoQuestionsWithIds.length,
rating: 5.0, rating: 5.0,
}, },
}); });
@ -161,6 +163,7 @@ async function main() {
}, },
]; ];
const familyQuestionsWithIds = ensureQuestionIds(familyQuestions);
const familyPack = await prisma.questionPack.upsert({ const familyPack = await prisma.questionPack.upsert({
where: { id: 'family-pack-1' }, where: { id: 'family-pack-1' },
update: {}, update: {},
@ -171,8 +174,8 @@ async function main() {
category: 'Семья', category: 'Семья',
isPublic: true, isPublic: true,
createdBy: demoUser.id, createdBy: demoUser.id,
questions: familyQuestions, questions: familyQuestionsWithIds as any,
questionCount: familyQuestions.length, questionCount: familyQuestionsWithIds.length,
rating: 4.8, rating: 4.8,
}, },
}); });
@ -195,6 +198,9 @@ async function main() {
answers: q.answers, answers: q.answers,
})); }));
// Add UUID to questions
const defaultQuestionsWithIds = ensureQuestionIds(defaultQuestions);
// Create default question pack // Create default question pack
const defaultPack = await prisma.questionPack.upsert({ const defaultPack = await prisma.questionPack.upsert({
where: { id: 'default-pack-1' }, where: { id: 'default-pack-1' },
@ -206,8 +212,8 @@ async function main() {
category: 'Новый год', category: 'Новый год',
isPublic: true, isPublic: true,
createdBy: demoUser.id, createdBy: demoUser.id,
questions: defaultQuestions, questions: defaultQuestionsWithIds as any,
questionCount: defaultQuestions.length, questionCount: defaultQuestionsWithIds.length,
rating: 5.0, rating: 5.0,
}, },
}); });

View file

@ -3,6 +3,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { PackFiltersDto } from './dto/pack-filters.dto'; import { PackFiltersDto } from './dto/pack-filters.dto';
import { CreatePackDto } from './dto/create-pack.dto'; import { CreatePackDto } from './dto/create-pack.dto';
import { UpdatePackDto } from './dto/update-pack.dto'; import { UpdatePackDto } from './dto/update-pack.dto';
import { ensureQuestionIds } from '../../utils/question-utils';
@Injectable() @Injectable()
export class AdminPacksService { export class AdminPacksService {
@ -90,13 +91,14 @@ export class AdminPacksService {
async create(createPackDto: CreatePackDto, createdBy: string) { async create(createPackDto: CreatePackDto, createdBy: string) {
const { questions, ...data } = createPackDto; const { questions, ...data } = createPackDto;
const questionsWithIds = ensureQuestionIds(questions as any);
return this.prisma.questionPack.create({ return this.prisma.questionPack.create({
data: { data: {
...data, ...data,
createdBy, createdBy,
questions: questions as any, questions: questionsWithIds as any,
questionCount: questions.length, questionCount: questionsWithIds.length,
}, },
include: { include: {
creator: { creator: {
@ -123,8 +125,9 @@ export class AdminPacksService {
const updateData: any = { ...data }; const updateData: any = { ...data };
if (questions) { if (questions) {
updateData.questions = questions; const questionsWithIds = ensureQuestionIds(questions as any);
updateData.questionCount = questions.length; updateData.questions = questionsWithIds;
updateData.questionCount = questionsWithIds.length;
} }
return this.prisma.questionPack.update({ return this.prisma.questionPack.update({

View file

@ -2,6 +2,10 @@ import { IsString, IsBoolean, IsArray, IsOptional, ValidateNested, IsNumber } fr
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
class AnswerDto { class AnswerDto {
@IsOptional()
@IsString()
id?: string;
@IsString() @IsString()
text: string; text: string;
@ -10,6 +14,10 @@ class AnswerDto {
} }
class QuestionDto { class QuestionDto {
@IsOptional()
@IsString()
id?: string;
@IsString() @IsString()
question: string; question: string;

View file

@ -2,6 +2,10 @@ import { IsString, IsBoolean, IsArray, IsOptional, ValidateNested, IsNumber } fr
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
class AnswerDto { class AnswerDto {
@IsOptional()
@IsString()
id?: string;
@IsString() @IsString()
text: string; text: string;
@ -10,6 +14,10 @@ class AnswerDto {
} }
class QuestionDto { class QuestionDto {
@IsOptional()
@IsString()
id?: string;
@IsString() @IsString()
question: string; question: string;

View file

@ -74,7 +74,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
} }
@SubscribeMessage('revealAnswer') @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); const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) { if (!isHost) {
client.emit('error', { message: 'Only the host can reveal answers' }); 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); 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') @SubscribeMessage('updateScore')
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string; userId: string; roomId: string }) { async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string; userId: string; roomId: string }) {
const isHost = await this.isHost(payload.roomId, payload.userId); const isHost = await this.isHost(payload.roomId, payload.userId);
@ -104,7 +137,50 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return; 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') @SubscribeMessage('endGame')

View file

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ensureQuestionIds } from '../utils/question-utils';
@Injectable() @Injectable()
export class RoomPackService { export class RoomPackService {
@ -25,8 +26,11 @@ export class RoomPackService {
}); });
if (sourcePack && sourcePack.questions) { if (sourcePack && sourcePack.questions) {
questions = sourcePack.questions; const sourceQuestions = Array.isArray(sourcePack.questions)
questionCount = sourcePack.questionCount; ? (sourcePack.questions as any[])
: [];
questions = ensureQuestionIds(sourceQuestions);
questionCount = questions.length;
} }
} }
@ -56,12 +60,14 @@ export class RoomPackService {
* Update room pack questions * Update room pack questions
*/ */
async updateQuestions(roomId: string, questions: any[]) { 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({ return this.prisma.roomPack.update({
where: { roomId }, where: { roomId },
data: { data: {
questions, questions: questionsWithIds as any,
questionCount, questionCount,
updatedAt: new Date(), updatedAt: new Date(),
}, },
@ -96,9 +102,16 @@ export class RoomPackService {
const questionsToImport = questionIndices const questionsToImport = questionIndices
.map(idx => sourceQuestions[idx]) .map(idx => sourceQuestions[idx])
.filter(Boolean) .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); return this.updateQuestions(roomId, updatedQuestions);
} }

View file

@ -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,
};
});
}

View file

@ -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;
}

View file

@ -11,6 +11,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { VoiceService } from './voice.service'; import { VoiceService } from './voice.service';
import { TTSRequestDto } from './dto/tts-request.dto';
@Controller('voice') @Controller('voice')
export class VoiceController { export class VoiceController {
@ -22,21 +23,39 @@ export class VoiceController {
@Post('tts') @Post('tts')
async generateTTS( async generateTTS(
@Body() body: { text: string; voice?: string }, @Body() body: TTSRequestDto,
@Res() res: Response, @Res() res: Response,
) { ) {
this.logger.log('POST /voice/tts - Request received'); this.logger.log('POST /voice/tts - Request received');
const { text, voice } = body; const { roomId, questionId, contentType, answerId, voice } = body;
this.logger.debug(`Request body: text="${text?.substring(0, 50)}...", voice=${voice}`); this.logger.debug(
`Request body: roomId=${roomId}, questionId=${questionId}, contentType=${contentType}, answerId=${answerId}, voice=${voice}`,
);
if (!text) { if (!roomId || !questionId || !contentType) {
this.logger.warn('POST /voice/tts - Text is missing'); this.logger.warn('POST /voice/tts - Required fields are missing');
return res.status(HttpStatus.BAD_REQUEST).json({ 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 { 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); const audioBuffer = await this.voiceService.generateTTS(text, voice);
this.logger.log(`POST /voice/tts - Success, sending ${audioBuffer.length} bytes`); this.logger.log(`POST /voice/tts - Success, sending ${audioBuffer.length} bytes`);

View file

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { VoiceService } from './voice.service'; import { VoiceService } from './voice.service';
import { VoiceController } from './voice.controller'; import { VoiceController } from './voice.controller';
import { RoomsModule } from '../rooms/rooms.module';
@Module({ @Module({
imports: [RoomsModule],
controllers: [VoiceController], controllers: [VoiceController],
providers: [VoiceService], providers: [VoiceService],
exports: [VoiceService], exports: [VoiceService],

View file

@ -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 { ConfigService } from '@nestjs/config';
import { RoomsService } from '../rooms/rooms.service';
import { TTSContentType } from './dto/tts-request.dto';
@Injectable() @Injectable()
export class VoiceService { export class VoiceService {
private readonly logger = new Logger(VoiceService.name); private readonly logger = new Logger(VoiceService.name);
private readonly voiceServiceUrl: string; private readonly voiceServiceUrl: string;
constructor(private configService: ConfigService) { constructor(
private configService: ConfigService,
private roomsService: RoomsService,
) {
this.logger.log('Initializing VoiceService...'); this.logger.log('Initializing VoiceService...');
var voiceServiceHost = this.configService.get<string>('VOICE_SERVICE_HOST'); var voiceServiceHost = this.configService.get<string>('VOICE_SERVICE_HOST');
@ -23,6 +28,64 @@ export class VoiceService {
this.logger.log(`VoiceService initialized with URL: ${this.voiceServiceUrl}`); this.logger.log(`VoiceService initialized with URL: ${this.voiceServiceUrl}`);
} }
async getQuestionText(
roomId: string,
questionId: string,
contentType: TTSContentType,
answerId?: string,
): Promise<string> {
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<Buffer> { async generateTTS(text: string, voice?: string): Promise<Buffer> {
this.logger.log(`Generating TTS for text: "${text.substring(0, 50)}..." (voice parameter ignored)`); this.logger.log(`Generating TTS for text: "${text.substring(0, 50)}..." (voice parameter ignored)`);
try { try {

View file

@ -1,6 +1,7 @@
import VoicePlayer from './VoicePlayer'
import './Answer.css' import './Answer.css'
const Answer = ({ answer, index, onClick, isRevealed }) => { const Answer = ({ answer, index, onClick, isRevealed, roomId, questionId }) => {
const getAnswerClass = () => { const getAnswerClass = () => {
if (!isRevealed) return 'answer-hidden' if (!isRevealed) return 'answer-hidden'
return 'answer-revealed' return 'answer-revealed'
@ -31,7 +32,7 @@ const Answer = ({ answer, index, onClick, isRevealed }) => {
} }
> >
{isRevealed ? ( {isRevealed ? (
<> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
<span className="answer-text">{answer.text}</span> <span className="answer-text">{answer.text}</span>
<span <span
className="answer-points" className="answer-points"
@ -39,7 +40,16 @@ const Answer = ({ answer, index, onClick, isRevealed }) => {
> >
{answer.points} {answer.points}
</span> </span>
</> {roomId && questionId && answer.id && (
<VoicePlayer
roomId={roomId}
questionId={questionId}
contentType="answer"
answerId={answer.id}
showButton={true}
/>
)}
</div>
) : ( ) : (
<span <span
className="answer-points-hidden" className="answer-points-hidden"

View file

@ -14,6 +14,7 @@ const Game = forwardRef(({
onQuestionsChange, onQuestionsChange,
roomParticipants = null, // Участники для онлайн игры roomParticipants = null, // Участники для онлайн игры
isOnlineMode = false, // Флаг онлайн режима isOnlineMode = false, // Флаг онлайн режима
roomId = null, // Room ID для TTS (только для онлайн режима)
}, ref) => { }, ref) => {
const { playEffect } = useVoice(); const { playEffect } = useVoice();
@ -409,6 +410,7 @@ const Game = forwardRef(({
onNextQuestion={handleNextQuestion} onNextQuestion={handleNextQuestion}
canGoPrevious={currentQuestionIndex > 0} canGoPrevious={currentQuestionIndex > 0}
canGoNext={currentQuestionIndex < questions.length - 1} canGoNext={currentQuestionIndex < questions.length - 1}
roomId={roomId}
/> />
) : ( ) : (
<div className="no-players-message"> <div className="no-players-message">

View file

@ -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);
}

View file

@ -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 (
<div className="game-mgmt-modal-backdrop" onClick={handleBackdropClick}>
<div className="game-mgmt-modal-content">
{/* Header with title and close button */}
<div className="game-mgmt-modal-header">
<h2>🎛 Управление игрой</h2>
<button className="game-mgmt-close" onClick={onClose}>×</button>
</div>
{/* Tabs navigation */}
<div className="game-mgmt-tabs">
<button
className={`tab ${activeTab === 'players' ? 'active' : ''}`}
onClick={() => setActiveTab('players')}
>
👥 Игроки
</button>
<button
className={`tab ${activeTab === 'game' ? 'active' : ''}`}
onClick={() => setActiveTab('game')}
>
🎮 Игра
</button>
<button
className={`tab ${activeTab === 'answers' ? 'active' : ''}`}
onClick={() => setActiveTab('answers')}
disabled={gameStatus !== 'PLAYING' || !currentQuestion}
>
👁 Ответы
</button>
<button
className={`tab ${activeTab === 'scoring' ? 'active' : ''}`}
onClick={() => setActiveTab('scoring')}
disabled={gameStatus !== 'PLAYING' || participants.length === 0}
>
Очки
</button>
</div>
{/* Tab content */}
<div className="game-mgmt-body">
{/* PLAYERS TAB */}
{activeTab === 'players' && (
<div className="tab-content">
<h3>Участники ({participants.length})</h3>
<div className="players-list">
{participants.length === 0 ? (
<p className="empty-message">Нет участников</p>
) : (
participants.map((participant) => (
<div key={participant.id} className="player-item">
<div className="player-info">
<span className="player-name">{participant.name}</span>
<span className="player-role">
{participant.role === 'HOST' && '👑 Ведущий'}
{participant.role === 'SPECTATOR' && '👀 Зритель'}
</span>
</div>
<div className="player-score">
{participant.score || 0} очков
</div>
</div>
))
)}
</div>
</div>
)}
{/* GAME CONTROLS TAB */}
{activeTab === 'game' && (
<div className="tab-content">
<h3>Управление игрой</h3>
<div className="game-status">
<strong>Статус:</strong>
{gameStatus === 'WAITING' && ' Ожидание'}
{gameStatus === 'PLAYING' && ' Идет игра'}
{gameStatus === 'FINISHED' && ' Завершена'}
</div>
{gameStatus === 'WAITING' && (
<button
className="mgmt-button start-button"
onClick={onStartGame}
disabled={participants.length < 2}
>
Начать игру
</button>
)}
{gameStatus === 'PLAYING' && (
<div className="game-controls">
<div className="question-nav">
<button
className="mgmt-button"
onClick={onPreviousQuestion}
disabled={currentQuestionIndex === 0}
>
Предыдущий
</button>
<span className="question-indicator">
Вопрос {currentQuestionIndex + 1} / {totalQuestions}
</span>
<button
className="mgmt-button"
onClick={onNextQuestion}
disabled={currentQuestionIndex >= totalQuestions - 1}
>
Следующий
</button>
</div>
<button
className="mgmt-button end-button"
onClick={onEndGame}
>
Завершить игру
</button>
</div>
)}
<div className="game-info">
<div className="info-item">
<span>Игроков:</span> <strong>{participants.length}</strong>
</div>
{gameStatus === 'PLAYING' && totalQuestions > 0 && (
<div className="info-item">
<span>Вопросов:</span> <strong>{totalQuestions}</strong>
</div>
)}
</div>
</div>
)}
{/* ANSWERS CONTROL TAB */}
{activeTab === 'answers' && currentQuestion && (
<div className="tab-content">
<h3>Управление ответами</h3>
<button
className="mgmt-button toggle-all-button"
onClick={areAllAnswersRevealed ? onHideAllAnswers : onShowAllAnswers}
>
{areAllAnswersRevealed ? '🙈 Скрыть все' : '👁 Показать все'}
</button>
<div className="answers-grid">
{currentQuestion.answers.map((answer, index) => (
<button
key={index}
className={`answer-button ${
revealedAnswers.includes(index) ? 'revealed' : 'hidden'
}`}
onClick={() => handleRevealAnswer(index)}
>
<span className="answer-num">{index + 1}</span>
<span className="answer-txt">{answer.text}</span>
<span className="answer-pts">{answer.points}</span>
</button>
))}
</div>
</div>
)}
{/* SCORING TAB */}
{activeTab === 'scoring' && (
<div className="tab-content">
<h3>Начисление очков</h3>
<div className="player-selector">
<label>Выберите игрока:</label>
<select
value={selectedPlayer || ''}
onChange={(e) => setSelectedPlayer(e.target.value || null)}
>
<option value="">-- Выберите игрока --</option>
{participants
.filter((p) => p.role === 'PLAYER')
.map((player) => (
<option key={player.id} value={player.id}>
{player.name} ({player.score || 0} очков)
</option>
))}
</select>
</div>
{selectedPlayer && (
<div className="scoring-section">
<div className="quick-points">
<button
className="mgmt-button points-button"
onClick={() => handleAwardPoints(5)}
>
+5
</button>
<button
className="mgmt-button points-button"
onClick={() => handleAwardPoints(10)}
>
+10
</button>
<button
className="mgmt-button points-button"
onClick={() => handleAwardPoints(20)}
>
+20
</button>
<button
className="mgmt-button penalty-button"
onClick={handlePenalty}
>
Промах
</button>
</div>
<div className="custom-points">
<input
type="number"
min="1"
max="100"
value={customPoints}
onChange={(e) => setCustomPoints(parseInt(e.target.value) || 0)}
/>
<button
className="mgmt-button custom-button"
onClick={() => handleAwardPoints(customPoints)}
>
Начислить {customPoints}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
)
}
export default GameManagementModal

View file

@ -12,6 +12,7 @@ const Question = ({
onNextQuestion, onNextQuestion,
canGoPrevious, canGoPrevious,
canGoNext, canGoNext,
roomId,
}) => { }) => {
const allAnswersRevealed = question.answers.every((_, index) => revealedAnswers.includes(index)) const allAnswersRevealed = question.answers.every((_, index) => revealedAnswers.includes(index))
const hasUnrevealedAnswers = revealedAnswers.length < question.answers.length const hasUnrevealedAnswers = revealedAnswers.length < question.answers.length
@ -31,7 +32,13 @@ const Question = ({
)} )}
<div className="question-text-wrapper"> <div className="question-text-wrapper">
<h2 className="question-text">{question.text}</h2> <h2 className="question-text">{question.text}</h2>
<VoicePlayer text={question.text} /> {roomId && question.id && (
<VoicePlayer
roomId={roomId}
questionId={question.id}
contentType="question"
/>
)}
</div> </div>
{canGoNext && onNextQuestion && ( {canGoNext && onNextQuestion && (
<button <button
@ -47,11 +54,13 @@ const Question = ({
<div className="answers-grid"> <div className="answers-grid">
{question.answers.map((answer, index) => ( {question.answers.map((answer, index) => (
<Answer <Answer
key={index} key={answer.id || index}
answer={answer} answer={answer}
index={index} index={index}
onClick={() => onAnswerClick(index, answer.points)} onClick={() => onAnswerClick(index, answer.points)}
isRevealed={revealedAnswers.includes(index)} isRevealed={revealedAnswers.includes(index)}
roomId={roomId}
questionId={question.id}
/> />
))} ))}
</div> </div>

View file

@ -2,33 +2,49 @@ import React from 'react';
import { useVoice } from '../hooks/useVoice'; import { useVoice } from '../hooks/useVoice';
import './VoicePlayer.css'; 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 { 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 = () => { const handleClick = () => {
if (isPlayingThis) { if (isPlayingThis) {
stop(); stop();
} else { } else if (roomId && questionId && contentType) {
speak(text); speak({ roomId, questionId, contentType, answerId });
} }
}; };
React.useEffect(() => { React.useEffect(() => {
if (autoPlay && isEnabled && text) { if (autoPlay && isEnabled && roomId && questionId && contentType) {
speak(text); 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) { if (!isEnabled || !showButton) {
return children || null; return children || null;
} }
const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId);
return ( return (
<div className="voice-player"> <div className="voice-player">
{children} {children}
{showButton && text && ( {showButton && canPlay && (
<button <button
className={`voice-player-button ${isPlayingThis ? 'playing' : ''}`} className={`voice-player-button ${isPlayingThis ? 'playing' : ''}`}
onClick={handleClick} onClick={handleClick}

View file

@ -41,23 +41,46 @@ export function useVoice() {
}, [effectsVolume]); }, [effectsVolume]);
/** /**
* Generate speech from text * Generate speech from question/answer IDs
* @param {string} text - Text to speak * @param {Object} params - Parameters
* @param {Object} options - Options * @param {string} params.roomId - Room ID
* @param {string} options.voice - Voice ID (optional) * @param {string} params.questionId - Question ID
* @param {string} params.contentType - 'question' or 'answer'
* @param {string} [params.answerId] - Answer ID (required if contentType is 'answer')
* @param {string} [params.voice] - Voice ID (optional)
* @param {boolean} [params.cache=true] - Whether to cache the result
* @returns {Promise<string>} Audio URL * @returns {Promise<string>} Audio URL
*/ */
const generateSpeech = useCallback(async (text, options = {}) => { const generateSpeech = useCallback(async (params, options = {}) => {
const { cache = true, voice } = options; const { roomId, questionId, contentType, answerId, voice } = params;
const { cache = true } = options;
// Check cache first // Validate required parameters
const cacheKey = text; if (!roomId || !questionId || !contentType) {
throw new Error('roomId, questionId, and contentType are required');
}
if (contentType === 'answer' && !answerId) {
throw new Error('answerId is required when contentType is "answer"');
}
// Create cache key from parameters
const cacheKey = `${roomId}:${questionId}:${contentType}:${answerId || ''}`;
if (cache && audioCache.current.has(cacheKey)) { if (cache && audioCache.current.has(cacheKey)) {
return audioCache.current.get(cacheKey); return audioCache.current.get(cacheKey);
} }
try { try {
const requestBody = { text }; const requestBody = {
roomId,
questionId,
contentType,
};
if (answerId) {
requestBody.answerId = answerId;
}
if (voice) { if (voice) {
requestBody.voice = voice; requestBody.voice = voice;
} }
@ -92,19 +115,21 @@ export function useVoice() {
}, []); }, []);
/** /**
* Speak text * Speak question or answer
* @param {string} text - Text to speak * @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
* @param {Object} options - Options * @param {Object} options - Options
*/ */
const speak = useCallback(async (text, options = {}) => { const speak = useCallback(async (params, options = {}) => {
if (!isEnabled) return; if (!isEnabled) return;
if (!text) return; if (!params || !params.roomId || !params.questionId || !params.contentType) return;
try { try {
setCurrentText(text); // Create a unique identifier for this speech request
const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`;
setCurrentText(speechId);
setIsPlaying(true); setIsPlaying(true);
const audioUrl = await generateSpeech(text, options); const audioUrl = await generateSpeech(params, options);
// Create or reuse audio element // Create or reuse audio element
if (!audioRef.current) { if (!audioRef.current) {
@ -163,31 +188,31 @@ export function useVoice() {
}, [isEnabled, effectsVolume]); }, [isEnabled, effectsVolume]);
/** /**
* Preload speech for text * Preload speech for question/answer
* @param {string} text - Text to preload * @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
* @param {Object} options - Options * @param {Object} options - Options
*/ */
const preload = useCallback(async (text, options = {}) => { const preload = useCallback(async (params, options = {}) => {
if (!isEnabled) return; if (!isEnabled) return;
if (!text) return; if (!params || !params.roomId || !params.questionId || !params.contentType) return;
try { try {
await generateSpeech(text, { ...options, cache: true }); await generateSpeech(params, { ...options, cache: true });
} catch (error) { } catch (error) {
console.error('Failed to preload speech:', error); console.error('Failed to preload speech:', error);
} }
}, [isEnabled, generateSpeech]); }, [isEnabled, generateSpeech]);
/** /**
* Preload multiple texts * Preload multiple questions/answers
* @param {Array<string>} texts - Texts to preload * @param {Array<Object>} items - Array of params objects to preload
*/ */
const preloadBatch = useCallback(async (texts) => { const preloadBatch = useCallback(async (items) => {
if (!isEnabled) return; if (!isEnabled) return;
if (!texts || texts.length === 0) return; if (!items || items.length === 0) return;
try { try {
const promises = texts.map((text) => preload(text)); const promises = items.map((params) => preload(params));
await Promise.all(promises); await Promise.all(promises);
} catch (error) { } catch (error) {
console.error('Failed to preload batch:', error); console.error('Failed to preload batch:', error);

View file

@ -107,3 +107,93 @@
width: 100%; width: 100%;
} }
} }
/* Game control bar */
.game-control-bar {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 1600px;
margin: 0 auto;
padding: clamp(5px, 1vh, 10px) clamp(10px, 2vw, 20px);
margin-bottom: clamp(5px, 1vh, 10px);
z-index: 10;
}
.game-control-left,
.game-control-right {
display: flex;
align-items: center;
gap: clamp(8px, 1.5vw, 12px);
}
.control-button {
width: clamp(35px, 4vw, 45px);
height: clamp(35px, 4vw, 45px);
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(1.1rem, 2.2vw, 1.4rem);
transition: all 0.3s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 0;
}
.control-button:hover {
transform: translateY(-2px) scale(1.1);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
border-color: rgba(255, 215, 0, 0.6);
background: rgba(255, 255, 255, 0.2);
}
.control-button:active {
transform: translateY(0) scale(1);
}
.control-button-qr:hover {
background: rgba(138, 43, 226, 0.3);
border-color: #8a2be2;
}
.control-button-management:hover {
background: rgba(255, 165, 0, 0.3);
border-color: #ffa500;
}
.control-button-questions:hover {
background: rgba(102, 126, 234, 0.3);
border-color: #667eea;
}
.question-counter {
padding: clamp(6px, 1vh, 10px) clamp(12px, 2vw, 18px);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: var(--border-radius-md, 12px);
font-weight: bold;
font-size: clamp(0.9rem, 2vw, 1.1rem);
color: var(--accent-primary, #ffd700);
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
/* Mobile responsive */
@media (max-width: 768px) {
.game-control-bar {
flex-direction: column;
gap: 10px;
}
.game-control-left,
.game-control-right {
width: 100%;
justify-content: center;
}
}

View file

@ -3,8 +3,14 @@ import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom'; import { useRoom } from '../hooks/useRoom';
import { questionsApi, roomsApi } from '../services/api'; import { questionsApi, roomsApi } from '../services/api';
import QRCode from 'qrcode';
import socketService from '../services/socket';
import Game from '../components/Game'; import Game from '../components/Game';
import QuestionsModal from '../components/QuestionsModal'; import QuestionsModal from '../components/QuestionsModal';
import QRModal from '../components/QRModal';
import GameManagementModal from '../components/GameManagementModal';
import ThemeSwitcher from '../components/ThemeSwitcher';
import VoiceSettings from '../components/VoiceSettings';
import './GamePage.css'; import './GamePage.css';
const GamePage = () => { const GamePage = () => {
@ -17,6 +23,11 @@ const GamePage = () => {
loading, loading,
error, error,
updateQuestionPack, updateQuestionPack,
startGame,
endGame,
nextQuestion,
revealAnswer,
updateScore,
} = useRoom(roomCode); } = useRoom(roomCode);
const [questions, setQuestions] = useState([]); const [questions, setQuestions] = useState([]);
@ -26,6 +37,10 @@ const GamePage = () => {
const [selectedPackId, setSelectedPackId] = useState(''); const [selectedPackId, setSelectedPackId] = useState('');
const [updatingPack, setUpdatingPack] = useState(false); const [updatingPack, setUpdatingPack] = useState(false);
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false); const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
const [isGameManagementModalOpen, setIsGameManagementModalOpen] = useState(false);
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
const [qrCode, setQrCode] = useState('');
const [revealedAnswers, setRevealedAnswers] = useState([]);
useEffect(() => { useEffect(() => {
const loadQuestions = async () => { const loadQuestions = async () => {
@ -89,6 +104,95 @@ const GamePage = () => {
} }
}, [room]); }, [room]);
// Generate QR code for room
useEffect(() => {
const generateQR = async () => {
try {
const origin = window.location.origin ||
`${window.location.protocol}//${window.location.host}`;
const url = `${origin}/join-room?code=${roomCode}`;
const qr = await QRCode.toDataURL(url, {
errorCorrectionLevel: 'M',
type: 'image/png',
quality: 0.92,
margin: 1,
});
setQrCode(qr);
} catch (err) {
console.error('QR generation error:', err);
}
};
if (roomCode) {
generateQR();
}
}, [roomCode]);
// Listen for socket events
useEffect(() => {
if (!room) return;
const handleAnswerRevealed = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers((prev) => {
if (!prev.includes(data.answerIndex)) {
return [...prev, data.answerIndex];
}
return prev;
});
}
};
const handleAnswerHidden = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers((prev) =>
prev.filter((idx) => idx !== data.answerIndex)
);
}
};
const handleAllAnswersShown = (data) => {
if (data.questionIndex === currentQuestionIndex && questions[currentQuestionIndex]) {
setRevealedAnswers(
Array.from({ length: questions[currentQuestionIndex].answers.length }, (_, i) => i)
);
}
};
const handleAllAnswersHidden = (data) => {
if (data.questionIndex === currentQuestionIndex) {
setRevealedAnswers([]);
}
};
const handleQuestionChanged = (data) => {
if (data.questionIndex !== undefined) {
setCurrentQuestionIndex(data.questionIndex);
// Reset revealed answers when question changes
setRevealedAnswers([]);
}
};
socketService.on('answerRevealed', handleAnswerRevealed);
socketService.on('answerHidden', handleAnswerHidden);
socketService.on('allAnswersShown', handleAllAnswersShown);
socketService.on('allAnswersHidden', handleAllAnswersHidden);
socketService.on('questionChanged', handleQuestionChanged);
return () => {
socketService.off('answerRevealed', handleAnswerRevealed);
socketService.off('answerHidden', handleAnswerHidden);
socketService.off('allAnswersShown', handleAllAnswersShown);
socketService.off('allAnswersHidden', handleAllAnswersHidden);
socketService.off('questionChanged', handleQuestionChanged);
};
}, [room, currentQuestionIndex, questions]);
// Reset revealed answers when question changes
useEffect(() => {
setRevealedAnswers([]);
}, [currentQuestionIndex]);
const handleUpdateQuestionPack = async () => { const handleUpdateQuestionPack = async () => {
if (!selectedPackId) { if (!selectedPackId) {
alert('Выберите пак вопросов'); alert('Выберите пак вопросов');
@ -160,8 +264,159 @@ const GamePage = () => {
} }
}; };
// Game control handlers
const handleStartGame = () => {
startGame();
};
const handleEndGame = () => {
if (window.confirm('Завершить игру? Весь прогресс будет сохранен.')) {
endGame();
}
};
const handleNextQuestion = () => {
if (currentQuestionIndex < questions.length - 1) {
nextQuestion();
// The question index will be updated via socket event
}
};
const handlePreviousQuestion = () => {
if (room && user && currentQuestionIndex > 0) {
socketService.emit('previousQuestion', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
});
// The question index will be updated via socket event (questionChanged)
}
};
const handleRevealAnswer = (answerIndex) => {
if (room && user) {
socketService.emit('revealAnswer', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
answerIndex,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers((prev) => {
if (!prev.includes(answerIndex)) {
return [...prev, answerIndex];
}
return prev;
});
}
};
const handleHideAnswer = (answerIndex) => {
if (room && user) {
socketService.emit('hideAnswer', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
answerIndex,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers((prev) =>
prev.filter((idx) => idx !== answerIndex)
);
}
};
const handleShowAllAnswers = () => {
if (room && user && questions[currentQuestionIndex]) {
socketService.emit('showAllAnswers', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers(
Array.from({ length: questions[currentQuestionIndex].answers.length }, (_, i) => i)
);
}
};
const handleHideAllAnswers = () => {
if (room && user) {
socketService.emit('hideAllAnswers', {
roomCode: room.code,
roomId: room.id,
userId: user.id,
questionIndex: currentQuestionIndex,
});
// Also update local state immediately for better UX
setRevealedAnswers([]);
}
};
const handleAwardPoints = (participantId, points) => {
if (room && user) {
const participant = participants.find((p) => p.id === participantId);
if (participant) {
const newScore = (participant.score || 0) + points;
updateScore(participantId, newScore);
}
}
};
const handlePenalty = (participantId) => {
if (room && user) {
const participant = participants.find((p) => p.id === participantId);
if (participant) {
const newScore = Math.max(0, (participant.score || 0) - 10);
updateScore(participantId, newScore);
}
}
};
return ( return (
<div className="game-page"> <div className="game-page">
{/* Control bar - only for host */}
{isHost && (
<div className="game-control-bar">
<div className="game-control-left">
<ThemeSwitcher />
<VoiceSettings />
<button
className="control-button control-button-qr"
onClick={() => setIsQRModalOpen(true)}
title="Показать QR-код"
>
🎫
</button>
<button
className="control-button control-button-management"
onClick={() => setIsGameManagementModalOpen(true)}
title="Управление игрой"
>
🎛
</button>
</div>
<div className="game-control-right">
{questions.length > 0 && (
<div className="question-counter">
{currentQuestionIndex + 1}/{questions.length}
</div>
)}
<button
className="control-button control-button-questions"
onClick={() => setIsQuestionsModalOpen(true)}
title="Управление вопросами"
>
</button>
</div>
</div>
)}
<div className="game-container"> <div className="game-container">
{questions.length === 0 && ( {questions.length === 0 && (
<div className="no-questions-banner"> <div className="no-questions-banner">
@ -174,17 +429,6 @@ const GamePage = () => {
</div> </div>
)} )}
{isHost && (
<div className="host-controls-inline">
<button
onClick={() => setIsQuestionsModalOpen(true)}
className="manage-questions-button"
>
Управление вопросами
</button>
</div>
)}
<Game <Game
questions={questions} questions={questions}
currentQuestionIndex={currentQuestionIndex} currentQuestionIndex={currentQuestionIndex}
@ -192,10 +436,13 @@ const GamePage = () => {
onQuestionsChange={handleQuestionsChange} onQuestionsChange={handleQuestionsChange}
roomParticipants={participants} roomParticipants={participants}
isOnlineMode={true} isOnlineMode={true}
roomId={room?.id}
/> />
</div> </div>
{/* Modals */}
{isHost && ( {isHost && (
<>
<QuestionsModal <QuestionsModal
isOpen={isQuestionsModalOpen} isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)} onClose={() => setIsQuestionsModalOpen(false)}
@ -205,6 +452,35 @@ const GamePage = () => {
roomId={room?.id} roomId={room?.id}
availablePacks={questionPacks} availablePacks={questionPacks}
/> />
<QRModal
isOpen={isQRModalOpen}
onClose={() => setIsQRModalOpen(false)}
qrCode={qrCode}
roomCode={roomCode}
/>
<GameManagementModal
isOpen={isGameManagementModalOpen}
onClose={() => setIsGameManagementModalOpen(false)}
room={room}
participants={participants}
currentQuestion={questions[currentQuestionIndex]}
currentQuestionIndex={currentQuestionIndex}
totalQuestions={questions.length}
revealedAnswers={revealedAnswers}
onStartGame={handleStartGame}
onEndGame={handleEndGame}
onNextQuestion={handleNextQuestion}
onPreviousQuestion={handlePreviousQuestion}
onRevealAnswer={handleRevealAnswer}
onHideAnswer={handleHideAnswer}
onShowAllAnswers={handleShowAllAnswers}
onHideAllAnswers={handleHideAllAnswers}
onAwardPoints={handleAwardPoints}
onPenalty={handlePenalty}
/>
</>
)} )}
</div> </div>
); );