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 type { AxiosError } from 'axios'
|
||||
|
||||
export interface ParticipantDto {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
role: 'HOST' | 'PLAYER' | 'SPECTATOR'
|
||||
score: number
|
||||
joinedAt: string
|
||||
isActive: boolean
|
||||
user: {
|
||||
id: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoomDto {
|
||||
id: string
|
||||
code: string
|
||||
status: 'WAITING' | 'PLAYING' | 'FINISHED'
|
||||
hostId: string
|
||||
createdAt: string
|
||||
expiresAt?: string
|
||||
expiresAt?: string | null
|
||||
isAdminRoom: boolean
|
||||
customCode?: string
|
||||
activeFrom?: string
|
||||
activeTo?: string
|
||||
themeId?: string
|
||||
questionPackId?: string
|
||||
customCode?: string | null
|
||||
activeFrom?: string | null
|
||||
activeTo?: string | null
|
||||
themeId?: string | null
|
||||
questionPackId?: string | null
|
||||
uiControls?: {
|
||||
allowThemeChange?: boolean
|
||||
allowPackChange?: boolean
|
||||
allowNameChange?: boolean
|
||||
allowScoreEdit?: boolean
|
||||
}
|
||||
} | null
|
||||
maxPlayers: number
|
||||
allowSpectators: boolean
|
||||
timerEnabled: boolean
|
||||
timerDuration: number
|
||||
autoAdvance?: boolean
|
||||
voiceMode?: boolean
|
||||
currentQuestionIndex?: number
|
||||
totalQuestions?: number
|
||||
answeredQuestions?: number
|
||||
currentQuestionId?: string | null
|
||||
currentPlayerId?: string | null
|
||||
isGameOver?: boolean
|
||||
revealedAnswers?: Record<string, string[]> | null
|
||||
host: {
|
||||
id: string
|
||||
name?: string
|
||||
email?: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
}
|
||||
theme?: {
|
||||
id: string
|
||||
name: string
|
||||
isPublic: boolean
|
||||
}
|
||||
} | null
|
||||
questionPack?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
description?: string | null
|
||||
questionCount?: number
|
||||
} | null
|
||||
participants?: ParticipantDto[]
|
||||
_count?: {
|
||||
participants: number
|
||||
}
|
||||
startedAt?: string
|
||||
finishedAt?: string
|
||||
startedAt?: string | null
|
||||
finishedAt?: string | null
|
||||
}
|
||||
|
||||
export interface CreateAdminRoomDto {
|
||||
|
|
|
|||
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,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink } from 'lucide-react'
|
||||
import { Search, Plus, Trash2, ChevronLeft, ChevronRight, ExternalLink, Eye } from 'lucide-react'
|
||||
import { CreateAdminRoomDialog } from '@/components/CreateAdminRoomDialog'
|
||||
import { RoomDetailsDialog } from '@/components/RoomDetailsDialog'
|
||||
|
||||
export default function RoomsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -36,6 +37,8 @@ export default function RoomsPage() {
|
|||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
const [roomToDelete, setRoomToDelete] = useState<RoomDto | null>(null)
|
||||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null)
|
||||
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false)
|
||||
|
||||
const limit = 20
|
||||
|
||||
|
|
@ -69,6 +72,16 @@ export default function RoomsPage() {
|
|||
setIsDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewDetails = (room: RoomDto) => {
|
||||
setSelectedRoomId(room.id)
|
||||
setIsDetailsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setIsDetailsDialogOpen(false)
|
||||
setSelectedRoomId(null)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (roomToDelete?.id) {
|
||||
deleteMutation.mutate(roomToDelete.id)
|
||||
|
|
@ -257,10 +270,19 @@ export default function RoomsPage() {
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(`/join/${room.code}`, '_blank')}
|
||||
title="Open in New Tab"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -268,6 +290,7 @@ export default function RoomsPage() {
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(room)}
|
||||
title="Delete Room"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
@ -348,6 +371,13 @@ export default function RoomsPage() {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Room Details Dialog */}
|
||||
<RoomDetailsDialog
|
||||
open={isDetailsDialogOpen}
|
||||
roomId={selectedRoomId}
|
||||
onClose={handleCloseDetails}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ model Room {
|
|||
questionPackId String?
|
||||
autoAdvance Boolean @default(false)
|
||||
voiceMode Boolean @default(false) // Голосовой режим
|
||||
password String? // Пароль для доступа к комнате
|
||||
|
||||
// Админские комнаты
|
||||
isAdminRoom Boolean @default(false)
|
||||
|
|
|
|||
|
|
@ -92,6 +92,13 @@ export class AdminRoomsService {
|
|||
email: true,
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isPublic: true,
|
||||
},
|
||||
},
|
||||
questionPack: {
|
||||
select: {
|
||||
id: true,
|
||||
|
|
|
|||
|
|
@ -630,11 +630,76 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
|||
return;
|
||||
}
|
||||
|
||||
// Получаем комнату с участниками
|
||||
const room = (await this.prisma.room.findUnique({
|
||||
where: { id: payload.roomId },
|
||||
include: {
|
||||
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
|
||||
} as Prisma.RoomInclude,
|
||||
})) as unknown as RoomWithPack | null;
|
||||
|
||||
if (!room) {
|
||||
client.emit('error', { message: 'Room not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем существование и активность участника
|
||||
const participant = await this.prisma.participant.findUnique({
|
||||
where: { id: payload.participantId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!participant || participant.roomId !== payload.roomId) {
|
||||
client.emit('error', { message: 'Participant not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!participant.isActive) {
|
||||
client.emit('error', { message: 'Participant is already inactive' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Запрещаем удаление хоста
|
||||
if (participant.role === 'HOST' || participant.userId === room.hostId) {
|
||||
client.emit('error', { message: 'Cannot kick the host' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Если удаляемый участник - текущий игрок, выбираем следующего
|
||||
let newCurrentPlayerId = room.currentPlayerId;
|
||||
if (room.currentPlayerId === payload.participantId) {
|
||||
const activeParticipants = room.participants.filter(p => p.id !== payload.participantId);
|
||||
if (activeParticipants.length > 0) {
|
||||
// Выбираем первого активного участника после удаляемого
|
||||
newCurrentPlayerId = activeParticipants[0].id;
|
||||
} else {
|
||||
newCurrentPlayerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Деактивируем участника
|
||||
await this.prisma.participant.update({
|
||||
where: { id: payload.participantId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
// Обновляем currentPlayerId если нужно
|
||||
if (newCurrentPlayerId !== room.currentPlayerId) {
|
||||
await this.prisma.room.update({
|
||||
where: { id: payload.roomId },
|
||||
data: { currentPlayerId: newCurrentPlayerId },
|
||||
});
|
||||
}
|
||||
|
||||
// Отправляем событие об удалении
|
||||
this.roomEventsService.emitPlayerKicked(payload.roomCode, {
|
||||
participantId: payload.participantId,
|
||||
userId: participant.userId,
|
||||
participantName: participant.name,
|
||||
newCurrentPlayerId,
|
||||
});
|
||||
|
||||
// Отправляем обновленное состояние
|
||||
await this.broadcastFullState(payload.roomCode);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Post, Get, Body, Param, Patch, Put } from '@nestjs/common';
|
||||
import { Controller, Post, Get, Body, Param, Patch, Put, Query } from '@nestjs/common';
|
||||
import { RoomsService } from './rooms.service';
|
||||
|
||||
@Controller('rooms')
|
||||
|
|
@ -11,8 +11,12 @@ export class RoomsController {
|
|||
}
|
||||
|
||||
@Get(':code')
|
||||
async getRoom(@Param('code') code: string) {
|
||||
return this.roomsService.getRoomByCode(code);
|
||||
async getRoom(
|
||||
@Param('code') code: string,
|
||||
@Query('password') password?: string,
|
||||
@Query('userId') userId?: string
|
||||
) {
|
||||
return this.roomsService.getRoomByCode(code, password, userId);
|
||||
}
|
||||
|
||||
@Post(':roomId/join')
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Injectable, Inject, forwardRef, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { RoomEventsService } from '../game/room-events.service';
|
||||
|
|
@ -21,6 +21,7 @@ export class RoomsService {
|
|||
|
||||
// Remove undefined values from settings and ensure questionPackId is handled correctly
|
||||
const cleanSettings = settings ? { ...settings } : {};
|
||||
const password = cleanSettings.password;
|
||||
if ('questionPackId' in cleanSettings) {
|
||||
delete cleanSettings.questionPackId;
|
||||
}
|
||||
|
|
@ -34,6 +35,7 @@ export class RoomsService {
|
|||
hostId,
|
||||
expiresAt,
|
||||
...cleanSettings,
|
||||
password: password ? password.trim() : null,
|
||||
questionPackId: questionPackId || null,
|
||||
},
|
||||
include: {
|
||||
|
|
@ -57,11 +59,11 @@ export class RoomsService {
|
|||
// Create RoomPack (always, even if empty)
|
||||
await this.roomPackService.create(room.id, questionPackId);
|
||||
|
||||
// Return room with roomPack
|
||||
return this.getRoomByCode(room.code);
|
||||
// Return room with roomPack (host doesn't need password)
|
||||
return this.getRoomByCode(room.code, undefined, hostId);
|
||||
}
|
||||
|
||||
async getRoomByCode(code: string) {
|
||||
async getRoomByCode(code: string, password?: string, userId?: string) {
|
||||
const room = await this.prisma.room.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
|
|
@ -88,7 +90,21 @@ export class RoomsService {
|
|||
throw new BadRequestException('Room is no longer active');
|
||||
}
|
||||
|
||||
return room;
|
||||
// Проверка пароля: если комната защищена паролем
|
||||
if (room.password) {
|
||||
// Хост всегда имеет доступ к своей комнате
|
||||
if (!userId || room.hostId !== userId) {
|
||||
// Если пароль не предоставлен или неверный
|
||||
if (!password || password.trim() !== room.password) {
|
||||
throw new UnauthorizedException('Room password required or incorrect');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Не возвращаем пароль в ответе
|
||||
const roomWithoutPassword = { ...room };
|
||||
delete roomWithoutPassword.password;
|
||||
return roomWithoutPassword;
|
||||
}
|
||||
|
||||
async joinRoom(roomId: string, userId: string, name: string, role: 'PLAYER' | 'SPECTATOR') {
|
||||
|
|
|
|||
|
|
@ -315,6 +315,19 @@
|
|||
color: var(--accent-primary, #ffd700);
|
||||
}
|
||||
|
||||
/* Answers control section */
|
||||
.answers-control-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 2px solid var(--border-color, rgba(255, 255, 255, 0.2));
|
||||
}
|
||||
|
||||
.answers-control-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.mgmt-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const GameManagementModal = ({
|
|||
onUpdatePlayerScore,
|
||||
onKickPlayer,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions
|
||||
const [activeTab, setActiveTab] = useState('players') // players | game | scoring | questions
|
||||
const [selectedPlayer, setSelectedPlayer] = useState(null)
|
||||
const [customPoints, setCustomPoints] = useState(10)
|
||||
|
||||
|
|
@ -484,13 +484,6 @@ const GameManagementModal = ({
|
|||
>
|
||||
🎮 Игра
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'answers' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('answers')}
|
||||
disabled={gameStatus !== 'PLAYING' || !currentQuestion}
|
||||
>
|
||||
👁 Ответы
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'scoring' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('scoring')}
|
||||
|
|
@ -649,6 +642,36 @@ const GameManagementModal = ({
|
|||
</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="info-item">
|
||||
<span>Игроков:</span> <strong>{participants.length}</strong>
|
||||
|
|
@ -662,36 +685,6 @@ const GameManagementModal = ({
|
|||
</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 */}
|
||||
{activeTab === 'scoring' && (
|
||||
<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;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
gap: clamp(6px, 1.2vw, 12px);
|
||||
column-gap: clamp(6px, 1.2vw, 12px);
|
||||
row-gap: clamp(6px, 0.8vh, 12px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.answers-grid {
|
||||
row-gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.answers-grid {
|
||||
gap: 12px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,10 +20,38 @@ const VoicePlayer = ({
|
|||
|
||||
const isPlayingThis = isPlaying && currentText === speechId;
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (e) => {
|
||||
// Prevent event propagation to parent button
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
console.log('[VoicePlayer] handleClick', {
|
||||
isPlayingThis,
|
||||
roomId,
|
||||
questionId,
|
||||
contentType,
|
||||
answerId,
|
||||
canPlay: roomId && questionId && contentType && (contentType !== 'answer' || answerId),
|
||||
});
|
||||
|
||||
if (isPlayingThis) {
|
||||
console.log('[VoicePlayer] Stopping playback');
|
||||
stop();
|
||||
} else if (roomId && questionId && contentType) {
|
||||
} else {
|
||||
// Validate before calling speak
|
||||
if (!roomId || !questionId || !contentType) {
|
||||
console.warn('[VoicePlayer] Missing required params:', { roomId, questionId, contentType });
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentType === 'answer' && !answerId) {
|
||||
console.warn('[VoicePlayer] answerId is required for answer contentType');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[VoicePlayer] Calling speak with params:', { roomId, questionId, contentType, answerId });
|
||||
speak({ roomId, questionId, contentType, answerId });
|
||||
}
|
||||
};
|
||||
|
|
@ -35,11 +63,27 @@ const VoicePlayer = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoPlay, isEnabled, roomId, questionId, contentType, answerId]);
|
||||
|
||||
if (!isEnabled || !showButton) {
|
||||
const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId);
|
||||
|
||||
if (!isEnabled) {
|
||||
console.log('[VoicePlayer] Voice is disabled, not rendering button');
|
||||
return children || null;
|
||||
}
|
||||
|
||||
const canPlay = roomId && questionId && contentType && (contentType !== 'answer' || answerId);
|
||||
if (!showButton) {
|
||||
return children || null;
|
||||
}
|
||||
|
||||
if (!canPlay) {
|
||||
console.warn('[VoicePlayer] Cannot play - missing params, button will not render:', {
|
||||
roomId: !!roomId,
|
||||
questionId: !!questionId,
|
||||
contentType: !!contentType,
|
||||
answerId: !!answerId,
|
||||
contentTypeValue: contentType,
|
||||
});
|
||||
return children || null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-player">
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { roomsApi } from '../services/api';
|
|||
import socketService from '../services/socket';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export const useRoom = (roomCode, onGameStarted = null) => {
|
||||
export const useRoom = (roomCode, onGameStarted = null, password = null) => {
|
||||
const { user } = useAuth();
|
||||
const [room, setRoom] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [requiresPassword, setRequiresPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomCode) {
|
||||
|
|
@ -19,12 +20,20 @@ export const useRoom = (roomCode, onGameStarted = null) => {
|
|||
const fetchRoom = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await roomsApi.getByCode(roomCode);
|
||||
const response = await roomsApi.getByCode(roomCode, password, user?.id);
|
||||
setRoom(response.data);
|
||||
setParticipants(response.data.participants || []);
|
||||
setError(null);
|
||||
setRequiresPassword(false);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
// Проверяем, требуется ли пароль (401 Unauthorized)
|
||||
if (err.response?.status === 401) {
|
||||
setRequiresPassword(true);
|
||||
setError('Room password required');
|
||||
} else {
|
||||
setError(err.response?.data?.message || err.message);
|
||||
setRequiresPassword(false);
|
||||
}
|
||||
console.error('Error fetching room:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -81,7 +90,7 @@ export const useRoom = (roomCode, onGameStarted = null) => {
|
|||
socketService.off('gameStarted', handleGameStarted);
|
||||
socketService.off('gameStateUpdated', handleGameStateUpdated);
|
||||
};
|
||||
}, [roomCode, onGameStarted, user?.id]);
|
||||
}, [roomCode, password, onGameStarted, user?.id]);
|
||||
|
||||
const createRoom = useCallback(async (hostId, questionPackId, settings = {}, hostName) => {
|
||||
try {
|
||||
|
|
@ -132,11 +141,35 @@ export const useRoom = (roomCode, onGameStarted = null) => {
|
|||
[room],
|
||||
);
|
||||
|
||||
const fetchRoomWithPassword = useCallback(async (roomPassword) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await roomsApi.getByCode(roomCode, roomPassword, user?.id);
|
||||
setRoom(response.data);
|
||||
setParticipants(response.data.participants || []);
|
||||
setError(null);
|
||||
setRequiresPassword(false);
|
||||
return response.data;
|
||||
} catch (err) {
|
||||
if (err.response?.status === 401) {
|
||||
setRequiresPassword(true);
|
||||
setError('Incorrect password');
|
||||
} else {
|
||||
setError(err.response?.data?.message || err.message);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [roomCode, user?.id]);
|
||||
|
||||
return {
|
||||
room,
|
||||
participants,
|
||||
loading,
|
||||
error,
|
||||
requiresPassword,
|
||||
fetchRoomWithPassword,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
startGame,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,9 @@ export function useVoice() {
|
|||
requestBody.voice = voice;
|
||||
}
|
||||
|
||||
console.log('[useVoice] Sending TTS request to:', `${API_URL}/voice/tts`);
|
||||
console.log('[useVoice] Request body:', requestBody);
|
||||
|
||||
const response = await fetch(`${API_URL}/voice/tts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
|
@ -94,12 +97,21 @@ export function useVoice() {
|
|||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
console.log('[useVoice] TTS response status:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Voice service error: ${response.status}`);
|
||||
const errorText = await response.text().catch(() => 'Unable to read error response');
|
||||
console.error('[useVoice] TTS request failed:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorText,
|
||||
});
|
||||
throw new Error(`Voice service error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
// Create blob URL from response
|
||||
const blob = await response.blob();
|
||||
console.log('[useVoice] Received audio blob, size:', blob.size, 'bytes, type:', blob.type);
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
|
||||
// Cache the URL
|
||||
|
|
@ -107,10 +119,20 @@ export function useVoice() {
|
|||
audioCache.current.set(cacheKey, audioUrl);
|
||||
}
|
||||
|
||||
console.log('[useVoice] Successfully generated speech, cached URL:', audioUrl);
|
||||
return audioUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate speech:', error);
|
||||
throw error;
|
||||
console.error('[useVoice] Failed to generate speech:', error);
|
||||
console.error('[useVoice] Error context:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
params,
|
||||
API_URL,
|
||||
});
|
||||
|
||||
// Re-throw with more context
|
||||
const errorMessage = error?.message || 'Unknown error during speech generation';
|
||||
throw new Error(`Failed to generate speech: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -120,16 +142,38 @@ export function useVoice() {
|
|||
* @param {Object} options - Options
|
||||
*/
|
||||
const speak = useCallback(async (params, options = {}) => {
|
||||
if (!isEnabled) return;
|
||||
if (!params || !params.roomId || !params.questionId || !params.contentType) return;
|
||||
console.log('[useVoice] speak called with:', params);
|
||||
|
||||
if (!isEnabled) {
|
||||
console.warn('[useVoice] Voice is disabled, cannot speak');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || !params.roomId || !params.questionId || !params.contentType) {
|
||||
console.warn('[useVoice] Missing required params:', {
|
||||
hasParams: !!params,
|
||||
roomId: params?.roomId,
|
||||
questionId: params?.questionId,
|
||||
contentType: params?.contentType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Early validation for answerId when contentType is 'answer'
|
||||
if (params.contentType === 'answer' && !params.answerId) {
|
||||
console.error('[useVoice] answerId is required when contentType is "answer"');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a unique identifier for this speech request
|
||||
const speechId = `${params.roomId}:${params.questionId}:${params.contentType}:${params.answerId || ''}`;
|
||||
console.log('[useVoice] Starting speech playback, speechId:', speechId);
|
||||
setCurrentText(speechId);
|
||||
setIsPlaying(true);
|
||||
|
||||
const audioUrl = await generateSpeech(params, options);
|
||||
console.log('[useVoice] Generated audio URL:', audioUrl);
|
||||
|
||||
// Create or reuse audio element
|
||||
if (!audioRef.current) {
|
||||
|
|
@ -151,9 +195,16 @@ export function useVoice() {
|
|||
setCurrentText(null);
|
||||
};
|
||||
|
||||
console.log('[useVoice] Playing audio');
|
||||
await audio.play();
|
||||
console.log('[useVoice] Audio playback started successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to speak:', error);
|
||||
console.error('[useVoice] Failed to speak:', error);
|
||||
console.error('[useVoice] Error details:', {
|
||||
message: error?.message,
|
||||
stack: error?.stack,
|
||||
params,
|
||||
});
|
||||
setIsPlaying(false);
|
||||
setCurrentText(null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const CreateRoom = () => {
|
|||
allowSpectators: true,
|
||||
timerEnabled: false,
|
||||
timerDuration: 30,
|
||||
password: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
||||
|
|
@ -75,10 +76,18 @@ const CreateRoom = () => {
|
|||
setIsHostNameModalOpen(false);
|
||||
|
||||
try {
|
||||
// Очищаем пустой пароль перед отправкой
|
||||
const cleanSettings = { ...settings };
|
||||
if (!cleanSettings.password || !cleanSettings.password.trim()) {
|
||||
delete cleanSettings.password;
|
||||
} else {
|
||||
cleanSettings.password = cleanSettings.password.trim();
|
||||
}
|
||||
|
||||
const room = await createRoom(
|
||||
user.id,
|
||||
selectedPackId || undefined,
|
||||
settings,
|
||||
cleanSettings,
|
||||
name.trim(),
|
||||
);
|
||||
navigate(`/room/${room.code}`);
|
||||
|
|
@ -166,6 +175,21 @@ const CreateRoom = () => {
|
|||
</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">
|
||||
<button
|
||||
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 { useAuth } from '../context/AuthContext';
|
||||
import { questionsApi, roomsApi } from '../services/api';
|
||||
|
|
@ -34,6 +34,9 @@ const GamePage = () => {
|
|||
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [questionPacks, setQuestionPacks] = useState([]);
|
||||
|
||||
// Храним participantId текущего пользователя для проверки удаления
|
||||
const currentUserParticipantIdRef = useRef(null);
|
||||
|
||||
// ЕДИНСТВЕННЫЙ обработчик состояния игры
|
||||
useEffect(() => {
|
||||
|
|
@ -43,6 +46,14 @@ const GamePage = () => {
|
|||
console.log('📦 Game state updated:', state);
|
||||
setGameState(state);
|
||||
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();
|
||||
|
|
@ -54,6 +65,32 @@ const GamePage = () => {
|
|||
};
|
||||
}, [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(() => {
|
||||
const handleReconnect = () => {
|
||||
|
|
@ -396,8 +433,8 @@ const GamePage = () => {
|
|||
playerScores={playerScores}
|
||||
currentPlayerId={gameState.currentPlayerId}
|
||||
onAnswerClick={handleAnswerClick}
|
||||
onPreviousQuestion={canGoPrev ? handlePrevQuestion : null}
|
||||
onNextQuestion={canGoNext ? handleNextQuestion : null}
|
||||
onPreviousQuestion={isHost && canGoPrev ? handlePrevQuestion : null}
|
||||
onNextQuestion={isHost && canGoNext ? handleNextQuestion : null}
|
||||
onSelectPlayer={isHost ? handleSelectPlayer : null}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { questionsApi } from '../services/api';
|
|||
import QRCode from 'qrcode';
|
||||
import QRModal from '../components/QRModal';
|
||||
import NameInputModal from '../components/NameInputModal';
|
||||
import PasswordModal from '../components/PasswordModal';
|
||||
|
||||
const RoomPage = () => {
|
||||
const { roomCode } = useParams();
|
||||
|
|
@ -17,19 +18,24 @@ const RoomPage = () => {
|
|||
navigate(`/game/${roomCode}`);
|
||||
}, [navigate, roomCode]);
|
||||
|
||||
const [password, setPassword] = useState(null);
|
||||
const {
|
||||
room,
|
||||
participants,
|
||||
loading,
|
||||
error,
|
||||
requiresPassword,
|
||||
fetchRoomWithPassword,
|
||||
joinRoom,
|
||||
startGame,
|
||||
updateQuestionPack,
|
||||
} = useRoom(roomCode, handleGameStartedEvent);
|
||||
} = useRoom(roomCode, handleGameStartedEvent, password);
|
||||
const [qrCode, setQrCode] = useState('');
|
||||
const [joined, setJoined] = useState(false);
|
||||
const [isQRModalOpen, setIsQRModalOpen] = useState(false);
|
||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
||||
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState(null);
|
||||
const [questionPacks, setQuestionPacks] = useState([]);
|
||||
const [selectedPackId, setSelectedPackId] = useState('');
|
||||
const [loadingPacks, setLoadingPacks] = useState(false);
|
||||
|
|
@ -59,14 +65,45 @@ const RoomPage = () => {
|
|||
}
|
||||
}, [roomCode]);
|
||||
|
||||
// Проверка пароля: показываем модальное окно, если требуется пароль
|
||||
// Хост не должен видеть модальное окно пароля (проверяется на бэкенде)
|
||||
useEffect(() => {
|
||||
if (requiresPassword && !isPasswordModalOpen && !loading && user) {
|
||||
// Проверяем, не является ли пользователь хостом
|
||||
// Если это хост, то requiresPassword не должно быть true (бэкенд должен разрешить доступ)
|
||||
setIsPasswordModalOpen(true);
|
||||
} else if (requiresPassword && !isPasswordModalOpen && !loading && !user) {
|
||||
// Если пользователь не авторизован, все равно показываем модальное окно
|
||||
// После авторизации проверим, является ли он хостом
|
||||
setIsPasswordModalOpen(true);
|
||||
}
|
||||
}, [requiresPassword, isPasswordModalOpen, loading, user]);
|
||||
|
||||
// Проверка авторизации и показ модального окна для ввода имени
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user && room && !loading) {
|
||||
if (!authLoading && !user && room && !loading && !requiresPassword) {
|
||||
setIsNameModalOpen(true);
|
||||
} else if (user) {
|
||||
setIsNameModalOpen(false);
|
||||
}
|
||||
}, [authLoading, user, room, loading]);
|
||||
}, [authLoading, user, room, loading, requiresPassword]);
|
||||
|
||||
// Обработка ввода пароля
|
||||
const handlePasswordSubmit = async (enteredPassword) => {
|
||||
try {
|
||||
setPasswordError(null);
|
||||
await fetchRoomWithPassword(enteredPassword);
|
||||
setPassword(enteredPassword);
|
||||
setIsPasswordModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Password error:', error);
|
||||
if (error.response?.status === 401) {
|
||||
setPasswordError('Неверный пароль. Попробуйте еще раз.');
|
||||
} else {
|
||||
setPasswordError('Ошибка при проверке пароля. Попробуйте еще раз.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработка ввода имени и авторизация
|
||||
const handleNameSubmit = async (name) => {
|
||||
|
|
@ -161,7 +198,8 @@ const RoomPage = () => {
|
|||
return <div className="loading">Загрузка комнаты...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Не показываем ошибку, если требуется пароль - покажем модальное окно
|
||||
if (error && !requiresPassword && error !== 'Room password required') {
|
||||
return (
|
||||
<div className="error-page">
|
||||
<h1>Ошибка</h1>
|
||||
|
|
@ -171,7 +209,7 @@ const RoomPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
if (!room && !requiresPassword && !loading) {
|
||||
return (
|
||||
<div className="error-page">
|
||||
<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 (
|
||||
<div className="room-page">
|
||||
|
|
@ -293,6 +346,13 @@ const RoomPage = () => {
|
|||
onSubmit={handleNameSubmit}
|
||||
onCancel={null}
|
||||
/>
|
||||
|
||||
<PasswordModal
|
||||
isOpen={isPasswordModalOpen}
|
||||
onSubmit={handlePasswordSubmit}
|
||||
onCancel={() => navigate('/')}
|
||||
error={passwordError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ export const authApi = {
|
|||
export const roomsApi = {
|
||||
create: (hostId, questionPackId, settings, hostName) =>
|
||||
api.post('/rooms', { hostId, questionPackId, settings, hostName }),
|
||||
getByCode: (code) => api.get(`/rooms/${code}`),
|
||||
getByCode: (code, password, userId) => {
|
||||
const params = {};
|
||||
if (password) params.password = password;
|
||||
if (userId) params.userId = userId;
|
||||
return api.get(`/rooms/${code}`, { params });
|
||||
},
|
||||
join: (roomId, userId, name, role) =>
|
||||
api.post(`/rooms/${roomId}/join`, { userId, name, role }),
|
||||
updateQuestionPack: (roomId, questionPackId) =>
|
||||
|
|
|
|||
Loading…
Reference in a new issue