This commit is contained in:
Dmitry 2026-01-09 19:44:33 +03:00
parent 560f016b08
commit aae72ac313
13 changed files with 588 additions and 10 deletions

View file

@ -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
)
}
},
} }

View file

@ -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

View file

@ -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>

View file

@ -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()) ||

View file

@ -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])
}

View file

@ -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);
}
} }

View file

@ -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,
};
}
} }

View 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[];
}

View file

@ -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);
}
} }

View file

@ -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 */

View file

@ -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>
)) ))
)} )}

View file

@ -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}
/> />
</> </>
)} )}

View file

@ -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();