@tinyweb_dev/oe-exam-sdk 0.1.31 → 0.1.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/take/components/QuestionRenderer.js +6 -2
- package/dist/components/exams/take/components/question-renderers/MoversCrosswordPuzzleRenderer.d.ts +3 -0
- package/dist/components/exams/take/components/question-renderers/MoversCrosswordPuzzleRenderer.js +285 -0
- package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
- package/dist/components/exams/take/components/question-renderers/index.js +1 -0
- package/dist/components/exams/take/types.d.ts +30 -0
- package/dist/components/exams/take/utils/answer-transformers.js +35 -5
- package/dist/components/exams/take/utils/question-transformers.d.ts +23 -1
- package/dist/components/exams/take/utils/question-transformers.js +36 -0
- package/dist/components/questions/_shared/config/question-types.config.js +6 -0
- package/dist/components/questions/_shared/types/crossword-puzzle.type.d.ts +16 -0
- package/dist/components/questions/_shared/types/crossword-puzzle.type.js +1 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +1 -1
- package/dist/components/questions/creator/question-type-registry.js +2 -0
- package/dist/components/questions/question-bank/QuestionEditorRenderer.d.ts +2 -1
- package/dist/components/questions/question-bank/QuestionEditorRenderer.js +16 -0
- package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.d.ts +11 -0
- package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.js +69 -0
- package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleCreator.d.ts +8 -0
- package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleCreator.js +213 -0
- package/dist/components/questions/types/crossword-puzzle/index.d.ts +4 -0
- package/dist/components/questions/types/crossword-puzzle/index.js +4 -0
- package/dist/components/questions/types/crossword-puzzle/register.d.ts +2 -0
- package/dist/components/questions/types/crossword-puzzle/register.js +29 -0
- package/dist/components/questions/types/crossword-puzzle/transform.d.ts +2 -0
- package/dist/components/questions/types/crossword-puzzle/transform.js +75 -0
- package/dist/components/questions/viewer/QuestionViewer.js +7 -4
- package/dist/components/results/renderers/review-question-body-movers.js +7 -2
- package/dist/components/results/review-data.js +8 -4
- package/dist/shared/constants/question-skills.js +2 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +54 -0
- package/dist/shared/lib/utils/question-transform-validation.js +12 -0
- package/dist/shared/lib/utils/question-transform.js +2 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/crossword-puzzle.d.ts +50 -0
- package/dist/shared/types/questions/crossword-puzzle.js +10 -0
- package/dist/shared/types/questions/index.d.ts +1 -0
- package/dist/shared/types/questions/index.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
export function CrosswordPuzzleClient({ content, correctAnswer, isReviewMode = false, userAnswers = {}, }) {
|
|
5
|
+
const rows = content?.gridSize?.rows || 12;
|
|
6
|
+
const cols = content?.gridSize?.cols || 12;
|
|
7
|
+
const acrossClues = content?.clues?.across || [];
|
|
8
|
+
const downClues = content?.clues?.down || [];
|
|
9
|
+
// Build grid cell mapping
|
|
10
|
+
const { cellMap, clueNumberMap } = useMemo(() => {
|
|
11
|
+
const cMap = new Map();
|
|
12
|
+
const numMap = new Map();
|
|
13
|
+
for (const c of acrossClues) {
|
|
14
|
+
numMap.set(`${c.startRow}-${c.startCol}`, c.number);
|
|
15
|
+
for (let i = 0; i < c.length; i++) {
|
|
16
|
+
const key = `${c.startRow}-${c.startCol + i}`;
|
|
17
|
+
const existing = cMap.get(key) || {};
|
|
18
|
+
cMap.set(key, { ...existing, across: c });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
for (const c of downClues) {
|
|
22
|
+
if (!numMap.has(`${c.startRow}-${c.startCol}`)) {
|
|
23
|
+
numMap.set(`${c.startRow}-${c.startCol}`, c.number);
|
|
24
|
+
}
|
|
25
|
+
for (let i = 0; i < c.length; i++) {
|
|
26
|
+
const key = `${c.startRow + i}-${c.startCol}`;
|
|
27
|
+
const existing = cMap.get(key) || {};
|
|
28
|
+
cMap.set(key, { ...existing, down: c });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { cellMap: cMap, clueNumberMap: numMap };
|
|
32
|
+
}, [acrossClues, downClues]);
|
|
33
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4 bg-white rounded-xl border border-gray-200", children: [content?.title && (_jsx("h3", { className: "text-lg font-bold text-gray-800 text-center", children: content.title })), _jsxs("div", { className: "grid grid-cols-1 lg:grid-cols-12 gap-6 items-start", children: [_jsxs("div", { className: "lg:col-span-5 flex flex-col gap-4", children: [_jsxs("div", { className: "rounded-lg border border-blue-200 bg-blue-50/50 p-4", children: [_jsxs("h4", { className: "font-bold text-blue-900 mb-2.5 flex items-center gap-2", children: [_jsx("span", { className: "px-2 py-0.5 rounded bg-blue-600 text-white text-xs font-semibold", children: "Across" }), _jsx("span", { children: "H\u00E0ng ngang" })] }), _jsx("div", { className: "flex flex-col gap-2 max-h-60 overflow-y-auto pr-1", children: acrossClues.map((c) => {
|
|
34
|
+
const answer = correctAnswer?.answers?.[`across-${c.number}`];
|
|
35
|
+
const userAns = userAnswers[`across-${c.number}`];
|
|
36
|
+
return (_jsxs("div", { className: "flex items-start gap-2 text-sm bg-white p-2 rounded border border-blue-100", children: [_jsxs("span", { className: "font-bold text-blue-700 min-w-[20px]", children: [c.number, "."] }), _jsxs("div", { className: "flex-1", children: [_jsx("span", { className: "text-gray-800", children: c.clue }), isReviewMode && answer && (_jsxs("div", { className: "text-xs mt-1", children: [_jsx("span", { className: "text-gray-500", children: "\u0110\u00E1p \u00E1n: " }), _jsx("span", { className: "font-bold text-green-700", children: answer }), userAns && userAns !== answer && (_jsx("span", { className: "ml-2 line-through text-red-500 font-semibold", children: userAns }))] }))] }), _jsxs("span", { className: "text-xs text-gray-400", children: ["(", c.length, " ch\u1EEF)"] })] }, `across-${c.number}`));
|
|
37
|
+
}) })] }), _jsxs("div", { className: "rounded-lg border border-purple-200 bg-purple-50/50 p-4", children: [_jsxs("h4", { className: "font-bold text-purple-900 mb-2.5 flex items-center gap-2", children: [_jsx("span", { className: "px-2 py-0.5 rounded bg-purple-600 text-white text-xs font-semibold", children: "Down" }), _jsx("span", { children: "H\u00E0ng d\u1ECDc" })] }), _jsx("div", { className: "flex flex-col gap-2 max-h-60 overflow-y-auto pr-1", children: downClues.map((c) => {
|
|
38
|
+
const answer = correctAnswer?.answers?.[`down-${c.number}`];
|
|
39
|
+
const userAns = userAnswers[`down-${c.number}`];
|
|
40
|
+
return (_jsxs("div", { className: "flex items-start gap-2 text-sm bg-white p-2 rounded border border-purple-100", children: [_jsxs("span", { className: "font-bold text-purple-700 min-w-[20px]", children: [c.number, "."] }), _jsxs("div", { className: "flex-1", children: [_jsx("span", { className: "text-gray-800", children: c.clue }), isReviewMode && answer && (_jsxs("div", { className: "text-xs mt-1", children: [_jsx("span", { className: "text-gray-500", children: "\u0110\u00E1p \u00E1n: " }), _jsx("span", { className: "font-bold text-green-700", children: answer }), userAns && userAns !== answer && (_jsx("span", { className: "ml-2 line-through text-red-500 font-semibold", children: userAns }))] }))] }), _jsxs("span", { className: "text-xs text-gray-400", children: ["(", c.length, " ch\u1EEF)"] })] }, `down-${c.number}`));
|
|
41
|
+
}) })] })] }), _jsx("div", { className: "lg:col-span-7 flex justify-center overflow-x-auto p-4 bg-gray-50 rounded-xl border border-gray-200", children: _jsx("div", { className: "inline-grid gap-1", style: {
|
|
42
|
+
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
43
|
+
}, children: Array.from({ length: rows }).map((_, rIdx) => {
|
|
44
|
+
const r = rIdx + 1;
|
|
45
|
+
return Array.from({ length: cols }).map((_, cIdx) => {
|
|
46
|
+
const c = cIdx + 1;
|
|
47
|
+
const key = `${r}-${c}`;
|
|
48
|
+
const cellInfo = cellMap.get(key);
|
|
49
|
+
const clueNum = clueNumberMap.get(key);
|
|
50
|
+
if (!cellInfo) {
|
|
51
|
+
return (_jsx("div", { className: "h-8 w-8 sm:h-9 sm:w-9 md:h-10 md:w-10 bg-transparent" }, key));
|
|
52
|
+
}
|
|
53
|
+
let previewLetter = '';
|
|
54
|
+
if (correctAnswer?.answers) {
|
|
55
|
+
if (cellInfo.across) {
|
|
56
|
+
const word = correctAnswer.answers[`across-${cellInfo.across.number}`] || '';
|
|
57
|
+
const offset = c - cellInfo.across.startCol;
|
|
58
|
+
previewLetter = word[offset] || '';
|
|
59
|
+
}
|
|
60
|
+
else if (cellInfo.down) {
|
|
61
|
+
const word = correctAnswer.answers[`down-${cellInfo.down.number}`] || '';
|
|
62
|
+
const offset = r - cellInfo.down.startRow;
|
|
63
|
+
previewLetter = word[offset] || '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return (_jsxs("div", { className: "relative flex items-center justify-center rounded border-2 border-gray-400 bg-white font-bold text-gray-800 shadow-sm h-8 w-8 sm:h-9 sm:w-9 md:h-10 md:w-10 text-sm sm:text-base md:text-lg", children: [clueNum && (_jsx("span", { className: "absolute top-0.5 left-1 text-[9px] font-bold text-gray-500 leading-none", children: clueNum })), _jsx("span", { children: previewLetter })] }, key));
|
|
67
|
+
});
|
|
68
|
+
}) }) })] })] }));
|
|
69
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
interface CrosswordPuzzleCreatorProps {
|
|
3
|
+
initialData?: any;
|
|
4
|
+
onChange?: (data: any) => void;
|
|
5
|
+
onUnsavedChangesChange?: (hasChanges: boolean) => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function CrosswordPuzzleCreator({ initialData, onChange, onUnsavedChangesChange, }: CrosswordPuzzleCreatorProps): React.JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
4
|
+
import { Plus, Trash2, Edit2, Check, AlertCircle } from 'lucide-react';
|
|
5
|
+
export function CrosswordPuzzleCreator({ initialData, onChange, onUnsavedChangesChange, }) {
|
|
6
|
+
const [title, setTitle] = useState(() => {
|
|
7
|
+
return initialData?.content?.title || 'Nationalities Crossword';
|
|
8
|
+
});
|
|
9
|
+
const [rows, setRows] = useState(() => {
|
|
10
|
+
return initialData?.content?.gridSize?.rows || initialData?.content?.rows || 12;
|
|
11
|
+
});
|
|
12
|
+
const [cols, setCols] = useState(() => {
|
|
13
|
+
return initialData?.content?.gridSize?.cols || initialData?.content?.cols || 12;
|
|
14
|
+
});
|
|
15
|
+
const [caseSensitive, setCaseSensitive] = useState(() => {
|
|
16
|
+
return initialData?.answer?.caseSensitive || initialData?.correctAnswer?.caseSensitive || false;
|
|
17
|
+
});
|
|
18
|
+
const [points, setPoints] = useState(() => {
|
|
19
|
+
return initialData?.points ?? 11;
|
|
20
|
+
});
|
|
21
|
+
const [clues, setClues] = useState(() => {
|
|
22
|
+
if (Array.isArray(initialData?.content?.clues)) {
|
|
23
|
+
return initialData.content.clues;
|
|
24
|
+
}
|
|
25
|
+
const across = initialData?.content?.clues?.across || [];
|
|
26
|
+
const down = initialData?.content?.clues?.down || [];
|
|
27
|
+
const answers = initialData?.answer?.answers || initialData?.correctAnswer?.answers || {};
|
|
28
|
+
const items = [];
|
|
29
|
+
for (const a of across) {
|
|
30
|
+
items.push({
|
|
31
|
+
id: `across-${a.number}-${Date.now()}-${Math.random()}`,
|
|
32
|
+
number: a.number,
|
|
33
|
+
clue: a.clue,
|
|
34
|
+
startRow: a.startRow,
|
|
35
|
+
startCol: a.startCol,
|
|
36
|
+
length: a.length,
|
|
37
|
+
direction: 'across',
|
|
38
|
+
answer: answers[`across-${a.number}`] || '',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
for (const d of down) {
|
|
42
|
+
items.push({
|
|
43
|
+
id: `down-${d.number}-${Date.now()}-${Math.random()}`,
|
|
44
|
+
number: d.number,
|
|
45
|
+
clue: d.clue,
|
|
46
|
+
startRow: d.startRow,
|
|
47
|
+
startCol: d.startCol,
|
|
48
|
+
length: d.length,
|
|
49
|
+
direction: 'down',
|
|
50
|
+
answer: answers[`down-${d.number}`] || '',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return items;
|
|
54
|
+
});
|
|
55
|
+
const [editingClueId, setEditingClueId] = useState(null);
|
|
56
|
+
const [formNumber, setFormNumber] = useState(1);
|
|
57
|
+
const [formDirection, setFormDirection] = useState('across');
|
|
58
|
+
const [formClue, setFormClue] = useState('');
|
|
59
|
+
const [formAnswer, setFormAnswer] = useState('');
|
|
60
|
+
const [formStartRow, setFormStartRow] = useState(1);
|
|
61
|
+
const [formStartCol, setFormStartCol] = useState(1);
|
|
62
|
+
const { cellMap, clueNumberMap, conflicts } = useMemo(() => {
|
|
63
|
+
const cMap = new Map();
|
|
64
|
+
const numMap = new Map();
|
|
65
|
+
const conflictList = [];
|
|
66
|
+
for (const item of clues) {
|
|
67
|
+
const startKey = `${item.startRow}-${item.startCol}`;
|
|
68
|
+
const existingNums = numMap.get(startKey) || [];
|
|
69
|
+
if (!existingNums.includes(item.number)) {
|
|
70
|
+
numMap.set(startKey, [...existingNums, item.number]);
|
|
71
|
+
}
|
|
72
|
+
const word = (item.answer || '').trim().toUpperCase();
|
|
73
|
+
const length = item.length || word.length || 1;
|
|
74
|
+
for (let i = 0; i < length; i++) {
|
|
75
|
+
const r = item.direction === 'across' ? item.startRow : item.startRow + i;
|
|
76
|
+
const c = item.direction === 'across' ? item.startCol + i : item.startCol;
|
|
77
|
+
const key = `${r}-${c}`;
|
|
78
|
+
const letter = word[i] || '';
|
|
79
|
+
const list = cMap.get(key) || [];
|
|
80
|
+
if (letter && list.some((x) => x.letter && x.letter !== letter)) {
|
|
81
|
+
conflictList.push(key);
|
|
82
|
+
}
|
|
83
|
+
list.push({ letter, clueId: item.id, dir: item.direction });
|
|
84
|
+
cMap.set(key, list);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { cellMap: cMap, clueNumberMap: numMap, conflicts: conflictList };
|
|
88
|
+
}, [clues]);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!onChange)
|
|
91
|
+
return;
|
|
92
|
+
const across = clues
|
|
93
|
+
.filter((c) => c.direction === 'across')
|
|
94
|
+
.map((c) => ({
|
|
95
|
+
number: c.number,
|
|
96
|
+
clue: c.clue.trim(),
|
|
97
|
+
startRow: c.startRow,
|
|
98
|
+
startCol: c.startCol,
|
|
99
|
+
length: c.length || c.answer.trim().length,
|
|
100
|
+
}));
|
|
101
|
+
const down = clues
|
|
102
|
+
.filter((c) => c.direction === 'down')
|
|
103
|
+
.map((c) => ({
|
|
104
|
+
number: c.number,
|
|
105
|
+
clue: c.clue.trim(),
|
|
106
|
+
startRow: c.startRow,
|
|
107
|
+
startCol: c.startCol,
|
|
108
|
+
length: c.length || c.answer.trim().length,
|
|
109
|
+
}));
|
|
110
|
+
const answers = {};
|
|
111
|
+
for (const c of clues) {
|
|
112
|
+
if (c.answer) {
|
|
113
|
+
answers[`${c.direction}-${c.number}`] = c.answer.trim().toUpperCase();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
onChange({
|
|
117
|
+
content: {
|
|
118
|
+
title,
|
|
119
|
+
gridSize: { rows, cols },
|
|
120
|
+
clues: { across, down },
|
|
121
|
+
},
|
|
122
|
+
answer: {
|
|
123
|
+
answers,
|
|
124
|
+
caseSensitive,
|
|
125
|
+
},
|
|
126
|
+
points,
|
|
127
|
+
});
|
|
128
|
+
onUnsavedChangesChange?.(true);
|
|
129
|
+
}, [title, rows, cols, clues, caseSensitive, points, onChange, onUnsavedChangesChange]);
|
|
130
|
+
const handleSaveClue = (e) => {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
if (!formClue.trim() || !formAnswer.trim())
|
|
133
|
+
return;
|
|
134
|
+
const cleanAnswer = formAnswer.trim().toUpperCase();
|
|
135
|
+
const length = cleanAnswer.length;
|
|
136
|
+
if (editingClueId) {
|
|
137
|
+
setClues((prev) => prev.map((c) => c.id === editingClueId
|
|
138
|
+
? {
|
|
139
|
+
...c,
|
|
140
|
+
number: formNumber,
|
|
141
|
+
direction: formDirection,
|
|
142
|
+
clue: formClue.trim(),
|
|
143
|
+
answer: cleanAnswer,
|
|
144
|
+
startRow: formStartRow,
|
|
145
|
+
startCol: formStartCol,
|
|
146
|
+
length,
|
|
147
|
+
}
|
|
148
|
+
: c));
|
|
149
|
+
setEditingClueId(null);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
const newClue = {
|
|
153
|
+
id: `clue-${Date.now()}-${Math.random()}`,
|
|
154
|
+
number: formNumber,
|
|
155
|
+
direction: formDirection,
|
|
156
|
+
clue: formClue.trim(),
|
|
157
|
+
answer: cleanAnswer,
|
|
158
|
+
startRow: formStartRow,
|
|
159
|
+
startCol: formStartCol,
|
|
160
|
+
length,
|
|
161
|
+
};
|
|
162
|
+
setClues((prev) => [...prev, newClue]);
|
|
163
|
+
}
|
|
164
|
+
setFormClue('');
|
|
165
|
+
setFormAnswer('');
|
|
166
|
+
setFormNumber((prev) => prev + 1);
|
|
167
|
+
};
|
|
168
|
+
const handleEditClue = (c) => {
|
|
169
|
+
setEditingClueId(c.id);
|
|
170
|
+
setFormNumber(c.number);
|
|
171
|
+
setFormDirection(c.direction);
|
|
172
|
+
setFormClue(c.clue);
|
|
173
|
+
setFormAnswer(c.answer);
|
|
174
|
+
setFormStartRow(c.startRow);
|
|
175
|
+
setFormStartCol(c.startCol);
|
|
176
|
+
};
|
|
177
|
+
const handleDeleteClue = (id) => {
|
|
178
|
+
setClues((prev) => prev.filter((c) => c.id !== id));
|
|
179
|
+
if (editingClueId === id) {
|
|
180
|
+
setEditingClueId(null);
|
|
181
|
+
setFormClue('');
|
|
182
|
+
setFormAnswer('');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 p-4 bg-white rounded-xl border border-gray-200", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-gray-700 mb-1", children: "Ti\u00EAu \u0111\u1EC1 \u00F4 ch\u1EEF" }), _jsx("input", { type: "text", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "vd: Nationalities Crossword", className: "w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 outline-none" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-gray-700 mb-1", children: "K\u00EDch th\u01B0\u1EDBc l\u01B0\u1EDBi (H\u00E0ng x C\u1ED9t)" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "number", min: 3, max: 30, value: rows, onChange: (e) => setRows(Math.max(3, parseInt(e.target.value) || 12)), className: "w-20 px-2 py-1.5 text-sm border border-gray-300 rounded outline-none" }), _jsx("span", { className: "text-gray-500 font-bold", children: "x" }), _jsx("input", { type: "number", min: 3, max: 30, value: cols, onChange: (e) => setCols(Math.max(3, parseInt(e.target.value) || 12)), className: "w-20 px-2 py-1.5 text-sm border border-gray-300 rounded outline-none" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-gray-700 mb-1", children: "\u0110i\u1EC3m s\u1ED1" }), _jsx("input", { type: "number", min: 0, step: 0.5, value: points, onChange: (e) => setPoints(parseFloat(e.target.value) || 1), className: "w-full px-3 py-1.5 text-sm border border-gray-300 rounded outline-none" })] }), _jsx("div", { className: "flex items-center pt-5", children: _jsxs("label", { className: "flex items-center gap-2 text-sm text-gray-700 cursor-pointer", children: [_jsx("input", { type: "checkbox", checked: caseSensitive, onChange: (e) => setCaseSensitive(e.target.checked), className: "rounded text-blue-600 focus:ring-blue-500 h-4 w-4" }), _jsx("span", { children: "Ph\u00E2n bi\u1EC7t hoa/th\u01B0\u1EDDng" })] }) })] }), _jsxs("div", { className: "grid grid-cols-1 lg:grid-cols-12 gap-6", children: [_jsxs("div", { className: "lg:col-span-5 flex flex-col gap-4", children: [_jsxs("form", { onSubmit: handleSaveClue, className: "p-4 rounded-xl border border-blue-200 bg-blue-50/40 flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-bold text-blue-900", children: editingClueId ? 'Chỉnh sửa gợi ý' : 'Thêm gợi ý mới' }), editingClueId && (_jsx("button", { type: "button", onClick: () => {
|
|
186
|
+
setEditingClueId(null);
|
|
187
|
+
setFormClue('');
|
|
188
|
+
setFormAnswer('');
|
|
189
|
+
}, className: "text-xs text-gray-500 hover:text-gray-700 underline", children: "H\u1EE7y ch\u1EC9nh s\u1EEDa" }))] }), _jsxs("div", { className: "grid grid-cols-3 gap-2", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-[11px] font-semibold text-gray-600 mb-0.5", children: "S\u1ED1 Clue" }), _jsx("input", { type: "number", min: 1, value: formNumber, onChange: (e) => setFormNumber(parseInt(e.target.value) || 1), className: "w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[11px] font-semibold text-gray-600 mb-0.5", children: "H\u01B0\u1EDBng" }), _jsxs("select", { value: formDirection, onChange: (e) => setFormDirection(e.target.value), className: "w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none bg-white", children: [_jsx("option", { value: "across", children: "Across (Ngang)" }), _jsx("option", { value: "down", children: "Down (D\u1ECDc)" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[11px] font-semibold text-gray-600 mb-0.5", children: "To\u1EA1 \u0111\u1ED9 (R, C)" }), _jsxs("div", { className: "flex gap-1", children: [_jsx("input", { type: "number", min: 1, max: rows, value: formStartRow, onChange: (e) => setFormStartRow(parseInt(e.target.value) || 1), placeholder: "R", className: "w-1/2 px-1 py-1 text-xs border border-gray-300 rounded text-center" }), _jsx("input", { type: "number", min: 1, max: cols, value: formStartCol, onChange: (e) => setFormStartCol(parseInt(e.target.value) || 1), placeholder: "C", className: "w-1/2 px-1 py-1 text-xs border border-gray-300 rounded text-center" })] })] })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[11px] font-semibold text-gray-600 mb-0.5", children: "C\u00E2u g\u1EE3i \u00FD (Clue)" }), _jsx("input", { type: "text", value: formClue, onChange: (e) => setFormClue(e.target.value), placeholder: "vd: Spain, Capital of France...", className: "w-full px-3 py-1.5 text-sm border border-gray-300 rounded outline-none", required: true })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-[11px] font-semibold text-gray-600 mb-0.5", children: "T\u1EEB \u0111\u00E1p \u00E1n (Answer Word)" }), _jsx("input", { type: "text", value: formAnswer, onChange: (e) => setFormAnswer(e.target.value), placeholder: "vd: SPANISH", className: "w-full px-3 py-1.5 text-sm border border-gray-300 rounded outline-none uppercase font-semibold", required: true })] }), _jsxs("button", { type: "submit", className: "mt-1 flex items-center justify-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm font-semibold transition", children: [editingClueId ? _jsx(Check, { className: "h-4 w-4" }) : _jsx(Plus, { className: "h-4 w-4" }), _jsx("span", { children: editingClueId ? 'Cập nhật gợi ý' : 'Thêm vào danh sách' })] })] }), _jsxs("div", { className: "flex flex-col gap-2 max-h-[420px] overflow-y-auto pr-1", children: [_jsxs("span", { className: "text-xs font-bold text-gray-600", children: ["Danh s\u00E1ch (", clues.length, " t\u1EEB):"] }), clues.length === 0 && (_jsx("p", { className: "text-xs text-gray-400 italic", children: "Ch\u01B0a c\u00F3 g\u1EE3i \u00FD n\u00E0o. H\u00E3y th\u00EAm g\u1EE3i \u00FD \u1EDF tr\u00EAn." })), clues.map((c) => (_jsxs("div", { className: "flex items-center justify-between p-2.5 bg-white rounded-lg border border-gray-200 hover:border-blue-300 transition text-sm shadow-sm", children: [_jsxs("div", { className: "flex items-center gap-2 flex-1 min-w-0", children: [_jsx("span", { className: `flex items-center justify-center h-6 w-6 rounded text-xs font-bold text-white ${c.direction === 'across' ? 'bg-blue-600' : 'bg-purple-600'}`, children: c.number }), _jsxs("div", { className: "flex flex-col min-w-0 flex-1", children: [_jsx("span", { className: "font-semibold text-gray-800 truncate", children: c.clue }), _jsxs("span", { className: "text-xs text-gray-500 flex items-center gap-2", children: [_jsx("span", { className: "font-bold text-blue-700", children: c.answer }), _jsxs("span", { children: ["(", c.length, " ch\u1EEF)"] }), _jsxs("span", { children: ["[", c.direction === 'across' ? '→ Ngang' : '↓ Dọc', " (", c.startRow, ",", c.startCol, ")]"] })] })] })] }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => handleEditClue(c), className: "p-1 text-gray-500 hover:text-blue-600 rounded", title: "S\u1EEDa", children: _jsx(Edit2, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", onClick: () => handleDeleteClue(c.id), className: "p-1 text-gray-500 hover:text-red-600 rounded", title: "X\u00F3a", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })] }, c.id)))] })] }), _jsxs("div", { className: "lg:col-span-7 flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-xs font-bold text-gray-700", children: "Ma tr\u1EADn l\u01B0\u1EDBi \u00F4 ch\u1EEF tr\u1EF1c quan:" }), conflicts.length > 0 && (_jsxs("span", { className: "flex items-center gap-1 text-xs font-bold text-red-600 bg-red-50 px-2 py-0.5 rounded border border-red-200", children: [_jsx(AlertCircle, { className: "h-3.5 w-3.5" }), _jsxs("span", { children: ["C\u00F3 ", conflicts.length, " \u00F4 b\u1ECB xung \u0111\u1ED9t ch\u1EEF c\u00E1i!"] })] }))] }), _jsx("div", { className: "flex justify-center overflow-x-auto p-4 bg-gray-100 rounded-xl border border-gray-300 min-h-[380px]", children: _jsx("div", { className: "inline-grid gap-1", style: {
|
|
190
|
+
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
191
|
+
}, children: Array.from({ length: rows }).map((_, rIdx) => {
|
|
192
|
+
const r = rIdx + 1;
|
|
193
|
+
return Array.from({ length: cols }).map((_, cIdx) => {
|
|
194
|
+
const c = cIdx + 1;
|
|
195
|
+
const key = `${r}-${c}`;
|
|
196
|
+
const cellEntries = cellMap.get(key);
|
|
197
|
+
const clueNums = clueNumberMap.get(key);
|
|
198
|
+
const isConflict = conflicts.includes(key);
|
|
199
|
+
if (!cellEntries || cellEntries.length === 0) {
|
|
200
|
+
return (_jsx("button", { type: "button", onClick: () => {
|
|
201
|
+
setFormStartRow(r);
|
|
202
|
+
setFormStartCol(c);
|
|
203
|
+
}, title: `Ô trống (${r},${c}) - Click để chọn toạ độ bắt đầu`, className: "h-8 w-8 sm:h-9 sm:w-9 md:h-10 md:w-10 bg-transparent hover:bg-gray-200/60 rounded flex items-center justify-center transition text-[10px] text-gray-300 hover:text-gray-500", children: "\u00B7" }, key));
|
|
204
|
+
}
|
|
205
|
+
const displayLetter = cellEntries[0]?.letter || '';
|
|
206
|
+
return (_jsxs("div", { className: `relative flex items-center justify-center rounded border-2 font-bold shadow-sm h-8 w-8 sm:h-9 sm:w-9 md:h-10 md:w-10 text-sm sm:text-base md:text-lg select-none transition ${isConflict
|
|
207
|
+
? 'border-red-500 bg-red-100 text-red-700 animate-pulse'
|
|
208
|
+
: cellEntries.length > 1
|
|
209
|
+
? 'border-indigo-400 bg-indigo-50 text-indigo-900'
|
|
210
|
+
: 'border-gray-400 bg-white text-gray-800'}`, children: [clueNums && clueNums.length > 0 && (_jsx("span", { className: "absolute top-0.5 left-0.5 text-[9px] font-bold text-gray-500 leading-none", children: clueNums.join('/') })), _jsx("span", { children: displayLetter })] }, key));
|
|
211
|
+
});
|
|
212
|
+
}) }) })] })] })] }));
|
|
213
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CrosswordPuzzleCreator } from './CrosswordPuzzleCreator';
|
|
2
|
+
export const crosswordPuzzleRegistration = {
|
|
3
|
+
component: CrosswordPuzzleCreator,
|
|
4
|
+
useRawInitialData: true,
|
|
5
|
+
getExtraProps: (ctx) => ({
|
|
6
|
+
onUnsavedChangesChange: ctx.onUnsavedChangesChange,
|
|
7
|
+
}),
|
|
8
|
+
wrapOnSave: (data, ctx) => ({
|
|
9
|
+
type: ctx.questionType,
|
|
10
|
+
content: ctx.state.content,
|
|
11
|
+
points: data.points ?? ctx.state.points ?? 1,
|
|
12
|
+
level: ctx.state.level,
|
|
13
|
+
difficulty: ctx.state.difficulty,
|
|
14
|
+
skill: ctx.state.skill,
|
|
15
|
+
...(data.content !== undefined ? { content: data.content } : {}),
|
|
16
|
+
answer: data.answer ?? data,
|
|
17
|
+
explanation: data.explanation || '',
|
|
18
|
+
}),
|
|
19
|
+
wrapOnChange: (data, ctx) => ({
|
|
20
|
+
type: ctx.questionType,
|
|
21
|
+
points: data.points ?? ctx.state.points ?? 1,
|
|
22
|
+
level: ctx.state.level,
|
|
23
|
+
difficulty: ctx.state.difficulty,
|
|
24
|
+
skill: ctx.state.skill,
|
|
25
|
+
content: data.content,
|
|
26
|
+
answer: data.answer,
|
|
27
|
+
explanation: data.explanation || '',
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const transformCrosswordPuzzle = (question) => {
|
|
2
|
+
const contentData = question.content;
|
|
3
|
+
const answerData = question.answer;
|
|
4
|
+
let content;
|
|
5
|
+
let correctAnswer;
|
|
6
|
+
if (contentData?.gridSize && contentData?.clues) {
|
|
7
|
+
content = {
|
|
8
|
+
title: contentData.title || '',
|
|
9
|
+
gridSize: {
|
|
10
|
+
rows: Number(contentData.gridSize.rows) || 12,
|
|
11
|
+
cols: Number(contentData.gridSize.cols) || 12,
|
|
12
|
+
},
|
|
13
|
+
clues: {
|
|
14
|
+
across: Array.isArray(contentData.clues.across) ? contentData.clues.across : [],
|
|
15
|
+
down: Array.isArray(contentData.clues.down) ? contentData.clues.down : [],
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
correctAnswer = {
|
|
19
|
+
answers: answerData?.answers || {},
|
|
20
|
+
caseSensitive: answerData?.caseSensitive || false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
else if (Array.isArray(contentData?.clues)) {
|
|
24
|
+
const formClues = contentData.clues;
|
|
25
|
+
const across = formClues
|
|
26
|
+
.filter((c) => c.direction === 'across')
|
|
27
|
+
.map((c) => ({
|
|
28
|
+
number: Number(c.number),
|
|
29
|
+
clue: String(c.clue || '').trim(),
|
|
30
|
+
startRow: Number(c.startRow) || 1,
|
|
31
|
+
startCol: Number(c.startCol) || 1,
|
|
32
|
+
length: Number(c.length) || (c.answer ? String(c.answer).trim().length : 1),
|
|
33
|
+
}));
|
|
34
|
+
const down = formClues
|
|
35
|
+
.filter((c) => c.direction === 'down')
|
|
36
|
+
.map((c) => ({
|
|
37
|
+
number: Number(c.number),
|
|
38
|
+
clue: String(c.clue || '').trim(),
|
|
39
|
+
startRow: Number(c.startRow) || 1,
|
|
40
|
+
startCol: Number(c.startCol) || 1,
|
|
41
|
+
length: Number(c.length) || (c.answer ? String(c.answer).trim().length : 1),
|
|
42
|
+
}));
|
|
43
|
+
content = {
|
|
44
|
+
title: contentData.title || '',
|
|
45
|
+
gridSize: {
|
|
46
|
+
rows: Number(contentData.rows) || 12,
|
|
47
|
+
cols: Number(contentData.cols) || 12,
|
|
48
|
+
},
|
|
49
|
+
clues: { across, down },
|
|
50
|
+
};
|
|
51
|
+
const answers = {};
|
|
52
|
+
for (const c of formClues) {
|
|
53
|
+
const key = `${c.direction}-${c.number}`;
|
|
54
|
+
if (c.answer) {
|
|
55
|
+
answers[key] = String(c.answer).trim().toUpperCase();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
correctAnswer = {
|
|
59
|
+
answers,
|
|
60
|
+
caseSensitive: contentData.caseSensitive || false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
content = {
|
|
65
|
+
gridSize: { rows: 12, cols: 12 },
|
|
66
|
+
clues: { across: [], down: [] },
|
|
67
|
+
};
|
|
68
|
+
correctAnswer = { answers: {}, caseSensitive: false };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
apiContent: content,
|
|
72
|
+
apiCorrectAnswer: correctAnswer,
|
|
73
|
+
explanation: answerData?.explanation || question.explanation || '',
|
|
74
|
+
};
|
|
75
|
+
};
|
|
@@ -19,6 +19,7 @@ import { LookPictureChooseCorrectAnswerClient } from '../types/look-picture-choo
|
|
|
19
19
|
import { LookPictureFillBlankChooseAnswerClient } from '../types/look-picture-fill-blank-choose-answer/LookPictureFillBlankChooseAnswerClient';
|
|
20
20
|
import { FillMissingWordsInGridClient } from '../types/fill-missing-words-in-grid/FillMissingWordsInGridClient';
|
|
21
21
|
import { WordFillStructuredFormClient } from '../types/word-fill-structured-form/WordFillStructuredFormClient';
|
|
22
|
+
import { CrosswordPuzzleClient } from '../types/crossword-puzzle/CrosswordPuzzleClient';
|
|
22
23
|
import { parseFillBlankStemLine } from '../_shared/utils/parseFillBlankStem';
|
|
23
24
|
import { WordFillStructuredFormViewer } from './WordFillStructuredFormViewer';
|
|
24
25
|
import { ChooseTheCorrectAnswerGroupViewer } from './ChooseTheCorrectAnswerGroupViewer';
|
|
@@ -643,6 +644,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
|
|
|
643
644
|
return renderWordFillParagraph();
|
|
644
645
|
case 'WORD_FILL_STRUCTURED_FORM':
|
|
645
646
|
return (_jsx(WordFillStructuredFormViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
|
|
647
|
+
case 'CROSSWORD_PUZZLE':
|
|
648
|
+
return (_jsx(CrosswordPuzzleClient, { content: question.content, correctAnswer: question.answer, isReviewMode: isReviewMode, userAnswers: userAnswer }));
|
|
646
649
|
default:
|
|
647
650
|
return _jsxs("div", { children: ["Lo\u1EA1i c\u00E2u h\u1ECFi kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3: ", question.type] });
|
|
648
651
|
}
|
|
@@ -653,8 +656,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
|
|
|
653
656
|
* parseFillBlankStemLine (or paragraph renderer). Dumping raw text shows markdown.
|
|
654
657
|
*/
|
|
655
658
|
const resolveCardTitle = () => {
|
|
656
|
-
// WFSF
|
|
657
|
-
if (question.type === 'WORD_FILL_STRUCTURED_FORM') {
|
|
659
|
+
// WFSF and Crossword show title/instruction inside the client — no outer card label.
|
|
660
|
+
if (question.type === 'WORD_FILL_STRUCTURED_FORM' || question.type === 'CROSSWORD_PUZZLE') {
|
|
658
661
|
return null;
|
|
659
662
|
}
|
|
660
663
|
// Modern fill stems live entirely in the body; title is a short label.
|
|
@@ -676,8 +679,8 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
|
|
|
676
679
|
return stripLightweightMarkdown(raw) || 'Câu hỏi';
|
|
677
680
|
};
|
|
678
681
|
const cardTitle = resolveCardTitle();
|
|
679
|
-
// WFSF already
|
|
680
|
-
if (question.type === 'WORD_FILL_STRUCTURED_FORM') {
|
|
682
|
+
// WFSF and Crossword already have their own bordered shell — avoid double Card border.
|
|
683
|
+
if (question.type === 'WORD_FILL_STRUCTURED_FORM' || question.type === 'CROSSWORD_PUZZLE') {
|
|
681
684
|
return (_jsx("div", { className: "space-y-4", children: renderQuestion() }));
|
|
682
685
|
}
|
|
683
686
|
return (_jsxs(Card, { children: [cardTitle ? (_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center justify-between", children: [_jsx("span", { className: "min-w-0 pr-2 text-base font-semibold leading-snug", children: cardTitle }), _jsxs("span", { className: "shrink-0 text-sm font-normal text-gray-500", children: [question.points, " \u0111i\u1EC3m"] })] }) })) : null, _jsx(CardContent, { className: cardTitle ? 'space-y-4' : 'space-y-4 pt-4', children: renderQuestion() })] }));
|
|
@@ -4,8 +4,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
4
4
|
* Movers fallback review cases (isReviewMode=true).
|
|
5
5
|
* Source: oe-exam-fe ReviewQuestionRenderer.tsx
|
|
6
6
|
*/
|
|
7
|
-
import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, } from '../../../components/exams/take/components/question-renderers';
|
|
8
|
-
import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWriteSentences, transformWriteShortLetter, } from '../../../components/exams/take/utils/question-transformers';
|
|
7
|
+
import { MoversAnswerTheQuestionRenderer, MoversFillWordHintRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversPictureChooseRenderer, MoversPictureFillBlankChooseRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, } from '../../../components/exams/take/components/question-renderers';
|
|
8
|
+
import { transformAnswerTheQuestion, transformFillWordHint, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformPictureChoose, transformPictureFillBlankChoose, transformReadingPassage, transformTrueFalse, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, } from '../../../components/exams/take/utils/question-transformers';
|
|
9
9
|
import { normalizeCrossOutWordGroupAnswer, transformCrossOutWordGroup, } from '../../../components/exams/take/utils/cross-out-word-group.transformer';
|
|
10
10
|
function asApiQuestions(questions) {
|
|
11
11
|
return questions;
|
|
@@ -142,6 +142,11 @@ export function renderMoversReviewBody(ctx) {
|
|
|
142
142
|
const data = transformWordFillStructuredForm(apiQuestions);
|
|
143
143
|
return (_jsx(MoversWordFillStructuredFormRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, audioUrl: data.audioUrl || part.audioUrl, groupTitle: data.groupTitle, groupContent: data.groupContent, groupImageUrl: data.groupImageUrl, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true }));
|
|
144
144
|
}
|
|
145
|
+
case 'CROSSWORD_PUZZLE': {
|
|
146
|
+
const data = transformCrosswordPuzzle(apiQuestions);
|
|
147
|
+
const firstQuestion = apiQuestions[0];
|
|
148
|
+
return (_jsx(MoversCrosswordPuzzleRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, title: data.title, gridSize: data.gridSize, clues: data.clues, answers: answers, correctAnswers: data.correctAnswers, onAnswerChange: handleAnswerChange, isReviewMode: true, caseSensitive: firstQuestion?.correct_answer?.caseSensitive || false }));
|
|
149
|
+
}
|
|
145
150
|
default:
|
|
146
151
|
return null;
|
|
147
152
|
}
|
|
@@ -137,8 +137,12 @@ function addCorrectAnswerMaps(result, answer) {
|
|
|
137
137
|
texts.forEach((text, index) => { result.correctAnswerTextMap[`${answer.questionId}-blank-${index}`] = text; });
|
|
138
138
|
if (answer.questionType === 'LABEL_THE_PICTURE' && isRecord(correct.labels))
|
|
139
139
|
Object.entries(correct.labels).forEach(([id, value]) => { result.correctAnswerTextMap[id] = String(value); });
|
|
140
|
-
if (answer.questionType === 'FILL_MISSING_WORDS_IN_GRID' && isRecord(correct.cells)) {
|
|
141
|
-
|
|
140
|
+
if ((answer.questionType === 'FILL_MISSING_WORDS_IN_GRID' || answer.questionType === 'CROSSWORD_PUZZLE') && isRecord(correct.cells || correct.answers)) {
|
|
141
|
+
const rawCells = (correct.cells || correct.answers);
|
|
142
|
+
result.cellAnswersMap[answer.questionId] = {
|
|
143
|
+
cells: Object.fromEntries(Object.entries(rawCells).map(([k, v]) => [k, { correctAnswer: String(v) }])),
|
|
144
|
+
caseSensitive: correct.caseSensitive === true,
|
|
145
|
+
};
|
|
142
146
|
}
|
|
143
147
|
}
|
|
144
148
|
function transformStudentAnswer(questionId, questionType, value) {
|
|
@@ -204,8 +208,8 @@ function transformStudentAnswer(questionId, questionType, value) {
|
|
|
204
208
|
const keys = Object.keys(record).filter((key) => /^\d+$/.test(key)).sort((a, b) => Number(a) - Number(b));
|
|
205
209
|
return keys.map((key) => record[key]);
|
|
206
210
|
}
|
|
207
|
-
if (questionType === 'READ_AND_COLOR_OBJECTS' || questionType === 'IMAGE_OBJECT_MATCHING')
|
|
208
|
-
return record.answers ?? value;
|
|
211
|
+
if (questionType === 'READ_AND_COLOR_OBJECTS' || questionType === 'IMAGE_OBJECT_MATCHING' || questionType === 'CROSSWORD_PUZZLE')
|
|
212
|
+
return record.cells ?? record.answers ?? value;
|
|
209
213
|
if (questionType === 'WRITE_A_SHORT_LETTER')
|
|
210
214
|
return typeof value === 'string' ? value : record.answer ?? '';
|
|
211
215
|
return record.answer ?? value;
|
|
@@ -78,6 +78,7 @@ export const QUESTION_TYPE_SKILLS = {
|
|
|
78
78
|
'READING_PASSAGE_FILL': SkillEnum.READING,
|
|
79
79
|
'FILL_BLANK': SkillEnum.READING,
|
|
80
80
|
'WORD_FILL_STRUCTURED_FORM': SkillEnum.READING,
|
|
81
|
+
'CROSSWORD_PUZZLE': SkillEnum.READING,
|
|
81
82
|
// ========================================
|
|
82
83
|
// WRITING SKILLS (Viết)
|
|
83
84
|
// ========================================
|
|
@@ -204,6 +205,7 @@ export const QUESTION_TYPE_LABELS = {
|
|
|
204
205
|
'WORD_FILL_PARAGRAPH': 'Điền từ vào đoạn văn',
|
|
205
206
|
'MATCH_BY_WRITING_ANSWER': 'Ghép đáp án bằng cách viết',
|
|
206
207
|
'WORD_FILL_STRUCTURED_FORM': 'Điền từ vào biểu mẫu có cấu trúc',
|
|
208
|
+
'CROSSWORD_PUZZLE': 'Giải ô chữ (Crossword)',
|
|
207
209
|
// ========================================
|
|
208
210
|
// WRITING SKILLS (Viết)
|
|
209
211
|
// ========================================
|
|
@@ -446,7 +446,61 @@ const handlers = {
|
|
|
446
446
|
SPEAKING_CONVERSATION: reverseGroupPayload,
|
|
447
447
|
SPEAKING_CUE_CARD: reverseSimpleAnswer,
|
|
448
448
|
GN_SPEAKING_INTERVIEW: reverseGroupPayload,
|
|
449
|
+
CROSSWORD_PUZZLE: reverseCrosswordPuzzle,
|
|
449
450
|
};
|
|
451
|
+
function reverseCrosswordPuzzle(apiQuestion) {
|
|
452
|
+
const apiContent = (apiQuestion.content || {});
|
|
453
|
+
const apiAnswer = (apiQuestion.correctAnswer || apiQuestion.correct_answer || apiQuestion.answer || {});
|
|
454
|
+
const rows = apiContent.gridSize?.rows || apiContent.rows || 12;
|
|
455
|
+
const cols = apiContent.gridSize?.cols || apiContent.cols || 12;
|
|
456
|
+
const title = apiContent.title || '';
|
|
457
|
+
const across = Array.isArray(apiContent.clues?.across) ? apiContent.clues.across : [];
|
|
458
|
+
const down = Array.isArray(apiContent.clues?.down) ? apiContent.clues.down : [];
|
|
459
|
+
const answers = apiAnswer.answers || {};
|
|
460
|
+
const clues = [];
|
|
461
|
+
for (const a of across) {
|
|
462
|
+
clues.push({
|
|
463
|
+
id: `across-${a.number}-${Date.now()}-${Math.random()}`,
|
|
464
|
+
number: a.number,
|
|
465
|
+
clue: a.clue || '',
|
|
466
|
+
startRow: a.startRow || 1,
|
|
467
|
+
startCol: a.startCol || 1,
|
|
468
|
+
length: a.length || 1,
|
|
469
|
+
direction: 'across',
|
|
470
|
+
answer: answers[`across-${a.number}`] || '',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
for (const d of down) {
|
|
474
|
+
clues.push({
|
|
475
|
+
id: `down-${d.number}-${Date.now()}-${Math.random()}`,
|
|
476
|
+
number: d.number,
|
|
477
|
+
clue: d.clue || '',
|
|
478
|
+
startRow: d.startRow || 1,
|
|
479
|
+
startCol: d.startCol || 1,
|
|
480
|
+
length: d.length || 1,
|
|
481
|
+
direction: 'down',
|
|
482
|
+
answer: answers[`down-${d.number}`] || '',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
content: {
|
|
487
|
+
title,
|
|
488
|
+
rows,
|
|
489
|
+
cols,
|
|
490
|
+
gridSize: { rows, cols },
|
|
491
|
+
clues: { across, down },
|
|
492
|
+
},
|
|
493
|
+
answer: {
|
|
494
|
+
title,
|
|
495
|
+
rows,
|
|
496
|
+
cols,
|
|
497
|
+
clues,
|
|
498
|
+
answers,
|
|
499
|
+
caseSensitive: apiAnswer.caseSensitive || false,
|
|
500
|
+
explanation: apiQuestion.explanation || '',
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
450
504
|
export function transformApiQuestionToFrontend(apiQuestion) {
|
|
451
505
|
const questionType = String(apiQuestion.questionType ?? apiQuestion.question_type ?? apiQuestion.type ?? '');
|
|
452
506
|
const handlerResult = (handlers[questionType] ?? defaultReverse)(apiQuestion);
|
|
@@ -54,11 +54,23 @@ export function validateQuestionForTransform(question) {
|
|
|
54
54
|
case 'WRITE_A_SHORT_LETTER':
|
|
55
55
|
validateWordLimits(answer, errors);
|
|
56
56
|
break;
|
|
57
|
+
case 'CROSSWORD_PUZZLE':
|
|
58
|
+
validateCrossword(content, answer, errors);
|
|
59
|
+
break;
|
|
57
60
|
default:
|
|
58
61
|
break;
|
|
59
62
|
}
|
|
60
63
|
return { isValid: errors.length === 0, errors };
|
|
61
64
|
}
|
|
65
|
+
function validateCrossword(content, answer, errors) {
|
|
66
|
+
const clues = (content?.clues || answer?.clues);
|
|
67
|
+
const hasAcrossOrDown = (Array.isArray(clues?.across) && clues.across.length > 0) ||
|
|
68
|
+
(Array.isArray(clues?.down) && clues.down.length > 0) ||
|
|
69
|
+
(Array.isArray(clues) && clues.length > 0);
|
|
70
|
+
if (!hasAcrossOrDown) {
|
|
71
|
+
errors.push('Cần ít nhất 1 gợi ý (Across hoặc Down) cho bài ô chữ');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
62
74
|
function validateGrid(content, answer, errors) {
|
|
63
75
|
if (!Array.isArray(content?.grid))
|
|
64
76
|
errors.push('Grid is required for FILL_MISSING_WORDS_IN_GRID');
|