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 { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL")
} }
model User { model User {
@ -58,13 +59,11 @@ model Room {
startedAt DateTime? startedAt DateTime?
finishedAt DateTime? finishedAt DateTime?
// Временный пак для комнаты (если хост редактирует вопросы)
customQuestions Json? // Кастомные вопросы для этой комнаты
// Связи // Связи
host User @relation("HostedRooms", fields: [hostId], references: [id]) host User @relation("HostedRooms", fields: [hostId], references: [id])
participants Participant[] participants Participant[]
questionPack QuestionPack? @relation(fields: [questionPackId], references: [id]) questionPack QuestionPack? @relation(fields: [questionPackId], references: [id])
roomPack RoomPack?
gameHistory GameHistory? gameHistory GameHistory?
} }
@ -118,6 +117,26 @@ model QuestionPack {
creator User @relation(fields: [createdBy], references: [id]) creator User @relation(fields: [createdBy], references: [id])
rooms Room[] 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 { model GameHistory {

View file

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

View file

@ -2,9 +2,10 @@ import { Module, forwardRef } from '@nestjs/common';
import { GameGateway } from './game.gateway'; import { GameGateway } from './game.gateway';
import { RoomEventsService } from './room-events.service'; import { RoomEventsService } from './room-events.service';
import { RoomsModule } from '../rooms/rooms.module'; import { RoomsModule } from '../rooms/rooms.module';
import { RoomPackModule } from '../room-pack/room-pack.module';
@Module({ @Module({
imports: [forwardRef(() => RoomsModule)], imports: [forwardRef(() => RoomsModule), RoomPackModule],
providers: [GameGateway, RoomEventsService], providers: [GameGateway, RoomEventsService],
exports: [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) { emitPlayerKicked(roomCode: string, data: any) {
if (this.server) { if (this.server) {
this.server.to(roomCode).emit('playerKicked', data); 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); 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') @Get(':roomId/questions')
async getEffectiveQuestions(@Param('roomId') roomId: string) { async getEffectiveQuestions(@Param('roomId') roomId: string) {
return this.roomsService.getEffectiveQuestions(roomId); return this.roomsService.getEffectiveQuestions(roomId);

View file

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

View file

@ -2,6 +2,7 @@ import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { RoomEventsService } from '../game/room-events.service'; import { RoomEventsService } from '../game/room-events.service';
import { RoomPackService } from '../room-pack/room-pack.service';
const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6); const nanoid = customAlphabet('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', 6);
@ -11,6 +12,7 @@ export class RoomsService {
private prisma: PrismaService, private prisma: PrismaService,
@Inject(forwardRef(() => RoomEventsService)) @Inject(forwardRef(() => RoomEventsService))
private roomEventsService: RoomEventsService, private roomEventsService: RoomEventsService,
private roomPackService: RoomPackService,
) {} ) {}
async createRoom(hostId: string, questionPackId?: string, settings?: any) { 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) { async getRoomByCode(code: string) {
@ -58,6 +64,7 @@ export class RoomsService {
include: { user: true }, include: { user: true },
}, },
questionPack: true, questionPack: true,
roomPack: true,
}, },
}); });
} }
@ -125,42 +132,45 @@ export class RoomsService {
} }
async updateCustomQuestions(roomId: string, questions: any) { 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 }, where: { id: roomId },
data: {
customQuestions: questions,
currentQuestionIndex: 0,
revealedAnswers: {},
},
include: { include: {
host: true, host: true,
participants: { participants: {
include: { user: true }, include: { user: true },
}, },
questionPack: true, questionPack: true,
roomPack: true,
}, },
}); });
this.roomEventsService.emitCustomQuestionsUpdated(room.code, room); this.roomEventsService.emitRoomPackUpdated(room.code, room);
return room; return room;
} }
async getEffectiveQuestions(roomId: string) { async getEffectiveQuestions(roomId: string) {
const room = await this.prisma.room.findUnique({ const room = await this.prisma.room.findUnique({
where: { id: roomId }, where: { id: roomId },
include: { questionPack: true }, include: { roomPack: true, questionPack: true },
}); });
if (!room) { if (!room) {
return null; return null;
} }
// Если есть кастомные вопросы, используем их // Priority 1: RoomPack questions
if (room.customQuestions) { if (room.roomPack && room.roomPack.questions) {
return room.customQuestions; return room.roomPack.questions;
} }
// Иначе используем вопросы из пака // Priority 2: QuestionPack (fallback for legacy rooms)
if (room.questionPack) { if (room.questionPack) {
return room.questionPack.questions; 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 { useState } from 'react'
import { questionsApi } from '../services/api'
import './QuestionsModal.css' 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 [editingQuestion, setEditingQuestion] = useState(null)
const [questionText, setQuestionText] = useState('') const [questionText, setQuestionText] = useState('')
const [answers, setAnswers] = useState([ const [answers, setAnswers] = useState([
@ -13,6 +22,11 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
{ text: '', points: 10 }, { text: '', points: 10 },
]) ])
const [jsonError, setJsonError] = useState('') 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 if (!isOpen) return null
@ -189,6 +203,55 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
input.click() 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 ( return (
<div className="questions-modal-backdrop" onClick={handleBackdropClick}> <div className="questions-modal-backdrop" onClick={handleBackdropClick}>
<div className="questions-modal-content"> <div className="questions-modal-content">
@ -212,12 +275,71 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
> >
📤 Импорт JSON 📤 Импорт JSON
</button> </button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"
onClick={() => setShowPackImport(!showPackImport)}
>
📦 {showPackImport ? 'Скрыть импорт' : 'Импорт из пака'}
</button>
)}
</div> </div>
{jsonError && ( {jsonError && (
<div className="questions-modal-error">{jsonError}</div> <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"> <div className="questions-modal-form">
<input <input
type="text" type="text"

View file

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

View file

@ -64,6 +64,35 @@
margin-bottom: 1rem; 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) { @media (max-width: 768px) {
.pack-selector-inline { .pack-selector-inline {
flex-direction: column; flex-direction: column;

View file

@ -2,8 +2,9 @@ import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 } from '../services/api'; import { questionsApi, roomsApi } from '../services/api';
import Game from '../components/Game'; import Game from '../components/Game';
import QuestionsModal from '../components/QuestionsModal';
import './GamePage.css'; import './GamePage.css';
const GamePage = () => { const GamePage = () => {
@ -24,6 +25,7 @@ const GamePage = () => {
const [questionPacks, setQuestionPacks] = useState([]); const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState(''); const [selectedPackId, setSelectedPackId] = useState('');
const [updatingPack, setUpdatingPack] = useState(false); const [updatingPack, setUpdatingPack] = useState(false);
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
const loadQuestions = async () => { const loadQuestions = async () => {
@ -31,31 +33,27 @@ const GamePage = () => {
setLoadingQuestions(true); setLoadingQuestions(true);
try { try {
if (room.questionPackId) { // Load from roomPack (always exists now)
// Загружаем вопросы из пака if (room.roomPack) {
if (room.questionPack && room.questionPack.questions) { const questions = room.roomPack.questions;
const packQuestions = room.questionPack.questions; setQuestions(Array.isArray(questions) ? questions : []);
if (Array.isArray(packQuestions)) { } else {
setQuestions(packQuestions); // 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 { } else {
setQuestions([]); const response = await questionsApi.getPack(room.questionPackId);
setQuestions(
response.data?.questions && Array.isArray(response.data.questions)
? response.data.questions
: []
);
} }
} else { } else {
// Загружаем пак отдельно, если он не включен в room setQuestions([]);
const response = await questionsApi.getPack(room.questionPackId);
if (response.data && response.data.questions) {
setQuestions(
Array.isArray(response.data.questions)
? response.data.questions
: [],
);
} else {
setQuestions([]);
}
} }
} else {
// Пак не выбран, начинаем с пустого списка вопросов
setQuestions([]);
} }
} catch (error) { } catch (error) {
console.error('Error loading questions:', error); console.error('Error loading questions:', error);
@ -150,61 +148,43 @@ const GamePage = () => {
const isHost = user && room.hostId === user.id; 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 ( return (
<div className="game-page"> <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"> <div className="game-container">
{questions.length === 0 && ( {questions.length === 0 && (
<div className="no-questions-banner"> <div className="no-questions-banner">
<p> <p>
Вопросы не загружены. Вопросы не загружены.
{isHost {isHost
? ' Выберите пак вопросов выше, чтобы начать игру.' ? ' Откройте управление вопросами, чтобы добавить вопросы.'
: ' Ожидайте, пока ведущий добавит вопросы.'} : ' Ожидайте, пока ведущий добавит вопросы.'}
</p> </p>
</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}
@ -214,6 +194,18 @@ const GamePage = () => {
isOnlineMode={true} isOnlineMode={true}
/> />
</div> </div>
{isHost && (
<QuestionsModal
isOpen={isQuestionsModalOpen}
onClose={() => setIsQuestionsModalOpen(false)}
questions={questions}
onUpdateQuestions={handleUpdateRoomQuestions}
isOnlineMode={true}
roomId={room?.id}
availablePacks={questionPacks}
/>
)}
</div> </div>
); );
}; };

View file

@ -23,6 +23,10 @@ export const roomsApi = {
api.post(`/rooms/${roomId}/join`, { userId, name, role }), api.post(`/rooms/${roomId}/join`, { userId, name, role }),
updateQuestionPack: (roomId, questionPackId) => updateQuestionPack: (roomId, questionPackId) =>
api.patch(`/rooms/${roomId}/question-pack`, { 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 // Questions endpoints

View file

@ -78,24 +78,38 @@ class SocketService {
this.emit('joinRoom', { roomCode, userId }); this.emit('joinRoom', { roomCode, userId });
} }
startGame(roomId, roomCode) { startGame(roomId, roomCode, userId) {
this.emit('startGame', { roomId, roomCode }); this.emit('startGame', { roomId, roomCode, userId });
} }
revealAnswer(roomCode, answerIndex) { revealAnswer(roomCode, roomId, userId, answerIndex) {
this.emit('revealAnswer', { roomCode, answerIndex }); this.emit('revealAnswer', { roomCode, roomId, userId, answerIndex });
} }
updateScore(participantId, score, roomCode) { updateScore(participantId, score, roomCode, roomId, userId) {
this.emit('updateScore', { participantId, score, roomCode }); this.emit('updateScore', { participantId, score, roomCode, roomId, userId });
} }
nextQuestion(roomCode) { nextQuestion(roomCode, roomId, userId) {
this.emit('nextQuestion', { roomCode }); this.emit('nextQuestion', { roomCode, roomId, userId });
} }
endGame(roomId, roomCode) { endGame(roomId, roomCode, userId) {
this.emit('endGame', { roomId, roomCode }); 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,
});
} }
} }