room
This commit is contained in:
parent
e7c38d102e
commit
fee1a5a36d
16 changed files with 651 additions and 119 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
10
backend/src/room-pack/room-pack.module.ts
Normal file
10
backend/src/room-pack/room-pack.module.ts
Normal 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 {}
|
||||
119
backend/src/room-pack/room-pack.service.ts
Normal file
119
backend/src/room-pack/room-pack.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue