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