fies
This commit is contained in:
parent
138aad77fe
commit
4e2ec9d8c7
19 changed files with 1177 additions and 87 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
554
admin/src/components/RoomDetailsDialog.tsx
Normal file
554
admin/src/components/RoomDetailsDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,22 +642,9 @@ const GameManagementModal = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="game-info">
|
{/* Управление ответами - показывается только во время активной игры */}
|
||||||
<div className="info-item">
|
{gameStatus === 'PLAYING' && currentQuestion && (
|
||||||
<span>Игроков:</span> <strong>{participants.length}</strong>
|
<div className="answers-control-section">
|
||||||
</div>
|
|
||||||
{gameStatus === 'PLAYING' && totalQuestions > 0 && (
|
|
||||||
<div className="info-item">
|
|
||||||
<span>Вопросов:</span> <strong>{totalQuestions}</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ANSWERS CONTROL TAB */}
|
|
||||||
{activeTab === 'answers' && currentQuestion && (
|
|
||||||
<div className="tab-content">
|
|
||||||
<h3>Управление ответами</h3>
|
<h3>Управление ответами</h3>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -692,6 +672,19 @@ const GameManagementModal = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="game-info">
|
||||||
|
<div className="info-item">
|
||||||
|
<span>Игроков:</span> <strong>{participants.length}</strong>
|
||||||
|
</div>
|
||||||
|
{gameStatus === 'PLAYING' && totalQuestions > 0 && (
|
||||||
|
<div className="info-item">
|
||||||
|
<span>Вопросов:</span> <strong>{totalQuestions}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* SCORING TAB */}
|
{/* SCORING TAB */}
|
||||||
{activeTab === 'scoring' && (
|
{activeTab === 'scoring' && (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
|
|
|
||||||
119
src/components/PasswordModal.jsx
Normal file
119
src/components/PasswordModal.jsx
Normal 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;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue