This commit is contained in:
Dmitry 2026-01-08 20:56:00 +03:00
parent e7c38d102e
commit fee1a5a36d
16 changed files with 651 additions and 119 deletions

View file

@ -7,6 +7,7 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
@ -58,13 +59,11 @@ model Room {
startedAt DateTime?
finishedAt DateTime?
// Временный пак для комнаты (если хост редактирует вопросы)
customQuestions Json? // Кастомные вопросы для этой комнаты
// Связи
host User @relation("HostedRooms", fields: [hostId], references: [id])
participants Participant[]
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
roomPack RoomPack?
gameHistory GameHistory?
}
@ -118,6 +117,26 @@ model QuestionPack {
creator User @relation(fields: [createdBy], references: [id])
rooms Room[]
roomPacks RoomPack[] @relation("RoomPackSource")
}
model RoomPack {
id String @id @default(uuid())
roomId String @unique
name String
description String @default("")
sourcePackId String?
questions Json @default("[]")
questionCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
sourcePack QuestionPack? @relation("RoomPackSource", fields: [sourcePackId], references: [id])
@@index([roomId])
@@index([sourcePackId])
}
model GameHistory {

View file

@ -10,6 +10,7 @@ import { Server, Socket } from 'socket.io';
import { RoomsService } from '../rooms/rooms.service';
import { RoomEventsService } from './room-events.service';
import { PrismaService } from '../prisma/prisma.service';
import { RoomPackService } from '../room-pack/room-pack.service';
@WebSocketGateway({
cors: {
@ -27,6 +28,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
private roomsService: RoomsService,
private roomEventsService: RoomEventsService,
private prisma: PrismaService,
private roomPackService: RoomPackService,
) {}
afterInit(server: Server) {
@ -181,19 +183,40 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
@SubscribeMessage('updateCustomQuestions')
async handleUpdateCustomQuestions(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any }) {
// DEPRECATED: Use updateRoomPack instead
return this.handleUpdateRoomPack(client, payload);
}
@SubscribeMessage('updateRoomPack')
async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can update questions' });
return;
}
await this.prisma.room.update({
where: { id: payload.roomId },
data: { customQuestions: payload.questions },
});
const room = await this.roomsService.updateRoomPack(payload.roomId, payload.questions);
this.server.to(payload.roomCode).emit('roomPackUpdated', room);
}
@SubscribeMessage('importQuestions')
async handleImportQuestions(client: Socket, payload: {
roomId: string;
roomCode: string;
userId: string;
sourcePackId: string;
questionIndices: number[];
}) {
const isHost = await this.isHost(payload.roomId, payload.userId);
if (!isHost) {
client.emit('error', { message: 'Only the host can import questions' });
return;
}
await this.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices);
const room = await this.roomsService.getRoomByCode(payload.roomCode);
this.server.to(payload.roomCode).emit('customQuestionsUpdated', room);
this.server.to(payload.roomCode).emit('roomPackUpdated', room);
}
@SubscribeMessage('kickPlayer')

View file

@ -2,9 +2,10 @@ import { Module, forwardRef } from '@nestjs/common';
import { GameGateway } from './game.gateway';
import { RoomEventsService } from './room-events.service';
import { RoomsModule } from '../rooms/rooms.module';
import { RoomPackModule } from '../room-pack/room-pack.module';
@Module({
imports: [forwardRef(() => RoomsModule)],
imports: [forwardRef(() => RoomsModule), RoomPackModule],
providers: [GameGateway, RoomEventsService],
exports: [RoomEventsService],
})

View file

@ -63,6 +63,12 @@ export class RoomEventsService {
}
}
emitRoomPackUpdated(roomCode: string, data: any) {
if (this.server) {
this.server.to(roomCode).emit('roomPackUpdated', data);
}
}
emitPlayerKicked(roomCode: string, data: any) {
if (this.server) {
this.server.to(roomCode).emit('playerKicked', data);

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RoomPackService } from './room-pack.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [RoomPackService],
exports: [RoomPackService],
})
export class RoomPackModule {}

View file

@ -0,0 +1,119 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class RoomPackService {
constructor(private prisma: PrismaService) {}
/**
* Create room pack (called when room is created)
*/
async create(roomId: string, sourcePackId?: string) {
const name = `Room Pack ${Date.now()}`;
const description = sourcePackId
? 'Copied from source pack'
: 'Custom room questions';
let questions = [];
let questionCount = 0;
// If source pack provided, copy questions
if (sourcePackId) {
const sourcePack = await this.prisma.questionPack.findUnique({
where: { id: sourcePackId },
select: { questions: true, questionCount: true },
});
if (sourcePack) {
questions = sourcePack.questions;
questionCount = sourcePack.questionCount;
}
}
return this.prisma.roomPack.create({
data: {
roomId,
name,
description,
sourcePackId,
questions,
questionCount,
},
});
}
/**
* Get room pack by roomId
*/
async findByRoomId(roomId: string) {
return this.prisma.roomPack.findUnique({
where: { roomId },
include: { sourcePack: true },
});
}
/**
* Update room pack questions
*/
async updateQuestions(roomId: string, questions: any[]) {
const questionCount = Array.isArray(questions) ? questions.length : 0;
return this.prisma.roomPack.update({
where: { roomId },
data: {
questions,
questionCount,
updatedAt: new Date(),
},
});
}
/**
* Add questions from another pack (import/copy)
*/
async importQuestions(roomId: string, sourcePackId: string, questionIndices: number[]) {
const roomPack = await this.findByRoomId(roomId);
const sourcePack = await this.prisma.questionPack.findUnique({
where: { id: sourcePackId },
select: { questions: true },
});
if (!sourcePack || !Array.isArray(sourcePack.questions)) {
throw new Error('Source pack not found or invalid');
}
// Get existing questions
const existingQuestions = Array.isArray(roomPack.questions)
? roomPack.questions
: [];
// Import selected questions (create copies)
const questionsToImport = questionIndices
.map(idx => sourcePack.questions[idx])
.filter(Boolean)
.map(q => ({ ...q })); // Deep copy
const updatedQuestions = [...existingQuestions, ...questionsToImport];
return this.updateQuestions(roomId, updatedQuestions);
}
/**
* Soft delete room pack
*/
async softDelete(roomId: string) {
return this.prisma.roomPack.update({
where: { roomId },
data: { deletedAt: new Date() },
});
}
/**
* Hard delete room pack (for cleanup)
*/
async hardDelete(roomId: string) {
return this.prisma.roomPack.delete({
where: { roomId },
});
}
}

View file

@ -39,6 +39,14 @@ export class RoomsController {
return this.roomsService.updateCustomQuestions(roomId, dto.questions);
}
@Patch(':roomId/room-pack')
async updateRoomPack(
@Param('roomId') roomId: string,
@Body() dto: { questions: any[] }
) {
return this.roomsService.updateRoomPack(roomId, dto.questions);
}
@Get(':roomId/questions')
async getEffectiveQuestions(@Param('roomId') roomId: string) {
return this.roomsService.getEffectiveQuestions(roomId);

View file

@ -2,9 +2,10 @@ import { Module, forwardRef } from '@nestjs/common';
import { RoomsService } from './rooms.service';
import { RoomsController } from './rooms.controller';
import { GameModule } from '../game/game.module';
import { RoomPackModule } from '../room-pack/room-pack.module';
@Module({
imports: [forwardRef(() => GameModule)],
imports: [forwardRef(() => GameModule), RoomPackModule],
controllers: [RoomsController],
providers: [RoomsService],
exports: [RoomsService],

View file

@ -2,6 +2,7 @@ import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid';
import { RoomEventsService } from '../game/room-events.service';
import { RoomPackService } from '../room-pack/room-pack.service';
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@ -11,6 +12,7 @@ export class RoomsService {
private prisma: PrismaService,
@Inject(forwardRef(() => RoomEventsService))
private roomEventsService: RoomEventsService,
private roomPackService: RoomPackService,
) {}
async createRoom(hostId: string, questionPackId?: string, settings?: any) {
@ -46,7 +48,11 @@ export class RoomsService {
},
});
return room;
// Create RoomPack (always, even if empty)
await this.roomPackService.create(room.id, questionPackId);
// Return room with roomPack
return this.getRoomByCode(room.code);
}
async getRoomByCode(code: string) {
@ -58,6 +64,7 @@ export class RoomsService {
include: { user: true },
},
questionPack: true,
roomPack: true,
},
});
}
@ -125,42 +132,45 @@ export class RoomsService {
}
async updateCustomQuestions(roomId: string, questions: any) {
const room = await this.prisma.room.update({
// DEPRECATED: Use updateRoomPack instead
return this.updateRoomPack(roomId, questions);
}
async updateRoomPack(roomId: string, questions: any[]) {
await this.roomPackService.updateQuestions(roomId, questions);
const room = await this.prisma.room.findUnique({
where: { id: roomId },
data: {
customQuestions: questions,
currentQuestionIndex: 0,
revealedAnswers: {},
},
include: {
host: true,
participants: {
include: { user: true },
},
questionPack: true,
roomPack: true,
},
});
this.roomEventsService.emitCustomQuestionsUpdated(room.code, room);
this.roomEventsService.emitRoomPackUpdated(room.code, room);
return room;
}
async getEffectiveQuestions(roomId: string) {
const room = await this.prisma.room.findUnique({
where: { id: roomId },
include: { questionPack: true },
include: { roomPack: true, questionPack: true },
});
if (!room) {
return null;
}
// Если есть кастомные вопросы, используем их
if (room.customQuestions) {
return room.customQuestions;
// Priority 1: RoomPack questions
if (room.roomPack && room.roomPack.questions) {
return room.roomPack.questions;
}
// Иначе используем вопросы из пака
// Priority 2: QuestionPack (fallback for legacy rooms)
if (room.questionPack) {
return room.questionPack.questions;
}

View file

@ -433,3 +433,168 @@
}
}
/* Pack Import Styles */
.questions-modal-pack-import-button {
flex: 1;
padding: 12px 20px;
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.questions-modal-pack-import-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(250, 112, 154, 0.4);
}
.pack-import-section {
margin: 20px 0;
padding: 20px;
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
}
.pack-import-section h3 {
color: #ffd700;
font-size: 1.3rem;
margin: 0 0 15px 0;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
.pack-import-select {
width: 100%;
padding: 12px 15px;
margin: 10px 0 20px 0;
border: 2px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
font-size: 1rem;
outline: none;
cursor: pointer;
transition: all 0.3s ease;
}
.pack-import-select:focus {
border-color: #ffd700;
background: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.3);
}
.pack-import-select option {
background: #1a1a2e;
color: #fff;
padding: 10px;
}
.pack-questions-list {
margin-top: 15px;
}
.pack-questions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid rgba(255, 215, 0, 0.2);
color: #fff;
font-size: 1.1rem;
}
.pack-import-confirm-button {
padding: 10px 20px;
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
}
.pack-import-confirm-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(78, 205, 196, 0.4);
}
.pack-import-confirm-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pack-questions-items {
max-height: 300px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
}
.pack-question-item {
display: flex;
gap: 12px;
padding: 12px;
margin: 5px 0;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.03);
}
.pack-question-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 215, 0, 0.3);
}
.pack-question-item input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
margin-top: 2px;
}
.pack-question-content {
flex: 1;
min-width: 0;
}
.pack-question-content strong {
color: #fff;
font-size: 1rem;
display: block;
margin-bottom: 5px;
word-wrap: break-word;
}
.pack-question-info {
color: rgba(255, 255, 255, 0.6);
font-size: 0.85rem;
}
@media (max-width: 768px) {
.pack-import-section {
padding: 15px;
}
.pack-questions-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.pack-import-confirm-button {
width: 100%;
}
}

View file

@ -1,7 +1,16 @@
import { useState } from 'react'
import { questionsApi } from '../services/api'
import './QuestionsModal.css'
const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
const QuestionsModal = ({
isOpen,
onClose,
questions,
onUpdateQuestions,
isOnlineMode = false,
roomId = null,
availablePacks = [],
}) => {
const [editingQuestion, setEditingQuestion] = useState(null)
const [questionText, setQuestionText] = useState('')
const [answers, setAnswers] = useState([
@ -13,6 +22,11 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
{ text: '', points: 10 },
])
const [jsonError, setJsonError] = useState('')
const [showPackImport, setShowPackImport] = useState(false)
const [selectedPack, setSelectedPack] = useState(null)
const [packQuestions, setPackQuestions] = useState([])
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set())
const [savingToRoom, setSavingToRoom] = useState(false)
if (!isOpen) return null
@ -158,16 +172,16 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
reader.onload = (event) => {
try {
const jsonContent = JSON.parse(event.target.result)
if (!Array.isArray(jsonContent)) {
setJsonError('JSON должен содержать массив вопросов')
return
}
// Валидация структуры
const isValid = jsonContent.every(q =>
q.id &&
typeof q.text === 'string' &&
const isValid = jsonContent.every(q =>
q.id &&
typeof q.text === 'string' &&
Array.isArray(q.answers) &&
q.answers.every(a => a.text && typeof a.points === 'number')
)
@ -189,6 +203,55 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
input.click()
}
const handleSelectPack = async (packId) => {
if (!packId) {
setPackQuestions([])
setSelectedPack(null)
return
}
try {
const response = await questionsApi.getPack(packId)
setPackQuestions(response.data.questions || [])
setSelectedPack(packId)
setSelectedQuestionIndices(new Set())
} catch (error) {
console.error('Error fetching pack:', error)
setJsonError('Ошибка загрузки пака вопросов')
}
}
const handleToggleQuestion = (index) => {
const newSelected = new Set(selectedQuestionIndices)
if (newSelected.has(index)) {
newSelected.delete(index)
} else {
newSelected.add(index)
}
setSelectedQuestionIndices(newSelected)
}
const handleImportSelected = () => {
const indices = Array.from(selectedQuestionIndices)
const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean)
// Create deep copies
const copiedQuestions = questionsToImport.map(q => ({
id: Date.now() + Math.random(), // Generate new ID
text: q.text,
answers: q.answers.map(a => ({ text: a.text, points: a.points })),
}))
const updatedQuestions = [...questions, ...copiedQuestions]
onUpdateQuestions(updatedQuestions)
// Reset
setSelectedQuestionIndices(new Set())
setShowPackImport(false)
setJsonError('')
alert(`Импортировано ${copiedQuestions.length} вопросов`)
}
return (
<div className="questions-modal-backdrop" onClick={handleBackdropClick}>
<div className="questions-modal-content">
@ -212,12 +275,71 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
>
📤 Импорт JSON
</button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"
onClick={() => setShowPackImport(!showPackImport)}
>
📦 {showPackImport ? 'Скрыть импорт' : 'Импорт из пака'}
</button>
)}
</div>
{jsonError && (
<div className="questions-modal-error">{jsonError}</div>
)}
{showPackImport && availablePacks.length > 0 && (
<div className="pack-import-section">
<h3>Импорт вопросов из пака</h3>
<select
value={selectedPack || ''}
onChange={(e) => handleSelectPack(e.target.value)}
className="pack-import-select"
>
<option value="">-- Выберите пак --</option>
{availablePacks.map(pack => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
{packQuestions.length > 0 && (
<div className="pack-questions-list">
<div className="pack-questions-header">
<span>Выберите вопросы для импорта:</span>
<button
onClick={handleImportSelected}
disabled={selectedQuestionIndices.size === 0}
className="pack-import-confirm-button"
>
Импортировать ({selectedQuestionIndices.size})
</button>
</div>
<div className="pack-questions-items">
{packQuestions.map((q, idx) => (
<div key={idx} className="pack-question-item">
<input
type="checkbox"
checked={selectedQuestionIndices.has(idx)}
onChange={() => handleToggleQuestion(idx)}
/>
<div className="pack-question-content">
<strong>{q.text}</strong>
<span className="pack-question-info">
{q.answers.length} ответов
</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="questions-modal-form">
<input
type="text"

View file

@ -1,8 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { roomsApi } from '../services/api';
import socketService from '../services/socket';
import { useAuth } from '../context/AuthContext';
export const useRoom = (roomCode, onGameStarted = null) => {
const { user } = useAuth();
const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@ -35,7 +37,7 @@ export const useRoom = (roomCode, onGameStarted = null) => {
socketService.connect();
// Join the room via WebSocket
socketService.joinRoom(roomCode);
socketService.joinRoom(roomCode, user?.id);
// Listen for room updates
const handleRoomUpdate = (updatedRoom) => {
@ -75,12 +77,18 @@ export const useRoom = (roomCode, onGameStarted = null) => {
}
};
const handleRoomPackUpdated = (updatedRoom) => {
console.log('Room pack updated:', updatedRoom);
setRoom(updatedRoom);
};
socketService.on('roomUpdate', handleRoomUpdate);
socketService.on('gameStarted', handleGameStarted);
socketService.on('answerRevealed', handleAnswerRevealed);
socketService.on('scoreUpdated', handleScoreUpdated);
socketService.on('questionChanged', handleQuestionChanged);
socketService.on('gameEnded', handleGameEnded);
socketService.on('roomPackUpdated', handleRoomPackUpdated);
return () => {
socketService.off('roomUpdate', handleRoomUpdate);
@ -89,8 +97,9 @@ export const useRoom = (roomCode, onGameStarted = null) => {
socketService.off('scoreUpdated', handleScoreUpdated);
socketService.off('questionChanged', handleQuestionChanged);
socketService.off('gameEnded', handleGameEnded);
socketService.off('roomPackUpdated', handleRoomPackUpdated);
};
}, [roomCode, onGameStarted]);
}, [roomCode, onGameStarted, room]);
const createRoom = useCallback(async (hostId, questionPackId, settings = {}) => {
try {
@ -114,34 +123,34 @@ export const useRoom = (roomCode, onGameStarted = null) => {
}, []);
const startGame = useCallback(() => {
if (room) {
socketService.startGame(room.id, room.code);
if (room && user) {
socketService.startGame(room.id, room.code, user.id);
}
}, [room]);
}, [room, user]);
const revealAnswer = useCallback((answerIndex) => {
if (room) {
socketService.revealAnswer(room.code, answerIndex);
if (room && user) {
socketService.revealAnswer(room.code, room.id, user.id, answerIndex);
}
}, [room]);
}, [room, user]);
const updateScore = useCallback((participantId, score) => {
if (room) {
socketService.updateScore(participantId, score, room.code);
if (room && user) {
socketService.updateScore(participantId, score, room.code, room.id, user.id);
}
}, [room]);
}, [room, user]);
const nextQuestion = useCallback(() => {
if (room) {
socketService.nextQuestion(room.code);
if (room && user) {
socketService.nextQuestion(room.code, room.id, user.id);
}
}, [room]);
}, [room, user]);
const endGame = useCallback(() => {
if (room) {
socketService.endGame(room.id, room.code);
if (room && user) {
socketService.endGame(room.id, room.code, user.id);
}
}, [room]);
}, [room, user]);
const updateQuestionPack = useCallback(
async (questionPackId) => {

View file

@ -64,6 +64,35 @@
margin-bottom: 1rem;
}
.host-controls-inline {
display: flex;
justify-content: center;
padding: 1rem;
gap: 1rem;
}
.manage-questions-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 12px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.manage-questions-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.manage-questions-button:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.pack-selector-inline {
flex-direction: column;

View file

@ -2,8 +2,9 @@ import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import { useRoom } from '../hooks/useRoom';
import { questionsApi } from '../services/api';
import { questionsApi, roomsApi } from '../services/api';
import Game from '../components/Game';
import QuestionsModal from '../components/QuestionsModal';
import './GamePage.css';
const GamePage = () => {
@ -24,6 +25,7 @@ const GamePage = () => {
const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState('');
const [updatingPack, setUpdatingPack] = useState(false);
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
useEffect(() => {
const loadQuestions = async () => {
@ -31,31 +33,27 @@ const GamePage = () => {
setLoadingQuestions(true);
try {
if (room.questionPackId) {
// Загружаем вопросы из пака
if (room.questionPack && room.questionPack.questions) {
const packQuestions = room.questionPack.questions;
if (Array.isArray(packQuestions)) {
setQuestions(packQuestions);
// Load from roomPack (always exists now)
if (room.roomPack) {
const questions = room.roomPack.questions;
setQuestions(Array.isArray(questions) ? questions : []);
} else {
// Fallback for legacy rooms without roomPack
if (room.questionPackId) {
if (room.questionPack && room.questionPack.questions) {
const packQuestions = room.questionPack.questions;
setQuestions(Array.isArray(packQuestions) ? packQuestions : []);
} else {
setQuestions([]);
const response = await questionsApi.getPack(room.questionPackId);
setQuestions(
response.data?.questions && Array.isArray(response.data.questions)
? response.data.questions
: []
);
}
} else {
// Загружаем пак отдельно, если он не включен в room
const response = await questionsApi.getPack(room.questionPackId);
if (response.data && response.data.questions) {
setQuestions(
Array.isArray(response.data.questions)
? response.data.questions
: [],
);
} else {
setQuestions([]);
}
setQuestions([]);
}
} else {
// Пак не выбран, начинаем с пустого списка вопросов
setQuestions([]);
}
} catch (error) {
console.error('Error loading questions:', error);
@ -150,61 +148,43 @@ const GamePage = () => {
const isHost = user && room.hostId === user.id;
const handleUpdateRoomQuestions = async (newQuestions) => {
setQuestions(newQuestions);
if (room) {
try {
await roomsApi.updateRoomPack(room.id, newQuestions);
} catch (error) {
console.error('Error updating room pack:', error);
alert('Ошибка при сохранении вопросов');
}
}
};
return (
<div className="game-page">
{isHost && (
<div className="host-controls">
<div className="pack-selector-inline">
<label>Пак вопросов:</label>
<select
value={selectedPackId}
onChange={(e) => setSelectedPackId(e.target.value)}
disabled={updatingPack}
>
<option value="">
{room.questionPackId
? 'Изменить пак вопросов'
: 'Выберите пак вопросов'}
</option>
{questionPacks.map((pack) => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
<button
onClick={handleUpdateQuestionPack}
disabled={
!selectedPackId ||
selectedPackId === room.questionPackId ||
updatingPack
}
className="secondary"
>
{updatingPack ? 'Сохранение...' : 'Применить'}
</button>
<button
onClick={() => navigate(`/room/${roomCode}`)}
className="secondary"
>
Назад в комнату
</button>
</div>
</div>
)}
<div className="game-container">
{questions.length === 0 && (
<div className="no-questions-banner">
<p>
Вопросы не загружены.
{isHost
? ' Выберите пак вопросов выше, чтобы начать игру.'
? ' Откройте управление вопросами, чтобы добавить вопросы.'
: ' Ожидайте, пока ведущий добавит вопросы.'}
</p>
</div>
)}
{isHost && (
<div className="host-controls-inline">
<button
onClick={() => setIsQuestionsModalOpen(true)}
className="manage-questions-button"
>
Управление вопросами
</button>
</div>
)}
<Game
questions={questions}
currentQuestionIndex={currentQuestionIndex}
@ -214,6 +194,18 @@ const GamePage = () => {
isOnlineMode={true}
/>
</div>
{isHost && (
<QuestionsModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
questions={questions}
onUpdateQuestions={handleUpdateRoomQuestions}
isOnlineMode={true}
roomId={room?.id}
availablePacks={questionPacks}
/>
)}
</div>
);
};

View file

@ -23,6 +23,10 @@ export const roomsApi = {
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
updateQuestionPack: (roomId, questionPackId) =>
api.patch(`/rooms/${roomId}/question-pack`, { questionPackId }),
updateRoomPack: (roomId, questions) =>
api.patch(`/rooms/${roomId}/room-pack`, { questions }),
getRoomPack: (roomId) =>
api.get(`/rooms/${roomId}/room-pack`),
};
// Questions endpoints

View file

@ -78,24 +78,38 @@ class SocketService {
this.emit('joinRoom', { roomCode, userId });
}
startGame(roomId, roomCode) {
this.emit('startGame', { roomId, roomCode });
startGame(roomId, roomCode, userId) {
this.emit('startGame', { roomId, roomCode, userId });
}
revealAnswer(roomCode, answerIndex) {
this.emit('revealAnswer', { roomCode, answerIndex });
revealAnswer(roomCode, roomId, userId, answerIndex) {
this.emit('revealAnswer', { roomCode, roomId, userId, answerIndex });
}
updateScore(participantId, score, roomCode) {
this.emit('updateScore', { participantId, score, roomCode });
updateScore(participantId, score, roomCode, roomId, userId) {
this.emit('updateScore', { participantId, score, roomCode, roomId, userId });
}
nextQuestion(roomCode) {
this.emit('nextQuestion', { roomCode });
nextQuestion(roomCode, roomId, userId) {
this.emit('nextQuestion', { roomCode, roomId, userId });
}
endGame(roomId, roomCode) {
this.emit('endGame', { roomId, roomCode });
endGame(roomId, roomCode, userId) {
this.emit('endGame', { roomId, roomCode, userId });
}
updateRoomPack(roomId, roomCode, userId, questions) {
this.emit('updateRoomPack', { roomId, roomCode, userId, questions });
}
importQuestions(roomId, roomCode, userId, sourcePackId, questionIndices) {
this.emit('importQuestions', {
roomId,
roomCode,
userId,
sourcePackId,
questionIndices,
});
}
}