tts and buttons
This commit is contained in:
parent
7d986ce528
commit
49fcad7f1d
20 changed files with 1502 additions and 85 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
39
backend/src/utils/question-utils.ts
Normal file
39
backend/src/utils/question-utils.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
25
backend/src/voice/dto/tts-request.dto.ts
Normal file
25
backend/src/voice/dto/tts-request.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,21 +23,39 @@ 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`);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<string>('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<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> {
|
||||
this.logger.log(`Generating TTS for text: "${text.substring(0, 50)}..." (voice parameter ignored)`);
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100%' }}>
|
||||
<span className="answer-text">{answer.text}</span>
|
||||
<span
|
||||
className="answer-points"
|
||||
|
|
@ -39,7 +40,16 @@ const Answer = ({ answer, index, onClick, isRevealed }) => {
|
|||
>
|
||||
{answer.points}
|
||||
</span>
|
||||
</>
|
||||
{roomId && questionId && answer.id && (
|
||||
<VoicePlayer
|
||||
roomId={roomId}
|
||||
questionId={questionId}
|
||||
contentType="answer"
|
||||
answerId={answer.id}
|
||||
showButton={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="answer-points-hidden"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const Game = forwardRef(({
|
|||
onQuestionsChange,
|
||||
roomParticipants = null, // Участники для онлайн игры
|
||||
isOnlineMode = false, // Флаг онлайн режима
|
||||
roomId = null, // Room ID для TTS (только для онлайн режима)
|
||||
}, ref) => {
|
||||
const { playEffect } = useVoice();
|
||||
|
||||
|
|
@ -409,6 +410,7 @@ const Game = forwardRef(({
|
|||
onNextQuestion={handleNextQuestion}
|
||||
canGoPrevious={currentQuestionIndex > 0}
|
||||
canGoNext={currentQuestionIndex < questions.length - 1}
|
||||
roomId={roomId}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-players-message">
|
||||
|
|
|
|||
427
src/components/GameManagementModal.css
Normal file
427
src/components/GameManagementModal.css
Normal 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);
|
||||
}
|
||||
|
||||
300
src/components/GameManagementModal.jsx
Normal file
300
src/components/GameManagementModal.jsx
Normal 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
|
||||
|
||||
|
|
@ -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 = ({
|
|||
)}
|
||||
<div className="question-text-wrapper">
|
||||
<h2 className="question-text">{question.text}</h2>
|
||||
<VoicePlayer text={question.text} />
|
||||
{roomId && question.id && (
|
||||
<VoicePlayer
|
||||
roomId={roomId}
|
||||
questionId={question.id}
|
||||
contentType="question"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canGoNext && onNextQuestion && (
|
||||
<button
|
||||
|
|
@ -47,11 +54,13 @@ const Question = ({
|
|||
<div className="answers-grid">
|
||||
{question.answers.map((answer, index) => (
|
||||
<Answer
|
||||
key={index}
|
||||
key={answer.id || index}
|
||||
answer={answer}
|
||||
index={index}
|
||||
onClick={() => onAnswerClick(index, answer.points)}
|
||||
isRevealed={revealedAnswers.includes(index)}
|
||||
roomId={roomId}
|
||||
questionId={question.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="voice-player">
|
||||
{children}
|
||||
{showButton && text && (
|
||||
{showButton && canPlay && (
|
||||
<button
|
||||
className={`voice-player-button ${isPlayingThis ? 'playing' : ''}`}
|
||||
onClick={handleClick}
|
||||
|
|
|
|||
|
|
@ -41,23 +41,46 @@ export function useVoice() {
|
|||
}, [effectsVolume]);
|
||||
|
||||
/**
|
||||
* Generate speech from text
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.voice - Voice ID (optional)
|
||||
* Generate speech from question/answer IDs
|
||||
* @param {Object} params - Parameters
|
||||
* @param {string} params.roomId - Room ID
|
||||
* @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
|
||||
*/
|
||||
const generateSpeech = useCallback(async (text, options = {}) => {
|
||||
const { cache = true, voice } = options;
|
||||
const generateSpeech = useCallback(async (params, options = {}) => {
|
||||
const { roomId, questionId, contentType, answerId, voice } = params;
|
||||
const { cache = true } = options;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = text;
|
||||
// Validate required parameters
|
||||
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)) {
|
||||
return audioCache.current.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody = { text };
|
||||
const requestBody = {
|
||||
roomId,
|
||||
questionId,
|
||||
contentType,
|
||||
};
|
||||
|
||||
if (answerId) {
|
||||
requestBody.answerId = answerId;
|
||||
}
|
||||
|
||||
if (voice) {
|
||||
requestBody.voice = voice;
|
||||
}
|
||||
|
|
@ -92,19 +115,21 @@ export function useVoice() {
|
|||
}, []);
|
||||
|
||||
/**
|
||||
* Speak text
|
||||
* @param {string} text - Text to speak
|
||||
* Speak question or answer
|
||||
* @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
|
||||
* @param {Object} options - Options
|
||||
*/
|
||||
const speak = useCallback(async (text, options = {}) => {
|
||||
const speak = useCallback(async (params, options = {}) => {
|
||||
if (!isEnabled) return;
|
||||
if (!text) return;
|
||||
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
|
||||
|
||||
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);
|
||||
|
||||
const audioUrl = await generateSpeech(text, options);
|
||||
const audioUrl = await generateSpeech(params, options);
|
||||
|
||||
// Create or reuse audio element
|
||||
if (!audioRef.current) {
|
||||
|
|
@ -163,31 +188,31 @@ export function useVoice() {
|
|||
}, [isEnabled, effectsVolume]);
|
||||
|
||||
/**
|
||||
* Preload speech for text
|
||||
* @param {string} text - Text to preload
|
||||
* Preload speech for question/answer
|
||||
* @param {Object} params - Parameters (roomId, questionId, contentType, answerId?, voice?)
|
||||
* @param {Object} options - Options
|
||||
*/
|
||||
const preload = useCallback(async (text, options = {}) => {
|
||||
const preload = useCallback(async (params, options = {}) => {
|
||||
if (!isEnabled) return;
|
||||
if (!text) return;
|
||||
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
|
||||
|
||||
try {
|
||||
await generateSpeech(text, { ...options, cache: true });
|
||||
await generateSpeech(params, { ...options, cache: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to preload speech:', error);
|
||||
}
|
||||
}, [isEnabled, generateSpeech]);
|
||||
|
||||
/**
|
||||
* Preload multiple texts
|
||||
* @param {Array<string>} texts - Texts to preload
|
||||
* Preload multiple questions/answers
|
||||
* @param {Array<Object>} items - Array of params objects to preload
|
||||
*/
|
||||
const preloadBatch = useCallback(async (texts) => {
|
||||
const preloadBatch = useCallback(async (items) => {
|
||||
if (!isEnabled) return;
|
||||
if (!texts || texts.length === 0) return;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
try {
|
||||
const promises = texts.map((text) => preload(text));
|
||||
const promises = items.map((params) => preload(params));
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
console.error('Failed to preload batch:', error);
|
||||
|
|
|
|||
|
|
@ -107,3 +107,93 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||
import { useAuth } from '../context/AuthContext';
|
||||
import { useRoom } from '../hooks/useRoom';
|
||||
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';
|
||||
import VoiceSettings from '../components/VoiceSettings';
|
||||
import './GamePage.css';
|
||||
|
||||
const GamePage = () => {
|
||||
|
|
@ -17,6 +23,11 @@ const GamePage = () => {
|
|||
loading,
|
||||
error,
|
||||
updateQuestionPack,
|
||||
startGame,
|
||||
endGame,
|
||||
nextQuestion,
|
||||
revealAnswer,
|
||||
updateScore,
|
||||
} = useRoom(roomCode);
|
||||
|
||||
const [questions, setQuestions] = useState([]);
|
||||
|
|
@ -26,6 +37,10 @@ const GamePage = () => {
|
|||
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('');
|
||||
const [revealedAnswers, setRevealedAnswers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadQuestions = async () => {
|
||||
|
|
@ -89,6 +104,95 @@ const GamePage = () => {
|
|||
}
|
||||
}, [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 () => {
|
||||
if (!selectedPackId) {
|
||||
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 (
|
||||
<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">
|
||||
{questions.length === 0 && (
|
||||
<div className="no-questions-banner">
|
||||
|
|
@ -174,17 +429,6 @@ const GamePage = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{isHost && (
|
||||
<div className="host-controls-inline">
|
||||
<button
|
||||
onClick={() => setIsQuestionsModalOpen(true)}
|
||||
className="manage-questions-button"
|
||||
>
|
||||
Управление вопросами
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Game
|
||||
questions={questions}
|
||||
currentQuestionIndex={currentQuestionIndex}
|
||||
|
|
@ -192,19 +436,51 @@ const GamePage = () => {
|
|||
onQuestionsChange={handleQuestionsChange}
|
||||
roomParticipants={participants}
|
||||
isOnlineMode={true}
|
||||
roomId={room?.id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{isHost && (
|
||||
<QuestionsModal
|
||||
isOpen={isQuestionsModalOpen}
|
||||
onClose={() => setIsQuestionsModalOpen(false)}
|
||||
questions={questions}
|
||||
onUpdateQuestions={handleUpdateRoomQuestions}
|
||||
isOnlineMode={true}
|
||||
roomId={room?.id}
|
||||
availablePacks={questionPacks}
|
||||
/>
|
||||
<>
|
||||
<QuestionsModal
|
||||
isOpen={isQuestionsModalOpen}
|
||||
onClose={() => setIsQuestionsModalOpen(false)}
|
||||
questions={questions}
|
||||
onUpdateQuestions={handleUpdateRoomQuestions}
|
||||
isOnlineMode={true}
|
||||
roomId={room?.id}
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue