This commit is contained in:
Dmitry 2026-01-10 03:18:08 +03:00
parent 138aad77fe
commit 4e2ec9d8c7
19 changed files with 1177 additions and 87 deletions

View file

@ -1,48 +1,75 @@
import { adminApiClient } from './client' import { adminApiClient } from './client'
import type { AxiosError } from 'axios' 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 { export interface RoomDto {
id: string id: string
code: string code: string
status: 'WAITING' | 'PLAYING' | 'FINISHED' status: 'WAITING' | 'PLAYING' | 'FINISHED'
hostId: string hostId: string
createdAt: string createdAt: string
expiresAt?: string expiresAt?: string | null
isAdminRoom: boolean isAdminRoom: boolean
customCode?: string customCode?: string | null
activeFrom?: string activeFrom?: string | null
activeTo?: string activeTo?: string | null
themeId?: string themeId?: string | null
questionPackId?: string questionPackId?: string | null
uiControls?: { uiControls?: {
allowThemeChange?: boolean allowThemeChange?: boolean
allowPackChange?: boolean allowPackChange?: boolean
allowNameChange?: boolean allowNameChange?: boolean
allowScoreEdit?: boolean allowScoreEdit?: boolean
} } | null
maxPlayers: number maxPlayers: number
allowSpectators: boolean allowSpectators: boolean
timerEnabled: boolean timerEnabled: boolean
timerDuration: number timerDuration: number
autoAdvance?: boolean
voiceMode?: boolean
currentQuestionIndex?: number
totalQuestions?: number
answeredQuestions?: number
currentQuestionId?: string | null
currentPlayerId?: string | null
isGameOver?: boolean
revealedAnswers?: Record<string, string[]> | null
host: { host: {
id: string id: string
name?: string name?: string | null
email?: string email?: string | null
} }
theme?: { theme?: {
id: string id: string
name: string name: string
isPublic: boolean isPublic: boolean
} } | null
questionPack?: { questionPack?: {
id: string id: string
name: string name: string
} description?: string | null
questionCount?: number
} | null
participants?: ParticipantDto[]
_count?: { _count?: {
participants: number participants: number
} }
startedAt?: string startedAt?: string | null
finishedAt?: string finishedAt?: string | null
} }
export interface CreateAdminRoomDto { export interface CreateAdminRoomDto {

View file

@ -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<RoomDto>({
queryKey: ['admin', 'room', roomId],
queryFn: () => roomsApi.getRoom(roomId!),
enabled: open && !!roomId,
})
const getStatusBadge = (status: string) => {
switch (status) {
case 'WAITING':
return <Badge variant="secondary">Waiting</Badge>
case 'PLAYING':
return <Badge variant="default">Playing</Badge>
case 'FINISHED':
return <Badge variant="outline">Finished</Badge>
default:
return <Badge>{status}</Badge>
}
}
const getRoleBadge = (role: string) => {
switch (role) {
case 'HOST':
return <Badge variant="default">Host</Badge>
case 'PLAYER':
return <Badge variant="secondary">Player</Badge>
case 'SPECTATOR':
return <Badge variant="outline">Spectator</Badge>
default:
return <Badge>{role}</Badge>
}
}
const formatDate = (dateString?: string | null) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString()
}
if (!open || !roomId) {
return null
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Room Details</DialogTitle>
<DialogDescription>
Complete information about the room
</DialogDescription>
</DialogHeader>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="py-8 text-center text-red-500">
{(error as AxiosError<{ message?: string }>).response?.data?.message ||
'Failed to load room details'}
</div>
)}
{room && (
<Tabs defaultValue="general" className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="game-state">Game State</TabsTrigger>
<TabsTrigger value="theme-pack">Theme & Pack</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">Code</div>
<div className="font-mono font-medium text-lg">
{room.code}
{room.isAdminRoom && (
<Badge variant="secondary" className="ml-2">Admin</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Status</div>
<div>{getStatusBadge(room.status)}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Created At</div>
<div>{formatDate(room.createdAt)}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Started At</div>
<div>{formatDate(room.startedAt)}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Finished At</div>
<div>{formatDate(room.finishedAt)}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Expires At</div>
<div>{formatDate(room.expiresAt)}</div>
</div>
{room.customCode && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Custom Code
</div>
<div className="font-mono">{room.customCode}</div>
</div>
)}
{room.activeFrom && room.activeTo && (
<>
<div>
<div className="text-sm font-medium text-muted-foreground">
Active From
</div>
<div>{formatDate(room.activeFrom)}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Active To</div>
<div>{formatDate(room.activeTo)}</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Host Information</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<div className="text-sm font-medium text-muted-foreground">Host ID</div>
<div className="font-mono text-sm">{room.host.id}</div>
</div>
{room.host.name && (
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<div>{room.host.name}</div>
</div>
)}
{room.host.email && (
<div>
<div className="text-sm font-medium text-muted-foreground">Email</div>
<div>{room.host.email}</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Game Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Max Players
</div>
<div>{room.maxPlayers}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Allow Spectators
</div>
<div>
{room.allowSpectators ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Timer Enabled
</div>
<div>
{room.timerEnabled ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
{room.timerEnabled && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Timer Duration
</div>
<div>{room.timerDuration} seconds</div>
</div>
)}
<div>
<div className="text-sm font-medium text-muted-foreground">Auto Advance</div>
<div>
{room.autoAdvance ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Voice Mode</div>
<div>
{room.voiceMode ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{room.uiControls && (
<Card>
<CardHeader>
<CardTitle>UI Controls</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Allow Theme Change
</div>
<div>
{room.uiControls.allowThemeChange ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Allow Pack Change
</div>
<div>
{room.uiControls.allowPackChange ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Allow Name Change
</div>
<div>
{room.uiControls.allowNameChange ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Allow Score Edit
</div>
<div>
{room.uiControls.allowScoreEdit ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="participants" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>
Participants ({room.participants?.length || 0})
</CardTitle>
<CardDescription>
All players and spectators in this room
</CardDescription>
</CardHeader>
<CardContent>
{!room.participants || room.participants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No participants
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Rank</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Score</TableHead>
<TableHead>Status</TableHead>
<TableHead>Joined At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{room.participants.map((participant, index) => (
<TableRow key={participant.id}>
<TableCell>{index + 1}</TableCell>
<TableCell className="font-medium">
{participant.name}
</TableCell>
<TableCell className="text-muted-foreground">
{participant.user.email || '-'}
</TableCell>
<TableCell>{getRoleBadge(participant.role)}</TableCell>
<TableCell className="font-medium">{participant.score}</TableCell>
<TableCell>
{participant.isActive ? (
<Badge variant="default">Active</Badge>
) : (
<Badge variant="outline">Inactive</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatDate(participant.joinedAt)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="game-state" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Current Game State</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="text-sm font-medium text-muted-foreground">
Game Over
</div>
<div>
{room.isGameOver ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Current Question Index
</div>
<div>{room.currentQuestionIndex ?? 0}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Total Questions
</div>
<div>{room.totalQuestions ?? 0}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">
Answered Questions
</div>
<div>{room.answeredQuestions ?? 0}</div>
</div>
{room.totalQuestions && room.totalQuestions > 0 && (
<div>
<div className="text-sm font-medium text-muted-foreground">Progress</div>
<div>
{Math.round(
((room.answeredQuestions ?? 0) / room.totalQuestions) * 100
)}{' '}
%
</div>
</div>
)}
{room.currentQuestionId && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Current Question ID
</div>
<div className="font-mono text-sm break-all">
{room.currentQuestionId}
</div>
</div>
)}
{room.currentPlayerId && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Current Player ID
</div>
<div className="font-mono text-sm break-all">
{room.currentPlayerId}
</div>
</div>
)}
</div>
{room.revealedAnswers &&
Object.keys(room.revealedAnswers).length > 0 && (
<div>
<div className="text-sm font-medium text-muted-foreground mb-2">
Revealed Answers
</div>
<div className="space-y-2">
{Object.entries(room.revealedAnswers).map(([questionId, answers]) => (
<div key={questionId} className="border rounded p-2">
<div className="text-xs font-mono text-muted-foreground mb-1">
{questionId}
</div>
<div className="text-sm">
Answers: {Array.isArray(answers) ? answers.join(', ') : '-'}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="theme-pack" className="space-y-4 mt-4">
<Card>
<CardHeader>
<CardTitle>Theme</CardTitle>
</CardHeader>
<CardContent>
{room.theme ? (
<div className="space-y-2">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<div>{room.theme.name}</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Public</div>
<div>
{room.theme.isPublic ? (
<Badge variant="default">Yes</Badge>
) : (
<Badge variant="outline">No</Badge>
)}
</div>
</div>
<div>
<div className="text-sm font-medium text-muted-foreground">Theme ID</div>
<div className="font-mono text-sm">{room.theme.id}</div>
</div>
</div>
) : (
<div className="text-muted-foreground">No theme assigned</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Question Pack</CardTitle>
</CardHeader>
<CardContent>
{room.questionPack ? (
<div className="space-y-2">
<div>
<div className="text-sm font-medium text-muted-foreground">Name</div>
<div>{room.questionPack.name}</div>
</div>
{room.questionPack.description && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Description
</div>
<div>{room.questionPack.description}</div>
</div>
)}
{room.questionPack.questionCount !== undefined && (
<div>
<div className="text-sm font-medium text-muted-foreground">
Question Count
</div>
<div>{room.questionPack.questionCount}</div>
</div>
)}
<div>
<div className="text-sm font-medium text-muted-foreground">Pack ID</div>
<div className="font-mono text-sm">{room.questionPack.id}</div>
</div>
</div>
) : (
<div className="text-muted-foreground">No question pack assigned</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
</DialogContent>
</Dialog>
)
}

View file

@ -25,8 +25,9 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Badge } from '@/components/ui/badge' 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 { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog'
import { RoomDetailsDialog } from '@/components/RoomDetailsDialog'
export default function RoomsPage() { export default function RoomsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -36,6 +37,8 @@ export default function RoomsPage() {
const [createDialogOpen, setCreateDialogOpen] = useState(false) const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [roomToDelete, setRoomToDelete] = useState<RoomDto | null>(null) const [roomToDelete, setRoomToDelete] = useState<RoomDto | null>(null)
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null)
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false)
const limit = 20 const limit = 20
@ -69,6 +72,16 @@ export default function RoomsPage() {
setIsDeleteDialogOpen(true) setIsDeleteDialogOpen(true)
} }
const handleViewDetails = (room: RoomDto) => {
setSelectedRoomId(room.id)
setIsDetailsDialogOpen(true)
}
const handleCloseDetails = () => {
setIsDetailsDialogOpen(false)
setSelectedRoomId(null)
}
const confirmDelete = () => { const confirmDelete = () => {
if (roomToDelete?.id) { if (roomToDelete?.id) {
deleteMutation.mutate(roomToDelete.id) deleteMutation.mutate(roomToDelete.id)
@ -257,10 +270,19 @@ export default function RoomsPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetails(room)}
title="View Details"
>
<Eye className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(`/join/${room.code}`, '_blank')} onClick={() => window.open(`/join/${room.code}`, '_blank')}
title="Open in New Tab"
> >
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Button> </Button>
@ -268,6 +290,7 @@ export default function RoomsPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleDelete(room)} onClick={() => handleDelete(room)}
title="Delete Room"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -348,6 +371,13 @@ export default function RoomsPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Room Details Dialog */}
<RoomDetailsDialog
open={isDetailsDialogOpen}
roomId={selectedRoomId}
onClose={handleCloseDetails}
/>
</div> </div>
) )
} }

View file

@ -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) // Голосовой режим
password String? // Пароль для доступа к комнате
// Админские комнаты // Админские комнаты
isAdminRoom Boolean @default(false) isAdminRoom Boolean @default(false)

View file

@ -92,6 +92,13 @@ export class AdminRoomsService {
email: true, email: true,
}, },
}, },
theme: {
select: {
id: true,
name: true,
isPublic: true,
},
},
questionPack: { questionPack: {
select: { select: {
id: true, id: true,

View file

@ -630,11 +630,76 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return; 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({ await this.prisma.participant.update({
where: { id: payload.participantId }, where: { id: payload.participantId },
data: { isActive: false }, 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); await this.broadcastFullState(payload.roomCode);
} }

View file

@ -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'; import { RoomsService } from './rooms.service';
@Controller('rooms') @Controller('rooms')
@ -11,8 +11,12 @@ export class RoomsController {
} }
@Get(':code') @Get(':code')
async getRoom(@Param('code') code: string) { async getRoom(
return this.roomsService.getRoomByCode(code); @Param('code') code: string,
@Query('password') password?: string,
@Query('userId') userId?: string
) {
return this.roomsService.getRoomByCode(code, password, userId);
} }
@Post(':roomId/join') @Post(':roomId/join')

View file

@ -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 { PrismaService } from '../prisma/prisma.service';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { RoomEventsService } from '../game/room-events.service'; 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 // Remove undefined values from settings and ensure questionPackId is handled correctly
const cleanSettings = settings ? { ...settings } : {}; const cleanSettings = settings ? { ...settings } : {};
const password = cleanSettings.password;
if ('questionPackId' in cleanSettings) { if ('questionPackId' in cleanSettings) {
delete cleanSettings.questionPackId; delete cleanSettings.questionPackId;
} }
@ -34,6 +35,7 @@ export class RoomsService {
hostId, hostId,
expiresAt, expiresAt,
...cleanSettings, ...cleanSettings,
password: password ? password.trim() : null,
questionPackId: questionPackId || null, questionPackId: questionPackId || null,
}, },
include: { include: {
@ -57,11 +59,11 @@ export class RoomsService {
// Create RoomPack (always, even if empty) // Create RoomPack (always, even if empty)
await this.roomPackService.create(room.id, questionPackId); await this.roomPackService.create(room.id, questionPackId);
// Return room with roomPack // Return room with roomPack (host doesn't need password)
return this.getRoomByCode(room.code); 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({ const room = await this.prisma.room.findUnique({
where: { code }, where: { code },
include: { include: {
@ -88,7 +90,21 @@ export class RoomsService {
throw new BadRequestException('Room is no longer active'); 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') { async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {

View file

@ -315,6 +315,19 @@
color: var(--accent-primary, #ffd700); 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 */ /* Buttons */
.mgmt-button { .mgmt-button {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;

View file

@ -29,7 +29,7 @@ const GameManagementModal = ({
onUpdatePlayerScore, onUpdatePlayerScore,
onKickPlayer, 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 [selectedPlayer, setSelectedPlayer] = useState(null)
const [customPoints, setCustomPoints] = useState(10) const [customPoints, setCustomPoints] = useState(10)
@ -484,13 +484,6 @@ const GameManagementModal = ({
> >
🎮 Игра 🎮 Игра
</button> </button>
<button
className={`tab ${activeTab === 'answers' ? 'active' : ''}`}
onClick={() => setActiveTab('answers')}
disabled={gameStatus !== 'PLAYING' || !currentQuestion}
>
👁 Ответы
</button>
<button <button
className={`tab ${activeTab === 'scoring' ? 'active' : ''}`} className={`tab ${activeTab === 'scoring' ? 'active' : ''}`}
onClick={() => setActiveTab('scoring')} onClick={() => setActiveTab('scoring')}
@ -649,6 +642,36 @@ const GameManagementModal = ({
</div> </div>
)} )}
{/* Управление ответами - показывается только во время активной игры */}
{gameStatus === 'PLAYING' && currentQuestion && (
<div className="answers-control-section">
<h3>Управление ответами</h3>
<button
className="mgmt-button toggle-all-button"
onClick={areAllAnswersRevealed ? onHideAllAnswers : onShowAllAnswers}
>
{areAllAnswersRevealed ? '🙈 Скрыть все' : '👁 Показать все'}
</button>
<div className="answers-grid">
{currentQuestion.answers.map((answer, index) => (
<button
key={answer.id || index}
className={`answer-button ${
revealedAnswers.includes(answer.id) ? 'revealed' : 'hidden'
}`}
onClick={() => handleRevealAnswer(answer.id)}
>
<span className="answer-num">{index + 1}</span>
<span className="answer-txt">{answer.text}</span>
<span className="answer-pts">{answer.points}</span>
</button>
))}
</div>
</div>
)}
<div className="game-info"> <div className="game-info">
<div className="info-item"> <div className="info-item">
<span>Игроков:</span> <strong>{participants.length}</strong> <span>Игроков:</span> <strong>{participants.length}</strong>
@ -662,36 +685,6 @@ const GameManagementModal = ({
</div> </div>
)} )}
{/* ANSWERS CONTROL TAB */}
{activeTab === 'answers' && currentQuestion && (
<div className="tab-content">
<h3>Управление ответами</h3>
<button
className="mgmt-button toggle-all-button"
onClick={areAllAnswersRevealed ? onHideAllAnswers : onShowAllAnswers}
>
{areAllAnswersRevealed ? '🙈 Скрыть все' : '👁 Показать все'}
</button>
<div className="answers-grid">
{currentQuestion.answers.map((answer, index) => (
<button
key={answer.id || index}
className={`answer-button ${
revealedAnswers.includes(answer.id) ? 'revealed' : 'hidden'
}`}
onClick={() => handleRevealAnswer(answer.id)}
>
<span className="answer-num">{index + 1}</span>
<span className="answer-txt">{answer.text}</span>
<span className="answer-pts">{answer.points}</span>
</button>
))}
</div>
</div>
)}
{/* SCORING TAB */} {/* SCORING TAB */}
{activeTab === 'scoring' && ( {activeTab === 'scoring' && (
<div className="tab-content"> <div className="tab-content">

View file

@ -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 (
<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">
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setLocalError('');
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit(e);
}
}}
placeholder="Пароль"
className="name-input-field"
autoFocus
/>
{displayError && (
<p className="name-input-error">{displayError}</p>
)}
</div>
</div>
<div className="name-input-modal-footer">
<button
type="submit"
className="name-input-submit-button primary"
disabled={!password.trim()}
>
Войти
</button>
{onCancel && (
<button
type="button"
onClick={onCancel}
className="name-input-cancel-button secondary"
>
Отмена
</button>
)}
</div>
</form>
</div>
</div>
);
};
export default PasswordModal;

View file

@ -128,14 +128,21 @@
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
grid-auto-rows: auto; 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; flex: 1;
min-height: 0; min-height: 0;
} }
@media (min-width: 900px) {
.answers-grid {
row-gap: 12px;
}
}
@media (min-width: 1200px) { @media (min-width: 1200px) {
.answers-grid { .answers-grid {
gap: 12px; column-gap: 12px;
} }
} }

View file

@ -20,10 +20,38 @@ const VoicePlayer = ({
const isPlayingThis = isPlaying && currentText === speechId; 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) { if (isPlayingThis) {
console.log('[VoicePlayer] Stopping playback');
stop(); 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 }); speak({ roomId, questionId, contentType, answerId });
} }
}; };
@ -35,11 +63,27 @@ const VoicePlayer = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoPlay, isEnabled, roomId, questionId, contentType, answerId]); }, [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; 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 ( return (
<div className="voice-player"> <div className="voice-player">

View file

@ -3,12 +3,13 @@ import { roomsApi } from '../services/api';
import socketService from '../services/socket'; import socketService from '../services/socket';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
export const useRoom = (roomCode, onGameStarted = null) => { export const useRoom = (roomCode, onGameStarted = null, password = null) => {
const { user } = useAuth(); const { user } = useAuth();
const [room, setRoom] = useState(null); const [room, setRoom] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [participants, setParticipants] = useState([]); const [participants, setParticipants] = useState([]);
const [requiresPassword, setRequiresPassword] = useState(false);
useEffect(() => { useEffect(() => {
if (!roomCode) { if (!roomCode) {
@ -19,12 +20,20 @@ export const useRoom = (roomCode, onGameStarted = null) => {
const fetchRoom = async () => { const fetchRoom = async () => {
try { try {
setLoading(true); setLoading(true);
const response = await roomsApi.getByCode(roomCode); const response = await roomsApi.getByCode(roomCode, password, user?.id);
setRoom(response.data); setRoom(response.data);
setParticipants(response.data.participants || []); setParticipants(response.data.participants || []);
setError(null); setError(null);
setRequiresPassword(false);
} catch (err) { } 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); console.error('Error fetching room:', err);
} finally { } finally {
setLoading(false); setLoading(false);
@ -81,7 +90,7 @@ export const useRoom = (roomCode, onGameStarted = null) => {
socketService.off('gameStarted', handleGameStarted); socketService.off('gameStarted', handleGameStarted);
socketService.off('gameStateUpdated', handleGameStateUpdated); socketService.off('gameStateUpdated', handleGameStateUpdated);
}; };
}, [roomCode, onGameStarted, user?.id]); }, [roomCode, password, onGameStarted, user?.id]);
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => { const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
try { try {
@ -132,11 +141,35 @@ export const useRoom = (roomCode, onGameStarted = null) => {
[room], [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 { return {
room, room,
participants, participants,
loading, loading,
error, error,
requiresPassword,
fetchRoomWithPassword,
createRoom, createRoom,
joinRoom, joinRoom,
startGame, startGame,

View file

@ -85,6 +85,9 @@ export function useVoice() {
requestBody.voice = voice; 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`, { const response = await fetch(`${API_URL}/voice/tts`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -94,12 +97,21 @@ export function useVoice() {
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
console.log('[useVoice] TTS response status:', response.status, response.statusText);
if (!response.ok) { 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 // Create blob URL from response
const blob = await response.blob(); const blob = await response.blob();
console.log('[useVoice] Received audio blob, size:', blob.size, 'bytes, type:', blob.type);
const audioUrl = URL.createObjectURL(blob); const audioUrl = URL.createObjectURL(blob);
// Cache the URL // Cache the URL
@ -107,10 +119,20 @@ export function useVoice() {
audioCache.current.set(cacheKey, audioUrl); audioCache.current.set(cacheKey, audioUrl);
} }
console.log('[useVoice] Successfully generated speech, cached URL:', audioUrl);
return audioUrl; return audioUrl;
} catch (error) { } catch (error) {
console.error('Failed to generate speech:', error); console.error('[useVoice] Failed to generate speech:', error);
throw 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 * @param {Object} options - Options
*/ */
const speak = useCallback(async (params, options = {}) => { const speak = useCallback(async (params, options = {}) => {
if (!isEnabled) return; console.log('[useVoice] speak called with:', params);
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
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 { try {
// Create a unique identifier for this speech request // Create a unique identifier for this speech request
const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`; const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`;
console.log('[useVoice] Starting speech playback, speechId:', speechId);
setCurrentText(speechId); setCurrentText(speechId);
setIsPlaying(true); setIsPlaying(true);
const audioUrl = await generateSpeech(params, options); const audioUrl = await generateSpeech(params, options);
console.log('[useVoice] Generated audio URL:', audioUrl);
// Create or reuse audio element // Create or reuse audio element
if (!audioRef.current) { if (!audioRef.current) {
@ -151,9 +195,16 @@ export function useVoice() {
setCurrentText(null); setCurrentText(null);
}; };
console.log('[useVoice] Playing audio');
await audio.play(); await audio.play();
console.log('[useVoice] Audio playback started successfully');
} catch (error) { } 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); setIsPlaying(false);
setCurrentText(null); setCurrentText(null);
} }

View file

@ -17,6 +17,7 @@ const CreateRoom = () => {
allowSpectators: true, allowSpectators: true,
timerEnabled: false, timerEnabled: false,
timerDuration: 30, timerDuration: 30,
password: '',
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isNameModalOpen, setIsNameModalOpen] = useState(false); const [isNameModalOpen, setIsNameModalOpen] = useState(false);
@ -75,10 +76,18 @@ const CreateRoom = () => {
setIsHostNameModalOpen(false); setIsHostNameModalOpen(false);
try { try {
// Очищаем пустой пароль перед отправкой
const cleanSettings = { ...settings };
if (!cleanSettings.password || !cleanSettings.password.trim()) {
delete cleanSettings.password;
} else {
cleanSettings.password = cleanSettings.password.trim();
}
const room = await createRoom( const room = await createRoom(
user.id, user.id,
selectedPackId || undefined, selectedPackId || undefined,
settings, cleanSettings,
name.trim(), name.trim(),
); );
navigate(`/room/${room.code}`); navigate(`/room/${room.code}`);
@ -166,6 +175,21 @@ const CreateRoom = () => {
</div> </div>
)} )}
<div className="form-group">
<label>Пароль на комнату (необязательно):</label>
<input
type="password"
value={settings.password}
onChange={(e) =>
setSettings({ ...settings, password: e.target.value })
}
placeholder="Оставьте пустым для публичной комнаты"
/>
<small style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: '0.85rem', marginTop: '5px', display: 'block' }}>
Если указан пароль, только игроки с паролем смогут присоединиться к комнате
</small>
</div>
<div className="button-group"> <div className="button-group">
<button <button
onClick={handleCreateRoom} onClick={handleCreateRoom}

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } 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 { questionsApi, roomsApi } from '../services/api'; import { questionsApi, roomsApi } from '../services/api';
@ -35,6 +35,9 @@ const GamePage = () => {
const [qrCode, setQrCode] = useState(''); const [qrCode, setQrCode] = useState('');
const [questionPacks, setQuestionPacks] = useState([]); const [questionPacks, setQuestionPacks] = useState([]);
// Храним participantId текущего пользователя для проверки удаления
const currentUserParticipantIdRef = useRef(null);
// ЕДИНСТВЕННЫЙ обработчик состояния игры // ЕДИНСТВЕННЫЙ обработчик состояния игры
useEffect(() => { useEffect(() => {
if (!roomCode) return; if (!roomCode) return;
@ -43,6 +46,14 @@ const GamePage = () => {
console.log('📦 Game state updated:', state); console.log('📦 Game state updated:', state);
setGameState(state); setGameState(state);
setLoading(false); setLoading(false);
// Обновляем ref с participantId текущего пользователя
if (user?.id && state.participants) {
const currentUserParticipant = state.participants.find(
(p) => p.userId === user.id
);
currentUserParticipantIdRef.current = currentUserParticipant?.id || null;
}
}; };
socketService.connect(); socketService.connect();
@ -54,6 +65,32 @@ const GamePage = () => {
}; };
}, [roomCode, user?.id]); }, [roomCode, user?.id]);
// Обработка события удаления игрока
useEffect(() => {
if (!roomCode || !user) return;
const handlePlayerKicked = (data) => {
const { participantId, userId: kickedUserId, participantName } = data;
// Проверяем, был ли удален текущий пользователь (по userId для надежности)
if (kickedUserId === user.id || currentUserParticipantIdRef.current === participantId) {
alert(`Вы были удалены из игры хостом`);
currentUserParticipantIdRef.current = null;
navigate('/');
return;
}
// Для остальных просто логируем - состояние обновится через gameStateUpdated
console.log(`Player ${participantName} was kicked from the game`);
};
socketService.on('playerKicked', handlePlayerKicked);
return () => {
socketService.off('playerKicked', handlePlayerKicked);
};
}, [roomCode, user, navigate]);
// Переподключение - автоматически получаем состояние // Переподключение - автоматически получаем состояние
useEffect(() => { useEffect(() => {
const handleReconnect = () => { const handleReconnect = () => {
@ -396,8 +433,8 @@ const GamePage = () => {
playerScores={playerScores} playerScores={playerScores}
currentPlayerId={gameState.currentPlayerId} currentPlayerId={gameState.currentPlayerId}
onAnswerClick={handleAnswerClick} onAnswerClick={handleAnswerClick}
onPreviousQuestion={canGoPrev ? handlePrevQuestion : null} onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
onNextQuestion={canGoNext ? handleNextQuestion : null} onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
onSelectPlayer={isHost ? handleSelectPlayer : null} onSelectPlayer={isHost ? handleSelectPlayer : null}
/> />
</div> </div>

View file

@ -6,6 +6,7 @@ import { questionsApi } from '../services/api';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
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';
const RoomPage = () => { const RoomPage = () => {
const { roomCode } = useParams(); const { roomCode } = useParams();
@ -17,19 +18,24 @@ const RoomPage = () => {
navigate(`/game/${roomCode}`); navigate(`/game/${roomCode}`);
}, [navigate, roomCode]); }, [navigate, roomCode]);
const [password, setPassword] = useState(null);
const { const {
room, room,
participants, participants,
loading, loading,
error, error,
requiresPassword,
fetchRoomWithPassword,
joinRoom, joinRoom,
startGame, startGame,
updateQuestionPack, updateQuestionPack,
} = useRoom(roomCode, handleGameStartedEvent); } = 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 [passwordError, setPasswordError] = useState(null);
const [questionPacks, setQuestionPacks] = useState([]); const [questionPacks, setQuestionPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState(''); const [selectedPackId, setSelectedPackId] = useState('');
const [loadingPacks, setLoadingPacks] = useState(false); const [loadingPacks, setLoadingPacks] = useState(false);
@ -59,14 +65,45 @@ const RoomPage = () => {
} }
}, [roomCode]); }, [roomCode]);
// Проверка пароля: показываем модальное окно, если требуется пароль
// Хост не должен видеть модальное окно пароля (проверяется на бэкенде)
useEffect(() => {
if (requiresPassword && !isPasswordModalOpen && !loading && user) {
// Проверяем, не является ли пользователь хостом
// Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ)
setIsPasswordModalOpen(true);
} else if (requiresPassword && !isPasswordModalOpen && !loading && !user) {
// Если пользователь не авторизован, все равно показываем модальное окно
// После авторизации проверим, является ли он хостом
setIsPasswordModalOpen(true);
}
}, [requiresPassword, isPasswordModalOpen, loading, user]);
// Проверка авторизации и показ модального окна для ввода имени // Проверка авторизации и показ модального окна для ввода имени
useEffect(() => { useEffect(() => {
if (!authLoading && !user && room && !loading) { if (!authLoading && !user && room && !loading && !requiresPassword) {
setIsNameModalOpen(true); setIsNameModalOpen(true);
} else if (user) { } else if (user) {
setIsNameModalOpen(false); 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) => { const handleNameSubmit = async (name) => {
@ -161,7 +198,8 @@ const RoomPage = () => {
return <div className="loading">Загрузка комнаты...</div>; return <div className="loading">Загрузка комнаты...</div>;
} }
if (error) { // Не показываем ошибку, если требуется пароль - покажем модальное окно
if (error && !requiresPassword && error !== 'Room password required') {
return ( return (
<div className="error-page"> <div className="error-page">
<h1>Ошибка</h1> <h1>Ошибка</h1>
@ -171,7 +209,7 @@ const RoomPage = () => {
); );
} }
if (!room) { if (!room && !requiresPassword && !loading) {
return ( return (
<div className="error-page"> <div className="error-page">
<h1>Комната не найдена</h1> <h1>Комната не найдена</h1>
@ -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 (
<>
<div className="loading">Загрузка комнаты...</div>
<PasswordModal
isOpen={isPasswordModalOpen}
onSubmit={handlePasswordSubmit}
onCancel={() => navigate('/')}
error={passwordError}
/>
</>
);
}
return ( return (
<div className="room-page"> <div className="room-page">
@ -293,6 +346,13 @@ const RoomPage = () => {
onSubmit={handleNameSubmit} onSubmit={handleNameSubmit}
onCancel={null} onCancel={null}
/> />
<PasswordModal
isOpen={isPasswordModalOpen}
onSubmit={handlePasswordSubmit}
onCancel={() => navigate('/')}
error={passwordError}
/>
</div> </div>
); );
}; };

View file

@ -18,7 +18,12 @@ export const authApi = {
export const roomsApi = { export const roomsApi = {
create: (hostId, questionPackId, settings, hostName) => create: (hostId, questionPackId, settings, hostName) =>
api.post('/rooms', { 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) => join: (roomId, userId, name, role) =>
api.post(`/rooms/${roomId}/join`, { userId, name, role }), api.post(`/rooms/${roomId}/join`, { userId, name, role }),
updateQuestionPack: (roomId, questionPackId) => updateQuestionPack: (roomId, questionPackId) =>