diff --git a/admin/src/api/rooms.ts b/admin/src/api/rooms.ts index fe14752..d288e5d 100644 --- a/admin/src/api/rooms.ts +++ b/admin/src/api/rooms.ts @@ -1,48 +1,75 @@ import { adminApiClient } from './client' import type { AxiosError } from 'axios' +export interface ParticipantDto { + id: string + userId: string + name: string + role: 'HOST' | 'PLAYER' | 'SPECTATOR' + score: number + joinedAt: string + isActive: boolean + user: { + id: string + name?: string + email?: string + } +} + export interface RoomDto { id: string code: string status: 'WAITING' | 'PLAYING' | 'FINISHED' hostId: string createdAt: string - expiresAt?: string + expiresAt?: string | null isAdminRoom: boolean - customCode?: string - activeFrom?: string - activeTo?: string - themeId?: string - questionPackId?: string + customCode?: string | null + activeFrom?: string | null + activeTo?: string | null + themeId?: string | null + questionPackId?: string | null uiControls?: { allowThemeChange?: boolean allowPackChange?: boolean allowNameChange?: boolean allowScoreEdit?: boolean - } + } | null maxPlayers: number allowSpectators: boolean timerEnabled: boolean timerDuration: number + autoAdvance?: boolean + voiceMode?: boolean + currentQuestionIndex?: number + totalQuestions?: number + answeredQuestions?: number + currentQuestionId?: string | null + currentPlayerId?: string | null + isGameOver?: boolean + revealedAnswers?: Record | null host: { id: string - name?: string - email?: string + name?: string | null + email?: string | null } theme?: { id: string name: string isPublic: boolean - } + } | null questionPack?: { id: string name: string - } + description?: string | null + questionCount?: number + } | null + participants?: ParticipantDto[] _count?: { participants: number } - startedAt?: string - finishedAt?: string + startedAt?: string | null + finishedAt?: string | null } export interface CreateAdminRoomDto { diff --git a/admin/src/components/RoomDetailsDialog.tsx b/admin/src/components/RoomDetailsDialog.tsx new file mode 100644 index 0000000..787eba1 --- /dev/null +++ b/admin/src/components/RoomDetailsDialog.tsx @@ -0,0 +1,554 @@ +import { useQuery } from '@tanstack/react-query' +import { roomsApi, type RoomDto } from '@/api/rooms' +import type { AxiosError } from 'axios' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Loader2 } from 'lucide-react' + +interface RoomDetailsDialogProps { + open: boolean + roomId: string | null + onClose: () => void +} + +export function RoomDetailsDialog({ + open, + roomId, + onClose, +}: RoomDetailsDialogProps) { + const { data: room, isLoading, error } = useQuery({ + queryKey: ['admin', 'room', roomId], + queryFn: () => roomsApi.getRoom(roomId!), + enabled: open && !!roomId, + }) + + const getStatusBadge = (status: string) => { + switch (status) { + case 'WAITING': + return Waiting + case 'PLAYING': + return Playing + case 'FINISHED': + return Finished + default: + return {status} + } + } + + const getRoleBadge = (role: string) => { + switch (role) { + case 'HOST': + return Host + case 'PLAYER': + return Player + case 'SPECTATOR': + return Spectator + default: + return {role} + } + } + + const formatDate = (dateString?: string | null) => { + if (!dateString) return '-' + return new Date(dateString).toLocaleString() + } + + if (!open || !roomId) { + return null + } + + return ( + + + + Room Details + + Complete information about the room + + + + {isLoading && ( +
+ +
+ )} + + {error && ( +
+ {(error as AxiosError<{ message?: string }>).response?.data?.message || + 'Failed to load room details'} +
+ )} + + {room && ( + + + General + Settings + Participants + Game State + Theme & Pack + + + + + + Basic Information + + +
+
+
Code
+
+ {room.code} + {room.isAdminRoom && ( + Admin + )} +
+
+
+
Status
+
{getStatusBadge(room.status)}
+
+
+
Created At
+
{formatDate(room.createdAt)}
+
+
+
Started At
+
{formatDate(room.startedAt)}
+
+
+
Finished At
+
{formatDate(room.finishedAt)}
+
+
+
Expires At
+
{formatDate(room.expiresAt)}
+
+ {room.customCode && ( +
+
+ Custom Code +
+
{room.customCode}
+
+ )} + {room.activeFrom && room.activeTo && ( + <> +
+
+ Active From +
+
{formatDate(room.activeFrom)}
+
+
+
Active To
+
{formatDate(room.activeTo)}
+
+ + )} +
+
+
+ + + + Host Information + + +
+
Host ID
+
{room.host.id}
+
+ {room.host.name && ( +
+
Name
+
{room.host.name}
+
+ )} + {room.host.email && ( +
+
Email
+
{room.host.email}
+
+ )} +
+
+
+ + + + + Game Settings + + +
+
+
+ Max Players +
+
{room.maxPlayers}
+
+
+
+ Allow Spectators +
+
+ {room.allowSpectators ? ( + Yes + ) : ( + No + )} +
+
+
+
+ Timer Enabled +
+
+ {room.timerEnabled ? ( + Yes + ) : ( + No + )} +
+
+ {room.timerEnabled && ( +
+
+ Timer Duration +
+
{room.timerDuration} seconds
+
+ )} +
+
Auto Advance
+
+ {room.autoAdvance ? ( + Yes + ) : ( + No + )} +
+
+
+
Voice Mode
+
+ {room.voiceMode ? ( + Yes + ) : ( + No + )} +
+
+
+
+
+ + {room.uiControls && ( + + + UI Controls + + +
+
+
+ Allow Theme Change +
+
+ {room.uiControls.allowThemeChange ? ( + Yes + ) : ( + No + )} +
+
+
+
+ Allow Pack Change +
+
+ {room.uiControls.allowPackChange ? ( + Yes + ) : ( + No + )} +
+
+
+
+ Allow Name Change +
+
+ {room.uiControls.allowNameChange ? ( + Yes + ) : ( + No + )} +
+
+
+
+ Allow Score Edit +
+
+ {room.uiControls.allowScoreEdit ? ( + Yes + ) : ( + No + )} +
+
+
+
+
+ )} +
+ + + + + + Participants ({room.participants?.length || 0}) + + + All players and spectators in this room + + + + {!room.participants || room.participants.length === 0 ? ( +
+ No participants +
+ ) : ( + + + + Rank + Name + Email + Role + Score + Status + Joined At + + + + {room.participants.map((participant, index) => ( + + {index + 1} + + {participant.name} + + + {participant.user.email || '-'} + + {getRoleBadge(participant.role)} + {participant.score} + + {participant.isActive ? ( + Active + ) : ( + Inactive + )} + + + {formatDate(participant.joinedAt)} + + + ))} + +
+ )} +
+
+
+ + + + + Current Game State + + +
+
+
+ Game Over +
+
+ {room.isGameOver ? ( + Yes + ) : ( + No + )} +
+
+
+
+ Current Question Index +
+
{room.currentQuestionIndex ?? 0}
+
+
+
+ Total Questions +
+
{room.totalQuestions ?? 0}
+
+
+
+ Answered Questions +
+
{room.answeredQuestions ?? 0}
+
+ {room.totalQuestions && room.totalQuestions > 0 && ( +
+
Progress
+
+ {Math.round( + ((room.answeredQuestions ?? 0) / room.totalQuestions) * 100 + )}{' '} + % +
+
+ )} + {room.currentQuestionId && ( +
+
+ Current Question ID +
+
+ {room.currentQuestionId} +
+
+ )} + {room.currentPlayerId && ( +
+
+ Current Player ID +
+
+ {room.currentPlayerId} +
+
+ )} +
+ {room.revealedAnswers && + Object.keys(room.revealedAnswers).length > 0 && ( +
+
+ Revealed Answers +
+
+ {Object.entries(room.revealedAnswers).map(([questionId, answers]) => ( +
+
+ {questionId} +
+
+ Answers: {Array.isArray(answers) ? answers.join(', ') : '-'} +
+
+ ))} +
+
+ )} +
+
+
+ + + + + Theme + + + {room.theme ? ( +
+
+
Name
+
{room.theme.name}
+
+
+
Public
+
+ {room.theme.isPublic ? ( + Yes + ) : ( + No + )} +
+
+
+
Theme ID
+
{room.theme.id}
+
+
+ ) : ( +
No theme assigned
+ )} +
+
+ + + + Question Pack + + + {room.questionPack ? ( +
+
+
Name
+
{room.questionPack.name}
+
+ {room.questionPack.description && ( +
+
+ Description +
+
{room.questionPack.description}
+
+ )} + {room.questionPack.questionCount !== undefined && ( +
+
+ Question Count +
+
{room.questionPack.questionCount}
+
+ )} +
+
Pack ID
+
{room.questionPack.id}
+
+
+ ) : ( +
No question pack assigned
+ )} +
+
+
+
+ )} +
+
+ ) +} diff --git a/admin/src/pages/RoomsPage.tsx b/admin/src/pages/RoomsPage.tsx index 7577819..a6bf567 100644 --- a/admin/src/pages/RoomsPage.tsx +++ b/admin/src/pages/RoomsPage.tsx @@ -25,8 +25,9 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Badge } from '@/components/ui/badge' -import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react' +import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink, Eye } from 'lucide-react' import { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog' +import { RoomDetailsDialog } from '@/components/RoomDetailsDialog' export default function RoomsPage() { const queryClient = useQueryClient() @@ -36,6 +37,8 @@ export default function RoomsPage() { const [createDialogOpen, setCreateDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [roomToDelete, setRoomToDelete] = useState(null) + const [selectedRoomId, setSelectedRoomId] = useState(null) + const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false) const limit = 20 @@ -69,6 +72,16 @@ export default function RoomsPage() { setIsDeleteDialogOpen(true) } + const handleViewDetails = (room: RoomDto) => { + setSelectedRoomId(room.id) + setIsDetailsDialogOpen(true) + } + + const handleCloseDetails = () => { + setIsDetailsDialogOpen(false) + setSelectedRoomId(null) + } + const confirmDelete = () => { if (roomToDelete?.id) { deleteMutation.mutate(roomToDelete.id) @@ -257,10 +270,19 @@ export default function RoomsPage() {
+ @@ -268,6 +290,7 @@ export default function RoomsPage() { variant="ghost" size="sm" onClick={() => handleDelete(room)} + title="Delete Room" > @@ -348,6 +371,13 @@ export default function RoomsPage() { + + {/* Room Details Dialog */} +
) } diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 0b06c74..62ce900 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -46,6 +46,7 @@ model Room { questionPackId String? autoAdvance Boolean @default(false) voiceMode Boolean @default(false) // Голосовой режим + password String? // Пароль для доступа к комнате // Админские комнаты isAdminRoom Boolean @default(false) diff --git a/backend/src/admin/rooms/admin-rooms.service.ts b/backend/src/admin/rooms/admin-rooms.service.ts index 8a3218b..20be32d 100644 --- a/backend/src/admin/rooms/admin-rooms.service.ts +++ b/backend/src/admin/rooms/admin-rooms.service.ts @@ -92,6 +92,13 @@ export class AdminRoomsService { email: true, }, }, + theme: { + select: { + id: true, + name: true, + isPublic: true, + }, + }, questionPack: { select: { id: true, diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index e4fd271..4d55a5a 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -630,11 +630,76 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On return; } + // Получаем комнату с участниками + const room = (await this.prisma.room.findUnique({ + where: { id: payload.roomId }, + include: { + participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } + } as Prisma.RoomInclude, + })) as unknown as RoomWithPack | null; + + if (!room) { + client.emit('error', { message: 'Room not found' }); + return; + } + + // Проверяем существование и активность участника + const participant = await this.prisma.participant.findUnique({ + where: { id: payload.participantId }, + include: { user: true }, + }); + + if (!participant || participant.roomId !== payload.roomId) { + client.emit('error', { message: 'Participant not found' }); + return; + } + + if (!participant.isActive) { + client.emit('error', { message: 'Participant is already inactive' }); + return; + } + + // Запрещаем удаление хоста + if (participant.role === 'HOST' || participant.userId === room.hostId) { + client.emit('error', { message: 'Cannot kick the host' }); + return; + } + + // Если удаляемый участник - текущий игрок, выбираем следующего + let newCurrentPlayerId = room.currentPlayerId; + if (room.currentPlayerId === payload.participantId) { + const activeParticipants = room.participants.filter(p => p.id !== payload.participantId); + if (activeParticipants.length > 0) { + // Выбираем первого активного участника после удаляемого + newCurrentPlayerId = activeParticipants[0].id; + } else { + newCurrentPlayerId = null; + } + } + + // Деактивируем участника await this.prisma.participant.update({ where: { id: payload.participantId }, data: { isActive: false }, }); + // Обновляем currentPlayerId если нужно + if (newCurrentPlayerId !== room.currentPlayerId) { + await this.prisma.room.update({ + where: { id: payload.roomId }, + data: { currentPlayerId: newCurrentPlayerId }, + }); + } + + // Отправляем событие об удалении + this.roomEventsService.emitPlayerKicked(payload.roomCode, { + participantId: payload.participantId, + userId: participant.userId, + participantName: participant.name, + newCurrentPlayerId, + }); + + // Отправляем обновленное состояние await this.broadcastFullState(payload.roomCode); } diff --git a/backend/src/rooms/rooms.controller.ts b/backend/src/rooms/rooms.controller.ts index c398cf5..6a68689 100644 --- a/backend/src/rooms/rooms.controller.ts +++ b/backend/src/rooms/rooms.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Body, Param, Patch, Put } from '@nestjs/common'; +import { Controller, Post, Get, Body, Param, Patch, Put, Query } from '@nestjs/common'; import { RoomsService } from './rooms.service'; @Controller('rooms') @@ -11,8 +11,12 @@ export class RoomsController { } @Get(':code') - async getRoom(@Param('code') code: string) { - return this.roomsService.getRoomByCode(code); + async getRoom( + @Param('code') code: string, + @Query('password') password?: string, + @Query('userId') userId?: string + ) { + return this.roomsService.getRoomByCode(code, password, userId); } @Post(':roomId/join') diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index c0f6336..39e9272 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { customAlphabet } from 'nanoid'; import { RoomEventsService } from '../game/room-events.service'; @@ -21,6 +21,7 @@ export class RoomsService { // Remove undefined values from settings and ensure questionPackId is handled correctly const cleanSettings = settings ? { ...settings } : {}; + const password = cleanSettings.password; if ('questionPackId' in cleanSettings) { delete cleanSettings.questionPackId; } @@ -34,6 +35,7 @@ export class RoomsService { hostId, expiresAt, ...cleanSettings, + password: password ? password.trim() : null, questionPackId: questionPackId || null, }, include: { @@ -57,11 +59,11 @@ export class RoomsService { // Create RoomPack (always, even if empty) await this.roomPackService.create(room.id, questionPackId); - // Return room with roomPack - return this.getRoomByCode(room.code); + // Return room with roomPack (host doesn't need password) + return this.getRoomByCode(room.code, undefined, hostId); } - async getRoomByCode(code: string) { + async getRoomByCode(code: string, password?: string, userId?: string) { const room = await this.prisma.room.findUnique({ where: { code }, include: { @@ -88,7 +90,21 @@ export class RoomsService { throw new BadRequestException('Room is no longer active'); } - return room; + // Проверка пароля: если комната защищена паролем + if (room.password) { + // Хост всегда имеет доступ к своей комнате + if (!userId || room.hostId !== userId) { + // Если пароль не предоставлен или неверный + if (!password || password.trim() !== room.password) { + throw new UnauthorizedException('Room password required or incorrect'); + } + } + } + + // Не возвращаем пароль в ответе + const roomWithoutPassword = { ...room }; + delete roomWithoutPassword.password; + return roomWithoutPassword; } async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') { diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css index c408106..378a594 100644 --- a/src/components/GameManagementModal.css +++ b/src/components/GameManagementModal.css @@ -315,6 +315,19 @@ color: var(--accent-primary, #ffd700); } +/* Answers control section */ +.answers-control-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 2px solid var(--border-color, rgba(255, 255, 255, 0.2)); +} + +.answers-control-section h3 { + margin: 0 0 1rem 0; + color: var(--text-primary, #ffffff); + font-size: 1.2rem; +} + /* Buttons */ .mgmt-button { padding: 0.75rem 1.5rem; diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index a39ddc5..758dd69 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -29,7 +29,7 @@ const GameManagementModal = ({ onUpdatePlayerScore, onKickPlayer, }) => { - const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions + const [activeTab, setActiveTab] = useState('players') // players | game | scoring | questions const [selectedPlayer, setSelectedPlayer] = useState(null) const [customPoints, setCustomPoints] = useState(10) @@ -484,13 +484,6 @@ const GameManagementModal = ({ > 🎮 Игра - + +
+ {currentQuestion.answers.map((answer, index) => ( + + ))} +
+ + )} +
Игроков: {participants.length} @@ -662,36 +685,6 @@ const GameManagementModal = ({
)} - {/* ANSWERS CONTROL TAB */} - {activeTab === 'answers' && currentQuestion && ( -
-

Управление ответами

- - - -
- {currentQuestion.answers.map((answer, index) => ( - - ))} -
-
- )} - {/* SCORING TAB */} {activeTab === 'scoring' && (
diff --git a/src/components/PasswordModal.jsx b/src/components/PasswordModal.jsx new file mode 100644 index 0000000..189b6c7 --- /dev/null +++ b/src/components/PasswordModal.jsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import './NameInputModal.css'; + +const PasswordModal = ({ + isOpen, + onSubmit, + onCancel, + title = 'Введите пароль комнаты', + description = 'Эта комната защищена паролем. Введите пароль для доступа.', + error = null +}) => { + const [password, setPassword] = useState(''); + const [localError, setLocalError] = useState(''); + + // Сброс формы при открытии модального окна + useEffect(() => { + if (isOpen) { + setPassword(''); + setLocalError(''); + } + }, [isOpen]); + + // Обновляем локальную ошибку при изменении пропса error + useEffect(() => { + if (error) { + setLocalError(error); + } + }, [error]); + + if (!isOpen) return null; + + const handleSubmit = (e) => { + e.preventDefault(); + const trimmedPassword = password.trim(); + + if (!trimmedPassword) { + setLocalError('Введите пароль'); + return; + } + + setLocalError(''); + onSubmit(trimmedPassword); + }; + + const handleBackdropClick = (e) => { + if (e.target === e.currentTarget && onCancel) { + onCancel(); + } + }; + + const displayError = localError || error; + + return ( +
+
+
+

{title}

+ {onCancel && ( + + )} +
+ +
+
+

+ {description} +

+ +
+ { + setPassword(e.target.value); + setLocalError(''); + }} + onKeyPress={(e) => { + if (e.key === 'Enter') { + handleSubmit(e); + } + }} + placeholder="Пароль" + className="name-input-field" + autoFocus + /> + {displayError && ( +

{displayError}

+ )} +
+
+ +
+ + {onCancel && ( + + )} +
+
+
+
+ ); +}; + +export default PasswordModal; + diff --git a/src/components/Question.css b/src/components/Question.css index e6bdb5f..01a6457 100644 --- a/src/components/Question.css +++ b/src/components/Question.css @@ -128,14 +128,21 @@ display: grid; grid-template-columns: repeat(2, 1fr); grid-auto-rows: auto; - gap: clamp(6px, 1.2vw, 12px); + column-gap: clamp(6px, 1.2vw, 12px); + row-gap: clamp(6px, 0.8vh, 12px); flex: 1; min-height: 0; } +@media (min-width: 900px) { + .answers-grid { + row-gap: 12px; + } +} + @media (min-width: 1200px) { .answers-grid { - gap: 12px; + column-gap: 12px; } } diff --git a/src/components/VoicePlayer.jsx b/src/components/VoicePlayer.jsx index 073f3b9..8cd405b 100644 --- a/src/components/VoicePlayer.jsx +++ b/src/components/VoicePlayer.jsx @@ -20,10 +20,38 @@ const VoicePlayer = ({ const isPlayingThis = isPlaying && currentText === speechId; - const handleClick = () => { + const handleClick = (e) => { + // Prevent event propagation to parent button + if (e) { + e.stopPropagation(); + e.preventDefault(); + } + + console.log('[VoicePlayer] handleClick', { + isPlayingThis, + roomId, + questionId, + contentType, + answerId, + canPlay: roomId && questionId && contentType && (contentType !== 'answer' || answerId), + }); + if (isPlayingThis) { + console.log('[VoicePlayer] Stopping playback'); stop(); - } else if (roomId && questionId && contentType) { + } else { + // Validate before calling speak + if (!roomId || !questionId || !contentType) { + console.warn('[VoicePlayer] Missing required params:', { roomId, questionId, contentType }); + return; + } + + if (contentType === 'answer' && !answerId) { + console.warn('[VoicePlayer] answerId is required for answer contentType'); + return; + } + + console.log('[VoicePlayer] Calling speak with params:', { roomId, questionId, contentType, answerId }); speak({ roomId, questionId, contentType, answerId }); } }; @@ -35,11 +63,27 @@ const VoicePlayer = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoPlay, isEnabled, roomId, questionId, contentType, answerId]); - if (!isEnabled || !showButton) { + const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId); + + if (!isEnabled) { + console.log('[VoicePlayer] Voice is disabled, not rendering button'); return children || null; } - const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId); + if (!showButton) { + return children || null; + } + + if (!canPlay) { + console.warn('[VoicePlayer] Cannot play - missing params, button will not render:', { + roomId: !!roomId, + questionId: !!questionId, + contentType: !!contentType, + answerId: !!answerId, + contentTypeValue: contentType, + }); + return children || null; + } return (
diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index f276df9..b081480 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -3,12 +3,13 @@ import { roomsApi } from '../services/api'; import socketService from '../services/socket'; import { useAuth } from '../context/AuthContext'; -export const useRoom = (roomCode, onGameStarted = null) => { +export const useRoom = (roomCode, onGameStarted = null, password = null) => { const { user } = useAuth(); const [room, setRoom] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [participants, setParticipants] = useState([]); + const [requiresPassword, setRequiresPassword] = useState(false); useEffect(() => { if (!roomCode) { @@ -19,12 +20,20 @@ export const useRoom = (roomCode, onGameStarted = null) => { const fetchRoom = async () => { try { setLoading(true); - const response = await roomsApi.getByCode(roomCode); + const response = await roomsApi.getByCode(roomCode, password, user?.id); setRoom(response.data); setParticipants(response.data.participants || []); setError(null); + setRequiresPassword(false); } catch (err) { - setError(err.message); + // Проверяем, требуется ли пароль (401 Unauthorized) + if (err.response?.status === 401) { + setRequiresPassword(true); + setError('Room password required'); + } else { + setError(err.response?.data?.message || err.message); + setRequiresPassword(false); + } console.error('Error fetching room:', err); } finally { setLoading(false); @@ -81,7 +90,7 @@ export const useRoom = (roomCode, onGameStarted = null) => { socketService.off('gameStarted', handleGameStarted); socketService.off('gameStateUpdated', handleGameStateUpdated); }; - }, [roomCode, onGameStarted, user?.id]); + }, [roomCode, password, onGameStarted, user?.id]); const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => { try { @@ -132,11 +141,35 @@ export const useRoom = (roomCode, onGameStarted = null) => { [room], ); + const fetchRoomWithPassword = useCallback(async (roomPassword) => { + try { + setLoading(true); + const response = await roomsApi.getByCode(roomCode, roomPassword, user?.id); + setRoom(response.data); + setParticipants(response.data.participants || []); + setError(null); + setRequiresPassword(false); + return response.data; + } catch (err) { + if (err.response?.status === 401) { + setRequiresPassword(true); + setError('Incorrect password'); + } else { + setError(err.response?.data?.message || err.message); + } + throw err; + } finally { + setLoading(false); + } + }, [roomCode, user?.id]); + return { room, participants, loading, error, + requiresPassword, + fetchRoomWithPassword, createRoom, joinRoom, startGame, diff --git a/src/hooks/useVoice.js b/src/hooks/useVoice.js index 19c7a0f..a48bc21 100644 --- a/src/hooks/useVoice.js +++ b/src/hooks/useVoice.js @@ -85,6 +85,9 @@ export function useVoice() { requestBody.voice = voice; } + console.log('[useVoice] Sending TTS request to:', `${API_URL}/voice/tts`); + console.log('[useVoice] Request body:', requestBody); + const response = await fetch(`${API_URL}/voice/tts`, { method: 'POST', headers: { @@ -94,12 +97,21 @@ export function useVoice() { body: JSON.stringify(requestBody), }); + console.log('[useVoice] TTS response status:', response.status, response.statusText); + if (!response.ok) { - throw new Error(`Voice service error: ${response.status}`); + const errorText = await response.text().catch(() => 'Unable to read error response'); + console.error('[useVoice] TTS request failed:', { + status: response.status, + statusText: response.statusText, + errorText, + }); + throw new Error(`Voice service error: ${response.status} - ${errorText}`); } // Create blob URL from response const blob = await response.blob(); + console.log('[useVoice] Received audio blob, size:', blob.size, 'bytes, type:', blob.type); const audioUrl = URL.createObjectURL(blob); // Cache the URL @@ -107,10 +119,20 @@ export function useVoice() { audioCache.current.set(cacheKey, audioUrl); } + console.log('[useVoice] Successfully generated speech, cached URL:', audioUrl); return audioUrl; } catch (error) { - console.error('Failed to generate speech:', error); - throw error; + console.error('[useVoice] Failed to generate speech:', error); + console.error('[useVoice] Error context:', { + message: error?.message, + stack: error?.stack, + params, + API_URL, + }); + + // Re-throw with more context + const errorMessage = error?.message || 'Unknown error during speech generation'; + throw new Error(`Failed to generate speech: ${errorMessage}`); } }, []); @@ -120,16 +142,38 @@ export function useVoice() { * @param {Object} options - Options */ const speak = useCallback(async (params, options = {}) => { - if (!isEnabled) return; - if (!params || !params.roomId || !params.questionId || !params.contentType) return; + console.log('[useVoice] speak called with:', params); + + if (!isEnabled) { + console.warn('[useVoice] Voice is disabled, cannot speak'); + return; + } + + if (!params || !params.roomId || !params.questionId || !params.contentType) { + console.warn('[useVoice] Missing required params:', { + hasParams: !!params, + roomId: params?.roomId, + questionId: params?.questionId, + contentType: params?.contentType, + }); + return; + } + + // Early validation for answerId when contentType is 'answer' + if (params.contentType === 'answer' && !params.answerId) { + console.error('[useVoice] answerId is required when contentType is "answer"'); + return; + } try { // Create a unique identifier for this speech request const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`; + console.log('[useVoice] Starting speech playback, speechId:', speechId); setCurrentText(speechId); setIsPlaying(true); const audioUrl = await generateSpeech(params, options); + console.log('[useVoice] Generated audio URL:', audioUrl); // Create or reuse audio element if (!audioRef.current) { @@ -151,9 +195,16 @@ export function useVoice() { setCurrentText(null); }; + console.log('[useVoice] Playing audio'); await audio.play(); + console.log('[useVoice] Audio playback started successfully'); } catch (error) { - console.error('Failed to speak:', error); + console.error('[useVoice] Failed to speak:', error); + console.error('[useVoice] Error details:', { + message: error?.message, + stack: error?.stack, + params, + }); setIsPlaying(false); setCurrentText(null); } diff --git a/src/pages/CreateRoom.jsx b/src/pages/CreateRoom.jsx index e19fbc8..d1015a5 100644 --- a/src/pages/CreateRoom.jsx +++ b/src/pages/CreateRoom.jsx @@ -17,6 +17,7 @@ const CreateRoom = () => { allowSpectators: true, timerEnabled: false, timerDuration: 30, + password: '', }); const [loading, setLoading] = useState(true); const [isNameModalOpen, setIsNameModalOpen] = useState(false); @@ -75,10 +76,18 @@ const CreateRoom = () => { setIsHostNameModalOpen(false); try { + // Очищаем пустой пароль перед отправкой + const cleanSettings = { ...settings }; + if (!cleanSettings.password || !cleanSettings.password.trim()) { + delete cleanSettings.password; + } else { + cleanSettings.password = cleanSettings.password.trim(); + } + const room = await createRoom( user.id, selectedPackId || undefined, - settings, + cleanSettings, name.trim(), ); navigate(`/room/${room.code}`); @@ -166,6 +175,21 @@ const CreateRoom = () => {
)} +
+ + + setSettings({ ...settings, password: e.target.value }) + } + placeholder="Оставьте пустым для публичной комнаты" + /> + + Если указан пароль, только игроки с паролем смогут присоединиться к комнате + +
+
diff --git a/src/pages/RoomPage.jsx b/src/pages/RoomPage.jsx index 6679408..1282e77 100644 --- a/src/pages/RoomPage.jsx +++ b/src/pages/RoomPage.jsx @@ -6,6 +6,7 @@ import { questionsApi } from '../services/api'; import QRCode from 'qrcode'; import QRModal from '../components/QRModal'; import NameInputModal from '../components/NameInputModal'; +import PasswordModal from '../components/PasswordModal'; const RoomPage = () => { const { roomCode } = useParams(); @@ -17,19 +18,24 @@ const RoomPage = () => { navigate(`/game/${roomCode}`); }, [navigate, roomCode]); + const [password, setPassword] = useState(null); const { room, participants, loading, error, + requiresPassword, + fetchRoomWithPassword, joinRoom, startGame, updateQuestionPack, - } = useRoom(roomCode, handleGameStartedEvent); + } = useRoom(roomCode, handleGameStartedEvent, password); const [qrCode, setQrCode] = useState(''); const [joined, setJoined] = useState(false); const [isQRModalOpen, setIsQRModalOpen] = useState(false); const [isNameModalOpen, setIsNameModalOpen] = useState(false); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [passwordError, setPasswordError] = useState(null); const [questionPacks, setQuestionPacks] = useState([]); const [selectedPackId, setSelectedPackId] = useState(''); const [loadingPacks, setLoadingPacks] = useState(false); @@ -59,14 +65,45 @@ const RoomPage = () => { } }, [roomCode]); + // Проверка пароля: показываем модальное окно, если требуется пароль + // Хост не должен видеть модальное окно пароля (проверяется на бэкенде) + useEffect(() => { + if (requiresPassword && !isPasswordModalOpen && !loading && user) { + // Проверяем, не является ли пользователь хостом + // Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ) + setIsPasswordModalOpen(true); + } else if (requiresPassword && !isPasswordModalOpen && !loading && !user) { + // Если пользователь не авторизован, все равно показываем модальное окно + // После авторизации проверим, является ли он хостом + setIsPasswordModalOpen(true); + } + }, [requiresPassword, isPasswordModalOpen, loading, user]); + // Проверка авторизации и показ модального окна для ввода имени useEffect(() => { - if (!authLoading && !user && room && !loading) { + if (!authLoading && !user && room && !loading && !requiresPassword) { setIsNameModalOpen(true); } else if (user) { setIsNameModalOpen(false); } - }, [authLoading, user, room, loading]); + }, [authLoading, user, room, loading, requiresPassword]); + + // Обработка ввода пароля + const handlePasswordSubmit = async (enteredPassword) => { + try { + setPasswordError(null); + await fetchRoomWithPassword(enteredPassword); + setPassword(enteredPassword); + setIsPasswordModalOpen(false); + } catch (error) { + console.error('Password error:', error); + if (error.response?.status === 401) { + setPasswordError('Неверный пароль. Попробуйте еще раз.'); + } else { + setPasswordError('Ошибка при проверке пароля. Попробуйте еще раз.'); + } + } + }; // Обработка ввода имени и авторизация const handleNameSubmit = async (name) => { @@ -161,7 +198,8 @@ const RoomPage = () => { return
Загрузка комнаты...
; } - if (error) { + // Не показываем ошибку, если требуется пароль - покажем модальное окно + if (error && !requiresPassword && error !== 'Room password required') { return (

Ошибка

@@ -171,7 +209,7 @@ const RoomPage = () => { ); } - if (!room) { + if (!room && !requiresPassword && !loading) { return (

Комната не найдена

@@ -180,7 +218,22 @@ const RoomPage = () => { ); } - const isHost = user && room.hostId === user.id; + const isHost = user && room && room.hostId === user.id; + + // Если требуется пароль, показываем только модальное окно + if (requiresPassword && !room) { + return ( + <> +
Загрузка комнаты...
+ navigate('/')} + error={passwordError} + /> + + ); + } return (
@@ -293,6 +346,13 @@ const RoomPage = () => { onSubmit={handleNameSubmit} onCancel={null} /> + + navigate('/')} + error={passwordError} + />
); }; diff --git a/src/services/api.js b/src/services/api.js index c9aec5d..b04c7f5 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -18,7 +18,12 @@ export const authApi = { export const roomsApi = { create: (hostId, questionPackId, settings, hostName) => api.post('/rooms', { hostId, questionPackId, settings, hostName }), - getByCode: (code) => api.get(`/rooms/${code}`), + getByCode: (code, password, userId) => { + const params = {}; + if (password) params.password = password; + if (userId) params.userId = userId; + return api.get(`/rooms/${code}`, { params }); + }, join: (roomId, userId, name, role) => api.post(`/rooms/${roomId}/join`, { userId, name, role }), updateQuestionPack: (roomId, questionPackId) =>