This commit is contained in:
Dmitry 2026-01-10 21:01:01 +03:00
parent dbc43d65c1
commit eb13424f20
20 changed files with 409 additions and 66 deletions

View file

@ -211,7 +211,7 @@ export const packsApi = {
getTemplate: async (): Promise<{ getTemplate: async (): Promise<{
templateVersion: string templateVersion: string
instructions: string instructions: string
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }>
}> => { }> => {
try { try {
const response = await adminApiClient.get('/api/admin/packs/export/template') const response = await adminApiClient.get('/api/admin/packs/export/template')
@ -239,7 +239,7 @@ export const packsApi = {
category: string category: string
isPublic: boolean isPublic: boolean
} }
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }>
}> => { }> => {
try { try {
const response = await adminApiClient.get(`/api/admin/packs/${packId}/export`) const response = await adminApiClient.get(`/api/admin/packs/${packId}/export`)
@ -273,7 +273,7 @@ export const packsApi = {
description: string description: string
category: string category: string
isPublic: boolean isPublic: boolean
questions: Array<{ question: string; answers: Array<{ text: string; points: number }> }> questions: Array<{ text: string; answers: Array<{ text: string; points: number }> }>
}): Promise<EditCardPackDto> => { }): Promise<EditCardPackDto> => {
try { try {
const response = await adminApiClient.post('/api/admin/packs/import', data) const response = await adminApiClient.post('/api/admin/packs/import', data)

View file

@ -42,7 +42,7 @@ export function GameQuestionEditorDialog({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (question) { if (question) {
setQuestionText(question.question || '') setQuestionText(question.text || '')
setAnswers( setAnswers(
question.answers && question.answers.length > 0 question.answers && question.answers.length > 0
? [...question.answers] ? [...question.answers]
@ -108,7 +108,7 @@ export function GameQuestionEditorDialog({
} }
const questionData: Question = { const questionData: Question = {
question: questionText.trim(), text: questionText.trim(),
answers: validAnswers, answers: validAnswers,
} }

View file

@ -82,7 +82,7 @@ export function GameQuestionsManager({
</span> </span>
</div> </div>
<div> <div>
<p className="font-medium">{question.question}</p> <p className="font-medium">{question.text}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{question.answers.length} answer {question.answers.length} answer
{question.answers.length !== 1 ? 's' : ''} (points:{' '} {question.answers.length !== 1 ? 's' : ''} (points:{' '}

View file

@ -78,7 +78,8 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr
// Validate questions structure // Validate questions structure
const isValid = questions.every( const isValid = questions.every(
(q: any) => (q: any) =>
(q.question || q.text) && q.text &&
typeof q.text === 'string' &&
Array.isArray(q.answers) && Array.isArray(q.answers) &&
q.answers.length > 0 && q.answers.length > 0 &&
q.answers.every( q.answers.every(
@ -87,7 +88,7 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr
) )
if (!isValid) { 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) setParseError(null)
@ -151,14 +152,18 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr
return return
} }
// Normalize questions to use 'question' field // Normalize questions to use 'text' field (ignore 'question' field if present)
const normalizedQuestions = parsed.map((q: any) => ({ const normalizedQuestions = parsed.map((q: any) => {
question: q.question || q.text, const { question, ...rest } = q
return {
...rest,
text: q.text || '',
answers: q.answers.map((a: any) => ({ answers: q.answers.map((a: any) => ({
text: a.text, text: a.text,
points: a.points, points: a.points,
})), })),
})) }
})
setIsLoading(true) setIsLoading(true)
try { try {
@ -227,7 +232,7 @@ export function PackImportDialog({ open, onImport, onClose }: PackImportDialogPr
id="jsonContent" id="jsonContent"
value={jsonContent} value={jsonContent}
onChange={(e) => handleJsonChange(e.target.value)} onChange={(e) => handleJsonChange(e.target.value)}
placeholder='[{"question": "...", "answers": [{"text": "...", "points": 100}]}]' placeholder='[{"text": "...", "answers": [{"text": "...", "points": 100}]}]'
rows={8} rows={8}
className="font-mono text-sm" className="font-mono text-sm"
/> />

View file

@ -55,7 +55,7 @@ export function InputButtonsQuestionForm({
const addButton = () => { const addButton = () => {
const newButton: TestButton = { const newButton: TestButton = {
id: `btn_${Date.now()}`, id: crypto.randomUUID(),
text: '', text: '',
} }
setButtons([...buttons, newButton]) setButtons([...buttons, newButton])

View file

@ -53,7 +53,7 @@ export function SimpleQuestionForm({
const addButton = () => { const addButton = () => {
const newButton: TestButton = { const newButton: TestButton = {
id: `btn_${Date.now()}`, id: crypto.randomUUID(),
text: '', text: '',
} }
setButtons([...buttons, newButton]) setButtons([...buttons, newButton])

View file

@ -158,10 +158,10 @@ export default function PacksPage() {
const fullPack = await packsApi.getPack(pack.id) const fullPack = await packsApi.getPack(pack.id)
setSelectedPack(fullPack) 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) const questions: Question[] = Array.isArray(fullPack.questions)
? fullPack.questions.map((q: any) => ({ ? fullPack.questions.map((q: any) => ({
question: q.question || q.text || '', text: q.text || '',
answers: Array.isArray(q.answers) answers: Array.isArray(q.answers)
? q.answers.map((a: any) => ({ ? q.answers.map((a: any) => ({
text: a.text || '', text: a.text || '',

View file

@ -7,7 +7,7 @@ export interface Answer {
} }
export interface Question { export interface Question {
question: string text: string
answers: Answer[] answers: Answer[]
} }

View file

@ -16,25 +16,26 @@ interface Answer {
interface Question { interface Question {
id?: string; id?: string;
text?: string; text?: string;
question?: string;
answers: Answer[]; answers: Answer[];
} }
function ensureQuestionIds(questions: Question[]): Question[] { function ensureQuestionIds(questions: Question[]): Question[] {
return questions.map((question) => { return questions.map((question) => {
const questionId = question.id || randomUUID(); const questionId = question.id || randomUUID();
const questionText = question.text || question.question || ''; const questionText = question.text || '';
const answersWithIds = question.answers.map((answer) => ({ const answersWithIds = question.answers.map((answer) => ({
...answer, ...answer,
id: answer.id || randomUUID(), id: answer.id || randomUUID(),
})); }));
// Удаляем поле question если оно было в исходном объекте
const { question: _, ...questionWithoutQuestion } = question;
return { return {
...question, ...questionWithoutQuestion,
id: questionId, id: questionId,
text: questionText, text: questionText,
question: questionText, // Keep both fields for compatibility
answers: answersWithIds, answers: answersWithIds,
}; };
}); });

View file

@ -35,10 +35,10 @@ export class AdminPacksController {
return { return {
templateVersion: '1.0', templateVersion: '1.0',
instructions: 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: [ questions: [
{ {
question: 'Your question here', text: 'Your question here',
answers: [ answers: [
{ text: 'Answer 1', points: 100 }, { text: 'Answer 1', points: 100 },
{ text: 'Answer 2', points: 80 }, { text: 'Answer 2', points: 80 },
@ -56,8 +56,8 @@ export class AdminPacksController {
// Validate question structure // Validate question structure
const isValid = importPackDto.questions.every( const isValid = importPackDto.questions.every(
(q) => (q) =>
q.question && q.text &&
typeof q.question === 'string' && typeof q.text === 'string' &&
Array.isArray(q.answers) && Array.isArray(q.answers) &&
q.answers.length > 0 && q.answers.length > 0 &&
q.answers.every( q.answers.every(
@ -70,7 +70,7 @@ export class AdminPacksController {
if (!isValid) { if (!isValid) {
throw new BadRequestException( 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.',
); );
} }

View file

@ -170,6 +170,10 @@ export class AdminPacksService {
throw new NotFoundException('Question pack not found'); throw new NotFoundException('Question pack not found');
} }
// Нормализуем вопросы при экспорте, удаляя поле question если оно есть
const packQuestions = Array.isArray(pack.questions) ? pack.questions as any[] : [];
const normalizedQuestions = ensureQuestionIds(packQuestions);
return { return {
templateVersion: '1.0', templateVersion: '1.0',
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
@ -179,7 +183,7 @@ export class AdminPacksService {
category: pack.category, category: pack.category,
isPublic: pack.isPublic, isPublic: pack.isPublic,
}, },
questions: pack.questions, questions: normalizedQuestions,
}; };
} }
} }

View file

@ -19,7 +19,7 @@ class QuestionDto {
id?: string; id?: string;
@IsString() @IsString()
question: string; text: string;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View file

@ -18,7 +18,7 @@ class ImportAnswerDto {
class ImportQuestionDto { class ImportQuestionDto {
@IsString() @IsString()
question: string; text: string;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View file

@ -19,7 +19,7 @@ class QuestionDto {
id?: string; id?: string;
@IsString() @IsString()
question: string; text: string;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View file

@ -27,7 +27,6 @@ interface PlayerAction {
interface Question { interface Question {
id: string; id: string;
text?: string; text?: string;
question?: string;
answers: Array<{ answers: Array<{
id: string; id: string;
text: string; text: string;
@ -441,7 +440,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
} }
return { return {
id: questionId || `temp-${Math.random()}`, id: questionId || `temp-${Math.random()}`,
text: q.text || q.question || '', text: q.text || '',
answers: (q.answers || []).map((a: any) => ({ answers: (q.answers || []).map((a: any) => ({
id: a.id || `answer-${Math.random()}`, id: a.id || `answer-${Math.random()}`,
text: a.text || '', text: a.text || '',

View file

@ -9,7 +9,6 @@ interface Answer {
interface Question { interface Question {
id?: string; id?: string;
text?: string; text?: string;
question?: string; // Поддержка обоих вариантов названия поля
answers: Answer[]; answers: Answer[];
} }
@ -34,7 +33,7 @@ export function ensureQuestionIds(questions: Question[]): Question[] {
return questions.map((question) => { return questions.map((question) => {
// Если ID нет или не является валидным UUID, создаем новый // Если ID нет или не является валидным UUID, создаем новый
const questionId = (question.id && isValidUUID(question.id)) ? question.id : randomUUID(); 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) => { const answersWithIds = question.answers.map((answer) => {
// Если ID нет или не является валидным UUID, создаем новый // Если ID нет или не является валидным UUID, создаем новый
@ -45,11 +44,13 @@ export function ensureQuestionIds(questions: Question[]): Question[] {
}; };
}); });
// Удаляем поле question если оно было в исходном объекте
const { question: _, ...questionWithoutQuestion } = question;
return { return {
...question, ...questionWithoutQuestion,
id: questionId, id: questionId,
text: questionText, text: questionText,
question: questionText, // Сохраняем оба поля для совместимости
answers: answersWithIds, answers: answersWithIds,
}; };
}); });

View file

@ -11,7 +11,6 @@ interface Answer {
interface Question { interface Question {
id: string; id: string;
text?: string; text?: string;
question?: string;
answers?: Answer[]; answers?: Answer[];
} }
@ -65,7 +64,7 @@ export class VoiceService {
} }
if (contentType === TTSContentType.QUESTION) { if (contentType === TTSContentType.QUESTION) {
const questionText = question.text || question.question; const questionText = question.text;
if (!questionText) { if (!questionText) {
this.logger.error(`Question text is empty for questionId=${questionId}`); this.logger.error(`Question text is empty for questionId=${questionId}`);
throw new NotFoundException('Question text is empty'); throw new NotFoundException('Question text is empty');

View file

@ -824,10 +824,102 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem;
padding: 1rem; padding: 1rem;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.2));
border-radius: var(--border-radius-sm, 8px); 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 { .questions-tab-content .questions-modal-item-content {
@ -850,6 +942,33 @@
gap: 0.5rem; 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-edit-button,
.questions-tab-content .questions-modal-delete-button { .questions-tab-content .questions-modal-delete-button {
width: 32px; width: 32px;
@ -902,6 +1021,20 @@
.questions-tab-content .questions-modal-actions button { .questions-tab-content .questions-modal-actions button {
width: 100%; 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 */ /* Custom Scrollbar */

View file

@ -83,6 +83,11 @@ const GameManagementModal = ({
const [viewingQuestion, setViewingQuestion] = useState(null) const [viewingQuestion, setViewingQuestion] = useState(null)
const [showAnswers, setShowAnswers] = useState(false) 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 при открытии модального окна // Сбрасываем вкладку на initialTab при открытии модального окна
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -237,11 +242,12 @@ const GameManagementModal = ({
if (!validateQuestionForm()) return if (!validateQuestionForm()) return
const questionData = { const questionData = {
id: editingQuestion ? editingQuestion.id : Date.now(), id: editingQuestion ? editingQuestion.id : crypto.randomUUID(),
text: questionText.trim(), text: questionText.trim(),
answers: answers answers: answers
.filter(a => a.text.trim()) .filter(a => a.text.trim())
.map(a => ({ .map(a => ({
id: a.id || crypto.randomUUID(),
text: a.text.trim(), text: a.text.trim(),
points: a.points, 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 = () => { const handleExportJson = () => {
try { 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 blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
@ -361,15 +497,19 @@ const GameManagementModal = ({
return return
} }
// Добавляем id если его нет // Нормализуем вопросы, удаляя поле question если оно есть, и добавляем id если его нет
const questionsWithIds = jsonContent.map((q, idx) => ({ const questionsWithIds = jsonContent.map((q) => {
...q, const { question, ...rest } = q
id: q.id || Date.now() + Math.random() + idx, return {
answers: q.answers.map((a, aidx) => ({ ...rest,
id: q.id || crypto.randomUUID(),
text: q.text || '',
answers: q.answers.map((a) => ({
...a, ...a,
id: a.id || `answer-${Date.now()}-${idx}-${aidx}` id: a.id || crypto.randomUUID()
}))
})) }))
}
})
onUpdateQuestions(questionsWithIds) onUpdateQuestions(questionsWithIds)
setJsonError('') setJsonError('')
@ -410,7 +550,7 @@ const GameManagementModal = ({
// Фильтрация вопросов по поисковому запросу // Фильтрация вопросов по поисковому запросу
const filteredPackQuestions = packQuestions.filter((q) => { const filteredPackQuestions = packQuestions.filter((q) => {
if (!searchQuery.trim()) return true if (!searchQuery.trim()) return true
const questionText = (q.text || q.question || '').toLowerCase() const questionText = (q.text || '').toLowerCase()
return questionText.includes(searchQuery.toLowerCase()) return questionText.includes(searchQuery.toLowerCase())
}) })
@ -476,10 +616,14 @@ const GameManagementModal = ({
const indices = Array.from(selectedQuestionIndices) const indices = Array.from(selectedQuestionIndices)
const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean) const questionsToImport = indices.map(idx => packQuestions[idx]).filter(Boolean)
const copiedQuestions = questionsToImport.map((q, idx) => ({ const copiedQuestions = questionsToImport.map((q) => ({
id: Date.now() + Math.random() + idx, // Generate new ID id: crypto.randomUUID(),
text: q.text || q.question || '', text: q.text || '',
answers: (q.answers || []).map(a => ({ text: a.text, points: a.points })), answers: (q.answers || []).map(a => ({
id: a.id || crypto.randomUUID(),
text: a.text,
points: a.points
})),
})) }))
const updatedQuestions = [...questions, ...copiedQuestions] const updatedQuestions = [...questions, ...copiedQuestions]
@ -1061,7 +1205,7 @@ const GameManagementModal = ({
onChange={() => handleToggleQuestion(originalIndex)} onChange={() => handleToggleQuestion(originalIndex)}
/> />
<div className="pack-question-content"> <div className="pack-question-content">
<strong>{q.text || q.question}</strong> <strong>{q.text || ''}</strong>
<span className="pack-question-info"> <span className="pack-question-info">
{q.answers?.length || 0} ответов {q.answers?.length || 0} ответов
</span> </span>
@ -1096,7 +1240,7 @@ const GameManagementModal = ({
</div> </div>
<div className="pack-question-viewer-content"> <div className="pack-question-viewer-content">
<div className="pack-question-viewer-text"> <div className="pack-question-viewer-text">
{viewingQuestion.text || viewingQuestion.question} {viewingQuestion.text || ''}
</div> </div>
<button <button
className="pack-show-answers-button" className="pack-show-answers-button"
@ -1197,8 +1341,50 @@ const GameManagementModal = ({
<p className="questions-modal-empty">Нет вопросов. Добавьте вопросы для игры.</p> <p className="questions-modal-empty">Нет вопросов. Добавьте вопросы для игры.</p>
) : ( ) : (
<div className="questions-modal-items"> <div className="questions-modal-items">
{questions.map((question) => ( {questions.map((question, index) => (
<div key={question.id} className="questions-modal-item"> <div
key={question.id}
className={`questions-modal-item ${
draggedQuestionIndex === index ? 'dragging' : ''
} ${dragOverIndex === index ? 'drag-over' : ''} ${
dragOverIndex === index && dragOverPosition ? `drag-over-${dragOverPosition}` : ''
}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
>
<div className="questions-modal-item-order">
<span className="questions-modal-item-number">{index + 1}</span>
<div className="questions-modal-item-order-buttons">
<button
className="questions-modal-order-button"
onClick={(e) => {
e.stopPropagation()
handleMoveQuestionUp(index)
}}
onMouseDown={(e) => e.stopPropagation()}
disabled={index === 0}
title="Переместить вверх"
>
</button>
<button
className="questions-modal-order-button"
onClick={(e) => {
e.stopPropagation()
handleMoveQuestionDown(index)
}}
onMouseDown={(e) => e.stopPropagation()}
disabled={index === questions.length - 1}
title="Переместить вниз"
>
</button>
</div>
</div>
<div className="questions-modal-item-content"> <div className="questions-modal-item-content">
<div className="questions-modal-item-text">{question.text}</div> <div className="questions-modal-item-text">{question.text}</div>
<div className="questions-modal-item-info"> <div className="questions-modal-item-info">
@ -1208,19 +1394,34 @@ const GameManagementModal = ({
<div className="questions-modal-item-actions"> <div className="questions-modal-item-actions">
<button <button
className="questions-modal-edit-button" className="questions-modal-edit-button"
onClick={() => handleEditQuestion(question)} onClick={(e) => {
e.stopPropagation()
handleEditQuestion(question)
}}
onMouseDown={(e) => e.stopPropagation()}
title="Редактировать" title="Редактировать"
> >
</button> </button>
<button <button
className="questions-modal-delete-button" className="questions-modal-delete-button"
onClick={() => handleDeleteQuestion(question.id)} onClick={(e) => {
e.stopPropagation()
handleDeleteQuestion(question.id)
}}
onMouseDown={(e) => e.stopPropagation()}
title="Удалить" title="Удалить"
> >
× ×
</button> </button>
</div> </div>
<div
className="questions-modal-item-drag-handle"
title="Перетащите для изменения порядка"
onMouseDown={(e) => e.stopPropagation()}
>
</div>
</div> </div>
))} ))}
</div> </div>

View file

@ -6,7 +6,7 @@ const UPDATE_INTERVAL = 500 // Check every 500ms
function createSnowflake(id, isInitial = false) { function createSnowflake(id, isInitial = false) {
return { return {
id: id || `snowflake-${Date.now()}-${Math.random()}`, id: id || crypto.randomUUID(),
left: Math.random() * 100, left: Math.random() * 100,
duration: Math.random() * 3 + 7, // 7-10s duration: Math.random() * 3 + 7, // 7-10s
delay: isInitial ? Math.random() * 2 : 0, // Only delay initial batch delay: isInitial ? Math.random() * 2 : 0, // Only delay initial batch