fixes
This commit is contained in:
parent
dbc43d65c1
commit
eb13424f20
20 changed files with 409 additions and 66 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:{' '}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export interface Answer {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Question {
|
export interface Question {
|
||||||
question: string
|
text: string
|
||||||
answers: Answer[]
|
answers: Answer[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class QuestionDto {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
question: string;
|
text: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class ImportAnswerDto {
|
||||||
|
|
||||||
class ImportQuestionDto {
|
class ImportQuestionDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
question: string;
|
text: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class QuestionDto {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
question: string;
|
text: string;
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue