wip
This commit is contained in:
parent
560f016b08
commit
aae72ac313
13 changed files with 588 additions and 10 deletions
|
|
@ -186,7 +186,7 @@ export const packsApi = {
|
||||||
return response.data
|
return response.data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
|
||||||
if (axiosError.response?.status === 404) {
|
if (axiosError.response?.status === 404) {
|
||||||
throw createPacksApiError(
|
throw createPacksApiError(
|
||||||
`Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`,
|
`Pack not found: The pack with ID "${packId}" does not exist and cannot be deleted.`,
|
||||||
|
|
@ -195,10 +195,10 @@ export const packsApi = {
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw createPacksApiError(
|
throw createPacksApiError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
axiosError.response?.data?.error ||
|
axiosError.response?.data?.error ||
|
||||||
`Failed to delete pack ${packId}`,
|
`Failed to delete pack ${packId}`,
|
||||||
axiosError.response?.status,
|
axiosError.response?.status,
|
||||||
undefined,
|
undefined,
|
||||||
|
|
@ -206,4 +206,88 @@ export const packsApi = {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get empty template for import
|
||||||
|
getTemplate: async (): Promise<{
|
||||||
|
templateVersion: string
|
||||||
|
instructions: string
|
||||||
|
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }>
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const response = await adminApiClient.get('/api/admin/packs/export/template')
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
throw createPacksApiError(
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.error ||
|
||||||
|
'Failed to get template',
|
||||||
|
axiosError.response?.status,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Export pack to JSON
|
||||||
|
exportPack: async (packId: string): Promise<{
|
||||||
|
templateVersion: string
|
||||||
|
exportedAt: string
|
||||||
|
packInfo: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
isPublic: boolean
|
||||||
|
}
|
||||||
|
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }>
|
||||||
|
}> => {
|
||||||
|
try {
|
||||||
|
const response = await adminApiClient.get(`/api/admin/packs/${packId}/export`)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
|
||||||
|
if (axiosError.response?.status === 404) {
|
||||||
|
throw createPacksApiError(
|
||||||
|
`Pack not found: The pack with ID "${packId}" does not exist.`,
|
||||||
|
axiosError.response.status,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createPacksApiError(
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.error ||
|
||||||
|
`Failed to export pack ${packId}`,
|
||||||
|
axiosError.response?.status,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Import pack from JSON
|
||||||
|
importPack: async (data: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
isPublic: boolean
|
||||||
|
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }>
|
||||||
|
}): Promise<EditCardPackDto> => {
|
||||||
|
try {
|
||||||
|
const response = await adminApiClient.post('/api/admin/packs/import', data)
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError<{ error?: string; message?: string }>
|
||||||
|
throw createPacksApiError(
|
||||||
|
axiosError.response?.data?.message ||
|
||||||
|
axiosError.response?.data?.error ||
|
||||||
|
'Failed to import pack',
|
||||||
|
axiosError.response?.status,
|
||||||
|
undefined,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,24 @@ export const usersApi = {
|
||||||
getUsers: async (params?: {
|
getUsers: async (params?: {
|
||||||
page?: number
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
|
search?: string
|
||||||
}): Promise<PaginatedResponse<UserDto>> => {
|
}): Promise<PaginatedResponse<UserDto>> => {
|
||||||
const response = await adminApiClient.get('/api/admin/users', {
|
const response = await adminApiClient.get('/api/admin/users', {
|
||||||
params: {
|
params: {
|
||||||
page: params?.page || 1,
|
page: params?.page || 1,
|
||||||
limit: params?.limit || 20,
|
limit: params?.limit || 20,
|
||||||
|
search: params?.search || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return response.data
|
// Transform backend response (users) to frontend format (items)
|
||||||
|
const backendData = response.data
|
||||||
|
return {
|
||||||
|
items: backendData.users || [],
|
||||||
|
total: backendData.total || 0,
|
||||||
|
page: backendData.page || 1,
|
||||||
|
limit: backendData.limit || 20,
|
||||||
|
totalPages: backendData.totalPages || 1,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single user by ID
|
// Get single user by ID
|
||||||
|
|
|
||||||
|
|
@ -372,7 +372,7 @@ export default function PacksPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.items.map((pack) => (
|
{(data?.items || []).map((pack) => (
|
||||||
<TableRow key={pack.id}>
|
<TableRow key={pack.id}>
|
||||||
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
|
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ export default function UsersPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.items
|
{(data?.items || [])
|
||||||
.filter(user =>
|
.filter(user =>
|
||||||
search === '' ||
|
search === '' ||
|
||||||
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
user.name?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
|
|
||||||
|
|
@ -173,3 +173,17 @@ enum CodeStatus {
|
||||||
USED
|
USED
|
||||||
EXPIRED
|
EXPIRED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Theme {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
isPublic Boolean @default(false)
|
||||||
|
createdBy String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
colors Json // { bgPrimary, bgOverlay, bgCard, textPrimary, textSecondary, accentPrimary, etc. }
|
||||||
|
settings Json // { shadowSm, shadowMd, blurAmount, borderRadius, animationSpeed, etc. }
|
||||||
|
|
||||||
|
creator User @relation(fields: [createdBy], references: [id])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@ import {
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AdminPacksService } from './admin-packs.service';
|
import { AdminPacksService } from './admin-packs.service';
|
||||||
import { PackFiltersDto } from './dto/pack-filters.dto';
|
import { PackFiltersDto } from './dto/pack-filters.dto';
|
||||||
import { CreatePackDto } from './dto/create-pack.dto';
|
import { CreatePackDto } from './dto/create-pack.dto';
|
||||||
import { UpdatePackDto } from './dto/update-pack.dto';
|
import { UpdatePackDto } from './dto/update-pack.dto';
|
||||||
|
import { ImportPackDto } from './dto/import-pack.dto';
|
||||||
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
import { AdminAuthGuard } from '../guards/admin-auth.guard';
|
||||||
import { AdminGuard } from '../guards/admin.guard';
|
import { AdminGuard } from '../guards/admin.guard';
|
||||||
|
|
||||||
|
|
@ -46,4 +48,55 @@ export class AdminPacksController {
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.adminPacksService.remove(id);
|
return this.adminPacksService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('export/template')
|
||||||
|
getTemplate() {
|
||||||
|
return {
|
||||||
|
templateVersion: '1.0',
|
||||||
|
instructions: 'Fill in your questions below. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: 'Your question here',
|
||||||
|
answers: [
|
||||||
|
{ text: 'Answer 1', points: 100 },
|
||||||
|
{ text: 'Answer 2', points: 80 },
|
||||||
|
{ text: 'Answer 3', points: 60 },
|
||||||
|
{ text: 'Answer 4', points: 40 },
|
||||||
|
{ text: 'Answer 5', points: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/export')
|
||||||
|
async exportPack(@Param('id') id: string) {
|
||||||
|
return this.adminPacksService.exportPack(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('import')
|
||||||
|
async importPack(@Body() importPackDto: ImportPackDto, @Request() req) {
|
||||||
|
// Validate question structure
|
||||||
|
const isValid = importPackDto.questions.every(
|
||||||
|
(q) =>
|
||||||
|
q.question &&
|
||||||
|
typeof q.question === 'string' &&
|
||||||
|
Array.isArray(q.answers) &&
|
||||||
|
q.answers.length > 0 &&
|
||||||
|
q.answers.every(
|
||||||
|
(a) =>
|
||||||
|
a.text &&
|
||||||
|
typeof a.text === 'string' &&
|
||||||
|
typeof a.points === 'number',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Invalid question format. Each question must have a "question" field and an "answers" array with "text" and "points" fields.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminPacksService.create(importPackDto, req.user.sub);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,4 +160,26 @@ export class AdminPacksService {
|
||||||
|
|
||||||
return { message: 'Question pack deleted successfully' };
|
return { message: 'Question pack deleted successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async exportPack(id: string) {
|
||||||
|
const pack = await this.prisma.questionPack.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!pack) {
|
||||||
|
throw new NotFoundException('Question pack not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
templateVersion: '1.0',
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
packInfo: {
|
||||||
|
name: pack.name,
|
||||||
|
description: pack.description,
|
||||||
|
category: pack.category,
|
||||||
|
isPublic: pack.isPublic,
|
||||||
|
},
|
||||||
|
questions: pack.questions,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
backend/src/admin/packs/dto/import-pack.dto.ts
Normal file
47
backend/src/admin/packs/dto/import-pack.dto.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsBoolean,
|
||||||
|
IsArray,
|
||||||
|
IsOptional,
|
||||||
|
ValidateNested,
|
||||||
|
IsNumber,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
class ImportAnswerDto {
|
||||||
|
@IsString()
|
||||||
|
text: string;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportQuestionDto {
|
||||||
|
@IsString()
|
||||||
|
question: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ImportAnswerDto)
|
||||||
|
answers: ImportAnswerDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ImportPackDto {
|
||||||
|
@IsString()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
category: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPublic?: boolean;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ImportQuestionDto)
|
||||||
|
questions: ImportQuestionDto[];
|
||||||
|
}
|
||||||
|
|
@ -608,4 +608,63 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
|
||||||
|
|
||||||
await this.broadcastFullState(payload.roomCode);
|
await this.broadcastFullState(payload.roomCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('updatePlayerName')
|
||||||
|
async handleUpdatePlayerName(client: Socket, payload: {
|
||||||
|
roomId: string;
|
||||||
|
roomCode: string;
|
||||||
|
userId: string;
|
||||||
|
participantId: string;
|
||||||
|
newName: string;
|
||||||
|
}) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can update player names' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.newName || payload.newName.trim().length === 0) {
|
||||||
|
client.emit('error', { message: 'Name cannot be empty' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.newName.trim().length > 50) {
|
||||||
|
client.emit('error', { message: 'Name is too long (max 50 characters)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.participant.update({
|
||||||
|
where: { id: payload.participantId },
|
||||||
|
data: { name: payload.newName.trim() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('updatePlayerScore')
|
||||||
|
async handleUpdatePlayerScore(client: Socket, payload: {
|
||||||
|
roomId: string;
|
||||||
|
roomCode: string;
|
||||||
|
userId: string;
|
||||||
|
participantId: string;
|
||||||
|
newScore: number;
|
||||||
|
}) {
|
||||||
|
const isHost = await this.isHost(payload.roomId, payload.userId);
|
||||||
|
if (!isHost) {
|
||||||
|
client.emit('error', { message: 'Only the host can update scores' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.newScore !== 'number' || isNaN(payload.newScore)) {
|
||||||
|
client.emit('error', { message: 'Invalid score value' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.participant.update({
|
||||||
|
where: { id: payload.participantId },
|
||||||
|
data: { score: Math.round(payload.newScore) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.broadcastFullState(payload.roomCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,122 @@
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent-primary, #ffd700);
|
color: var(--accent-primary, #ffd700);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-score-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player editing */
|
||||||
|
.player-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item:hover .player-edit-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-btn:hover {
|
||||||
|
color: var(--accent-primary, #ffd700);
|
||||||
|
background: rgba(255, 215, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-input {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--bg-card, #1a1a1a);
|
||||||
|
border: 2px solid var(--accent-primary, #ffd700);
|
||||||
|
border-radius: var(--border-radius-sm, 8px);
|
||||||
|
color: var(--text-primary, #ffffff);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-input-score {
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 100px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-save,
|
||||||
|
.player-edit-cancel {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-save {
|
||||||
|
background: var(--accent-success, #4ecdc4);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-save:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-cancel {
|
||||||
|
background: rgba(255, 0, 0, 0.2);
|
||||||
|
color: var(--accent-secondary, #ff6b6b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-edit-cancel:hover {
|
||||||
|
background: rgba(255, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-kick-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-item:hover .player-kick-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-kick-btn:hover {
|
||||||
|
color: var(--accent-secondary, #ff6b6b);
|
||||||
|
background: rgba(255, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Game controls tab */
|
/* Game controls tab */
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,19 @@ const GameManagementModal = ({
|
||||||
onHideAllAnswers,
|
onHideAllAnswers,
|
||||||
onAwardPoints,
|
onAwardPoints,
|
||||||
onPenalty,
|
onPenalty,
|
||||||
|
onUpdatePlayerName,
|
||||||
|
onUpdatePlayerScore,
|
||||||
|
onKickPlayer,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions
|
const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions
|
||||||
const [selectedPlayer, setSelectedPlayer] = useState(null)
|
const [selectedPlayer, setSelectedPlayer] = useState(null)
|
||||||
const [customPoints, setCustomPoints] = useState(10)
|
const [customPoints, setCustomPoints] = useState(10)
|
||||||
|
|
||||||
|
// Player editing state
|
||||||
|
const [editingPlayerId, setEditingPlayerId] = useState(null)
|
||||||
|
const [editingPlayerName, setEditingPlayerName] = useState('')
|
||||||
|
const [editingPlayerScore, setEditingPlayerScore] = useState('')
|
||||||
|
const [editMode, setEditMode] = useState(null) // 'name' | 'score'
|
||||||
|
|
||||||
// Questions management state
|
// Questions management state
|
||||||
const [editingQuestion, setEditingQuestion] = useState(null)
|
const [editingQuestion, setEditingQuestion] = useState(null)
|
||||||
|
|
@ -82,6 +91,53 @@ const GameManagementModal = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Player editing handlers
|
||||||
|
const handleStartEditName = (participant) => {
|
||||||
|
setEditingPlayerId(participant.id)
|
||||||
|
setEditingPlayerName(participant.name)
|
||||||
|
setEditMode('name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditScore = (participant) => {
|
||||||
|
setEditingPlayerId(participant.id)
|
||||||
|
setEditingPlayerScore(String(participant.score || 0))
|
||||||
|
setEditMode('score')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingPlayerId(null)
|
||||||
|
setEditingPlayerName('')
|
||||||
|
setEditingPlayerScore('')
|
||||||
|
setEditMode(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePlayerName = () => {
|
||||||
|
if (editingPlayerName.trim() && onUpdatePlayerName) {
|
||||||
|
onUpdatePlayerName(editingPlayerId, editingPlayerName.trim())
|
||||||
|
}
|
||||||
|
handleCancelEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePlayerScore = () => {
|
||||||
|
const score = parseInt(editingPlayerScore, 10)
|
||||||
|
if (!isNaN(score) && onUpdatePlayerScore) {
|
||||||
|
onUpdatePlayerScore(editingPlayerId, score)
|
||||||
|
}
|
||||||
|
handleCancelEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e, type) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
if (type === 'name') {
|
||||||
|
handleSavePlayerName()
|
||||||
|
} else if (type === 'score') {
|
||||||
|
handleSavePlayerScore()
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
handleCancelEdit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Questions management handlers
|
// Questions management handlers
|
||||||
const resetQuestionForm = () => {
|
const resetQuestionForm = () => {
|
||||||
setEditingQuestion(null)
|
setEditingQuestion(null)
|
||||||
|
|
@ -410,15 +466,77 @@ const GameManagementModal = ({
|
||||||
participants.map((participant) => (
|
participants.map((participant) => (
|
||||||
<div key={participant.id} className="player-item">
|
<div key={participant.id} className="player-item">
|
||||||
<div className="player-info">
|
<div className="player-info">
|
||||||
<span className="player-name">{participant.name}</span>
|
{editingPlayerId === participant.id && editMode === 'name' ? (
|
||||||
|
<div className="player-edit-field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingPlayerName}
|
||||||
|
onChange={(e) => setEditingPlayerName(e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, 'name')}
|
||||||
|
autoFocus
|
||||||
|
maxLength={50}
|
||||||
|
className="player-edit-input"
|
||||||
|
/>
|
||||||
|
<button className="player-edit-save" onClick={handleSavePlayerName}>✓</button>
|
||||||
|
<button className="player-edit-cancel" onClick={handleCancelEdit}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="player-name">
|
||||||
|
{participant.name}
|
||||||
|
<button
|
||||||
|
className="player-edit-btn"
|
||||||
|
onClick={() => handleStartEditName(participant)}
|
||||||
|
title="Редактировать имя"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="player-role">
|
<span className="player-role">
|
||||||
{participant.role === 'HOST' && '👑 Ведущий'}
|
{participant.role === 'HOST' && '👑 Ведущий'}
|
||||||
{participant.role === 'SPECTATOR' && '👀 Зритель'}
|
{participant.role === 'SPECTATOR' && '👀 Зритель'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="player-score">
|
<div className="player-score-section">
|
||||||
{participant.score || 0} очков
|
{editingPlayerId === participant.id && editMode === 'score' ? (
|
||||||
|
<div className="player-edit-field">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={editingPlayerScore}
|
||||||
|
onChange={(e) => setEditingPlayerScore(e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, 'score')}
|
||||||
|
autoFocus
|
||||||
|
className="player-edit-input player-edit-input-score"
|
||||||
|
/>
|
||||||
|
<button className="player-edit-save" onClick={handleSavePlayerScore}>✓</button>
|
||||||
|
<button className="player-edit-cancel" onClick={handleCancelEdit}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="player-score">
|
||||||
|
{participant.score || 0} очков
|
||||||
|
<button
|
||||||
|
className="player-edit-btn"
|
||||||
|
onClick={() => handleStartEditScore(participant)}
|
||||||
|
title="Редактировать очки"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{participant.role !== 'HOST' && onKickPlayer && (
|
||||||
|
<button
|
||||||
|
className="player-kick-btn"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Удалить игрока ${participant.name}?`)) {
|
||||||
|
onKickPlayer(participant.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Удалить игрока"
|
||||||
|
>
|
||||||
|
🚫
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,38 @@ const GamePage = () => {
|
||||||
console.warn('Manual point award not implemented yet');
|
console.warn('Manual point award not implemented yet');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdatePlayerName = (participantId, newName) => {
|
||||||
|
if (!gameState.roomId || !user) return;
|
||||||
|
socketService.updatePlayerName(
|
||||||
|
gameState.roomId,
|
||||||
|
gameState.roomCode,
|
||||||
|
user.id,
|
||||||
|
participantId,
|
||||||
|
newName
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdatePlayerScore = (participantId, newScore) => {
|
||||||
|
if (!gameState.roomId || !user) return;
|
||||||
|
socketService.updatePlayerScore(
|
||||||
|
gameState.roomId,
|
||||||
|
gameState.roomCode,
|
||||||
|
user.id,
|
||||||
|
participantId,
|
||||||
|
newScore
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKickPlayer = (participantId) => {
|
||||||
|
if (!gameState.roomId || !user) return;
|
||||||
|
socketService.emit('kickPlayer', {
|
||||||
|
roomId: gameState.roomId,
|
||||||
|
roomCode: gameState.roomCode,
|
||||||
|
userId: user.id,
|
||||||
|
participantId: participantId
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectPlayer = (participantId) => {
|
const handleSelectPlayer = (participantId) => {
|
||||||
if (!gameState.roomId || !user) return;
|
if (!gameState.roomId || !user) return;
|
||||||
if (!isHost) return; // Только хост может выбирать игрока
|
if (!isHost) return; // Только хост может выбирать игрока
|
||||||
|
|
@ -407,6 +439,9 @@ const GamePage = () => {
|
||||||
onShowAllAnswers={handleShowAllAnswers}
|
onShowAllAnswers={handleShowAllAnswers}
|
||||||
onHideAllAnswers={handleHideAllAnswers}
|
onHideAllAnswers={handleHideAllAnswers}
|
||||||
onAwardPoints={handleAwardPoints}
|
onAwardPoints={handleAwardPoints}
|
||||||
|
onUpdatePlayerName={handleUpdatePlayerName}
|
||||||
|
onUpdatePlayerScore={handleUpdatePlayerScore}
|
||||||
|
onKickPlayer={handleKickPlayer}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,26 @@ class SocketService {
|
||||||
questionIndices,
|
questionIndices,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePlayerName(roomId, roomCode, userId, participantId, newName) {
|
||||||
|
this.emit('updatePlayerName', {
|
||||||
|
roomId,
|
||||||
|
roomCode,
|
||||||
|
userId,
|
||||||
|
participantId,
|
||||||
|
newName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayerScore(roomId, roomCode, userId, participantId, newScore) {
|
||||||
|
this.emit('updatePlayerScore', {
|
||||||
|
roomId,
|
||||||
|
roomCode,
|
||||||
|
userId,
|
||||||
|
participantId,
|
||||||
|
newScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new SocketService();
|
export default new SocketService();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue