@tinyweb_dev/oe-exam-sdk 0.1.31 → 0.2.0

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.
Files changed (39) hide show
  1. package/dist/components/exams/take/components/QuestionRenderer.js +6 -2
  2. package/dist/components/exams/take/components/question-renderers/MoversCrosswordPuzzleRenderer.d.ts +3 -0
  3. package/dist/components/exams/take/components/question-renderers/MoversCrosswordPuzzleRenderer.js +285 -0
  4. package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
  5. package/dist/components/exams/take/components/question-renderers/index.js +1 -0
  6. package/dist/components/exams/take/types.d.ts +30 -0
  7. package/dist/components/exams/take/utils/answer-transformers.js +35 -5
  8. package/dist/components/exams/take/utils/question-transformers.d.ts +23 -1
  9. package/dist/components/exams/take/utils/question-transformers.js +36 -0
  10. package/dist/components/questions/_shared/config/question-types.config.js +6 -0
  11. package/dist/components/questions/_shared/types/crossword-puzzle.type.d.ts +16 -0
  12. package/dist/components/questions/_shared/types/crossword-puzzle.type.js +1 -0
  13. package/dist/components/questions/components/QuestionSearchDropdown.js +1 -1
  14. package/dist/components/questions/creator/question-type-registry.js +2 -0
  15. package/dist/components/questions/question-bank/QuestionEditorRenderer.d.ts +2 -1
  16. package/dist/components/questions/question-bank/QuestionEditorRenderer.js +16 -0
  17. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.d.ts +11 -0
  18. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.js +80 -0
  19. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleCreator.d.ts +8 -0
  20. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleCreator.js +213 -0
  21. package/dist/components/questions/types/crossword-puzzle/index.d.ts +4 -0
  22. package/dist/components/questions/types/crossword-puzzle/index.js +4 -0
  23. package/dist/components/questions/types/crossword-puzzle/register.d.ts +2 -0
  24. package/dist/components/questions/types/crossword-puzzle/register.js +29 -0
  25. package/dist/components/questions/types/crossword-puzzle/transform.d.ts +2 -0
  26. package/dist/components/questions/types/crossword-puzzle/transform.js +75 -0
  27. package/dist/components/questions/viewer/QuestionViewer.js +7 -4
  28. package/dist/components/results/renderers/review-question-body-movers.js +7 -2
  29. package/dist/components/results/review-data.js +8 -4
  30. package/dist/shared/constants/question-skills.js +2 -0
  31. package/dist/shared/lib/utils/question-reverse-transform.js +54 -0
  32. package/dist/shared/lib/utils/question-transform-validation.js +12 -0
  33. package/dist/shared/lib/utils/question-transform.js +2 -0
  34. package/dist/shared/types/common.types.d.ts +1 -1
  35. package/dist/shared/types/questions/crossword-puzzle.d.ts +50 -0
  36. package/dist/shared/types/questions/crossword-puzzle.js +10 -0
  37. package/dist/shared/types/questions/index.d.ts +1 -0
  38. package/dist/shared/types/questions/index.js +2 -0
  39. package/package.json +1 -1
@@ -0,0 +1,80 @@
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
+ const isMarked = content?.markedColumn && c === content.markedColumn;
54
+ const isExample = Boolean(cellInfo.across?.isExample || cellInfo.down?.isExample);
55
+ // If in review mode or cell is an example, display correct answer letter
56
+ let previewLetter = '';
57
+ if (isReviewMode || isExample) {
58
+ if (cellInfo.across && correctAnswer?.answers) {
59
+ const word = correctAnswer.answers[`across-${cellInfo.across.number}`] || '';
60
+ const offset = c - cellInfo.across.startCol;
61
+ previewLetter = word[offset] || '';
62
+ }
63
+ else if (cellInfo.down && correctAnswer?.answers) {
64
+ const word = correctAnswer.answers[`down-${cellInfo.down.number}`] || '';
65
+ const offset = r - cellInfo.down.startRow;
66
+ previewLetter = word[offset] || '';
67
+ }
68
+ }
69
+ else if (userAnswers?.[key]) {
70
+ previewLetter = userAnswers[key];
71
+ }
72
+ let cellBg = isMarked ? 'bg-slate-200' : 'bg-white';
73
+ let textColor = isExample ? 'text-orange-600' : 'text-gray-800';
74
+ if (isReviewMode && previewLetter) {
75
+ textColor = 'text-green-700 font-extrabold';
76
+ }
77
+ return (_jsxs("div", { className: `relative flex items-center justify-center rounded border-2 border-gray-400 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 ${cellBg} ${textColor}`, 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));
78
+ });
79
+ }) }) })] })] }));
80
+ }
@@ -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,4 @@
1
+ export * from './CrosswordPuzzleCreator';
2
+ export * from './CrosswordPuzzleClient';
3
+ export * from './register';
4
+ export * from './transform';
@@ -0,0 +1,4 @@
1
+ export * from './CrosswordPuzzleCreator';
2
+ export * from './CrosswordPuzzleClient';
3
+ export * from './register';
4
+ export * from './transform';
@@ -0,0 +1,2 @@
1
+ import type { QuestionTypeRegistration } from '../../creator/question-type-registry';
2
+ export declare const crosswordPuzzleRegistration: QuestionTypeRegistration;
@@ -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,2 @@
1
+ import type { TransformHandler } from '../../../../shared/lib/utils/question-transform-types';
2
+ export declare const transformCrosswordPuzzle: TransformHandler;
@@ -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 shows Bài N + instruction inside the client — no outer card label.
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 has its own bordered shell — avoid double Card border.
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
- result.cellAnswersMap[answer.questionId] = { cells: correct.cells, caseSensitive: correct.caseSensitive === true };
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);