stuff
This commit is contained in:
parent
e036011998
commit
6e940aceb0
27 changed files with 1033 additions and 1317 deletions
19
README.md
19
README.md
|
|
@ -1,22 +1,16 @@
|
||||||
# 100 к 1 - Multiplayer Game
|
# 100 к 1 - Multiplayer Game
|
||||||
|
|
||||||
Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера и локальной игры.
|
Интерактивная веб-игра "100 к 1" с поддержкой мультиплеера.
|
||||||
|
|
||||||
## 🎮 Возможности
|
## 🎮 Возможности
|
||||||
|
|
||||||
### 🌐 Мультиплеер (NEW!)
|
### 🌐 Мультиплеер
|
||||||
- **Игровые комнаты** с уникальными кодами
|
- **Игровые комнаты** с уникальными кодами
|
||||||
- **QR-коды** для быстрого присоединения
|
- **QR-коды** для быстрого присоединения
|
||||||
- **Real-time синхронизация** через WebSocket
|
- **Real-time синхронизация** через WebSocket
|
||||||
- **Роли**: Ведущий, Игрок, Зритель
|
- **Роли**: Ведущий, Игрок, Зритель
|
||||||
- **Статистика игр** с историей
|
- **Статистика игр** с историей
|
||||||
|
|
||||||
### 🏠 Локальная игра
|
|
||||||
- Оригинальная версия для одного устройства
|
|
||||||
- Управление участниками
|
|
||||||
- Редактирование вопросов
|
|
||||||
- Автосохранение прогресса
|
|
||||||
|
|
||||||
## 🛠 Технологический стек
|
## 🛠 Технологический стек
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
@ -56,7 +50,7 @@ sto_k_odnomu/
|
||||||
│ │ ├── CreateRoom.jsx # Создание комнаты
|
│ │ ├── CreateRoom.jsx # Создание комнаты
|
||||||
│ │ ├── JoinRoom.jsx # Присоединение
|
│ │ ├── JoinRoom.jsx # Присоединение
|
||||||
│ │ ├── RoomPage.jsx # Лобби комнаты
|
│ │ ├── RoomPage.jsx # Лобби комнаты
|
||||||
│ │ └── LocalGame.jsx # Локальная игра
|
│ │ └── GamePage.jsx # Игровая страница
|
||||||
│ ├── services/ # API & WebSocket
|
│ ├── services/ # API & WebSocket
|
||||||
│ ├── context/ # React Context
|
│ ├── context/ # React Context
|
||||||
│ ├── hooks/ # Custom hooks
|
│ ├── hooks/ # Custom hooks
|
||||||
|
|
@ -125,12 +119,6 @@ Frontend: http://localhost:5173
|
||||||
4. **Начать игру** (ведущий)
|
4. **Начать игру** (ведущий)
|
||||||
5. Игроки открывают ответы в реальном времени
|
5. Игроки открывают ответы в реальном времени
|
||||||
|
|
||||||
### Локальная игра
|
|
||||||
|
|
||||||
1. Главная → **Локальная игра**
|
|
||||||
2. Добавьте участников (👥)
|
|
||||||
3. Играйте на одном устройстве
|
|
||||||
|
|
||||||
## 📊 API Endpoints
|
## 📊 API Endpoints
|
||||||
|
|
||||||
### REST API
|
### REST API
|
||||||
|
|
@ -286,7 +274,6 @@ npm run preview # Preview build
|
||||||
|
|
||||||
- ❄️ Новогодняя анимация снежинок
|
- ❄️ Новогодняя анимация снежинок
|
||||||
- 🎨 Адаптивный дизайн
|
- 🎨 Адаптивный дизайн
|
||||||
- 💾 Автосохранение прогресса
|
|
||||||
- 🔄 Real-time синхронизация
|
- 🔄 Real-time синхронизация
|
||||||
- 📱 QR-коды для присоединения
|
- 📱 QR-коды для присоединения
|
||||||
- 📊 Статистика и история игр
|
- 📊 Статистика и история игр
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export interface ThemeSettings {
|
||||||
borderRadiusMd: string
|
borderRadiusMd: string
|
||||||
borderRadiusLg: string
|
borderRadiusLg: string
|
||||||
animationSpeed: string
|
animationSpeed: string
|
||||||
|
particlesEnabled?: boolean
|
||||||
|
particleSymbol?: string
|
||||||
|
particleColor?: string
|
||||||
|
particleGlow?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
|
|
@ -243,4 +247,8 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
||||||
borderRadiusMd: '8px',
|
borderRadiusMd: '8px',
|
||||||
borderRadiusLg: '12px',
|
borderRadiusLg: '12px',
|
||||||
animationSpeed: '0.3s',
|
animationSpeed: '0.3s',
|
||||||
|
particlesEnabled: true,
|
||||||
|
particleSymbol: '❄',
|
||||||
|
particleColor: '#ffffff',
|
||||||
|
particleGlow: 'rgba(255, 255, 255, 0.8)',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ export function ThemeEditorDialog({
|
||||||
setColors((prev) => ({ ...prev, [key]: value }))
|
setColors((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSetting = (key: keyof ThemeSettings, value: string) => {
|
const updateSetting = (key: keyof ThemeSettings, value: string | boolean) => {
|
||||||
setSettings((prev) => ({ ...prev, [key]: value }))
|
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,6 +554,47 @@ export function ThemeEditorDialog({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Particles Section */}
|
||||||
|
<div className="space-y-4 pt-4 border-t">
|
||||||
|
<h3 className="text-lg font-semibold">Particles (Частицы)</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="particlesEnabled"
|
||||||
|
checked={settings.particlesEnabled ?? true}
|
||||||
|
onCheckedChange={(checked) => updateSetting('particlesEnabled', checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="particlesEnabled" className="cursor-pointer">
|
||||||
|
Enable Particles (Включить частицы)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Particle Symbol (Emoji)</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.particleSymbol || '❄'}
|
||||||
|
onChange={(e) => updateSetting('particleSymbol', e.target.value)}
|
||||||
|
placeholder="❄"
|
||||||
|
maxLength={2}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Символ для частиц. Рекомендуется использовать эмодзи (например: ❄, 🌸, 🎉, ✨)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ColorField
|
||||||
|
label="Particle Color"
|
||||||
|
value={settings.particleColor || colors.textPrimary}
|
||||||
|
onChange={(v) => updateSetting('particleColor', v)}
|
||||||
|
description="Цвет частиц. По умолчанию используется Text Primary цвет"
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label="Particle Glow"
|
||||||
|
value={settings.particleGlow || colors.textGlow}
|
||||||
|
onChange={(v) => updateSetting('particleGlow', v)}
|
||||||
|
description="Цвет свечения частиц. По умолчанию используется Text Glow цвет"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ model Room {
|
||||||
questionPackId String?
|
questionPackId String?
|
||||||
autoAdvance Boolean @default(false)
|
autoAdvance Boolean @default(false)
|
||||||
voiceMode Boolean @default(false) // Голосовой режим
|
voiceMode Boolean @default(false) // Голосовой режим
|
||||||
|
particlesEnabled Boolean? // null = использовать настройку из темы, true/false = override
|
||||||
password String? // Пароль для доступа к комнате
|
password String? // Пароль для доступа к комнате
|
||||||
|
|
||||||
// Админские комнаты
|
// Админские комнаты
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,10 @@ async function main() {
|
||||||
borderRadiusMd: '15px',
|
borderRadiusMd: '15px',
|
||||||
borderRadiusLg: '20px',
|
borderRadiusLg: '20px',
|
||||||
animationSpeed: '0.3s',
|
animationSpeed: '0.3s',
|
||||||
|
particlesEnabled: true,
|
||||||
|
particleSymbol: '❄',
|
||||||
|
particleColor: '#ffffff',
|
||||||
|
particleGlow: 'rgba(255, 215, 0, 0.8)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -317,6 +321,10 @@ async function main() {
|
||||||
borderRadiusMd: '15px',
|
borderRadiusMd: '15px',
|
||||||
borderRadiusLg: '20px',
|
borderRadiusLg: '20px',
|
||||||
animationSpeed: '0.3s',
|
animationSpeed: '0.3s',
|
||||||
|
particlesEnabled: true,
|
||||||
|
particleSymbol: '🌸',
|
||||||
|
particleColor: '#2d3748',
|
||||||
|
particleGlow: 'rgba(47, 128, 237, 0.6)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -348,6 +356,10 @@ async function main() {
|
||||||
borderRadiusMd: '15px',
|
borderRadiusMd: '15px',
|
||||||
borderRadiusLg: '20px',
|
borderRadiusLg: '20px',
|
||||||
animationSpeed: '0.2s',
|
animationSpeed: '0.2s',
|
||||||
|
particlesEnabled: true,
|
||||||
|
particleSymbol: '🎉',
|
||||||
|
particleColor: '#ffffff',
|
||||||
|
particleGlow: 'rgba(255, 87, 108, 0.8)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -379,6 +391,10 @@ async function main() {
|
||||||
borderRadiusMd: '15px',
|
borderRadiusMd: '15px',
|
||||||
borderRadiusLg: '20px',
|
borderRadiusLg: '20px',
|
||||||
animationSpeed: '0.3s',
|
animationSpeed: '0.3s',
|
||||||
|
particlesEnabled: true,
|
||||||
|
particleSymbol: '✨',
|
||||||
|
particleColor: '#e0e0e0',
|
||||||
|
particleGlow: 'rgba(100, 255, 218, 0.6)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,22 @@ export class ThemeSettingsDto {
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
animationSpeed: string;
|
animationSpeed: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
particlesEnabled?: boolean;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
particleSymbol?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
particleColor?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
particleGlow?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateThemeDto {
|
export class CreateThemeDto {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ type RoomWithPack = Prisma.RoomGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
roomPack: true;
|
roomPack: true;
|
||||||
participants: true;
|
participants: true;
|
||||||
|
theme: true;
|
||||||
}
|
}
|
||||||
}> & {
|
}> & {
|
||||||
currentQuestionId?: string | null;
|
currentQuestionId?: string | null;
|
||||||
|
|
@ -78,11 +79,16 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isHost(roomId: string, userId: string): Promise<boolean> {
|
private async isHost(roomId: string, userId: string): Promise<boolean> {
|
||||||
const room = await this.prisma.room.findUnique({
|
// Проверяем роль участника (role === 'HOST') для поддержки нескольких хостов
|
||||||
where: { id: roomId },
|
const participant = await this.prisma.participant.findFirst({
|
||||||
select: { hostId: true },
|
where: {
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
role: 'HOST',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return room?.hostId === userId;
|
return !!participant;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isCurrentPlayer(roomId: string, participantId: string): Promise<boolean> {
|
private async isCurrentPlayer(roomId: string, participantId: string): Promise<boolean> {
|
||||||
|
|
@ -169,8 +175,22 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем участника для проверки роли
|
||||||
|
const participant = room.participants.find(p => p.id === payload.participantId);
|
||||||
|
|
||||||
|
if (!participant) {
|
||||||
|
client.emit('error', { message: 'Participant not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем роль участника - зрители не могут выполнять действия игрока
|
||||||
|
if (participant.role === 'SPECTATOR') {
|
||||||
|
client.emit('error', { message: 'Spectators cannot perform player actions' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем права
|
// Проверяем права
|
||||||
const isHost = room.hostId === payload.userId;
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
const isCurrentPlayer = room.currentPlayerId === payload.participantId;
|
const isCurrentPlayer = room.currentPlayerId === payload.participantId;
|
||||||
|
|
||||||
if (!isHost && !isCurrentPlayer) {
|
if (!isHost && !isCurrentPlayer) {
|
||||||
|
|
@ -323,7 +343,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
orderBy: { joinedAt: 'asc' }
|
orderBy: { joinedAt: 'asc' }
|
||||||
},
|
},
|
||||||
roomPack: true,
|
roomPack: true,
|
||||||
host: { select: { id: true, name: true } }
|
host: { select: { id: true, name: true } },
|
||||||
|
theme: true
|
||||||
} as Prisma.RoomInclude,
|
} as Prisma.RoomInclude,
|
||||||
})) as unknown as RoomWithPack | null;
|
})) as unknown as RoomWithPack | null;
|
||||||
|
|
||||||
|
|
@ -402,6 +423,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
revealedAnswers: room.revealedAnswers as RevealedAnswers,
|
revealedAnswers: room.revealedAnswers as RevealedAnswers,
|
||||||
isGameOver: room.isGameOver,
|
isGameOver: room.isGameOver,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
|
themeId: (room as any).themeId || null,
|
||||||
|
particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null,
|
||||||
participants: room.participants.map((p) => ({
|
participants: room.participants.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
userId: p.userId,
|
userId: p.userId,
|
||||||
|
|
@ -528,6 +551,38 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
await this.broadcastFullState(payload.roomCode);
|
await this.broadcastFullState(payload.roomCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('changeRoomTheme')
|
||||||
|
async handleChangeRoomTheme(client: Socket, payload: { roomId: string; roomCode: string; userId: string; themeId: string | null }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can change room theme' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: payload.roomId },
|
||||||
|
data: { themeId: payload.themeId } as Prisma.RoomUpdateInput
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('toggleParticles')
|
||||||
|
async handleToggleParticles(client: Socket, payload: { roomId: string; roomCode: string; userId: string; particlesEnabled: boolean }) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can toggle particles' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.room.update({
|
||||||
|
where: { id: payload.roomId },
|
||||||
|
data: { particlesEnabled: payload.particlesEnabled } as any
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeMessage('updateRoomPack')
|
@SubscribeMessage('updateRoomPack')
|
||||||
async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) {
|
async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) {
|
||||||
const isHost = await this.isHost(payload.roomId, payload.userId);
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
|
@ -660,7 +715,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запрещаем удаление хоста
|
// Запрещаем удаление хоста
|
||||||
if (participant.role === 'HOST' || participant.userId === room.hostId) {
|
if (participant.role === 'HOST') {
|
||||||
client.emit('error', { message: 'Cannot kick the host' });
|
client.emit('error', { message: 'Cannot kick the host' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -783,4 +838,35 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
|
|
||||||
await this.broadcastFullState(payload.roomCode);
|
await this.broadcastFullState(payload.roomCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('changeParticipantRole')
|
||||||
|
async handleChangeParticipantRole(client: Socket, payload: {
|
||||||
|
roomId: string;
|
||||||
|
roomCode: string;
|
||||||
|
userId: string;
|
||||||
|
participantId: string;
|
||||||
|
newRole: 'HOST' | 'PLAYER' | 'SPECTATOR';
|
||||||
|
}) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only hosts can change participant roles' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const room = await this.roomsService.updateParticipantRole(
|
||||||
|
payload.roomId,
|
||||||
|
payload.participantId,
|
||||||
|
payload.newRole,
|
||||||
|
payload.userId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing participant role:', error);
|
||||||
|
client.emit('error', {
|
||||||
|
message: error.message || 'Failed to change participant role'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,4 +84,18 @@ export class RoomsController {
|
||||||
) {
|
) {
|
||||||
return this.roomsService.kickPlayer(roomId, participantId);
|
return this.roomsService.kickPlayer(roomId, participantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Patch(':roomId/participants/:participantId/role')
|
||||||
|
async updateParticipantRole(
|
||||||
|
@Param('roomId') roomId: string,
|
||||||
|
@Param('participantId') participantId: string,
|
||||||
|
@Body() dto: { role: 'HOST' | 'PLAYER' | 'SPECTATOR'; requestedByUserId: string }
|
||||||
|
) {
|
||||||
|
return this.roomsService.updateParticipantRole(
|
||||||
|
roomId,
|
||||||
|
participantId,
|
||||||
|
dto.role,
|
||||||
|
dto.requestedByUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,20 @@ export class RoomsService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
|
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
|
||||||
|
// Получаем комнату для проверки настроек
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
throw new NotFoundException('Room not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, разрешены ли зрители
|
||||||
|
if (role === 'SPECTATOR' && !room.allowSpectators) {
|
||||||
|
throw new BadRequestException('Spectators are not allowed in this room');
|
||||||
|
}
|
||||||
|
|
||||||
const participant = await this.prisma.participant.create({
|
const participant = await this.prisma.participant.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -117,7 +131,7 @@ export class RoomsService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получаем обновленную комнату со всеми участниками
|
// Получаем обновленную комнату со всеми участниками
|
||||||
const room = await this.prisma.room.findUnique({
|
const updatedRoom = await this.prisma.room.findUnique({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
include: {
|
include: {
|
||||||
host: true,
|
host: true,
|
||||||
|
|
@ -129,8 +143,8 @@ export class RoomsService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Отправляем событие roomUpdate всем клиентам в комнате
|
// Отправляем событие roomUpdate всем клиентам в комнате
|
||||||
if (room) {
|
if (updatedRoom) {
|
||||||
this.roomEventsService.emitRoomUpdate(room.code, room);
|
this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
return participant;
|
return participant;
|
||||||
|
|
@ -337,4 +351,92 @@ export class RoomsService {
|
||||||
|
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateParticipantRole(
|
||||||
|
roomId: string,
|
||||||
|
participantId: string,
|
||||||
|
newRole: 'HOST' | 'PLAYER' | 'SPECTATOR',
|
||||||
|
requestedByUserId: string,
|
||||||
|
) {
|
||||||
|
// Проверяем права: запрашивающий должен быть хостом
|
||||||
|
const requester = await this.prisma.participant.findFirst({
|
||||||
|
where: {
|
||||||
|
roomId,
|
||||||
|
userId: requestedByUserId,
|
||||||
|
role: 'HOST',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!requester) {
|
||||||
|
throw new UnauthorizedException('Only hosts can change participant roles');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем участника, которому меняем роль
|
||||||
|
const participant = await this.prisma.participant.findUnique({
|
||||||
|
where: { id: participantId },
|
||||||
|
include: { room: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!participant || participant.roomId !== roomId) {
|
||||||
|
throw new NotFoundException('Participant not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participant.isActive) {
|
||||||
|
throw new BadRequestException('Cannot change role of inactive participant');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем настройки комнаты для зрителей
|
||||||
|
if (newRole === 'SPECTATOR' && !participant.room.allowSpectators) {
|
||||||
|
throw new BadRequestException('Spectators are not allowed in this room');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что не изменяем роль последнего хоста
|
||||||
|
if (participant.role === 'HOST' && newRole !== 'HOST') {
|
||||||
|
const hostCount = await this.prisma.participant.count({
|
||||||
|
where: {
|
||||||
|
roomId,
|
||||||
|
role: 'HOST',
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hostCount <= 1) {
|
||||||
|
throw new BadRequestException('Cannot remove the last host');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если игра идет и меняем роль текущего игрока на SPECTATOR, запрещаем
|
||||||
|
if (
|
||||||
|
participant.room.status === 'PLAYING' &&
|
||||||
|
participant.room.currentPlayerId === participantId &&
|
||||||
|
newRole === 'SPECTATOR'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('Cannot change current player role to SPECTATOR during the game');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем роль
|
||||||
|
await this.prisma.participant.update({
|
||||||
|
where: { id: participantId },
|
||||||
|
data: { role: newRole },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Получаем обновленную комнату со всеми участниками
|
||||||
|
const room = await this.prisma.room.findUnique({
|
||||||
|
where: { id: roomId },
|
||||||
|
include: {
|
||||||
|
host: true,
|
||||||
|
participants: {
|
||||||
|
include: { user: true },
|
||||||
|
},
|
||||||
|
questionPack: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (room) {
|
||||||
|
this.roomEventsService.emitRoomUpdate(room.code, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return room;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import CreateRoom from './pages/CreateRoom';
|
||||||
import JoinRoom from './pages/JoinRoom';
|
import JoinRoom from './pages/JoinRoom';
|
||||||
import RoomPage from './pages/RoomPage';
|
import RoomPage from './pages/RoomPage';
|
||||||
import GamePage from './pages/GamePage';
|
import GamePage from './pages/GamePage';
|
||||||
import LocalGame from './pages/LocalGame';
|
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -17,14 +16,14 @@ function App() {
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<>
|
<>
|
||||||
<Snowflakes />
|
{/* Snowflakes for non-game pages (uses theme settings only) */}
|
||||||
|
<Snowflakes roomParticlesEnabled={null} />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/create-room" element={<CreateRoom />} />
|
<Route path="/create-room" element={<CreateRoom />} />
|
||||||
<Route path="/join-room" element={<JoinRoom />} />
|
<Route path="/join-room" element={<JoinRoom />} />
|
||||||
<Route path="/room/:roomCode" element={<RoomPage />} />
|
<Route path="/room/:roomCode" element={<RoomPage />} />
|
||||||
<Route path="/game/:roomCode" element={<GamePage />} />
|
<Route path="/game/:roomCode" element={<GamePage />} />
|
||||||
<Route path="/local-game" element={<LocalGame />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,64 @@
|
||||||
color: var(--accent-primary, #ffd700);
|
color: var(--accent-primary, #ffd700);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Visual Effects Section */
|
||||||
|
.visual-effects-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-effects-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: var(--text-primary, #ffffff);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-effects-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: var(--accent-primary, #ffd700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-checkbox {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-primary, #ffd700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-text {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-primary, #ffffff);
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visual-effects-description {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
/* Answers control section */
|
/* Answers control section */
|
||||||
.answers-control-section {
|
.answers-control-section {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { questionsApi } from '../services/api'
|
import { questionsApi } from '../services/api'
|
||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
import './GameManagementModal.css'
|
import './GameManagementModal.css'
|
||||||
import './QuestionsModal.css'
|
import './QuestionsModal.css'
|
||||||
|
|
||||||
|
|
@ -28,10 +29,27 @@ const GameManagementModal = ({
|
||||||
onUpdatePlayerName,
|
onUpdatePlayerName,
|
||||||
onUpdatePlayerScore,
|
onUpdatePlayerScore,
|
||||||
onKickPlayer,
|
onKickPlayer,
|
||||||
|
onChangeParticipantRole,
|
||||||
|
particlesEnabled = null,
|
||||||
|
onToggleParticles,
|
||||||
|
initialTab = 'players',
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState('players') // players | game | scoring | questions
|
const { currentThemeData } = useTheme()
|
||||||
|
const [activeTab, setActiveTab] = useState(initialTab) // players | game | scoring | questions
|
||||||
const [selectedPlayer, setSelectedPlayer] = useState(null)
|
const [selectedPlayer, setSelectedPlayer] = useState(null)
|
||||||
const [customPoints, setCustomPoints] = useState(10)
|
const [customPoints, setCustomPoints] = useState(10)
|
||||||
|
|
||||||
|
// Determine actual particles enabled state (room override or theme default)
|
||||||
|
const getActualParticlesEnabled = () => {
|
||||||
|
if (particlesEnabled === true || particlesEnabled === false) {
|
||||||
|
return particlesEnabled
|
||||||
|
}
|
||||||
|
// If room override is null, use theme setting
|
||||||
|
return currentThemeData?.settings?.particlesEnabled ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualParticlesEnabled = getActualParticlesEnabled()
|
||||||
|
const hasRoomOverride = particlesEnabled !== null && particlesEnabled !== undefined
|
||||||
|
|
||||||
// Player editing state
|
// Player editing state
|
||||||
const [editingPlayerId, setEditingPlayerId] = useState(null)
|
const [editingPlayerId, setEditingPlayerId] = useState(null)
|
||||||
|
|
@ -59,6 +77,13 @@ const GameManagementModal = ({
|
||||||
const [viewingQuestion, setViewingQuestion] = useState(null)
|
const [viewingQuestion, setViewingQuestion] = useState(null)
|
||||||
const [showAnswers, setShowAnswers] = useState(false)
|
const [showAnswers, setShowAnswers] = useState(false)
|
||||||
|
|
||||||
|
// Сбрасываем вкладку на initialTab при открытии модального окна
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveTab(initialTab)
|
||||||
|
}
|
||||||
|
}, [isOpen, initialTab])
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
const gameStatus = room?.status || 'WAITING'
|
const gameStatus = room?.status || 'WAITING'
|
||||||
|
|
@ -538,10 +563,46 @@ const GameManagementModal = ({
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="player-role">
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
{participant.role === 'HOST' && '👑 Ведущий'}
|
{onChangeParticipantRole ? (
|
||||||
{participant.role === 'SPECTATOR' && '👀 Зритель'}
|
<select
|
||||||
</span>
|
value={participant.role}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRole = e.target.value;
|
||||||
|
// Проверка: нельзя изменить роль последнего хоста на не-HOST
|
||||||
|
if (participant.role === 'HOST' && newRole !== 'HOST') {
|
||||||
|
const hostCount = participants.filter(p =>
|
||||||
|
p.role === 'HOST' && (p.isActive !== false)
|
||||||
|
).length;
|
||||||
|
if (hostCount <= 1) {
|
||||||
|
alert('Нельзя изменить роль последнего хоста');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChangeParticipantRole(participant.id, newRole);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '5px 10px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: 'var(--text-primary)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
title="Изменить роль участника"
|
||||||
|
>
|
||||||
|
<option value="HOST">👑 Ведущий</option>
|
||||||
|
<option value="PLAYER">🎮 Игрок</option>
|
||||||
|
<option value="SPECTATOR">👀 Зритель</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="player-role">
|
||||||
|
{participant.role === 'HOST' && '👑 Ведущий'}
|
||||||
|
{participant.role === 'SPECTATOR' && '👀 Зритель'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="player-score-section">
|
<div className="player-score-section">
|
||||||
{editingPlayerId === participant.id && editMode === 'score' ? (
|
{editingPlayerId === participant.id && editMode === 'score' ? (
|
||||||
|
|
@ -682,6 +743,39 @@ const GameManagementModal = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Visual Effects Section */}
|
||||||
|
{onToggleParticles && (
|
||||||
|
<div className="visual-effects-section">
|
||||||
|
<h3>🎨 Визуальные эффекты</h3>
|
||||||
|
<div className="visual-effects-controls">
|
||||||
|
<label className="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={actualParticlesEnabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (onToggleParticles) {
|
||||||
|
onToggleParticles(e.target.checked)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="toggle-checkbox"
|
||||||
|
/>
|
||||||
|
<span className="toggle-text">
|
||||||
|
{hasRoomOverride
|
||||||
|
? (particlesEnabled ? 'Частицы включены (переопределено)' : 'Частицы выключены (переопределено)')
|
||||||
|
: `Частицы ${actualParticlesEnabled ? 'включены' : 'выключены'} (по умолчанию из темы)`
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="visual-effects-description">
|
||||||
|
{hasRoomOverride
|
||||||
|
? 'Вы переопределили настройку из темы. Переключение изменит настройку комнаты.'
|
||||||
|
: 'Текущее состояние берется из настроек темы. Переключение создаст переопределение для этой комнаты.'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
import { useState, useRef, useEffect } from 'react'
|
|
||||||
import Game from './Game'
|
|
||||||
import QuestionsModal from './QuestionsModal'
|
|
||||||
import ThemeSwitcher from './ThemeSwitcher'
|
|
||||||
import VoiceSettings from './VoiceSettings'
|
|
||||||
import { questions as initialQuestions } from '../data/questions'
|
|
||||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies'
|
|
||||||
import '../App.css'
|
|
||||||
|
|
||||||
function LocalGameApp() {
|
|
||||||
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false)
|
|
||||||
const [questions, setQuestions] = useState(() => {
|
|
||||||
const savedQuestions = getCookie('gameQuestions')
|
|
||||||
return savedQuestions || initialQuestions
|
|
||||||
})
|
|
||||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(() => {
|
|
||||||
const savedIndex = getCookie('gameQuestionIndex')
|
|
||||||
return savedIndex !== null ? savedIndex : 0
|
|
||||||
})
|
|
||||||
const [areAllRevealed, setAreAllRevealed] = useState(false)
|
|
||||||
const gameRef = useRef(null)
|
|
||||||
|
|
||||||
const currentQuestion = questions[currentQuestionIndex]
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (questions.length > 0) {
|
|
||||||
setCookie('gameQuestions', questions)
|
|
||||||
}
|
|
||||||
}, [questions])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCookie('gameQuestionIndex', currentQuestionIndex)
|
|
||||||
}, [currentQuestionIndex])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkRevealedState = () => {
|
|
||||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
|
||||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
|
||||||
} else {
|
|
||||||
setAreAllRevealed(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkRevealedState()
|
|
||||||
const interval = setInterval(checkRevealedState, 200)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [currentQuestionIndex, questions])
|
|
||||||
|
|
||||||
const handleUpdateQuestions = (updatedQuestions) => {
|
|
||||||
setQuestions(updatedQuestions)
|
|
||||||
if (currentQuestionIndex >= updatedQuestions.length) {
|
|
||||||
setCurrentQuestionIndex(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpenPlayersModal = () => {
|
|
||||||
if (gameRef.current) {
|
|
||||||
gameRef.current.openPlayersModal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleNewGame = () => {
|
|
||||||
if (window.confirm('Начать новую игру? Текущий прогресс будет потерян.')) {
|
|
||||||
deleteCookie('gameQuestions')
|
|
||||||
deleteCookie('gameQuestionIndex')
|
|
||||||
deleteCookie('gamePlayers')
|
|
||||||
deleteCookie('gamePlayerScores')
|
|
||||||
deleteCookie('gameCurrentPlayerId')
|
|
||||||
deleteCookie('gameRevealedAnswers')
|
|
||||||
deleteCookie('gameOver')
|
|
||||||
|
|
||||||
setQuestions(initialQuestions)
|
|
||||||
setCurrentQuestionIndex(0)
|
|
||||||
|
|
||||||
if (gameRef.current) {
|
|
||||||
gameRef.current.newGame()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShowAll = () => {
|
|
||||||
if (gameRef.current && gameRef.current.showAllAnswers) {
|
|
||||||
gameRef.current.showAllAnswers()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (gameRef.current && gameRef.current.areAllAnswersRevealed) {
|
|
||||||
setAreAllRevealed(gameRef.current.areAllAnswersRevealed())
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app">
|
|
||||||
<div className="app-content">
|
|
||||||
<div className="app-title-bar">
|
|
||||||
<div className="app-control-buttons">
|
|
||||||
<ThemeSwitcher />
|
|
||||||
<VoiceSettings />
|
|
||||||
<button
|
|
||||||
className="control-button control-button-players"
|
|
||||||
onClick={handleOpenPlayersModal}
|
|
||||||
title="Управление участниками"
|
|
||||||
>
|
|
||||||
👥
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="control-button control-button-questions"
|
|
||||||
onClick={() => setIsQuestionsModalOpen(true)}
|
|
||||||
title="Управление вопросами"
|
|
||||||
>
|
|
||||||
❓
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="control-button control-button-new-game"
|
|
||||||
onClick={handleNewGame}
|
|
||||||
title="Новая игра"
|
|
||||||
>
|
|
||||||
🎮
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="app-title">
|
|
||||||
<span className="title-number">100</span>
|
|
||||||
<span className="title-to">к</span>
|
|
||||||
<span className="title-number">1</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{questions.length > 0 && currentQuestion && (
|
|
||||||
<div className="question-counter-wrapper">
|
|
||||||
<div className="question-counter">
|
|
||||||
{currentQuestionIndex + 1}/{questions.length}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="show-all-button-top"
|
|
||||||
onClick={handleShowAll}
|
|
||||||
title={areAllRevealed ? "Скрыть все ответы" : "Показать все ответы"}
|
|
||||||
>
|
|
||||||
{areAllRevealed ? "Скрыть все" : "Показать все"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<QuestionsModal
|
|
||||||
isOpen={isQuestionsModalOpen}
|
|
||||||
onClose={() => setIsQuestionsModalOpen(false)}
|
|
||||||
questions={questions}
|
|
||||||
onUpdateQuestions={handleUpdateQuestions}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Game
|
|
||||||
ref={gameRef}
|
|
||||||
questions={questions}
|
|
||||||
currentQuestionIndex={currentQuestionIndex}
|
|
||||||
onQuestionIndexChange={setCurrentQuestionIndex}
|
|
||||||
onQuestionsChange={setQuestions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LocalGameApp
|
|
||||||
|
|
@ -1,611 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { questionsApi } from '../services/api'
|
|
||||||
import './QuestionsModal.css'
|
|
||||||
|
|
||||||
const QuestionsModal = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
questions,
|
|
||||||
onUpdateQuestions,
|
|
||||||
isOnlineMode = false,
|
|
||||||
roomId = null,
|
|
||||||
availablePacks = [],
|
|
||||||
}) => {
|
|
||||||
const [editingQuestion, setEditingQuestion] = useState(null)
|
|
||||||
const [questionText, setQuestionText] = useState('')
|
|
||||||
const [answers, setAnswers] = useState([
|
|
||||||
{ text: '', points: 100 },
|
|
||||||
{ text: '', points: 80 },
|
|
||||||
{ text: '', points: 60 },
|
|
||||||
{ text: '', points: 40 },
|
|
||||||
{ text: '', points: 20 },
|
|
||||||
{ 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)
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [viewingQuestion, setViewingQuestion] = useState(null)
|
|
||||||
const [showAnswers, setShowAnswers] = useState(false)
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setEditingQuestion(null)
|
|
||||||
setQuestionText('')
|
|
||||||
setAnswers([
|
|
||||||
{ text: '', points: 100 },
|
|
||||||
{ text: '', points: 80 },
|
|
||||||
{ text: '', points: 60 },
|
|
||||||
{ text: '', points: 40 },
|
|
||||||
{ text: '', points: 20 },
|
|
||||||
{ text: '', points: 10 },
|
|
||||||
])
|
|
||||||
setJsonError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBackdropClick = (e) => {
|
|
||||||
if (e.target === e.currentTarget) {
|
|
||||||
onClose()
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
onClose()
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEdit = (question) => {
|
|
||||||
setEditingQuestion(question)
|
|
||||||
setQuestionText(question.text)
|
|
||||||
setAnswers([...question.answers])
|
|
||||||
setJsonError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAnswerChange = (index, field, value) => {
|
|
||||||
const updatedAnswers = [...answers]
|
|
||||||
if (field === 'text') {
|
|
||||||
updatedAnswers[index].text = value
|
|
||||||
} else if (field === 'points') {
|
|
||||||
updatedAnswers[index].points = parseInt(value) || 0
|
|
||||||
}
|
|
||||||
setAnswers(updatedAnswers)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddAnswer = () => {
|
|
||||||
const minPoints = Math.min(...answers.map(a => a.points))
|
|
||||||
setAnswers([...answers, { text: '', points: Math.max(0, minPoints - 10) }])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveAnswer = (index) => {
|
|
||||||
if (answers.length > 1) {
|
|
||||||
setAnswers(answers.filter((_, i) => i !== index))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateForm = () => {
|
|
||||||
if (!questionText.trim()) {
|
|
||||||
setJsonError('Введите текст вопроса')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (answers.length === 0) {
|
|
||||||
setJsonError('Добавьте хотя бы один ответ')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const hasEmptyAnswers = answers.some(a => !a.text.trim())
|
|
||||||
if (hasEmptyAnswers) {
|
|
||||||
setJsonError('Заполните все ответы')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!validateForm()) return
|
|
||||||
|
|
||||||
const questionData = {
|
|
||||||
id: editingQuestion ? editingQuestion.id : Date.now(),
|
|
||||||
text: questionText.trim(),
|
|
||||||
answers: answers
|
|
||||||
.filter(a => a.text.trim())
|
|
||||||
.map(a => ({
|
|
||||||
text: a.text.trim(),
|
|
||||||
points: a.points,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
let updatedQuestions
|
|
||||||
if (editingQuestion) {
|
|
||||||
updatedQuestions = questions.map(q =>
|
|
||||||
q.id === editingQuestion.id ? questionData : q
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
updatedQuestions = [...questions, questionData]
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdateQuestions(updatedQuestions)
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = (questionId) => {
|
|
||||||
if (window.confirm('Вы уверены, что хотите удалить этот вопрос?')) {
|
|
||||||
const updatedQuestions = questions.filter(q => q.id !== questionId)
|
|
||||||
onUpdateQuestions(updatedQuestions)
|
|
||||||
if (editingQuestion && editingQuestion.id === questionId) {
|
|
||||||
resetForm()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportJson = () => {
|
|
||||||
try {
|
|
||||||
const jsonString = JSON.stringify(questions, null, 2)
|
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = 'questions.json'
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
setJsonError('')
|
|
||||||
} catch (error) {
|
|
||||||
setJsonError('Ошибка при экспорте: ' + error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleImportJson = () => {
|
|
||||||
const input = document.createElement('input')
|
|
||||||
input.type = 'file'
|
|
||||||
input.accept = '.json'
|
|
||||||
input.onchange = (e) => {
|
|
||||||
const file = e.target.files[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
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' &&
|
|
||||||
Array.isArray(q.answers) &&
|
|
||||||
q.answers.every(a => a.text && typeof a.points === 'number')
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
setJsonError('Неверный формат JSON. Ожидается массив объектов с полями: id, text, answers')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onUpdateQuestions(jsonContent)
|
|
||||||
setJsonError('')
|
|
||||||
alert(`Успешно импортировано ${jsonContent.length} вопросов`)
|
|
||||||
} catch (error) {
|
|
||||||
setJsonError('Ошибка при импорте: ' + error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reader.readAsText(file)
|
|
||||||
}
|
|
||||||
input.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectPack = async (packId) => {
|
|
||||||
if (!packId) {
|
|
||||||
setPackQuestions([])
|
|
||||||
setSelectedPack(null)
|
|
||||||
setSearchQuery('')
|
|
||||||
setViewingQuestion(null)
|
|
||||||
setShowAnswers(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await questionsApi.getPack(packId)
|
|
||||||
setPackQuestions(response.data.questions || [])
|
|
||||||
setSelectedPack(packId)
|
|
||||||
setSelectedQuestionIndices(new Set())
|
|
||||||
setSearchQuery('')
|
|
||||||
setViewingQuestion(null)
|
|
||||||
setShowAnswers(false)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching pack:', error)
|
|
||||||
setJsonError('Ошибка загрузки пака вопросов')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтрация вопросов по поисковому запросу
|
|
||||||
const filteredPackQuestions = packQuestions.filter((q) => {
|
|
||||||
if (!searchQuery.trim()) return true
|
|
||||||
const questionText = (q.text || q.question || '').toLowerCase()
|
|
||||||
return questionText.includes(searchQuery.toLowerCase())
|
|
||||||
})
|
|
||||||
|
|
||||||
// Выбор всех видимых вопросов
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
const allVisibleIndices = new Set(
|
|
||||||
filteredPackQuestions.map((q) => {
|
|
||||||
const originalIndex = packQuestions.findIndex(pq => pq === q)
|
|
||||||
return originalIndex
|
|
||||||
}).filter(idx => idx !== -1)
|
|
||||||
)
|
|
||||||
const newSelected = new Set(selectedQuestionIndices)
|
|
||||||
allVisibleIndices.forEach(idx => newSelected.add(idx))
|
|
||||||
setSelectedQuestionIndices(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Снятие выбора со всех видимых вопросов
|
|
||||||
const handleDeselectAll = () => {
|
|
||||||
const visibleIndices = new Set(
|
|
||||||
filteredPackQuestions.map((q) => {
|
|
||||||
const originalIndex = packQuestions.findIndex(pq => pq === q)
|
|
||||||
return originalIndex
|
|
||||||
}).filter(idx => idx !== -1)
|
|
||||||
)
|
|
||||||
const newSelected = new Set(selectedQuestionIndices)
|
|
||||||
visibleIndices.forEach(idx => newSelected.delete(idx))
|
|
||||||
setSelectedQuestionIndices(newSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка, выбраны ли все видимые вопросы
|
|
||||||
const areAllVisibleSelected = () => {
|
|
||||||
if (filteredPackQuestions.length === 0) return false
|
|
||||||
const visibleIndices = filteredPackQuestions.map((q) => {
|
|
||||||
const originalIndex = packQuestions.findIndex(pq => pq === q)
|
|
||||||
return originalIndex
|
|
||||||
}).filter(idx => idx !== -1)
|
|
||||||
return visibleIndices.every(idx => selectedQuestionIndices.has(idx))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Просмотр вопроса
|
|
||||||
const handleViewQuestion = (question) => {
|
|
||||||
setViewingQuestion(question)
|
|
||||||
setShowAnswers(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрытие просмотра вопроса
|
|
||||||
const handleCloseViewer = () => {
|
|
||||||
setViewingQuestion(null)
|
|
||||||
setShowAnswers(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
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, idx) => ({
|
|
||||||
id: Date.now() + Math.random() + idx, // Generate new ID
|
|
||||||
text: q.text || q.question || '',
|
|
||||||
answers: (q.answers || []).map(a => ({ text: a.text, points: a.points })),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const updatedQuestions = [...questions, ...copiedQuestions]
|
|
||||||
onUpdateQuestions(updatedQuestions)
|
|
||||||
|
|
||||||
// Reset
|
|
||||||
setSelectedQuestionIndices(new Set())
|
|
||||||
setSearchQuery('')
|
|
||||||
setShowPackImport(false)
|
|
||||||
setJsonError('')
|
|
||||||
alert(`Импортировано ${copiedQuestions.length} вопросов`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="questions-modal-backdrop" onClick={handleBackdropClick}>
|
|
||||||
<div className="questions-modal-content">
|
|
||||||
<div className="questions-modal-header">
|
|
||||||
<h2 className="questions-modal-title">Управление вопросами</h2>
|
|
||||||
<button className="questions-modal-close" onClick={handleClose}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="questions-modal-actions">
|
|
||||||
<button
|
|
||||||
className="questions-modal-export-button"
|
|
||||||
onClick={handleExportJson}
|
|
||||||
>
|
|
||||||
📥 Экспорт JSON
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="questions-modal-import-button"
|
|
||||||
onClick={handleImportJson}
|
|
||||||
>
|
|
||||||
📤 Импорт 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-search-container">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="🔍 Поиск вопросов..."
|
|
||||||
className="pack-search-input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pack-questions-header">
|
|
||||||
<div className="pack-questions-header-left">
|
|
||||||
<span>Выберите вопросы для импорта:</span>
|
|
||||||
<div className="pack-select-all-buttons">
|
|
||||||
{filteredPackQuestions.length > 0 && (
|
|
||||||
<>
|
|
||||||
{areAllVisibleSelected() ? (
|
|
||||||
<button
|
|
||||||
onClick={handleDeselectAll}
|
|
||||||
className="pack-select-all-button"
|
|
||||||
>
|
|
||||||
Снять выбор
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleSelectAll}
|
|
||||||
className="pack-select-all-button"
|
|
||||||
>
|
|
||||||
Выбрать все ({filteredPackQuestions.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleImportSelected}
|
|
||||||
disabled={selectedQuestionIndices.size === 0}
|
|
||||||
className="pack-import-confirm-button"
|
|
||||||
>
|
|
||||||
Импортировать ({selectedQuestionIndices.size})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pack-questions-items">
|
|
||||||
{filteredPackQuestions.length === 0 ? (
|
|
||||||
<div className="pack-no-results">
|
|
||||||
{searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredPackQuestions.map((q, filteredIdx) => {
|
|
||||||
const originalIndex = packQuestions.findIndex(pq => pq === q)
|
|
||||||
return (
|
|
||||||
<div key={originalIndex} className="pack-question-item">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedQuestionIndices.has(originalIndex)}
|
|
||||||
onChange={() => handleToggleQuestion(originalIndex)}
|
|
||||||
/>
|
|
||||||
<div className="pack-question-content">
|
|
||||||
<strong>{q.text || q.question}</strong>
|
|
||||||
<span className="pack-question-info">
|
|
||||||
{q.answers?.length || 0} ответов
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewQuestion(q)}
|
|
||||||
className="pack-view-question-button"
|
|
||||||
title="Просмотр вопроса"
|
|
||||||
>
|
|
||||||
👁
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Модальное окно просмотра вопроса */}
|
|
||||||
{viewingQuestion && (
|
|
||||||
<div className="pack-question-viewer-backdrop" onClick={handleCloseViewer}>
|
|
||||||
<div className="pack-question-viewer" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="pack-question-viewer-header">
|
|
||||||
<h4>Просмотр вопроса</h4>
|
|
||||||
<button
|
|
||||||
className="pack-question-viewer-close"
|
|
||||||
onClick={handleCloseViewer}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="pack-question-viewer-content">
|
|
||||||
<div className="pack-question-viewer-text">
|
|
||||||
{viewingQuestion.text || viewingQuestion.question}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="pack-show-answers-button"
|
|
||||||
onClick={() => setShowAnswers(!showAnswers)}
|
|
||||||
>
|
|
||||||
{showAnswers ? '🙈 Скрыть ответы' : '👁 Показать ответы'}
|
|
||||||
</button>
|
|
||||||
{showAnswers && (
|
|
||||||
<div className="pack-question-answers">
|
|
||||||
{viewingQuestion.answers?.map((answer, idx) => (
|
|
||||||
<div key={idx} className="pack-answer-item">
|
|
||||||
<span className="pack-answer-text">{answer.text}</span>
|
|
||||||
<span className="pack-answer-points">{answer.points} очков</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="questions-modal-form">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={questionText}
|
|
||||||
onChange={(e) => setQuestionText(e.target.value)}
|
|
||||||
placeholder="Введите текст вопроса"
|
|
||||||
className="questions-modal-input"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="questions-modal-answers">
|
|
||||||
<div className="questions-modal-answers-header">
|
|
||||||
<span>Ответы:</span>
|
|
||||||
<button
|
|
||||||
className="questions-modal-add-answer-button"
|
|
||||||
onClick={handleAddAnswer}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
+ Добавить ответ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{answers.map((answer, index) => (
|
|
||||||
<div key={index} className="questions-modal-answer-row">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={answer.text}
|
|
||||||
onChange={(e) => handleAnswerChange(index, 'text', e.target.value)}
|
|
||||||
placeholder={`Ответ ${index + 1}`}
|
|
||||||
className="questions-modal-answer-input"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={answer.points}
|
|
||||||
onChange={(e) => handleAnswerChange(index, 'points', e.target.value)}
|
|
||||||
className="questions-modal-points-input"
|
|
||||||
min="0"
|
|
||||||
/>
|
|
||||||
{answers.length > 1 && (
|
|
||||||
<button
|
|
||||||
className="questions-modal-remove-answer-button"
|
|
||||||
onClick={() => handleRemoveAnswer(index)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="questions-modal-form-buttons">
|
|
||||||
<button
|
|
||||||
className="questions-modal-save-button"
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{editingQuestion ? 'Сохранить изменения' : 'Добавить вопрос'}
|
|
||||||
</button>
|
|
||||||
{editingQuestion && (
|
|
||||||
<button
|
|
||||||
className="questions-modal-cancel-button"
|
|
||||||
onClick={handleCancelEdit}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="questions-modal-list">
|
|
||||||
<h3 className="questions-modal-list-title">
|
|
||||||
Вопросы ({questions.length})
|
|
||||||
</h3>
|
|
||||||
{questions.length === 0 ? (
|
|
||||||
<p className="questions-modal-empty">Нет вопросов. Добавьте вопросы для игры.</p>
|
|
||||||
) : (
|
|
||||||
<div className="questions-modal-items">
|
|
||||||
{questions.map((question) => (
|
|
||||||
<div key={question.id} className="questions-modal-item">
|
|
||||||
<div className="questions-modal-item-content">
|
|
||||||
<div className="questions-modal-item-text">{question.text}</div>
|
|
||||||
<div className="questions-modal-item-info">
|
|
||||||
{question.answers.length} ответов
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="questions-modal-item-actions">
|
|
||||||
<button
|
|
||||||
className="questions-modal-edit-button"
|
|
||||||
onClick={() => handleEdit(question)}
|
|
||||||
title="Редактировать"
|
|
||||||
>
|
|
||||||
✏️
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="questions-modal-delete-button"
|
|
||||||
onClick={() => handleDelete(question.id)}
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default QuestionsModal
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
180
src/components/RoleSelectionModal.jsx
Normal file
180
src/components/RoleSelectionModal.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './NameInputModal.css';
|
||||||
|
|
||||||
|
const RoleSelectionModal = ({
|
||||||
|
isOpen,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
allowSpectators = true,
|
||||||
|
title = 'Выберите роль',
|
||||||
|
description = 'Выберите роль для присоединения к комнате'
|
||||||
|
}) => {
|
||||||
|
const [selectedRole, setSelectedRole] = useState('PLAYER');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Сброс формы при открытии модального окна
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setSelectedRole('PLAYER');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedRole) {
|
||||||
|
setError('Выберите роль');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка, разрешены ли зрители
|
||||||
|
if (selectedRole === 'SPECTATOR' && !allowSpectators) {
|
||||||
|
setError('Зрители не разрешены в этой комнате');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
onSubmit(selectedRole);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e) => {
|
||||||
|
if (e.target === e.currentTarget && onCancel) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="name-input-modal-backdrop" onClick={handleBackdropClick}>
|
||||||
|
<div className="name-input-modal-content">
|
||||||
|
<div className="name-input-modal-header">
|
||||||
|
<h2 className="name-input-modal-title">{title}</h2>
|
||||||
|
{onCancel && (
|
||||||
|
<button className="name-input-modal-close" onClick={onCancel}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="name-input-modal-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="name-input-modal-body">
|
||||||
|
<p className="name-input-modal-description">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="name-input-group">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '15px 20px',
|
||||||
|
background: selectedRole === 'PLAYER' ? 'rgba(255, 215, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: `2px solid ${selectedRole === 'PLAYER' ? 'rgba(255, 215, 0, 0.5)' : 'rgba(255, 255, 255, 0.3)'}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedRole !== 'PLAYER') {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedRole !== 'PLAYER') {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="role"
|
||||||
|
value="PLAYER"
|
||||||
|
checked={selectedRole === 'PLAYER'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRole(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: '500' }}>
|
||||||
|
🎮 Игрок
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{allowSpectators && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
padding: '15px 20px',
|
||||||
|
background: selectedRole === 'SPECTATOR' ? 'rgba(255, 215, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: `2px solid ${selectedRole === 'SPECTATOR' ? 'rgba(255, 215, 0, 0.5)' : 'rgba(255, 255, 255, 0.3)'}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (selectedRole !== 'SPECTATOR') {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.15)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (selectedRole !== 'SPECTATOR') {
|
||||||
|
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="role"
|
||||||
|
value="SPECTATOR"
|
||||||
|
checked={selectedRole === 'SPECTATOR'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedRole(e.target.value);
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--text-primary)', fontSize: '1.1rem', fontWeight: '500' }}>
|
||||||
|
👀 Зритель
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="name-input-error">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="name-input-modal-footer">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="name-input-submit-button primary"
|
||||||
|
disabled={!selectedRole}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</button>
|
||||||
|
{onCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="name-input-cancel-button secondary"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoleSelectionModal;
|
||||||
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
|
||||||
const SNOWFLAKE_LIFETIME = 15000 // 15 seconds max lifetime
|
const SNOWFLAKE_LIFETIME = 15000 // 15 seconds max lifetime
|
||||||
const TARGET_COUNT = 30 // Target number of snowflakes
|
const TARGET_COUNT = 30 // Target number of snowflakes
|
||||||
|
|
@ -16,17 +17,50 @@ function createSnowflake(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Snowflakes = () => {
|
const Snowflakes = ({ roomParticlesEnabled = null }) => {
|
||||||
|
const { currentThemeData } = useTheme()
|
||||||
const [snowflakes, setSnowflakes] = useState([])
|
const [snowflakes, setSnowflakes] = useState([])
|
||||||
|
|
||||||
// Initialize snowflakes
|
// Determine if particles should be enabled
|
||||||
|
// Priority: room override (if explicitly set to true/false) > theme setting > default (true)
|
||||||
|
const getParticlesEnabled = () => {
|
||||||
|
// Check room override first (if explicitly set to true or false, not null)
|
||||||
|
if (roomParticlesEnabled === true || roomParticlesEnabled === false) {
|
||||||
|
return roomParticlesEnabled
|
||||||
|
}
|
||||||
|
// If room override is null/undefined, use theme setting if available
|
||||||
|
if (currentThemeData?.settings?.particlesEnabled !== undefined) {
|
||||||
|
return currentThemeData.settings.particlesEnabled
|
||||||
|
}
|
||||||
|
// Default: enabled
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get particle symbol from theme or default
|
||||||
|
const getParticleSymbol = () => {
|
||||||
|
return currentThemeData?.settings?.particleSymbol || '❄'
|
||||||
|
}
|
||||||
|
|
||||||
|
const particlesEnabled = getParticlesEnabled()
|
||||||
|
const particleSymbol = getParticleSymbol()
|
||||||
|
|
||||||
|
// Initialize snowflakes only if particles are enabled
|
||||||
|
// Also re-initialize when theme changes (particleSymbol might change)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!particlesEnabled) {
|
||||||
|
setSnowflakes([])
|
||||||
|
return
|
||||||
|
}
|
||||||
const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i))
|
const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i))
|
||||||
setSnowflakes(initial)
|
setSnowflakes(initial)
|
||||||
}, [])
|
}, [particlesEnabled, particleSymbol, currentThemeData])
|
||||||
|
|
||||||
// Update cycle - remove old snowflakes and add new ones
|
// Update cycle - remove old snowflakes and add new ones
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!particlesEnabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setSnowflakes((prev) => {
|
setSnowflakes((prev) => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
@ -49,7 +83,12 @@ const Snowflakes = () => {
|
||||||
}, UPDATE_INTERVAL)
|
}, UPDATE_INTERVAL)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [particlesEnabled])
|
||||||
|
|
||||||
|
// Don't render if particles are disabled
|
||||||
|
if (!particlesEnabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="snowflakes-container">
|
<div className="snowflakes-container">
|
||||||
|
|
@ -65,7 +104,7 @@ const Snowflakes = () => {
|
||||||
opacity: snowflake.opacity,
|
opacity: snowflake.opacity,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
❄
|
{particleSymbol}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import socketService from '../services/socket';
|
||||||
import './ThemeSwitcher.css';
|
import './ThemeSwitcher.css';
|
||||||
|
|
||||||
const ThemeSwitcher = () => {
|
const ThemeSwitcher = ({ roomId, roomCode, isHost, userId }) => {
|
||||||
const { currentTheme, currentThemeData, themes, changeTheme, loading } = useTheme();
|
const { currentTheme, currentThemeData, themes, changeTheme, loading } = useTheme();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const handleThemeChange = (themeId) => {
|
const handleThemeChange = (themeId) => {
|
||||||
changeTheme(themeId);
|
// Если это комната и пользователь хост, синхронизируем тему через сервер
|
||||||
|
if (roomId && roomCode && isHost && userId) {
|
||||||
|
// Применяем тему локально сразу для мгновенного отклика
|
||||||
|
changeTheme(themeId);
|
||||||
|
// Отправляем событие на сервер для синхронизации со всеми игроками
|
||||||
|
socketService.changeRoomTheme(roomId, roomCode, userId, themeId);
|
||||||
|
} else {
|
||||||
|
// Обычное локальное изменение темы
|
||||||
|
changeTheme(themeId);
|
||||||
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ export const useTheme = () => {
|
||||||
export const ThemeProvider = ({ children }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
const [themes, setThemes] = useState([]);
|
const [themes, setThemes] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pendingThemeId, setPendingThemeId] = useState(null);
|
||||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||||
const saved = localStorage.getItem('app-theme');
|
const saved = localStorage.getItem('app-theme');
|
||||||
return saved || null;
|
return saved || null;
|
||||||
|
|
@ -53,6 +54,18 @@ export const ThemeProvider = ({ children }) => {
|
||||||
loadThemes();
|
loadThemes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Применяем pending theme после загрузки тем
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && themes.length > 0 && pendingThemeId) {
|
||||||
|
if (themes.find((t) => t.id === pendingThemeId)) {
|
||||||
|
setCurrentTheme(pendingThemeId);
|
||||||
|
setPendingThemeId(null);
|
||||||
|
} else {
|
||||||
|
setPendingThemeId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [loading, themes, pendingThemeId]);
|
||||||
|
|
||||||
// Apply theme when currentTheme or themes change
|
// Apply theme when currentTheme or themes change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentTheme || themes.length === 0) return;
|
if (!currentTheme || themes.length === 0) return;
|
||||||
|
|
@ -75,16 +88,35 @@ export const ThemeProvider = ({ children }) => {
|
||||||
// Apply theme settings
|
// Apply theme settings
|
||||||
if (theme.settings) {
|
if (theme.settings) {
|
||||||
Object.entries(theme.settings).forEach(([key, value]) => {
|
Object.entries(theme.settings).forEach(([key, value]) => {
|
||||||
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
// Skip boolean values (like particlesEnabled) - they are handled separately
|
||||||
|
if (typeof value !== 'boolean' && value !== null && value !== undefined) {
|
||||||
|
root.style.setProperty(`--${camelToKebab(key)}`, String(value));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply particle CSS variables
|
||||||
|
if (theme.settings) {
|
||||||
|
const particleColor = theme.settings.particleColor || theme.colors?.textPrimary || '#ffffff';
|
||||||
|
const particleGlow = theme.settings.particleGlow || theme.colors?.textGlow || 'rgba(255, 255, 255, 0.8)';
|
||||||
|
root.style.setProperty('--particle-color', particleColor);
|
||||||
|
root.style.setProperty('--particle-glow', particleGlow);
|
||||||
|
}
|
||||||
|
|
||||||
localStorage.setItem('app-theme', currentTheme);
|
localStorage.setItem('app-theme', currentTheme);
|
||||||
}, [currentTheme, themes]);
|
}, [currentTheme, themes]);
|
||||||
|
|
||||||
const changeTheme = (themeId) => {
|
const changeTheme = (themeId) => {
|
||||||
|
// Если темы еще не загружены, сохраняем themeId для применения после загрузки
|
||||||
|
if (loading || themes.length === 0) {
|
||||||
|
setPendingThemeId(themeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если тема существует в списке, применяем её
|
||||||
if (themes.find((t) => t.id === themeId)) {
|
if (themes.find((t) => t.id === themeId)) {
|
||||||
setCurrentTheme(themeId);
|
setCurrentTheme(themeId);
|
||||||
|
setPendingThemeId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
export const questions = [
|
|
||||||
{
|
|
||||||
id: 18,
|
|
||||||
text: 'Что дед мороз делает летом?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Отдыхает', points: 100 },
|
|
||||||
{ text: 'Готовит подарки', points: 80 },
|
|
||||||
{ text: 'Спит', points: 60 },
|
|
||||||
{ text: 'Путешествует', points: 40 },
|
|
||||||
{ text: 'Загорает', points: 20 },
|
|
||||||
{ text: 'Работает', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 30,
|
|
||||||
text: 'Что намазывают на хлеб?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Масло', points: 100 },
|
|
||||||
{ text: 'Икру', points: 80 },
|
|
||||||
{ text: 'Варенье', points: 60 },
|
|
||||||
{ text: 'Паштет', points: 40 },
|
|
||||||
{ text: 'Майонез', points: 20 },
|
|
||||||
{ text: 'Горчицу', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20,
|
|
||||||
text: 'Кто работает в новый год?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Дед Мороз', points: 100 },
|
|
||||||
{ text: 'Снегурочка', points: 80 },
|
|
||||||
{ text: 'Врач', points: 60 },
|
|
||||||
{ text: 'Полицейский', points: 40 },
|
|
||||||
{ text: 'Таксист', points: 20 },
|
|
||||||
{ text: 'Продавец', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 19,
|
|
||||||
text: 'Почему лошадь не курит?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Боится умереть', points: 100 },
|
|
||||||
{ text: 'Неудобно (копыта мешают)', points: 80 },
|
|
||||||
{ text: 'Не хочет', points: 60 },
|
|
||||||
{ text: 'Не продают', points: 40 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 31,
|
|
||||||
text: 'Какая самая "лошадиная" фамилия?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Овсов', points: 100 },
|
|
||||||
{ text: 'Лошадкин', points: 80 },
|
|
||||||
{ text: 'Конев', points: 60 },
|
|
||||||
{ text: 'Жеребцов', points: 40 },
|
|
||||||
{ text: 'Скакунов', points: 20 },
|
|
||||||
{ text: 'Рысаков', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
text: 'Кто больше всех ест на Новый год?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Миша', points: 100 },
|
|
||||||
{ text: 'Егор', points: 40 },
|
|
||||||
{ text: 'Лера', points: 40 },
|
|
||||||
{ text: 'Бабуля', points: 40 },
|
|
||||||
{ text: 'Вика', points: 40 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 13,
|
|
||||||
text: 'Кто лучше всех говорит тосты?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Миша', points: 100 },
|
|
||||||
{ text: 'Андрей', points: 80 },
|
|
||||||
{ text: 'Егор', points: 60 },
|
|
||||||
{ text: 'Бабуля', points: 40 },
|
|
||||||
{ text: 'Надя', points: 20 },
|
|
||||||
{ text: 'ИИ', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 14,
|
|
||||||
text: 'Что любят лошади?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Яблоки', points: 100 },
|
|
||||||
{ text: 'Морковь', points: 80 },
|
|
||||||
{ text: 'Свежую траву', points: 60 },
|
|
||||||
{ text: 'Сахар', points: 40 },
|
|
||||||
{ text: 'Когда их гладят', points: 20 },
|
|
||||||
{ text: 'Овёс', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 21,
|
|
||||||
text: 'Что может быть вязаным?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Шарф', points: 100 },
|
|
||||||
{ text: 'Свитер', points: 80 },
|
|
||||||
{ text: 'Носки', points: 60 },
|
|
||||||
{ text: 'Шапка', points: 40 },
|
|
||||||
{ text: 'Варежки', points: 20 },
|
|
||||||
{ text: 'Жилет', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 25,
|
|
||||||
text: 'Что бы вы хотели выиграть в лотерею?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Деньги', points: 100 },
|
|
||||||
{ text: 'Машину', points: 80 },
|
|
||||||
{ text: 'Квартиру', points: 60 },
|
|
||||||
{ text: 'Путешествие', points: 40 },
|
|
||||||
{ text: 'Технику', points: 20 },
|
|
||||||
{ text: 'Дом', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 8,
|
|
||||||
text: 'Кто дольше всех собирается за стол?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Надя', points: 100 },
|
|
||||||
{ text: 'Вика', points: 40 },
|
|
||||||
{ text: 'Лера', points: 40 },
|
|
||||||
{ text: 'Бабуля', points: 40 },
|
|
||||||
{ text: 'Катя', points: 40 },
|
|
||||||
{ text: 'Миша', points: 40 },
|
|
||||||
{ text: 'Андрей', points: 40 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 24,
|
|
||||||
text: 'Где мы встретим следующий новый год?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Тут', points: 50 },
|
|
||||||
{ text: 'Не тут', points: 50 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 29,
|
|
||||||
text: 'Кому очень холодно зимой?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Елочке', points: 100 },
|
|
||||||
{ text: 'Людям', points: 80 },
|
|
||||||
{ text: 'Птицам', points: 60 },
|
|
||||||
{ text: 'Собаке', points: 40 },
|
|
||||||
{ text: 'Зайцу', points: 20 },
|
|
||||||
{ text: 'Деду Морозу', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
text: 'Что обычно остаётся на утро после праздника?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Посуда', points: 100 },
|
|
||||||
{ text: 'Остатки еды', points: 80 },
|
|
||||||
{ text: 'Усталость', points: 60 },
|
|
||||||
{ text: 'Хлопушки', points: 40 },
|
|
||||||
{ text: 'Мишура', points: 20 },
|
|
||||||
{ text: 'Украшения', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 16,
|
|
||||||
text: 'Зачем деду морозу посох?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Для опоры', points: 100 },
|
|
||||||
{ text: 'Для волшебства', points: 80 },
|
|
||||||
{ text: 'Для красоты', points: 60 },
|
|
||||||
{ text: 'Для заморозки', points: 40 },
|
|
||||||
{ text: 'По традиции', points: 20 },
|
|
||||||
{ text: 'Для защиты', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
text: 'Что обещают себе с 1 января?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Похудеть', points: 100 },
|
|
||||||
{ text: 'Раньше ложиться спать', points: 80 },
|
|
||||||
{ text: 'Начать заниматься спортом', points: 60 },
|
|
||||||
{ text: 'Больше зарабатывать', points: 40 },
|
|
||||||
{ text: 'Выучить язык', points: 20 },
|
|
||||||
{ text: 'Больше читать', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
text: 'Что чаще всего забывают купить перед Новым годом?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Шампанское', points: 100 },
|
|
||||||
{ text: 'Майонез', points: 80 },
|
|
||||||
{ text: 'Мандарины', points: 60 },
|
|
||||||
{ text: 'Петарды', points: 40 },
|
|
||||||
{ text: 'Салфетки', points: 20 },
|
|
||||||
{ text: 'Свечи', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 333,
|
|
||||||
text: 'Что важнее всего в новогоднюю ночь?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Семя', points: 100 },
|
|
||||||
{ text: 'Компания', points: 80 },
|
|
||||||
{ text: 'Оливье', points: 60 },
|
|
||||||
{ text: 'Доесть еду', points: 40 },
|
|
||||||
{ text: 'Миша', points: 20 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
text: 'Чем обычно заканчивается новогодняя ночь?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Сном', points: 100 },
|
|
||||||
{ text: 'Тостом', points: 80 },
|
|
||||||
{ text: 'Фейерверком', points: 60 },
|
|
||||||
{ text: 'Песнями', points: 40 },
|
|
||||||
{ text: 'Танцами', points: 20 },
|
|
||||||
{ text: 'Играми', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 15,
|
|
||||||
text: 'С чем у людей чаще всего ассоциируется лошадь?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Скачки / бег', points: 100 },
|
|
||||||
{ text: 'Свобода', points: 80 },
|
|
||||||
{ text: 'Сила', points: 60 },
|
|
||||||
{ text: 'Деревня / поле', points: 40 },
|
|
||||||
{ text: 'Ковбои', points: 20 },
|
|
||||||
{ text: 'Красота', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
text: 'Что нужно иметь покорителю Северного полюса?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Компас', points: 100 },
|
|
||||||
{ text: 'Лыжи', points: 80 },
|
|
||||||
{ text: 'Теплую одежду', points: 60 },
|
|
||||||
{ text: 'Еду', points: 40 },
|
|
||||||
{ text: 'Флаг', points: 20 },
|
|
||||||
{ text: 'Обувь', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 17,
|
|
||||||
text: 'Где дед мороз берет подарки?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'В мастерской', points: 100 },
|
|
||||||
{ text: 'Покупает', points: 80 },
|
|
||||||
{ text: 'Делает сам', points: 60 },
|
|
||||||
{ text: 'В магазине', points: 40 },
|
|
||||||
{ text: 'У эльфов', points: 20 },
|
|
||||||
{ text: 'Волшебством', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 26,
|
|
||||||
text: 'Во что упаковывают подарок?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'В коробку', points: 100 },
|
|
||||||
{ text: 'В пакет', points: 80 },
|
|
||||||
{ text: 'В бумагу', points: 60 },
|
|
||||||
{ text: 'В фольгу', points: 40 },
|
|
||||||
{ text: 'В упаковку', points: 20 },
|
|
||||||
{ text: 'В газету', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
text: 'Что чаще всего стоит на новогоднем столе?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Оливье', points: 100 },
|
|
||||||
{ text: 'Шампанское', points: 80 },
|
|
||||||
{ text: 'Мандарины', points: 60 },
|
|
||||||
{ text: 'Селедка под шубой', points: 40 },
|
|
||||||
{ text: 'Икра', points: 20 },
|
|
||||||
{ text: 'Торт', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
text: 'Самая популярная новогодняя традиция',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Загадывать желание', points: 100 },
|
|
||||||
{ text: 'Смотреть "Иронию судьбы"', points: 80 },
|
|
||||||
{ text: 'Дарить подарки', points: 60 },
|
|
||||||
{ text: 'Наряжать ёлку', points: 40 },
|
|
||||||
{ text: 'Запускать фейерверки', points: 20 },
|
|
||||||
{ text: 'Встречать с семьёй', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 23,
|
|
||||||
text: 'Кто живёт в Антарктиде?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Пингвины', points: 100 },
|
|
||||||
{ text: 'Медведи', points: 80 },
|
|
||||||
{ text: 'Полярники', points: 60 },
|
|
||||||
{ text: 'Моржи', points: 40 },
|
|
||||||
{ text: 'Тюлени', points: 20 },
|
|
||||||
{ text: 'Морские котики', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 28,
|
|
||||||
text: 'Какое слово очень холодное?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Снег', points: 100 },
|
|
||||||
{ text: 'Мороз', points: 80 },
|
|
||||||
{ text: 'Лед', points: 60 },
|
|
||||||
{ text: 'Зима', points: 40 },
|
|
||||||
{ text: 'Мороженое', points: 20 },
|
|
||||||
{ text: 'Холод', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 7,
|
|
||||||
text: 'Что чаще всего дарят взрослым на Новый год?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Деньги', points: 100 },
|
|
||||||
{ text: 'Парфюм', points: 80 },
|
|
||||||
{ text: 'Книги', points: 60 },
|
|
||||||
{ text: 'Одежду', points: 40 },
|
|
||||||
{ text: 'Сладости', points: 20 },
|
|
||||||
{ text: 'Цветы', points: 10 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 27,
|
|
||||||
text: 'Кто стучится в дверь ко мне?',
|
|
||||||
answers: [
|
|
||||||
{ text: 'Почтальон', points: 100 },
|
|
||||||
{ text: 'Сосед', points: 80 },
|
|
||||||
{ text: 'Гость', points: 60 },
|
|
||||||
{ text: 'Друг', points: 40 },
|
|
||||||
{ text: 'Дед мороз', points: 20 },
|
|
||||||
{ text: 'Полиция', points: 10 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
@ -81,14 +81,24 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Обработчик обновления вопросов комнаты
|
||||||
|
const handleRoomPackUpdated = (updatedRoom) => {
|
||||||
|
setRoom(updatedRoom);
|
||||||
|
if (updatedRoom.participants) {
|
||||||
|
setParticipants(updatedRoom.participants);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
socketService.on('roomUpdate', handleRoomUpdate);
|
socketService.on('roomUpdate', handleRoomUpdate);
|
||||||
socketService.on('gameStarted', handleGameStarted);
|
socketService.on('gameStarted', handleGameStarted);
|
||||||
socketService.on('gameStateUpdated', handleGameStateUpdated);
|
socketService.on('gameStateUpdated', handleGameStateUpdated);
|
||||||
|
socketService.on('roomPackUpdated', handleRoomPackUpdated);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socketService.off('roomUpdate', handleRoomUpdate);
|
socketService.off('roomUpdate', handleRoomUpdate);
|
||||||
socketService.off('gameStarted', handleGameStarted);
|
socketService.off('gameStarted', handleGameStarted);
|
||||||
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
||||||
|
socketService.off('roomPackUpdated', handleRoomPackUpdated);
|
||||||
};
|
};
|
||||||
}, [roomCode, password, onGameStarted, user?.id]);
|
}, [roomCode, password, onGameStarted, user?.id]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,10 +125,10 @@ body {
|
||||||
.snowflake {
|
.snowflake {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -20px;
|
top: -20px;
|
||||||
color: var(--text-primary);
|
color: var(--particle-color, var(--text-primary, #ffffff));
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-family: Arial;
|
font-family: Arial;
|
||||||
text-shadow: 0 0 5px rgba(255, 255, 255, 0.8);
|
text-shadow: 0 0 5px var(--particle-glow, rgba(255, 255, 255, 0.8));
|
||||||
animation: snow linear forwards;
|
animation: snow linear forwards;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
will-change: transform, opacity;
|
will-change: transform, opacity;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useRoom } from '../hooks/useRoom';
|
import { useRoom } from '../hooks/useRoom';
|
||||||
import { questionsApi } from '../services/api';
|
|
||||||
import NameInputModal from '../components/NameInputModal';
|
import NameInputModal from '../components/NameInputModal';
|
||||||
|
|
||||||
const CreateRoom = () => {
|
const CreateRoom = () => {
|
||||||
|
|
@ -10,8 +9,6 @@ const CreateRoom = () => {
|
||||||
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
const { user, loginAnonymous, loading: authLoading } = useAuth();
|
||||||
const { createRoom, loading: roomLoading } = useRoom();
|
const { createRoom, loading: roomLoading } = useRoom();
|
||||||
|
|
||||||
const [questionPacks, setQuestionPacks] = useState([]);
|
|
||||||
const [selectedPackId, setSelectedPackId] = useState('');
|
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
maxPlayers: 10,
|
maxPlayers: 10,
|
||||||
allowSpectators: true,
|
allowSpectators: true,
|
||||||
|
|
@ -19,7 +16,6 @@ const CreateRoom = () => {
|
||||||
timerDuration: 30,
|
timerDuration: 30,
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
||||||
const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false);
|
const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -43,25 +39,6 @@ const CreateRoom = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPacks = async () => {
|
|
||||||
try {
|
|
||||||
const response = await questionsApi.getPacks(user?.id);
|
|
||||||
setQuestionPacks(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching question packs:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
fetchPacks();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const handleCreateRoom = async () => {
|
const handleCreateRoom = async () => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setIsNameModalOpen(true);
|
setIsNameModalOpen(true);
|
||||||
|
|
@ -86,7 +63,7 @@ const CreateRoom = () => {
|
||||||
|
|
||||||
const room = await createRoom(
|
const room = await createRoom(
|
||||||
user.id,
|
user.id,
|
||||||
selectedPackId || undefined,
|
undefined,
|
||||||
cleanSettings,
|
cleanSettings,
|
||||||
name.trim(),
|
name.trim(),
|
||||||
);
|
);
|
||||||
|
|
@ -97,30 +74,11 @@ const CreateRoom = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <div className="loading">Загрузка...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-room-page">
|
<div className="create-room-page">
|
||||||
<div className="create-room-container">
|
<div className="create-room-container">
|
||||||
<h1>Создать комнату</h1>
|
<h1>Создать комнату</h1>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>Выберите пак вопросов (можно добавить позже):</label>
|
|
||||||
<select
|
|
||||||
value={selectedPackId}
|
|
||||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Без пака вопросов</option>
|
|
||||||
{questionPacks.map((pack) => (
|
|
||||||
<option key={pack.id} value={pack.id}>
|
|
||||||
{pack.name} ({pack.questionCount} вопросов)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Максимум игроков:</label>
|
<label>Максимум игроков:</label>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { questionsApi, roomsApi } from '../services/api';
|
import { questionsApi, roomsApi } from '../services/api';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import socketService from '../services/socket';
|
import socketService from '../services/socket';
|
||||||
|
|
@ -9,12 +10,14 @@ import QRModal from '../components/QRModal';
|
||||||
import GameManagementModal from '../components/GameManagementModal';
|
import GameManagementModal from '../components/GameManagementModal';
|
||||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||||
import VoiceSettings from '../components/VoiceSettings';
|
import VoiceSettings from '../components/VoiceSettings';
|
||||||
|
import Snowflakes from '../components/Snowflakes';
|
||||||
import './GamePage.css';
|
import './GamePage.css';
|
||||||
|
|
||||||
const GamePage = () => {
|
const GamePage = () => {
|
||||||
const { roomCode } = useParams();
|
const { roomCode } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { changeTheme } = useTheme();
|
||||||
|
|
||||||
// ВСЁ состояние игры в одном объекте
|
// ВСЁ состояние игры в одном объекте
|
||||||
const [gameState, setGameState] = useState({
|
const [gameState, setGameState] = useState({
|
||||||
|
|
@ -27,6 +30,8 @@ const GamePage = () => {
|
||||||
questions: [],
|
questions: [],
|
||||||
hostId: null,
|
hostId: null,
|
||||||
roomCode: null,
|
roomCode: null,
|
||||||
|
themeId: null,
|
||||||
|
particlesEnabled: null, // null = использовать настройку из темы, true/false = override
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -37,6 +42,8 @@ const GamePage = () => {
|
||||||
|
|
||||||
// Храним participantId текущего пользователя для проверки удаления
|
// Храним participantId текущего пользователя для проверки удаления
|
||||||
const currentUserParticipantIdRef = useRef(null);
|
const currentUserParticipantIdRef = useRef(null);
|
||||||
|
// Храним предыдущий themeId комнаты для отслеживания изменений
|
||||||
|
const previousThemeIdRef = useRef(null);
|
||||||
|
|
||||||
// ЕДИНСТВЕННЫЙ обработчик состояния игры
|
// ЕДИНСТВЕННЫЙ обработчик состояния игры
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -54,6 +61,15 @@ const GamePage = () => {
|
||||||
);
|
);
|
||||||
currentUserParticipantIdRef.current = currentUserParticipant?.id || null;
|
currentUserParticipantIdRef.current = currentUserParticipant?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Применяем тему комнаты, если она изменилась
|
||||||
|
const currentThemeId = state.themeId || null;
|
||||||
|
if (currentThemeId !== previousThemeIdRef.current) {
|
||||||
|
previousThemeIdRef.current = currentThemeId;
|
||||||
|
if (currentThemeId) {
|
||||||
|
changeTheme(currentThemeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socketService.connect();
|
socketService.connect();
|
||||||
|
|
@ -63,7 +79,7 @@ const GamePage = () => {
|
||||||
return () => {
|
return () => {
|
||||||
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
||||||
};
|
};
|
||||||
}, [roomCode, user?.id]);
|
}, [roomCode, user?.id, changeTheme]);
|
||||||
|
|
||||||
// Обработка события удаления игрока
|
// Обработка события удаления игрока
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -107,7 +123,13 @@ const GamePage = () => {
|
||||||
// Загрузка доступных паков для хоста
|
// Загрузка доступных паков для хоста
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPacks = async () => {
|
const fetchPacks = async () => {
|
||||||
if (user && gameState.hostId === user.id) {
|
// Проверяем роль участника для поддержки нескольких хостов
|
||||||
|
const currentUserParticipant = user
|
||||||
|
? gameState.participants.find(p => p.userId === user.id)
|
||||||
|
: null;
|
||||||
|
const isHost = currentUserParticipant?.role === 'HOST';
|
||||||
|
|
||||||
|
if (user && isHost) {
|
||||||
try {
|
try {
|
||||||
const response = await questionsApi.getPacks(user.id);
|
const response = await questionsApi.getPacks(user.id);
|
||||||
setQuestionPacks(response.data);
|
setQuestionPacks(response.data);
|
||||||
|
|
@ -118,7 +140,7 @@ const GamePage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}, [user, gameState.hostId]);
|
}, [user, gameState.participants]);
|
||||||
|
|
||||||
// Генерация QR кода
|
// Генерация QR кода
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -147,11 +169,17 @@ const GamePage = () => {
|
||||||
// === Handlers для действий игрока ===
|
// === Handlers для действий игрока ===
|
||||||
|
|
||||||
const handleAnswerClick = (answerId, points) => {
|
const handleAnswerClick = (answerId, points) => {
|
||||||
if (!gameState.roomId || !user) return;
|
if (!gameState.roomId || !user || !canPerformActions) return;
|
||||||
|
|
||||||
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
||||||
if (!myParticipant) return;
|
if (!myParticipant) return;
|
||||||
|
|
||||||
|
// Зрители не могут отвечать на вопросы
|
||||||
|
if (isSpectator) {
|
||||||
|
alert('Зрители не могут отвечать на вопросы');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Проверка очереди (только для не-хостов)
|
// Проверка очереди (только для не-хостов)
|
||||||
if (!isHost && gameState.currentPlayerId !== myParticipant.id) {
|
if (!isHost && gameState.currentPlayerId !== myParticipant.id) {
|
||||||
alert('Сейчас не ваша очередь!');
|
alert('Сейчас не ваша очередь!');
|
||||||
|
|
@ -171,11 +199,14 @@ const GamePage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextQuestion = () => {
|
const handleNextQuestion = () => {
|
||||||
if (!gameState.roomId || !user) return;
|
if (!gameState.roomId || !user || !canPerformActions) return;
|
||||||
|
|
||||||
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
||||||
if (!myParticipant) return;
|
if (!myParticipant) return;
|
||||||
|
|
||||||
|
// Зрители не могут переключать вопросы
|
||||||
|
if (isSpectator) return;
|
||||||
|
|
||||||
socketService.emit('playerAction', {
|
socketService.emit('playerAction', {
|
||||||
action: 'nextQuestion',
|
action: 'nextQuestion',
|
||||||
roomId: gameState.roomId,
|
roomId: gameState.roomId,
|
||||||
|
|
@ -186,11 +217,14 @@ const GamePage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevQuestion = () => {
|
const handlePrevQuestion = () => {
|
||||||
if (!gameState.roomId || !user) return;
|
if (!gameState.roomId || !user || !canPerformActions) return;
|
||||||
|
|
||||||
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
const myParticipant = gameState.participants.find(p => p.userId === user.id);
|
||||||
if (!myParticipant) return;
|
if (!myParticipant) return;
|
||||||
|
|
||||||
|
// Зрители не могут переключать вопросы
|
||||||
|
if (isSpectator) return;
|
||||||
|
|
||||||
socketService.emit('playerAction', {
|
socketService.emit('playerAction', {
|
||||||
action: 'prevQuestion',
|
action: 'prevQuestion',
|
||||||
roomId: gameState.roomId,
|
roomId: gameState.roomId,
|
||||||
|
|
@ -318,6 +352,17 @@ const GamePage = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeParticipantRole = (participantId, newRole) => {
|
||||||
|
if (!gameState.roomId || !user) return;
|
||||||
|
socketService.changeParticipantRole(
|
||||||
|
gameState.roomId,
|
||||||
|
gameState.roomCode,
|
||||||
|
user.id,
|
||||||
|
participantId,
|
||||||
|
newRole
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectPlayer = (participantId) => {
|
const handleSelectPlayer = (participantId) => {
|
||||||
if (!gameState.roomId || !user) return;
|
if (!gameState.roomId || !user) return;
|
||||||
if (!isHost) return; // Только хост может выбирать игрока
|
if (!isHost) return; // Только хост может выбирать игрока
|
||||||
|
|
@ -360,9 +405,21 @@ const GamePage = () => {
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
const isHost = user && gameState.hostId === user.id;
|
// Определяем роль текущего пользователя
|
||||||
|
const currentUserParticipant = user
|
||||||
|
? gameState.participants.find(p => p.userId === user.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const userRole = currentUserParticipant?.role || null;
|
||||||
|
const isHost = userRole === 'HOST'; // Проверяем роль участника для поддержки нескольких хостов
|
||||||
|
const isSpectator = userRole === 'SPECTATOR';
|
||||||
|
const isPlayer = userRole === 'PLAYER';
|
||||||
|
|
||||||
const canGoPrev = currentQuestionIndex > 0;
|
const canGoPrev = currentQuestionIndex > 0;
|
||||||
const canGoNext = currentQuestionIndex < gameState.questions.length - 1;
|
const canGoNext = currentQuestionIndex < gameState.questions.length - 1;
|
||||||
|
|
||||||
|
// Зрители не могут выполнять действия игрока
|
||||||
|
const canPerformActions = isHost || isPlayer;
|
||||||
|
|
||||||
// === Render ===
|
// === Render ===
|
||||||
|
|
||||||
|
|
@ -381,11 +438,19 @@ const GamePage = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-page">
|
<div className="game-page">
|
||||||
|
{/* Particles with room override */}
|
||||||
|
<Snowflakes roomParticlesEnabled={gameState.particlesEnabled} />
|
||||||
|
|
||||||
{/* Control bar - только для хоста */}
|
{/* Control bar - только для хоста */}
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="game-control-bar">
|
<div className="game-control-bar">
|
||||||
<div className="game-control-left">
|
<div className="game-control-left">
|
||||||
<ThemeSwitcher />
|
<ThemeSwitcher
|
||||||
|
roomId={gameState.roomId}
|
||||||
|
roomCode={gameState.roomCode}
|
||||||
|
isHost={isHost}
|
||||||
|
userId={user?.id}
|
||||||
|
/>
|
||||||
<VoiceSettings />
|
<VoiceSettings />
|
||||||
<button
|
<button
|
||||||
className="control-button control-button-qr"
|
className="control-button control-button-qr"
|
||||||
|
|
@ -425,6 +490,22 @@ const GamePage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isSpectator && (
|
||||||
|
<div className="spectator-info" style={{
|
||||||
|
padding: '20px',
|
||||||
|
margin: '20px',
|
||||||
|
background: 'rgba(255, 215, 0, 0.1)',
|
||||||
|
border: '2px solid rgba(255, 215, 0, 0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'var(--text-primary)'
|
||||||
|
}}>
|
||||||
|
<p style={{ fontSize: '1.1rem', margin: 0 }}>
|
||||||
|
👀 Вы в роли зрителя. Вы можете наблюдать за игрой, но не можете отвечать на вопросы.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Game
|
<Game
|
||||||
currentQuestion={currentQuestion}
|
currentQuestion={currentQuestion}
|
||||||
roomParticipants={gameState.participants}
|
roomParticipants={gameState.participants}
|
||||||
|
|
@ -432,7 +513,7 @@ const GamePage = () => {
|
||||||
revealedAnswers={revealedForCurrentQ}
|
revealedAnswers={revealedForCurrentQ}
|
||||||
playerScores={playerScores}
|
playerScores={playerScores}
|
||||||
currentPlayerId={gameState.currentPlayerId}
|
currentPlayerId={gameState.currentPlayerId}
|
||||||
onAnswerClick={handleAnswerClick}
|
onAnswerClick={canPerformActions ? handleAnswerClick : null}
|
||||||
onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
|
onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
|
||||||
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
||||||
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
||||||
|
|
@ -479,6 +560,9 @@ const GamePage = () => {
|
||||||
onUpdatePlayerName={handleUpdatePlayerName}
|
onUpdatePlayerName={handleUpdatePlayerName}
|
||||||
onUpdatePlayerScore={handleUpdatePlayerScore}
|
onUpdatePlayerScore={handleUpdatePlayerScore}
|
||||||
onKickPlayer={handleKickPlayer}
|
onKickPlayer={handleKickPlayer}
|
||||||
|
onChangeParticipantRole={handleChangeParticipantRole}
|
||||||
|
particlesEnabled={gameState.particlesEnabled}
|
||||||
|
onToggleParticles={handleToggleParticles}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@ const Home = () => {
|
||||||
navigate('/join-room');
|
navigate('/join-room');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLocalGame = () => {
|
|
||||||
navigate('/local-game');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home-page">
|
<div className="home-page">
|
||||||
<div className="home-theme-switcher-wrapper">
|
<div className="home-theme-switcher-wrapper">
|
||||||
|
|
@ -52,10 +48,6 @@ const Home = () => {
|
||||||
<button onClick={handleJoinRoom} className="menu-button">
|
<button onClick={handleJoinRoom} className="menu-button">
|
||||||
Присоединиться к комнате
|
Присоединиться к комнате
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button onClick={handleLocalGame} className="menu-button">
|
|
||||||
Локальная игра
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import LocalGameApp from '../components/LocalGameApp';
|
|
||||||
|
|
||||||
const LocalGame = () => {
|
|
||||||
return <LocalGameApp />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LocalGame;
|
|
||||||
|
|
@ -4,9 +4,12 @@ import { useAuth } from '../context/AuthContext';
|
||||||
import { useRoom } from '../hooks/useRoom';
|
import { useRoom } from '../hooks/useRoom';
|
||||||
import { questionsApi } from '../services/api';
|
import { questionsApi } from '../services/api';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
import socketService from '../services/socket';
|
||||||
import QRModal from '../components/QRModal';
|
import QRModal from '../components/QRModal';
|
||||||
import NameInputModal from '../components/NameInputModal';
|
import NameInputModal from '../components/NameInputModal';
|
||||||
import PasswordModal from '../components/PasswordModal';
|
import PasswordModal from '../components/PasswordModal';
|
||||||
|
import RoleSelectionModal from '../components/RoleSelectionModal';
|
||||||
|
import GameManagementModal from '../components/GameManagementModal';
|
||||||
|
|
||||||
const RoomPage = () => {
|
const RoomPage = () => {
|
||||||
const { roomCode } = useParams();
|
const { roomCode } = useParams();
|
||||||
|
|
@ -28,18 +31,18 @@ const RoomPage = () => {
|
||||||
fetchRoomWithPassword,
|
fetchRoomWithPassword,
|
||||||
joinRoom,
|
joinRoom,
|
||||||
startGame,
|
startGame,
|
||||||
updateQuestionPack,
|
|
||||||
} = useRoom(roomCode, handleGameStartedEvent, password);
|
} = useRoom(roomCode, handleGameStartedEvent, password);
|
||||||
const [qrCode, setQrCode] = useState('');
|
const [qrCode, setQrCode] = useState('');
|
||||||
const [joined, setJoined] = useState(false);
|
const [joined, setJoined] = useState(false);
|
||||||
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
||||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
||||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||||
|
const [isRoleSelectionModalOpen, setIsRoleSelectionModalOpen] = useState(false);
|
||||||
|
const [isQuestionsModalOpen, setIsQuestionsModalOpen] = useState(false);
|
||||||
const [passwordError, setPasswordError] = useState(null);
|
const [passwordError, setPasswordError] = useState(null);
|
||||||
|
const [joinError, setJoinError] = useState(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState('PLAYER');
|
||||||
const [questionPacks, setQuestionPacks] = useState([]);
|
const [questionPacks, setQuestionPacks] = useState([]);
|
||||||
const [selectedPackId, setSelectedPackId] = useState('');
|
|
||||||
const [loadingPacks, setLoadingPacks] = useState(false);
|
|
||||||
const [updatingPack, setUpdatingPack] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generateQR = async () => {
|
const generateQR = async () => {
|
||||||
|
|
@ -116,16 +119,38 @@ const RoomPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Показываем модальное окно выбора роли, если allowSpectators === true и пользователь авторизован
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
room &&
|
||||||
|
user &&
|
||||||
|
!joined &&
|
||||||
|
!isRoleSelectionModalOpen &&
|
||||||
|
room.allowSpectators &&
|
||||||
|
!participants.some((p) => p.userId === user.id)
|
||||||
|
) {
|
||||||
|
setIsRoleSelectionModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [room, user, joined, participants, isRoleSelectionModalOpen]);
|
||||||
|
|
||||||
|
// Автоматическое присоединение как PLAYER, если зрители не разрешены
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleJoin = async () => {
|
const handleJoin = async () => {
|
||||||
if (room && user && !joined) {
|
if (room && user && !joined && !isRoleSelectionModalOpen) {
|
||||||
const isParticipant = participants.some((p) => p.userId === user.id);
|
const isParticipant = participants.some((p) => p.userId === user.id);
|
||||||
if (!isParticipant) {
|
if (!isParticipant) {
|
||||||
try {
|
// Если зрители не разрешены, присоединяемся как PLAYER автоматически
|
||||||
await joinRoom(room.id, user.id, user.name || 'Гость', 'PLAYER');
|
if (!room.allowSpectators) {
|
||||||
setJoined(true);
|
try {
|
||||||
} catch (error) {
|
setJoinError(null);
|
||||||
console.error('Join error:', error);
|
await joinRoom(room.id, user.id, user.name || 'Гость', 'PLAYER');
|
||||||
|
setJoined(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Join error:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Ошибка при присоединении к комнате';
|
||||||
|
setJoinError(errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setJoined(true);
|
setJoined(true);
|
||||||
|
|
@ -134,35 +159,50 @@ const RoomPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
handleJoin();
|
handleJoin();
|
||||||
}, [room, user, participants, joined, joinRoom]);
|
}, [room, user, participants, joined, joinRoom, isRoleSelectionModalOpen]);
|
||||||
|
|
||||||
|
// Обработка выбора роли
|
||||||
|
const handleRoleSubmit = async (role) => {
|
||||||
|
if (!room || !user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setJoinError(null);
|
||||||
|
setIsRoleSelectionModalOpen(false);
|
||||||
|
await joinRoom(room.id, user.id, user.name || 'Гость', role);
|
||||||
|
setSelectedRole(role);
|
||||||
|
setJoined(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Join error:', error);
|
||||||
|
const errorMessage = error.response?.data?.message || error.message || 'Ошибка при присоединении к комнате';
|
||||||
|
setJoinError(errorMessage);
|
||||||
|
alert(errorMessage);
|
||||||
|
// Открываем модальное окно снова при ошибке
|
||||||
|
setIsRoleSelectionModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPacks = async () => {
|
const fetchPacks = async () => {
|
||||||
if (user) {
|
if (user) {
|
||||||
try {
|
try {
|
||||||
setLoadingPacks(true);
|
|
||||||
const response = await questionsApi.getPacks(user.id);
|
const response = await questionsApi.getPacks(user.id);
|
||||||
setQuestionPacks(response.data);
|
setQuestionPacks(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching question packs:', error);
|
console.error('Error fetching question packs:', error);
|
||||||
} finally {
|
|
||||||
setLoadingPacks(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (room && user && room.hostId === user.id) {
|
// Проверяем роль участника для поддержки нескольких хостов
|
||||||
|
const currentUserParticipant = user && participants
|
||||||
|
? participants.find(p => p.userId === user.id)
|
||||||
|
: null;
|
||||||
|
const isHost = currentUserParticipant?.role === 'HOST';
|
||||||
|
|
||||||
|
if (room && user && isHost) {
|
||||||
fetchPacks();
|
fetchPacks();
|
||||||
}
|
}
|
||||||
}, [room, user]);
|
}, [room, user, participants]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (room && room.questionPackId) {
|
|
||||||
setSelectedPackId(room.questionPackId);
|
|
||||||
} else {
|
|
||||||
setSelectedPackId('');
|
|
||||||
}
|
|
||||||
}, [room]);
|
|
||||||
|
|
||||||
// Автоматически перенаправляем на страницу игры, если игра уже началась
|
// Автоматически перенаправляем на страницу игры, если игра уже началась
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -176,23 +216,61 @@ const RoomPage = () => {
|
||||||
navigate(`/game/${roomCode}`);
|
navigate(`/game/${roomCode}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateQuestionPack = async () => {
|
// Получаем вопросы из roomPack (может быть JSON строкой или массивом)
|
||||||
if (!selectedPackId) {
|
const getRoomQuestions = () => {
|
||||||
alert('Выберите пак вопросов');
|
if (!room?.roomPack?.questions) return [];
|
||||||
return;
|
const questions = room.roomPack.questions;
|
||||||
}
|
if (typeof questions === 'string') {
|
||||||
|
try {
|
||||||
try {
|
return JSON.parse(questions);
|
||||||
setUpdatingPack(true);
|
} catch {
|
||||||
await updateQuestionPack(selectedPackId);
|
return [];
|
||||||
alert('Пак вопросов успешно добавлен');
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating question pack:', error);
|
|
||||||
alert('Ошибка при обновлении пака вопросов');
|
|
||||||
} finally {
|
|
||||||
setUpdatingPack(false);
|
|
||||||
}
|
}
|
||||||
|
return Array.isArray(questions) ? questions : [];
|
||||||
};
|
};
|
||||||
|
const roomQuestions = getRoomQuestions();
|
||||||
|
|
||||||
|
// Обновление вопросов через WebSocket
|
||||||
|
const handleUpdateRoomQuestions = useCallback(
|
||||||
|
async (newQuestions) => {
|
||||||
|
if (!room?.id || !user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
socketService.updateRoomPack(
|
||||||
|
room.id,
|
||||||
|
room.code,
|
||||||
|
user.id,
|
||||||
|
newQuestions
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating room questions:', error);
|
||||||
|
alert('Ошибка при сохранении вопросов');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Изменение роли участника через WebSocket
|
||||||
|
const handleChangeParticipantRole = useCallback(
|
||||||
|
(participantId, newRole) => {
|
||||||
|
if (!room?.id || !user) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
socketService.changeParticipantRole(
|
||||||
|
room.id,
|
||||||
|
room.code,
|
||||||
|
user.id,
|
||||||
|
participantId,
|
||||||
|
newRole
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error changing participant role:', error);
|
||||||
|
alert('Ошибка при изменении роли участника');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[room, user]
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading">Загрузка комнаты...</div>;
|
return <div className="loading">Загрузка комнаты...</div>;
|
||||||
|
|
@ -218,7 +296,11 @@ const RoomPage = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHost = user && room && room.hostId === user.id;
|
// Проверяем роль участника для поддержки нескольких хостов
|
||||||
|
const currentUserParticipant = user && participants
|
||||||
|
? participants.find(p => p.userId === user.id)
|
||||||
|
: null;
|
||||||
|
const isHost = currentUserParticipant?.role === 'HOST';
|
||||||
|
|
||||||
// Если требуется пароль, показываем только модальное окно
|
// Если требуется пароль, показываем только модальное окно
|
||||||
if (requiresPassword && !room) {
|
if (requiresPassword && !room) {
|
||||||
|
|
@ -258,55 +340,25 @@ const RoomPage = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="question-pack-section">
|
<div className="question-pack-section">
|
||||||
<h3>Пак вопросов:</h3>
|
<h3>Вопросы:</h3>
|
||||||
{room.questionPack ? (
|
<div className="pack-info">
|
||||||
<div className="pack-info">
|
<p>
|
||||||
<p>
|
Вопросов в комнате: <strong>{roomQuestions.length}</strong>
|
||||||
<strong>{room.questionPack.name}</strong> (
|
</p>
|
||||||
{room.questionPack.questionCount || 0} вопросов)
|
{isHost && (
|
||||||
</p>
|
|
||||||
{isHost && (
|
|
||||||
<p className="pack-hint">
|
|
||||||
Можете изменить пак вопросов в любой момент
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="pack-info">
|
|
||||||
<p className="pack-hint">
|
<p className="pack-hint">
|
||||||
Пак вопросов не выбран. Вы можете начать игру без пака и
|
Вы можете настроить вопросы перед началом игры
|
||||||
добавить его позже.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{isHost && (
|
{isHost && (
|
||||||
<div className="pack-selector">
|
<button
|
||||||
<select
|
onClick={() => setIsQuestionsModalOpen(true)}
|
||||||
value={selectedPackId}
|
className="secondary"
|
||||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
>
|
||||||
disabled={loadingPacks || updatingPack}
|
Настроить вопросы
|
||||||
>
|
</button>
|
||||||
<option value="">Выберите пак вопросов</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 ||
|
|
||||||
loadingPacks
|
|
||||||
}
|
|
||||||
className="secondary"
|
|
||||||
>
|
|
||||||
{updatingPack ? 'Сохранение...' : 'Сохранить пак'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -353,6 +405,40 @@ const RoomPage = () => {
|
||||||
onCancel={() => navigate('/')}
|
onCancel={() => navigate('/')}
|
||||||
error={passwordError}
|
error={passwordError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RoleSelectionModal
|
||||||
|
isOpen={isRoleSelectionModalOpen}
|
||||||
|
onSubmit={handleRoleSubmit}
|
||||||
|
onCancel={() => navigate('/')}
|
||||||
|
allowSpectators={room?.allowSpectators}
|
||||||
|
title="Выберите роль"
|
||||||
|
description={room?.allowSpectators
|
||||||
|
? "Выберите роль для присоединения к комнате"
|
||||||
|
: "Присоединиться как игрок"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isHost && room && (
|
||||||
|
<GameManagementModal
|
||||||
|
isOpen={isQuestionsModalOpen}
|
||||||
|
onClose={() => setIsQuestionsModalOpen(false)}
|
||||||
|
initialTab="questions"
|
||||||
|
room={{
|
||||||
|
id: room.id,
|
||||||
|
code: room.code,
|
||||||
|
status: room.status,
|
||||||
|
hostId: room.hostId,
|
||||||
|
}}
|
||||||
|
participants={participants}
|
||||||
|
currentQuestion={null}
|
||||||
|
currentQuestionIndex={0}
|
||||||
|
totalQuestions={roomQuestions.length}
|
||||||
|
revealedAnswers={[]}
|
||||||
|
questions={roomQuestions}
|
||||||
|
onUpdateQuestions={handleUpdateRoomQuestions}
|
||||||
|
availablePacks={questionPacks}
|
||||||
|
onChangeParticipantRole={handleChangeParticipantRole}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,34 @@ class SocketService {
|
||||||
newScore,
|
newScore,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
changeRoomTheme(roomId, roomCode, userId, themeId) {
|
||||||
|
this.emit('changeRoomTheme', {
|
||||||
|
roomId,
|
||||||
|
roomCode,
|
||||||
|
userId,
|
||||||
|
themeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
changeParticipantRole(roomId, roomCode, userId, participantId, newRole) {
|
||||||
|
this.emit('changeParticipantRole', {
|
||||||
|
roomId,
|
||||||
|
roomCode,
|
||||||
|
userId,
|
||||||
|
participantId,
|
||||||
|
newRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleParticles(roomId, roomCode, userId, particlesEnabled) {
|
||||||
|
this.emit('toggleParticles', {
|
||||||
|
roomId,
|
||||||
|
roomCode,
|
||||||
|
userId,
|
||||||
|
particlesEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SocketService();
|
export default new SocketService();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue