diff --git a/backend/src/admin/feature-flags/admin-feature-flags.service.ts b/backend/src/admin/feature-flags/admin-feature-flags.service.ts index 1c043f3..a50d223 100644 --- a/backend/src/admin/feature-flags/admin-feature-flags.service.ts +++ b/backend/src/admin/feature-flags/admin-feature-flags.service.ts @@ -27,19 +27,21 @@ export class AdminFeatureFlagsService { async update(key: string, dto: UpdateFeatureFlagDto) { try { - const flag = await this.prisma.featureFlag.update({ + const flag = await this.prisma.featureFlag.upsert({ where: { key }, - data: { + update: { enabled: dto.enabled, description: dto.description, }, + create: { + key, + enabled: dto.enabled, + description: dto.description || null, + }, }); return flag; } catch (error) { - if (error.code === 'P2025') { - throw new NotFoundException(`Feature flag with key "${key}" not found`); - } throw new BadRequestException(`Failed to update feature flag: ${error.message}`); } } diff --git a/backend/src/admin/themes/admin-themes.controller.ts b/backend/src/admin/themes/admin-themes.controller.ts index 5cc7f73..e1343cd 100644 --- a/backend/src/admin/themes/admin-themes.controller.ts +++ b/backend/src/admin/themes/admin-themes.controller.ts @@ -38,6 +38,16 @@ export class AdminThemesController { return this.adminThemesService.create(createThemeDto, req.user.sub); } + @Patch('reorder') + reorder(@Body() reorderDto: ReorderThemesDto) { + return this.adminThemesService.reorderThemes(reorderDto.themeIds); + } + + @Patch(':id/set-default') + setDefault(@Param('id') id: string) { + return this.adminThemesService.setDefaultTheme(id); + } + @Patch(':id') update(@Param('id') id: string, @Body() updateThemeDto: UpdateThemeDto) { return this.adminThemesService.update(id, updateThemeDto); @@ -47,14 +57,4 @@ export class AdminThemesController { remove(@Param('id') id: string) { return this.adminThemesService.remove(id); } - - @Patch(':id/set-default') - setDefault(@Param('id') id: string) { - return this.adminThemesService.setDefaultTheme(id); - } - - @Patch('reorder') - reorder(@Body() reorderDto: ReorderThemesDto) { - return this.adminThemesService.reorderThemes(reorderDto.themeIds); - } } diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 3372b2c..66e8281 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -103,6 +103,117 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On @SubscribeMessage('joinRoom') async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) { client.join(payload.roomCode); + + // Получаем полное состояние для отправки присоединившемуся клиенту + const room = (await this.prisma.room.findUnique({ + where: { code: payload.roomCode }, + include: { + participants: { + where: { isActive: true }, + orderBy: { joinedAt: 'asc' } + }, + roomPack: true, + host: { select: { id: true, name: true } }, + theme: true + } as Prisma.RoomInclude, + })) as unknown as RoomWithPack | null; + + if (room) { + // Используем тот же метод, что и в broadcastFullState, но отправляем напрямую клиенту + const roomPackQuestions = (room.roomPack as unknown as { questions?: any } | null)?.questions; + let questions: Question[] = []; + + if (roomPackQuestions) { + if (Array.isArray(roomPackQuestions)) { + questions = roomPackQuestions as Question[]; + } else if (typeof roomPackQuestions === 'string') { + try { + questions = JSON.parse(roomPackQuestions) as Question[]; + } catch (e) { + console.error('Error parsing roomPack.questions:', e); + questions = []; + } + } + } + + let currentQuestionId = (room.currentQuestionId as string | null) || null; + if (currentQuestionId) { + const questionExists = questions.some((q: any) => q.id === currentQuestionId); + if (!questionExists) { + currentQuestionId = null; + } + } + + if (!currentQuestionId && questions.length > 0) { + const firstQuestion = questions[0]; + if (firstQuestion.id && typeof firstQuestion.id === 'string') { + currentQuestionId = firstQuestion.id; + await this.prisma.room.update({ + where: { id: room.id }, + data: { + currentQuestionId: currentQuestionId, + currentQuestionIndex: 0 + } + }); + } + } + + let currentPlayerId = room.currentPlayerId; + if (!currentPlayerId && room.participants.length > 0) { + const hostParticipant = room.participants.find(p => p.userId === room.hostId); + const firstParticipant = hostParticipant || room.participants[0]; + + if (firstParticipant) { + currentPlayerId = firstParticipant.id; + await this.prisma.room.update({ + where: { id: room.id }, + data: { currentPlayerId: currentPlayerId } + }); + } + } + + const fullState = { + roomId: room.id, + roomCode: room.code, + status: room.status, + currentQuestionId: currentQuestionId, + currentPlayerId: currentPlayerId, + revealedAnswers: room.revealedAnswers as RevealedAnswers, + isGameOver: room.isGameOver, + hostId: room.hostId, + themeId: (room as any).themeId || null, + voiceMode: (room as any).voiceMode !== undefined ? (room as any).voiceMode : false, + particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null, + maxPlayers: (room as any).maxPlayers || 10, + participants: room.participants.map((p) => ({ + id: p.id, + userId: p.userId, + name: p.name, + role: p.role, + score: p.score + })), + questions: questions.map((q: any) => { + const questionId = q.id || (typeof q === 'object' && 'id' in q ? q.id : null); + if (!questionId) { + console.warn('⚠️ Question without ID:', q); + } + return { + id: questionId || `temp-${Math.random()}`, + text: q.text || '', + answers: (q.answers || []).map((a: any) => ({ + id: a.id || `answer-${Math.random()}`, + text: a.text || '', + points: a.points || 0 + })) + }; + }) + }; + + // Отправляем состояние напрямую присоединившемуся клиенту + client.emit('gameStateUpdated', fullState); + } + + // Также отправляем всем остальным в комнате (broadcast) await this.broadcastFullState(payload.roomCode); } @@ -427,6 +538,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On isGameOver: room.isGameOver, hostId: room.hostId, themeId: (room as any).themeId || null, + voiceMode: (room as any).voiceMode !== undefined ? (room as any).voiceMode : false, particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null, maxPlayers: (room as any).maxPlayers || 10, participants: room.participants.map((p) => ({ diff --git a/src/components/Question.css b/src/components/Question.css index 27d2437..cb927db 100644 --- a/src/components/Question.css +++ b/src/components/Question.css @@ -132,6 +132,10 @@ row-gap: clamp(6px, 0.8vh, 12px); flex: 1; min-height: 0; + overflow-y: auto; + /* Scrollbar styling */ + scrollbar-width: thin; + scrollbar-color: rgba(255, 215, 0, 0.5) rgba(255, 255, 255, 0.1); } @media (min-width: 900px) { @@ -149,6 +153,7 @@ @media (max-width: 768px) { .answers-grid { grid-template-columns: 1fr; + grid-auto-rows: minmax(auto, clamp(100px, 15vh, 140px)); } } @@ -162,7 +167,27 @@ /* Для телефонов в портретной ориентации */ @media (max-width: 768px) and (max-height: 900px) { .answers-grid { - grid-auto-rows: minmax(auto, 150px); + grid-auto-rows: minmax(auto, 120px); } } +/* Webkit scrollbar styling for answers-grid */ +.answers-grid::-webkit-scrollbar { + width: 8px; +} + +.answers-grid::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.answers-grid::-webkit-scrollbar-thumb { + background: rgba(255, 215, 0, 0.5); + border-radius: 4px; + transition: background 0.3s ease; +} + +.answers-grid::-webkit-scrollbar-thumb:hover { + background: rgba(255, 215, 0, 0.7); +} + diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index f8e1a59..db2075a 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -69,14 +69,30 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { setParticipants(state.participants); } - // Также обновляем статус комнаты - if (state.status) { - setRoom(prevRoom => prevRoom ? { ...prevRoom, status: state.status } : prevRoom); - - // Если игра началась, вызываем callback - if (state.status === 'PLAYING' && onGameStarted) { - onGameStarted(state); + // Обновляем базовую информацию о комнате из состояния + setRoom(prevRoom => { + if (!prevRoom) return prevRoom; + + const updatedRoom = { ...prevRoom }; + + if (state.status) { + updatedRoom.status = state.status; } + + if (state.themeId !== undefined) { + updatedRoom.themeId = state.themeId; + } + + if (state.voiceMode !== undefined) { + updatedRoom.voiceMode = state.voiceMode; + } + + return updatedRoom; + }); + + // Если игра началась, вызываем callback + if (state.status === 'PLAYING' && onGameStarted) { + onGameStarted(state); } }; @@ -115,6 +131,16 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { const joinRoom = useCallback(async (roomId, userId, name, role = 'PLAYER') => { try { const response = await roomsApi.join(roomId, userId, name, role); + + // После успешного присоединения запрашиваем полное состояние через WebSocket + // Это гарантирует получение актуального состояния (список игроков, тема, voiceMode) + if (response.data?.code) { + // Небольшая задержка, чтобы убедиться, что WebSocket подключен + setTimeout(() => { + socketService.emit('requestFullState', { roomCode: response.data.code }); + }, 100); + } + return response.data; } catch (err) { setError(err.message); diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index 3e74d62..3ceddc3 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -32,6 +32,7 @@ const GamePage = () => { hostId: null, roomCode: null, themeId: null, + voiceMode: false, particlesEnabled: null, // null = использовать настройку из темы, true/false = override maxPlayers: 10, }); diff --git a/src/pages/RoomPage.jsx b/src/pages/RoomPage.jsx index 64900fc..7d4461b 100644 --- a/src/pages/RoomPage.jsx +++ b/src/pages/RoomPage.jsx @@ -218,6 +218,7 @@ const RoomPage = () => { const handleGameStateUpdated = (state) => { const currentThemeId = state.themeId || null; + // Применяем тему если она изменилась или если это первое присоединение (previousThemeIdRef.current === null) if (currentThemeId !== previousThemeIdRef.current) { previousThemeIdRef.current = currentThemeId; if (currentThemeId) { @@ -226,22 +227,23 @@ const RoomPage = () => { } }; - // Также проверяем тему из room при изменении - if (room?.themeId) { - const currentThemeId = room.themeId || null; - if (currentThemeId !== previousThemeIdRef.current) { - previousThemeIdRef.current = currentThemeId; - if (currentThemeId) { - changeTheme(currentThemeId); - } - } - } - socketService.on('gameStateUpdated', handleGameStateUpdated); return () => { socketService.off('gameStateUpdated', handleGameStateUpdated); }; - }, [roomCode, room, changeTheme]); + }, [roomCode, changeTheme]); + + // Применяем тему из room при первом присоединении или при изменении + useEffect(() => { + if (!roomCode || !room) return; + + const currentThemeId = room.themeId || null; + // Применяем тему если она существует и еще не применена (первое присоединение) или изменилась + if (currentThemeId && currentThemeId !== previousThemeIdRef.current) { + previousThemeIdRef.current = currentThemeId; + changeTheme(currentThemeId); + } + }, [roomCode, room?.themeId, changeTheme]); const handleStartGame = () => { startGame();