diff --git a/admin/src/api/packs.ts b/admin/src/api/packs.ts index 556229b..64bfc18 100644 --- a/admin/src/api/packs.ts +++ b/admin/src/api/packs.ts @@ -186,7 +186,7 @@ export const packsApi = { 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 and cannot be deleted.`, @@ -195,10 +195,10 @@ export const packsApi = { error ) } - + throw createPacksApiError( - axiosError.response?.data?.message || - axiosError.response?.data?.error || + axiosError.response?.data?.message || + axiosError.response?.data?.error || `Failed to delete pack ${packId}`, axiosError.response?.status, 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 => { + 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 + ) + } + }, } diff --git a/admin/src/api/users.ts b/admin/src/api/users.ts index 6759481..334dd3a 100644 --- a/admin/src/api/users.ts +++ b/admin/src/api/users.ts @@ -6,14 +6,24 @@ export const usersApi = { getUsers: async (params?: { page?: number limit?: number + search?: string }): Promise> => { const response = await adminApiClient.get('/api/admin/users', { params: { page: params?.page || 1, 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 diff --git a/admin/src/pages/PacksPage.tsx b/admin/src/pages/PacksPage.tsx index 23090da..16a5d65 100644 --- a/admin/src/pages/PacksPage.tsx +++ b/admin/src/pages/PacksPage.tsx @@ -372,7 +372,7 @@ export default function PacksPage() { - {data?.items.map((pack) => ( + {(data?.items || []).map((pack) => ( {pack.id} diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx index bda82e4..aa7b756 100644 --- a/admin/src/pages/UsersPage.tsx +++ b/admin/src/pages/UsersPage.tsx @@ -200,7 +200,7 @@ export default function UsersPage() { - {data?.items + {(data?.items || []) .filter(user => search === '' || user.name?.toLowerCase().includes(search.toLowerCase()) || diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1faefd5..79299a5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -173,3 +173,17 @@ enum CodeStatus { USED 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]) +} diff --git a/backend/src/admin/packs/admin-packs.controller.ts b/backend/src/admin/packs/admin-packs.controller.ts index c279083..638f0f7 100644 --- a/backend/src/admin/packs/admin-packs.controller.ts +++ b/backend/src/admin/packs/admin-packs.controller.ts @@ -9,11 +9,13 @@ import { Body, UseGuards, Request, + BadRequestException, } from '@nestjs/common'; import { AdminPacksService } from './admin-packs.service'; import { PackFiltersDto } from './dto/pack-filters.dto'; import { CreatePackDto } from './dto/create-pack.dto'; import { UpdatePackDto } from './dto/update-pack.dto'; +import { ImportPackDto } from './dto/import-pack.dto'; import { AdminAuthGuard } from '../guards/admin-auth.guard'; import { AdminGuard } from '../guards/admin.guard'; @@ -46,4 +48,55 @@ export class AdminPacksController { remove(@Param('id') id: string) { 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); + } } diff --git a/backend/src/admin/packs/admin-packs.service.ts b/backend/src/admin/packs/admin-packs.service.ts index 484d19f..8b7d900 100644 --- a/backend/src/admin/packs/admin-packs.service.ts +++ b/backend/src/admin/packs/admin-packs.service.ts @@ -160,4 +160,26 @@ export class AdminPacksService { 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, + }; + } } diff --git a/backend/src/admin/packs/dto/import-pack.dto.ts b/backend/src/admin/packs/dto/import-pack.dto.ts new file mode 100644 index 0000000..74bbd07 --- /dev/null +++ b/backend/src/admin/packs/dto/import-pack.dto.ts @@ -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[]; +} diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 5045f3e..bec7169 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -608,4 +608,63 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On 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); + } } diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css index 3bb311d..7da3199 100644 --- a/src/components/GameManagementModal.css +++ b/src/components/GameManagementModal.css @@ -152,6 +152,122 @@ font-size: 1.1rem; font-weight: bold; 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 */ diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index 3aed55d..b253f4e 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -25,10 +25,19 @@ const GameManagementModal = ({ onHideAllAnswers, onAwardPoints, onPenalty, + onUpdatePlayerName, + onUpdatePlayerScore, + onKickPlayer, }) => { const [activeTab, setActiveTab] = useState('players') // players | game | answers | scoring | questions const [selectedPlayer, setSelectedPlayer] = useState(null) 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 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 const resetQuestionForm = () => { setEditingQuestion(null) @@ -410,15 +466,77 @@ const GameManagementModal = ({ participants.map((participant) => (
- {participant.name} + {editingPlayerId === participant.id && editMode === 'name' ? ( +
+ setEditingPlayerName(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'name')} + autoFocus + maxLength={50} + className="player-edit-input" + /> + + +
+ ) : ( + + {participant.name} + + + )} {participant.role === 'HOST' && '👑 Ведущий'} {participant.role === 'SPECTATOR' && '👀 Зритель'}
-
- {participant.score || 0} очков +
+ {editingPlayerId === participant.id && editMode === 'score' ? ( +
+ setEditingPlayerScore(e.target.value)} + onKeyDown={(e) => handleKeyDown(e, 'score')} + autoFocus + className="player-edit-input player-edit-input-score" + /> + + +
+ ) : ( + + {participant.score || 0} очков + + + )}
+ {participant.role !== 'HOST' && onKickPlayer && ( + + )}
)) )} diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index e688b85..513de7c 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -249,6 +249,38 @@ const GamePage = () => { 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) => { if (!gameState.roomId || !user) return; if (!isHost) return; // Только хост может выбирать игрока @@ -407,6 +439,9 @@ const GamePage = () => { onShowAllAnswers={handleShowAllAnswers} onHideAllAnswers={handleHideAllAnswers} onAwardPoints={handleAwardPoints} + onUpdatePlayerName={handleUpdatePlayerName} + onUpdatePlayerScore={handleUpdatePlayerScore} + onKickPlayer={handleKickPlayer} /> )} diff --git a/src/services/socket.js b/src/services/socket.js index ca0c62d..b88656b 100644 --- a/src/services/socket.js +++ b/src/services/socket.js @@ -117,6 +117,26 @@ class SocketService { 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();