From fee1a5a36d507710501cefc3fc73c2a27f0723d5 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Thu, 8 Jan 2026 20:56:00 +0300 Subject: [PATCH] room --- backend/prisma/schema.prisma | 25 +++- backend/src/game/game.gateway.ts | 33 ++++- backend/src/game/game.module.ts | 3 +- backend/src/game/room-events.service.ts | 6 + backend/src/room-pack/room-pack.module.ts | 10 ++ backend/src/room-pack/room-pack.service.ts | 119 +++++++++++++++ backend/src/rooms/rooms.controller.ts | 8 + backend/src/rooms/rooms.module.ts | 3 +- backend/src/rooms/rooms.service.ts | 36 +++-- src/components/QuestionsModal.css | 165 +++++++++++++++++++++ src/components/QuestionsModal.jsx | 132 ++++++++++++++++- src/hooks/useRoom.js | 43 +++--- src/pages/GamePage.css | 29 ++++ src/pages/GamePage.jsx | 120 +++++++-------- src/services/api.js | 4 + src/services/socket.js | 34 +++-- 16 files changed, 651 insertions(+), 119 deletions(-) create mode 100644 backend/src/room-pack/room-pack.module.ts create mode 100644 backend/src/room-pack/room-pack.service.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 832208e..c1b0f84 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 532ee20..2d35f86 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -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') diff --git a/backend/src/game/game.module.ts b/backend/src/game/game.module.ts index 411662a..19ec1f6 100644 --- a/backend/src/game/game.module.ts +++ b/backend/src/game/game.module.ts @@ -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], }) diff --git a/backend/src/game/room-events.service.ts b/backend/src/game/room-events.service.ts index 8117701..d0907aa 100644 --- a/backend/src/game/room-events.service.ts +++ b/backend/src/game/room-events.service.ts @@ -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); diff --git a/backend/src/room-pack/room-pack.module.ts b/backend/src/room-pack/room-pack.module.ts new file mode 100644 index 0000000..67c5df5 --- /dev/null +++ b/backend/src/room-pack/room-pack.module.ts @@ -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 {} diff --git a/backend/src/room-pack/room-pack.service.ts b/backend/src/room-pack/room-pack.service.ts new file mode 100644 index 0000000..9a6b35b --- /dev/null +++ b/backend/src/room-pack/room-pack.service.ts @@ -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 }, + }); + } +} diff --git a/backend/src/rooms/rooms.controller.ts b/backend/src/rooms/rooms.controller.ts index 60fa9aa..933181a 100644 --- a/backend/src/rooms/rooms.controller.ts +++ b/backend/src/rooms/rooms.controller.ts @@ -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); diff --git a/backend/src/rooms/rooms.module.ts b/backend/src/rooms/rooms.module.ts index f01534c..e0018ec 100644 --- a/backend/src/rooms/rooms.module.ts +++ b/backend/src/rooms/rooms.module.ts @@ -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], diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index d745a1b..cb1e8a0 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -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; } diff --git a/src/components/QuestionsModal.css b/src/components/QuestionsModal.css index 70c73ce..683982a 100644 --- a/src/components/QuestionsModal.css +++ b/src/components/QuestionsModal.css @@ -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%; + } +} + diff --git a/src/components/QuestionsModal.jsx b/src/components/QuestionsModal.jsx index de091c7..7b4e0fa 100644 --- a/src/components/QuestionsModal.jsx +++ b/src/components/QuestionsModal.jsx @@ -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 (
@@ -212,12 +275,71 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => { > 📤 Импорт JSON + {availablePacks.length > 0 && ( + + )}
{jsonError && (
{jsonError}
)} + {showPackImport && availablePacks.length > 0 && ( +
+

Импорт вопросов из пака

+ + + {packQuestions.length > 0 && ( +
+
+ Выберите вопросы для импорта: + +
+ +
+ {packQuestions.map((q, idx) => ( +
+ handleToggleQuestion(idx)} + /> +
+ {q.text} + + {q.answers.length} ответов + +
+
+ ))} +
+
+ )} +
+ )} +
{ + 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) => { diff --git a/src/pages/GamePage.css b/src/pages/GamePage.css index 568592b..f352e27 100644 --- a/src/pages/GamePage.css +++ b/src/pages/GamePage.css @@ -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; diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index 1348375..03b2768 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -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 (
- {isHost && ( -
-
- - - - -
-
- )} -
{questions.length === 0 && (

Вопросы не загружены. {isHost - ? ' Выберите пак вопросов выше, чтобы начать игру.' + ? ' Откройте управление вопросами, чтобы добавить вопросы.' : ' Ожидайте, пока ведущий добавит вопросы.'}

)} + {isHost && ( +
+ +
+ )} + { isOnlineMode={true} />
+ + {isHost && ( + setIsQuestionsModalOpen(false)} + questions={questions} + onUpdateQuestions={handleUpdateRoomQuestions} + isOnlineMode={true} + roomId={room?.id} + availablePacks={questionPacks} + /> + )}
); }; diff --git a/src/services/api.js b/src/services/api.js index 69073b7..5a093c9 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -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 diff --git a/src/services/socket.js b/src/services/socket.js index 8ffc52a..e28161d 100644 --- a/src/services/socket.js +++ b/src/services/socket.js @@ -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, + }); } }