multiplayer
This commit is contained in:
parent
bfd202bf68
commit
3c612f4491
8 changed files with 356 additions and 11 deletions
|
|
@ -7,6 +7,7 @@ generator client {
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
|
|
@ -44,6 +45,7 @@ model Room {
|
||||||
timerDuration Int @default(30)
|
timerDuration Int @default(30)
|
||||||
questionPackId String?
|
questionPackId String?
|
||||||
autoAdvance Boolean @default(false)
|
autoAdvance Boolean @default(false)
|
||||||
|
voiceMode Boolean @default(false) // Голосовой режим
|
||||||
|
|
||||||
// Состояние игры
|
// Состояние игры
|
||||||
currentQuestionIndex Int @default(0)
|
currentQuestionIndex Int @default(0)
|
||||||
|
|
@ -57,6 +59,9 @@ 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[]
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { Server, Socket } from 'socket.io';
|
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';
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
|
|
@ -25,6 +26,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
constructor(
|
constructor(
|
||||||
private roomsService: RoomsService,
|
private roomsService: RoomsService,
|
||||||
private roomEventsService: RoomEventsService,
|
private roomEventsService: RoomEventsService,
|
||||||
|
private prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit(server: Server) {
|
afterInit(server: Server) {
|
||||||
|
|
@ -39,6 +41,14 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
console.log(`Client disconnected: ${client.id}`);
|
console.log(`Client disconnected: ${client.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async isHost(roomId: string, userId: string): Promise<boolean> {
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
select: { hostId: true },
|
||||||
|
});
|
||||||
|
return room?.hostId === userId;
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('joinRoom')
|
@SubscribeMessage('joinRoom')
|
||||||
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
|
async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) {
|
||||||
client.join(payload.roomCode);
|
client.join(payload.roomCode);
|
||||||
|
|
@ -47,7 +57,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('startGame')
|
@SubscribeMessage('startGame')
|
||||||
async handleStartGame(client: Socket, payload: { roomId: string; roomCode: string }) {
|
async handleStartGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can start the game' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING');
|
await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING');
|
||||||
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||||
if (room) {
|
if (room) {
|
||||||
|
|
@ -56,24 +72,144 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('revealAnswer')
|
@SubscribeMessage('revealAnswer')
|
||||||
handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number }) {
|
async handleRevealAnswer(client: Socket, payload: { roomCode: string; answerIndex: number; userId: string; roomId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can reveal answers' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.server.to(payload.roomCode).emit('answerRevealed', payload);
|
this.server.to(payload.roomCode).emit('answerRevealed', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('updateScore')
|
@SubscribeMessage('updateScore')
|
||||||
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string }) {
|
async handleUpdateScore(client: Socket, payload: { participantId: string; score: number; roomCode: string; userId: string; roomId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can update scores' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.roomsService.updateParticipantScore(payload.participantId, payload.score);
|
await this.roomsService.updateParticipantScore(payload.participantId, payload.score);
|
||||||
this.server.to(payload.roomCode).emit('scoreUpdated', payload);
|
this.server.to(payload.roomCode).emit('scoreUpdated', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('nextQuestion')
|
@SubscribeMessage('nextQuestion')
|
||||||
handleNextQuestion(client: Socket, payload: { roomCode: string }) {
|
async handleNextQuestion(client: Socket, payload: { roomCode: string; userId: string; roomId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can change questions' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.server.to(payload.roomCode).emit('questionChanged', payload);
|
this.server.to(payload.roomCode).emit('questionChanged', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage('endGame')
|
@SubscribeMessage('endGame')
|
||||||
async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string }) {
|
async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can end the game' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED');
|
await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED');
|
||||||
this.server.to(payload.roomCode).emit('gameEnded', payload);
|
this.server.to(payload.roomCode).emit('gameEnded', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('setCurrentPlayer')
|
||||||
|
async handleSetCurrentPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; playerId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can select the current player' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: payload.roomId },
|
||||||
|
data: { currentPlayerId: payload.playerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.server.to(payload.roomCode).emit('currentPlayerChanged', { playerId: payload.playerId });
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('updateRoomSettings')
|
||||||
|
async handleUpdateRoomSettings(client: Socket, payload: { roomId: string; roomCode: string; userId: string; settings: any }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can update room settings' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: payload.roomId },
|
||||||
|
data: payload.settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||||
|
this.server.to(payload.roomCode).emit('roomUpdate', room);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('restartGame')
|
||||||
|
async handleRestartGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can restart the game' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: payload.roomId },
|
||||||
|
data: {
|
||||||
|
status: 'WAITING',
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
revealedAnswers: {},
|
||||||
|
currentPlayerId: null,
|
||||||
|
isGameOver: false,
|
||||||
|
answeredQuestions: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.participant.updateMany({
|
||||||
|
where: { roomId: payload.roomId },
|
||||||
|
data: { score: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||||
|
this.server.to(payload.roomCode).emit('gameRestarted', room);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('updateCustomQuestions')
|
||||||
|
async handleUpdateCustomQuestions(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.getRoomByCode(payload.roomCode);
|
||||||
|
this.server.to(payload.roomCode).emit('customQuestionsUpdated', room);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('kickPlayer')
|
||||||
|
async handleKickPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can kick players' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.participant.update({
|
||||||
|
where: { id: payload.participantId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const room = await this.roomsService.getRoomByCode(payload.roomCode);
|
||||||
|
this.server.to(payload.roomCode).emit('playerKicked', { participantId: payload.participantId, room });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,4 +44,28 @@ export class RoomEventsService {
|
||||||
this.server.to(roomCode).emit('gameEnded', data);
|
this.server.to(roomCode).emit('gameEnded', data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitCurrentPlayerChanged(roomCode: string, data: any) {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.to(roomCode).emit('currentPlayerChanged', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitGameRestarted(roomCode: string, data: any) {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.to(roomCode).emit('gameRestarted', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitCustomQuestionsUpdated(roomCode: string, data: any) {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.to(roomCode).emit('customQuestionsUpdated', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emitPlayerKicked(roomCode: string, data: any) {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.to(roomCode).emit('playerKicked', data);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor() {
|
constructor() {
|
||||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
super();
|
||||||
const adapter = new PrismaPg(pool);
|
|
||||||
super({ adapter });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Controller, Post, Get, Body, Param, Patch } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Param, Patch, Put } from '@nestjs/common';
|
||||||
import { RoomsService } from './rooms.service';
|
import { RoomsService } from './rooms.service';
|
||||||
|
|
||||||
@Controller('rooms')
|
@Controller('rooms')
|
||||||
|
|
@ -30,4 +30,46 @@ export class RoomsController {
|
||||||
) {
|
) {
|
||||||
return this.roomsService.updateQuestionPack(roomId, dto.questionPackId);
|
return this.roomsService.updateQuestionPack(roomId, dto.questionPackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':roomId/custom-questions')
|
||||||
|
async updateCustomQuestions(
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: { questions: any }
|
||||||
|
) {
|
||||||
|
return this.roomsService.updateCustomQuestions(roomId, dto.questions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':roomId/questions')
|
||||||
|
async getEffectiveQuestions(@Param('roomId') roomId: string) {
|
||||||
|
return this.roomsService.getEffectiveQuestions(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':roomId/settings')
|
||||||
|
async updateRoomSettings(
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: { settings: any }
|
||||||
|
) {
|
||||||
|
return this.roomsService.updateRoomSettings(roomId, dto.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':roomId/restart')
|
||||||
|
async restartGame(@Param('roomId') roomId: string) {
|
||||||
|
return this.roomsService.restartGame(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':roomId/current-player')
|
||||||
|
async setCurrentPlayer(
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Body() dto: { playerId: string }
|
||||||
|
) {
|
||||||
|
return this.roomsService.setCurrentPlayer(roomId, dto.playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':roomId/kick/:participantId')
|
||||||
|
async kickPlayer(
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Param('participantId') participantId: string
|
||||||
|
) {
|
||||||
|
return this.roomsService.kickPlayer(roomId, participantId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,4 +123,142 @@ export class RoomsService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCustomQuestions(roomId: string, questions: any) {
|
||||||
|
const room = await this.prisma.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: {
|
||||||
|
customQuestions: questions,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
revealedAnswers: {},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomEventsService.emitCustomQuestionsUpdated(room.code, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEffectiveQuestions(roomId: string) {
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: { questionPack: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть кастомные вопросы, используем их
|
||||||
|
if (room.customQuestions) {
|
||||||
|
return room.customQuestions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе используем вопросы из пака
|
||||||
|
if (room.questionPack) {
|
||||||
|
return room.questionPack.questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRoomSettings(roomId: string, settings: any) {
|
||||||
|
const room = await this.prisma.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: settings,
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartGame(roomId: string) {
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: {
|
||||||
|
status: 'WAITING',
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
revealedAnswers: {},
|
||||||
|
currentPlayerId: null,
|
||||||
|
isGameOver: false,
|
||||||
|
answeredQuestions: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.prisma.participant.updateMany({
|
||||||
|
where: { roomId },
|
||||||
|
data: { score: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
this.roomEventsService.emitGameRestarted(room.code, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCurrentPlayer(roomId: string, playerId: string) {
|
||||||
|
const room = await this.prisma.room.update({
|
||||||
|
where: { id: roomId },
|
||||||
|
data: { currentPlayerId: playerId },
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.roomEventsService.emitCurrentPlayerChanged(room.code, { playerId });
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
async kickPlayer(roomId: string, participantId: string) {
|
||||||
|
await this.prisma.participant.update({
|
||||||
|
where: { id: participantId },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
this.roomEventsService.emitPlayerKicked(room.code, { participantId, room });
|
||||||
|
}
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,5 @@ const PlayersModal = ({ isOpen, onClose, players, onAddPlayer, onRemovePlayer })
|
||||||
export default PlayersModal
|
export default PlayersModal
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -331,3 +331,5 @@ const QuestionsModal = ({ isOpen, onClose, questions, onUpdateQuestions }) => {
|
||||||
export default QuestionsModal
|
export default QuestionsModal
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue