From eb13424f205b2027245c015515674c16fe84f6c0 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 10 Jan 2026 21:01:01 +0300 Subject: [PATCH] fixes --- admin/src/api/packs.ts | 6 +- .../components/GameQuestionEditorDialog.tsx | 4 +- admin/src/components/GameQuestionsManager.tsx | 2 +- admin/src/components/PackImportDialog.tsx | 27 +- .../forms/InputButtonsQuestionForm.tsx | 2 +- .../components/forms/SimpleQuestionForm.tsx | 2 +- admin/src/pages/PacksPage.tsx | 4 +- admin/src/types/models.ts | 2 +- backend/prisma/seed.ts | 9 +- .../src/admin/packs/admin-packs.controller.ts | 10 +- .../src/admin/packs/admin-packs.service.ts | 6 +- .../src/admin/packs/dto/create-pack.dto.ts | 2 +- .../src/admin/packs/dto/import-pack.dto.ts | 2 +- .../src/admin/packs/dto/update-pack.dto.ts | 2 +- backend/src/game/game.gateway.ts | 3 +- backend/src/utils/question-utils.ts | 9 +- backend/src/voice/voice.service.ts | 3 +- src/components/GameManagementModal.css | 133 ++++++++++ src/components/GameManagementModal.jsx | 245 ++++++++++++++++-- src/components/Snowflakes.jsx | 2 +- 20 files changed, 409 insertions(+), 66 deletions(-) diff --git a/admin/src/api/packs.ts b/admin/src/api/packs.ts index 64bfc18..f87b373 100644 --- a/admin/src/api/packs.ts +++ b/admin/src/api/packs.ts @@ -211,7 +211,7 @@ export const packsApi = { getTemplate: async (): Promise<{ templateVersion: string instructions: string - questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> + questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }> }> => { try { const response = await adminApiClient.get('/api/admin/packs/export/template') @@ -239,7 +239,7 @@ export const packsApi = { category: string isPublic: boolean } - questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> + questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }> }> => { try { const response = await adminApiClient.get(`/api/admin/packs/${packId}/export`) @@ -273,7 +273,7 @@ export const packsApi = { description: string category: string isPublic: boolean - questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> + questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }> }): Promise => { try { const response = await adminApiClient.post('/api/admin/packs/import', data) diff --git a/admin/src/components/GameQuestionEditorDialog.tsx b/admin/src/components/GameQuestionEditorDialog.tsx index 26ccf20..a95d431 100644 --- a/admin/src/components/GameQuestionEditorDialog.tsx +++ b/admin/src/components/GameQuestionEditorDialog.tsx @@ -42,7 +42,7 @@ export function GameQuestionEditorDialog({ useEffect(() => { if (open) { if (question) { - setQuestionText(question.question || '') + setQuestionText(question.text || '') setAnswers( question.answers && question.answers.length > 0 ? [...question.answers] @@ -108,7 +108,7 @@ export function GameQuestionEditorDialog({ } const questionData: Question = { - question: questionText.trim(), + text: questionText.trim(), answers: validAnswers, } diff --git a/admin/src/components/GameQuestionsManager.tsx b/admin/src/components/GameQuestionsManager.tsx index 428bf0d..724d5c7 100644 --- a/admin/src/components/GameQuestionsManager.tsx +++ b/admin/src/components/GameQuestionsManager.tsx @@ -82,7 +82,7 @@ export function GameQuestionsManager({
-

{question.question}

+

{question.text}

{question.answers.length} answer {question.answers.length !== 1 ? 's' : ''} (points:{' '} diff --git a/admin/src/components/PackImportDialog.tsx b/admin/src/components/PackImportDialog.tsx index 2a042c9..50e197a 100644 --- a/admin/src/components/PackImportDialog.tsx +++ b/admin/src/components/PackImportDialog.tsx @@ -78,7 +78,8 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr // Validate questions structure const isValid = questions.every( (q: any) => - (q.question || q.text) && + q.text && + typeof q.text === 'string' && Array.isArray(q.answers) && q.answers.length > 0 && q.answers.every( @@ -87,7 +88,7 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr ) if (!isValid) { - throw new Error('Invalid question format. Each question must have "question" (or "text") and "answers" array with "text" and "points" fields.') + throw new Error('Invalid question format. Each question must have "text" field and "answers" array with "text" and "points" fields.') } setParseError(null) @@ -151,14 +152,18 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr return } - // Normalize questions to use 'question' field - const normalizedQuestions = parsed.map((q: any) => ({ - question: q.question || q.text, - answers: q.answers.map((a: any) => ({ - text: a.text, - points: a.points, - })), - })) + // Normalize questions to use 'text' field (ignore 'question' field if present) + const normalizedQuestions = parsed.map((q: any) => { + const { question, ...rest } = q + return { + ...rest, + text: q.text || '', + answers: q.answers.map((a: any) => ({ + text: a.text, + points: a.points, + })), + } + }) setIsLoading(true) try { @@ -227,7 +232,7 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr id="jsonContent" value={jsonContent} onChange={(e) => handleJsonChange(e.target.value)} - placeholder='[{"question": "...", "answers": [{"text": "...", "points": 100}]}]' + placeholder='[{"text": "...", "answers": [{"text": "...", "points": 100}]}]' rows={8} className="font-mono text-sm" /> diff --git a/admin/src/components/forms/InputButtonsQuestionForm.tsx b/admin/src/components/forms/InputButtonsQuestionForm.tsx index 8d4f976..605234d 100644 --- a/admin/src/components/forms/InputButtonsQuestionForm.tsx +++ b/admin/src/components/forms/InputButtonsQuestionForm.tsx @@ -55,7 +55,7 @@ export function InputButtonsQuestionForm({ const addButton = () => { const newButton: TestButton = { - id: `btn_${Date.now()}`, + id: crypto.randomUUID(), text: '', } setButtons([...buttons, newButton]) diff --git a/admin/src/components/forms/SimpleQuestionForm.tsx b/admin/src/components/forms/SimpleQuestionForm.tsx index 58015d8..f96a90c 100644 --- a/admin/src/components/forms/SimpleQuestionForm.tsx +++ b/admin/src/components/forms/SimpleQuestionForm.tsx @@ -53,7 +53,7 @@ export function SimpleQuestionForm({ const addButton = () => { const newButton: TestButton = { - id: `btn_${Date.now()}`, + id: crypto.randomUUID(), text: '', } setButtons([...buttons, newButton]) diff --git a/admin/src/pages/PacksPage.tsx b/admin/src/pages/PacksPage.tsx index 36a50c4..edd7cd5 100644 --- a/admin/src/pages/PacksPage.tsx +++ b/admin/src/pages/PacksPage.tsx @@ -158,10 +158,10 @@ export default function PacksPage() { const fullPack = await packsApi.getPack(pack.id) setSelectedPack(fullPack) - // Convert questions from backend format (supports both 'question' and 'text' fields) + // Convert questions from backend format (using 'text' field) const questions: Question[] = Array.isArray(fullPack.questions) ? fullPack.questions.map((q: any) => ({ - question: q.question || q.text || '', + text: q.text || '', answers: Array.isArray(q.answers) ? q.answers.map((a: any) => ({ text: a.text || '', diff --git a/admin/src/types/models.ts b/admin/src/types/models.ts index 3656fd0..2dd8da1 100644 --- a/admin/src/types/models.ts +++ b/admin/src/types/models.ts @@ -7,7 +7,7 @@ export interface Answer { } export interface Question { - question: string + text: string answers: Answer[] } diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index f23511f..5aebc64 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -16,25 +16,26 @@ interface Answer { interface Question { id?: string; text?: string; - question?: string; answers: Answer[]; } function ensureQuestionIds(questions: Question[]): Question[] { return questions.map((question) => { const questionId = question.id || randomUUID(); - const questionText = question.text || question.question || ''; + const questionText = question.text || ''; const answersWithIds = question.answers.map((answer) => ({ ...answer, id: answer.id || randomUUID(), })); + // Удаляем поле question если оно было в исходном объекте + const { question: _, ...questionWithoutQuestion } = question; + return { - ...question, + ...questionWithoutQuestion, id: questionId, text: questionText, - question: questionText, // Keep both fields for compatibility answers: answersWithIds, }; }); diff --git a/backend/src/admin/packs/admin-packs.controller.ts b/backend/src/admin/packs/admin-packs.controller.ts index c742040..d905dd5 100644 --- a/backend/src/admin/packs/admin-packs.controller.ts +++ b/backend/src/admin/packs/admin-packs.controller.ts @@ -35,10 +35,10 @@ export class AdminPacksController { 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.', + 'Fill in your questions below. Each question must have a "text" field and an "answers" array with "text" and "points" fields.', questions: [ { - question: 'Your question here', + text: 'Your question here', answers: [ { text: 'Answer 1', points: 100 }, { text: 'Answer 2', points: 80 }, @@ -56,8 +56,8 @@ export class AdminPacksController { // Validate question structure const isValid = importPackDto.questions.every( (q) => - q.question && - typeof q.question === 'string' && + q.text && + typeof q.text === 'string' && Array.isArray(q.answers) && q.answers.length > 0 && q.answers.every( @@ -70,7 +70,7 @@ export class AdminPacksController { if (!isValid) { throw new BadRequestException( - 'Invalid question format. Each question must have a "question" field and an "answers" array with "text" and "points" fields.', + 'Invalid question format. Each question must have a "text" field and an "answers" array with "text" and "points" fields.', ); } diff --git a/backend/src/admin/packs/admin-packs.service.ts b/backend/src/admin/packs/admin-packs.service.ts index 8b7d900..34e89a4 100644 --- a/backend/src/admin/packs/admin-packs.service.ts +++ b/backend/src/admin/packs/admin-packs.service.ts @@ -170,6 +170,10 @@ export class AdminPacksService { throw new NotFoundException('Question pack not found'); } + // Нормализуем вопросы при экспорте, удаляя поле question если оно есть + const packQuestions = Array.isArray(pack.questions) ? pack.questions as any[] : []; + const normalizedQuestions = ensureQuestionIds(packQuestions); + return { templateVersion: '1.0', exportedAt: new Date().toISOString(), @@ -179,7 +183,7 @@ export class AdminPacksService { category: pack.category, isPublic: pack.isPublic, }, - questions: pack.questions, + questions: normalizedQuestions, }; } } diff --git a/backend/src/admin/packs/dto/create-pack.dto.ts b/backend/src/admin/packs/dto/create-pack.dto.ts index 5dfab9c..67a61d3 100644 --- a/backend/src/admin/packs/dto/create-pack.dto.ts +++ b/backend/src/admin/packs/dto/create-pack.dto.ts @@ -19,7 +19,7 @@ class QuestionDto { id?: string; @IsString() - question: string; + text: string; @IsArray() @ValidateNested({ each: true }) diff --git a/backend/src/admin/packs/dto/import-pack.dto.ts b/backend/src/admin/packs/dto/import-pack.dto.ts index 74bbd07..069d813 100644 --- a/backend/src/admin/packs/dto/import-pack.dto.ts +++ b/backend/src/admin/packs/dto/import-pack.dto.ts @@ -18,7 +18,7 @@ class ImportAnswerDto { class ImportQuestionDto { @IsString() - question: string; + text: string; @IsArray() @ValidateNested({ each: true }) diff --git a/backend/src/admin/packs/dto/update-pack.dto.ts b/backend/src/admin/packs/dto/update-pack.dto.ts index 84fe6fc..a67316f 100644 --- a/backend/src/admin/packs/dto/update-pack.dto.ts +++ b/backend/src/admin/packs/dto/update-pack.dto.ts @@ -19,7 +19,7 @@ class QuestionDto { id?: string; @IsString() - question: string; + text: string; @IsArray() @ValidateNested({ each: true }) diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 1d177cc..d07d00c 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -27,7 +27,6 @@ interface PlayerAction { interface Question { id: string; text?: string; - question?: string; answers: Array<{ id: string; text: string; @@ -441,7 +440,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On } return { id: questionId || `temp-${Math.random()}`, - text: q.text || q.question || '', + text: q.text || '', answers: (q.answers || []).map((a: any) => ({ id: a.id || `answer-${Math.random()}`, text: a.text || '', diff --git a/backend/src/utils/question-utils.ts b/backend/src/utils/question-utils.ts index c85d741..ec77944 100644 --- a/backend/src/utils/question-utils.ts +++ b/backend/src/utils/question-utils.ts @@ -9,7 +9,6 @@ interface Answer { interface Question { id?: string; text?: string; - question?: string; // Поддержка обоих вариантов названия поля answers: Answer[]; } @@ -34,7 +33,7 @@ export function ensureQuestionIds(questions: Question[]): Question[] { return questions.map((question) => { // Если ID нет или не является валидным UUID, создаем новый const questionId = (question.id && isValidUUID(question.id)) ? question.id : randomUUID(); - const questionText = question.text || question.question || ''; + const questionText = question.text || ''; const answersWithIds = question.answers.map((answer) => { // Если ID нет или не является валидным UUID, создаем новый @@ -45,11 +44,13 @@ export function ensureQuestionIds(questions: Question[]): Question[] { }; }); + // Удаляем поле question если оно было в исходном объекте + const { question: _, ...questionWithoutQuestion } = question; + return { - ...question, + ...questionWithoutQuestion, id: questionId, text: questionText, - question: questionText, // Сохраняем оба поля для совместимости answers: answersWithIds, }; }); diff --git a/backend/src/voice/voice.service.ts b/backend/src/voice/voice.service.ts index 40bcbec..b0df887 100644 --- a/backend/src/voice/voice.service.ts +++ b/backend/src/voice/voice.service.ts @@ -11,7 +11,6 @@ interface Answer { interface Question { id: string; text?: string; - question?: string; answers?: Answer[]; } @@ -65,7 +64,7 @@ export class VoiceService { } if (contentType === TTSContentType.QUESTION) { - const questionText = question.text || question.question; + const questionText = question.text; if (!questionText) { this.logger.error(`Question text is empty for questionId=${questionId}`); throw new NotFoundException('Question text is empty'); diff --git a/src/components/GameManagementModal.css b/src/components/GameManagementModal.css index 545c915..2d146d2 100644 --- a/src/components/GameManagementModal.css +++ b/src/components/GameManagementModal.css @@ -824,10 +824,102 @@ display: flex; justify-content: space-between; align-items: center; + gap: 1rem; padding: 1rem; background: rgba(255, 255, 255, 0.05); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2)); border-radius: var(--border-radius-sm, 8px); + transition: all 0.2s; + cursor: grab; + position: relative; +} + +.questions-tab-content .questions-modal-item:hover { + background: rgba(255, 255, 255, 0.08); + border-color: var(--accent-primary, #ffd700); +} + +.questions-tab-content .questions-modal-item.dragging { + opacity: 0.5; + cursor: grabbing !important; +} + +.questions-tab-content .questions-modal-item.drag-over { + background: rgba(255, 215, 0, 0.1); + transform: translateY(-2px); +} + +.questions-tab-content .questions-modal-item.drag-over-above { + border-top: 3px solid var(--accent-primary, #ffd700); + padding-top: calc(1rem - 1px); +} + +.questions-tab-content .questions-modal-item.drag-over-below { + border-bottom: 3px solid var(--accent-primary, #ffd700); + padding-bottom: calc(1rem - 1px); +} + +.questions-tab-content .questions-modal-item button { + cursor: pointer; +} + +.questions-tab-content .questions-modal-item button:hover { + cursor: pointer !important; +} + +.questions-tab-content .questions-modal-item-order { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + min-width: 50px; +} + +.questions-tab-content .questions-modal-item-number { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--accent-primary, #ffd700); + color: var(--bg-primary, #000000); + border-radius: 50%; + font-weight: bold; + font-size: 0.9rem; +} + +.questions-tab-content .questions-modal-item-order-buttons { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.questions-tab-content .questions-modal-order-button { + width: 28px; + height: 28px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2)); + border-radius: 4px; + color: var(--text-primary, #ffffff); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + transition: all 0.2s; + padding: 0; +} + +.questions-tab-content .questions-modal-order-button:hover:not(:disabled) { + background: var(--accent-primary, #ffd700); + color: var(--bg-primary, #000000); + border-color: var(--accent-primary, #ffd700); + transform: scale(1.1); +} + +.questions-tab-content .questions-modal-order-button:disabled { + opacity: 0.3; + cursor: not-allowed; } .questions-tab-content .questions-modal-item-content { @@ -850,6 +942,33 @@ gap: 0.5rem; } +.questions-tab-content .questions-modal-item-drag-handle { + color: var(--text-secondary, rgba(255, 255, 255, 0.4)); + font-size: 1rem; + line-height: 1; + cursor: grab; + padding: 0.5rem 0.25rem; + user-select: none; + opacity: 0; + transition: opacity 0.2s, color 0.2s, transform 0.2s; + display: flex; + align-items: center; + justify-content: center; + min-width: 24px; + letter-spacing: -0.3rem; +} + +.questions-tab-content .questions-modal-item:hover .questions-modal-item-drag-handle { + opacity: 1; +} + +.questions-tab-content .questions-modal-item.dragging .questions-modal-item-drag-handle, +.questions-tab-content .questions-modal-item:hover .questions-modal-item-drag-handle:hover { + color: var(--accent-primary, #ffd700); + cursor: grabbing; + transform: scale(1.2); +} + .questions-tab-content .questions-modal-edit-button, .questions-tab-content .questions-modal-delete-button { width: 32px; @@ -902,6 +1021,20 @@ .questions-tab-content .questions-modal-actions button { width: 100%; } + + .questions-tab-content .questions-modal-item { + flex-wrap: wrap; + } + + .questions-tab-content .questions-modal-item-order { + min-width: 40px; + } + + .questions-tab-content .questions-modal-order-button { + width: 24px; + height: 24px; + font-size: 0.8rem; + } } /* Custom Scrollbar */ diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index 9dac36d..1065c27 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -82,6 +82,11 @@ const GameManagementModal = ({ const [searchQuery, setSearchQuery] = useState('') const [viewingQuestion, setViewingQuestion] = useState(null) const [showAnswers, setShowAnswers] = useState(false) + + // Drag and drop state + const [draggedQuestionIndex, setDraggedQuestionIndex] = useState(null) + const [dragOverIndex, setDragOverIndex] = useState(null) + const [dragOverPosition, setDragOverPosition] = useState(null) // 'above' | 'below' // Сбрасываем вкладку на initialTab при открытии модального окна useEffect(() => { @@ -237,11 +242,12 @@ const GameManagementModal = ({ if (!validateQuestionForm()) return const questionData = { - id: editingQuestion ? editingQuestion.id : Date.now(), + id: editingQuestion ? editingQuestion.id : crypto.randomUUID(), text: questionText.trim(), answers: answers .filter(a => a.text.trim()) .map(a => ({ + id: a.id || crypto.randomUUID(), text: a.text.trim(), points: a.points, })), @@ -270,9 +276,139 @@ const GameManagementModal = ({ } } + const handleMoveQuestionUp = (index) => { + if (index === 0) return + const updatedQuestions = [...questions] + const temp = updatedQuestions[index] + updatedQuestions[index] = updatedQuestions[index - 1] + updatedQuestions[index - 1] = temp + onUpdateQuestions(updatedQuestions) + } + + const handleMoveQuestionDown = (index) => { + if (index === questions.length - 1) return + const updatedQuestions = [...questions] + const temp = updatedQuestions[index] + updatedQuestions[index] = updatedQuestions[index + 1] + updatedQuestions[index + 1] = temp + onUpdateQuestions(updatedQuestions) + } + + // Drag and drop handlers + const handleDragStart = (e, index) => { + // Предотвращаем перетаскивание при клике на кнопки или инпуты + const target = e.target + const clickedButton = target.tagName === 'BUTTON' || target.closest('button') + const clickedInput = target.tagName === 'INPUT' || target.closest('input') + + // Если кликнули на кнопку или инпут, не запускаем drag + if (clickedButton || clickedInput) { + e.preventDefault() + return false + } + + setDraggedQuestionIndex(index) + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', index.toString()) + // Находим элемент вопроса и делаем его полупрозрачным + const item = e.currentTarget + item.style.opacity = '0.5' + } + + const handleDragEnd = (e) => { + // Восстанавливаем opacity элемента + const item = e.currentTarget + item.style.opacity = '' + setDraggedQuestionIndex(null) + setDragOverIndex(null) + setDragOverPosition(null) + } + + const handleDragOver = (e, index) => { + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + + if (draggedQuestionIndex === null || draggedQuestionIndex === index) { + setDragOverIndex(null) + setDragOverPosition(null) + return + } + + // Определяем, куда вставлять элемент (выше или ниже) + const rect = e.currentTarget.getBoundingClientRect() + const mouseY = e.clientY + const elementMiddleY = rect.top + rect.height / 2 + const position = mouseY < elementMiddleY ? 'above' : 'below' + + if (dragOverIndex !== index || dragOverPosition !== position) { + setDragOverIndex(index) + setDragOverPosition(position) + } + } + + const handleDragLeave = () => { + setDragOverIndex(null) + setDragOverPosition(null) + } + + const handleDrop = (e, dropIndex) => { + e.preventDefault() + e.stopPropagation() + + if (draggedQuestionIndex === null || draggedQuestionIndex === dropIndex) { + setDragOverIndex(null) + setDragOverPosition(null) + setDraggedQuestionIndex(null) + return + } + + const updatedQuestions = [...questions] + const draggedQuestion = updatedQuestions[draggedQuestionIndex] + + // Определяем финальную позицию вставки на основе позиции перетаскивания + let insertIndex = dropIndex + if (dragOverPosition === 'below') { + insertIndex = dropIndex + 1 + } + + // Удаляем элемент из старой позиции + updatedQuestions.splice(draggedQuestionIndex, 1) + + // Корректируем индекс вставки после удаления элемента + // Если удалили элемент выше позиции вставки, нужно уменьшить индекс на 1 + if (draggedQuestionIndex < insertIndex) { + insertIndex -= 1 + } + + // Ограничиваем индекс диапазоном массива + insertIndex = Math.max(0, Math.min(insertIndex, updatedQuestions.length)) + + // Вставляем элемент в новую позицию + updatedQuestions.splice(insertIndex, 0, draggedQuestion) + + onUpdateQuestions(updatedQuestions) + setDraggedQuestionIndex(null) + setDragOverIndex(null) + setDragOverPosition(null) + } + const handleExportJson = () => { try { - const jsonString = JSON.stringify(questions, null, 2) + // Нормализуем вопросы, удаляя поле question если оно есть + const normalizedQuestions = questions.map(q => { + const { question, ...questionWithoutQuestion } = q + return { + ...questionWithoutQuestion, + text: q.text || '', + answers: q.answers.map(a => ({ + id: a.id, + text: a.text, + points: a.points + })) + } + }) + const jsonString = JSON.stringify(normalizedQuestions, null, 2) const blob = new Blob([jsonString], { type: 'application/json' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') @@ -361,15 +497,19 @@ const GameManagementModal = ({ return } - // Добавляем id если его нет - const questionsWithIds = jsonContent.map((q, idx) => ({ - ...q, - id: q.id || Date.now() + Math.random() + idx, - answers: q.answers.map((a, aidx) => ({ - ...a, - id: a.id || `answer-${Date.now()}-${idx}-${aidx}` - })) - })) + // Нормализуем вопросы, удаляя поле question если оно есть, и добавляем id если его нет + const questionsWithIds = jsonContent.map((q) => { + const { question, ...rest } = q + return { + ...rest, + id: q.id || crypto.randomUUID(), + text: q.text || '', + answers: q.answers.map((a) => ({ + ...a, + id: a.id || crypto.randomUUID() + })) + } + }) onUpdateQuestions(questionsWithIds) setJsonError('') @@ -410,7 +550,7 @@ const GameManagementModal = ({ // Фильтрация вопросов по поисковому запросу const filteredPackQuestions = packQuestions.filter((q) => { if (!searchQuery.trim()) return true - const questionText = (q.text || q.question || '').toLowerCase() + const questionText = (q.text || '').toLowerCase() return questionText.includes(searchQuery.toLowerCase()) }) @@ -476,10 +616,14 @@ const GameManagementModal = ({ const indices = Array.from(selectedQuestionIndices) const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean) - const copiedQuestions = questionsToImport.map((q, idx) => ({ - id: Date.now() + Math.random() + idx, // Generate new ID - text: q.text || q.question || '', - answers: (q.answers || []).map(a => ({ text: a.text, points: a.points })), + const copiedQuestions = questionsToImport.map((q) => ({ + id: crypto.randomUUID(), + text: q.text || '', + answers: (q.answers || []).map(a => ({ + id: a.id || crypto.randomUUID(), + text: a.text, + points: a.points + })), })) const updatedQuestions = [...questions, ...copiedQuestions] @@ -1061,7 +1205,7 @@ const GameManagementModal = ({ onChange={() => handleToggleQuestion(originalIndex)} />

- {q.text || q.question} + {q.text || ''} {q.answers?.length || 0} ответов @@ -1096,7 +1240,7 @@ const GameManagementModal = ({
- {viewingQuestion.text || viewingQuestion.question} + {viewingQuestion.text || ''}
+ +
+
{question.text}
@@ -1208,19 +1394,34 @@ const GameManagementModal = ({
+
e.stopPropagation()} + > + ⋮⋮ +
))}
diff --git a/src/components/Snowflakes.jsx b/src/components/Snowflakes.jsx index 741cb59..d24c48d 100644 --- a/src/components/Snowflakes.jsx +++ b/src/components/Snowflakes.jsx @@ -6,7 +6,7 @@ const UPDATE_INTERVAL = 500 // Check every 500ms function createSnowflake(id, isInitial = false) { return { - id: id || `snowflake-${Date.now()}-${Math.random()}`, + id: id || crypto.randomUUID(), left: Math.random() * 100, duration: Math.random() * 3 + 7, // 7-10s delay: isInitial ? Math.random() * 2 : 0, // Only delay initial batch