@tinyweb_dev/oe-exam-sdk 0.2.12 → 0.2.14

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 (116) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +13 -3
  3. package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.d.ts +14 -0
  4. package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.js +32 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.d.ts +1 -1
  6. package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.js +10 -2
  7. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
  8. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
  9. package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.d.ts +16 -0
  10. package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.js +23 -0
  11. package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
  12. package/dist/components/exams/take/components/question-renderers/index.js +2 -0
  13. package/dist/components/exams/take/utils/answer-transformers.js +34 -1
  14. package/dist/components/exams/take/utils/question-transformers.d.ts +43 -4
  15. package/dist/components/exams/take/utils/question-transformers.js +71 -1
  16. package/dist/components/questions/_shared/config/question-types.config.js +12 -0
  17. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +7 -0
  18. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +19 -1
  19. package/dist/components/questions/_shared/types/answer-the-question.type.d.ts +3 -1
  20. package/dist/components/questions/_shared/types/answer-the-question.type.js +19 -1
  21. package/dist/components/questions/_shared/types/choose-then-answer-group.type.d.ts +47 -0
  22. package/dist/components/questions/_shared/types/choose-then-answer-group.type.js +2 -0
  23. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
  24. package/dist/components/questions/_shared/types/index.d.ts +2 -0
  25. package/dist/components/questions/_shared/types/index.js +2 -0
  26. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +10 -1
  27. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +19 -0
  28. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
  29. package/dist/components/questions/_shared/types/sort-words-into-categories.type.d.ts +43 -0
  30. package/dist/components/questions/_shared/types/sort-words-into-categories.type.js +1 -0
  31. package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
  32. package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
  33. package/dist/components/questions/creator/question-type-registry.js +4 -0
  34. package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +30 -6
  35. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.d.ts +2 -0
  36. package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.js +56 -0
  37. package/dist/components/questions/types/answer-the-question/transform.js +9 -3
  38. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +47 -3
  39. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +8 -2
  40. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +21 -2
  41. package/dist/components/questions/types/answer-the-question-group/transform.js +10 -1
  42. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.d.ts +2 -0
  43. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +34 -0
  44. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.d.ts +2 -0
  45. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js +174 -0
  46. package/dist/components/questions/types/choose-then-answer-group/index.d.ts +5 -0
  47. package/dist/components/questions/types/choose-then-answer-group/index.js +5 -0
  48. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.d.ts +11 -0
  49. package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.js +127 -0
  50. package/dist/components/questions/types/choose-then-answer-group/register.d.ts +3 -0
  51. package/dist/components/questions/types/choose-then-answer-group/register.js +37 -0
  52. package/dist/components/questions/types/choose-then-answer-group/transform.d.ts +2 -0
  53. package/dist/components/questions/types/choose-then-answer-group/transform.js +37 -0
  54. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
  55. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
  56. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
  57. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
  58. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
  59. package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
  60. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +41 -12
  61. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +23 -8
  62. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +11 -2
  63. package/dist/components/questions/types/match-word-to-picture-group/transform.js +25 -9
  64. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
  65. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
  66. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
  67. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
  68. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
  69. package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
  70. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts +12 -0
  71. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js +74 -0
  72. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.d.ts +2 -0
  73. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +24 -0
  74. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.d.ts +2 -0
  75. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js +171 -0
  76. package/dist/components/questions/types/sort-words-into-categories/index.d.ts +4 -0
  77. package/dist/components/questions/types/sort-words-into-categories/index.js +4 -0
  78. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.d.ts +11 -0
  79. package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.js +153 -0
  80. package/dist/components/questions/types/sort-words-into-categories/register.d.ts +3 -0
  81. package/dist/components/questions/types/sort-words-into-categories/register.js +37 -0
  82. package/dist/components/questions/types/sort-words-into-categories/transform.d.ts +2 -0
  83. package/dist/components/questions/types/sort-words-into-categories/transform.js +33 -0
  84. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
  85. package/dist/components/questions/viewer/AnswerTheQuestionViewer.d.ts +9 -0
  86. package/dist/components/questions/viewer/AnswerTheQuestionViewer.js +8 -0
  87. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.d.ts +10 -0
  88. package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.js +39 -0
  89. package/dist/components/questions/viewer/QuestionViewer.js +13 -2
  90. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.d.ts +10 -0
  91. package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.js +27 -0
  92. package/dist/components/questions/viewer/index.d.ts +3 -0
  93. package/dist/components/questions/viewer/index.js +3 -0
  94. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +2 -1
  95. package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +12 -3
  96. package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
  97. package/dist/components/results/renderers/review-question-body-movers.js +24 -2
  98. package/dist/components/results/review-data.js +28 -0
  99. package/dist/index.d.ts +1 -1
  100. package/dist/index.js +1 -1
  101. package/dist/shared/constants/question-skills.js +4 -0
  102. package/dist/shared/lib/utils/question-reverse-transform.js +126 -6
  103. package/dist/shared/lib/utils/question-transform-types.d.ts +1 -1
  104. package/dist/shared/lib/utils/question-transform.js +4 -0
  105. package/dist/shared/types/common.types.d.ts +1 -1
  106. package/dist/shared/types/questions/answer-the-question-group.d.ts +3 -0
  107. package/dist/shared/types/questions/choose-then-answer-group.d.ts +46 -0
  108. package/dist/shared/types/questions/choose-then-answer-group.js +1 -0
  109. package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
  110. package/dist/shared/types/questions/index.d.ts +2 -0
  111. package/dist/shared/types/questions/index.js +3 -0
  112. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +7 -1
  113. package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
  114. package/dist/shared/types/questions/sort-words-into-categories.d.ts +37 -0
  115. package/dist/shared/types/questions/sort-words-into-categories.js +1 -0
  116. package/package.json +1 -1
@@ -8,12 +8,13 @@ import { Textarea } from '../../../../components/ui/textarea';
8
8
  import { PointsInput } from '../../../../components/ui/points-input';
9
9
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
10
10
  import { Switch } from '../../../../components/ui/switch';
11
+ import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
11
12
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
12
13
  import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
13
14
  import { FillInBlankGroupClient } from './FillInBlankGroupClient';
14
15
  import { FillInBlankGroupMediaField } from './FillInBlankGroupMediaField';
15
16
  import { createEmptyFillInBlankGroupItem } from './map-fill-in-blank-group-data';
16
- import { blankSlotCount, extractPlaceholderIndices, joinSlashAnswers, normalizeAnswersMatrix, parseSlashAnswers, } from './blank-utils';
17
+ import { blankSlotCount, extractPlaceholderIndices, fromFillInBlankImageUrls, hasAnyBlankOptions, joinSlashAnswers, normalizeAnswersMatrix, normalizeWordBank, parseSlashAnswers, toFillInBlankImageUrls, } from './blank-utils';
17
18
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
18
19
  function parsePointsValue(raw, allowZero = false) {
19
20
  if (raw === '')
@@ -27,6 +28,7 @@ function parsePointsValue(raw, allowZero = false) {
27
28
  function syncItemAnswers(item) {
28
29
  const indices = extractPlaceholderIndices(item.question || '');
29
30
  const requiredLength = blankSlotCount(item.question || '');
31
+ const wordBank = normalizeWordBank(item.wordBank, requiredLength);
30
32
  return {
31
33
  ...item,
32
34
  answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
@@ -34,6 +36,7 @@ function syncItemAnswers(item) {
34
36
  return [];
35
37
  return alts.length > 0 ? alts : [''];
36
38
  }),
39
+ ...(wordBank ? { wordBank } : { wordBank: undefined }),
37
40
  };
38
41
  }
39
42
  function normalizeItems(rawItems) {
@@ -54,8 +57,9 @@ function sumPoints(items) {
54
57
  }
55
58
  export function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
56
59
  const [instruction, setInstruction] = useState(initialData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION);
60
+ const [passage, setPassage] = useState(initialData?.passage || '');
57
61
  const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
58
- const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
62
+ const [imageUrl, setImageUrl] = useState(fromFillInBlankImageUrls(toFillInBlankImageUrls(initialData?.imageUrl)) ?? '');
59
63
  const [showHints, setShowHints] = useState(initialData?.showHints === true);
60
64
  const [hintsText, setHintsText] = useState((initialData?.hints || []).join(' / '));
61
65
  const [items, setItems] = useState(() => normalizeItems(initialData?.items));
@@ -68,16 +72,22 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
68
72
  onChangeRef.current = onChange;
69
73
  }, [onChange]);
70
74
  const parseHints = (value) => parseSlashAnswers(value);
71
- const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText) => ({
72
- instruction: nextInstruction,
73
- audioUrl: nextAudioUrl,
74
- imageUrl: nextImageUrl,
75
- showHints: nextShowHints,
76
- hints: parseHints(nextHintsText),
77
- items: nextItems,
78
- explanation: nextExplanation,
79
- points: sumPoints(nextItems),
80
- });
75
+ const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText, nextPassage = passage) => {
76
+ const viewMode = hasAnyBlankOptions(nextItems) ? 'WITH_OPTIONS' : undefined;
77
+ const groupImageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(nextImageUrl));
78
+ return {
79
+ instruction: nextInstruction,
80
+ ...(nextPassage.trim() ? { passage: nextPassage } : {}),
81
+ audioUrl: nextAudioUrl,
82
+ ...(groupImageUrl !== undefined ? { imageUrl: groupImageUrl } : {}),
83
+ showHints: nextShowHints,
84
+ hints: parseHints(nextHintsText),
85
+ ...(viewMode ? { viewMode } : {}),
86
+ items: nextItems,
87
+ explanation: nextExplanation,
88
+ points: sumPoints(nextItems),
89
+ };
90
+ };
81
91
  const debouncedOnChange = useDebouncedCallback((payload) => {
82
92
  onChangeRef.current?.(payload);
83
93
  }, 300);
@@ -89,7 +99,7 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
89
99
  debouncedOnChange(payload);
90
100
  onUnsavedChangesChange?.(true);
91
101
  }
92
- }, [instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
102
+ }, [instruction, passage, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
93
103
  const validate = () => {
94
104
  const nextErrors = [];
95
105
  if (!instruction.trim()) {
@@ -111,6 +121,16 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
111
121
  if (!alts.some((alt) => alt.trim())) {
112
122
  nextErrors.push(`Câu ${index + 1}: Blank {${blankIndex}} cần ít nhất một đáp án.`);
113
123
  }
124
+ const options = item.wordBank?.[blankIndex]?.options || [];
125
+ if (options.length >= 2) {
126
+ const missing = alts
127
+ .map((alt) => alt.trim())
128
+ .filter(Boolean)
129
+ .filter((alt) => !options.some((option) => option.toLowerCase() === alt.toLowerCase()));
130
+ if (missing.length > 0) {
131
+ nextErrors.push(`Câu ${index + 1}: Đáp án {${blankIndex}} phải nằm trong options (${options.join(' / ')}).`);
132
+ }
133
+ }
114
134
  });
115
135
  });
116
136
  setErrors(nextErrors);
@@ -123,7 +143,7 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
123
143
  getFormData: () => buildPayload(),
124
144
  };
125
145
  }
126
- }, [validationRef, instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
146
+ }, [validationRef, instruction, passage, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
127
147
  const updateItem = (index, patch) => {
128
148
  setItems((prev) => {
129
149
  const next = [...prev];
@@ -148,11 +168,23 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
148
168
  };
149
169
  const currentPayload = buildPayload();
150
170
  const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
151
- return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: imageUrl, onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
171
+ return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx("div", { className: "mt-1", children: _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" }) })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: toFillInBlankImageUrls(imageUrl)[0] || '', onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
152
172
  points: parsePointsValue(event.target.value, true),
153
- }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
154
- const next = [...item.answers];
155
- next[blankIndex] = parseSlashAnswers(e.target.value);
156
- updateItem(index, { answers: next });
157
- }, placeholder: "is / 's", className: "mt-1" })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
173
+ }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
174
+ const next = [...item.answers];
175
+ next[blankIndex] = parseSlashAnswers(e.target.value);
176
+ updateItem(index, { answers: next });
177
+ }, placeholder: "is / 's", className: "mt-1" })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `options-${index}-${blankIndex}`, children: ["Options blank ", '{' + blankIndex + '}', " (dropdown, c\u00E1ch nhau /)"] }), _jsx(Input, { id: `options-${index}-${blankIndex}`, value: joinSlashAnswers(item.wordBank?.[blankIndex]?.options), onChange: (e) => {
178
+ const requiredLength = blankSlotCount(item.question);
179
+ const next = [...(item.wordBank || [])];
180
+ while (next.length < requiredLength)
181
+ next.push(null);
182
+ const options = parseSlashAnswers(e.target.value);
183
+ next[blankIndex] = options.length >= 2 ? { options } : null;
184
+ updateItem(index, {
185
+ wordBank: next.some((entry) => entry && entry.options.length >= 2)
186
+ ? next
187
+ : undefined,
188
+ });
189
+ }, placeholder: "can / can't", className: "mt-1" })] })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: Array.isArray(item.imageUrl) ? (item.imageUrl[0] || '') : (item.imageUrl || ''), onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
158
190
  }
@@ -1,3 +1,4 @@
1
+ import type { FillInBlankWordBankEntry } from '../../_shared/types/fill-in-blank-group.type';
1
2
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
3
  export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
3
4
  export type FillBlankStemPart = {
@@ -15,6 +16,28 @@ export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: nu
15
16
  export declare function joinSlashAnswers(alts: string[] | undefined): string;
16
17
  export declare function parseSlashAnswers(text: string): string[];
17
18
  export declare function splitStem(question: string): FillBlankStemPart[];
19
+ export type StemTextDecoration = {
20
+ type: 'text';
21
+ value: string;
22
+ } | {
23
+ type: 'tick';
24
+ value: string;
25
+ } | {
26
+ type: 'cross';
27
+ value: string;
28
+ } | {
29
+ type: 'br';
30
+ };
31
+ /** Split a stem text fragment into ticks, crosses, line breaks, and plain text. */
32
+ export declare function splitStemTextDecorations(text: string): StemTextDecoration[];
18
33
  export declare function normalizeStudentText(value: string): string;
19
34
  export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
35
+ export declare function parseWordBankEntry(raw: unknown): FillInBlankWordBankEntry;
36
+ export declare function normalizeWordBank(raw: unknown, requiredLength?: number): FillInBlankWordBankEntry[] | undefined;
37
+ export declare function resolveBlankOptions(blankIndex: number, itemWordBank?: FillInBlankWordBankEntry[], groupWordBank?: FillInBlankWordBankEntry[]): string[] | undefined;
38
+ export declare function hasAnyBlankOptions(items: Array<{
39
+ wordBank?: FillInBlankWordBankEntry[];
40
+ }>, groupWordBank?: FillInBlankWordBankEntry[]): boolean;
41
+ export declare function toFillInBlankImageUrls(value: unknown): string[];
42
+ export declare function fromFillInBlankImageUrls(urls: string[]): string | string[] | undefined;
20
43
  export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];
@@ -75,6 +75,42 @@ export function splitStem(question) {
75
75
  }
76
76
  return parts;
77
77
  }
78
+ const STEM_TICK_MARKS = new Set(['✓', '✔', '✅']);
79
+ const STEM_CROSS_MARKS = new Set(['✗', '✘', '❌', '×']);
80
+ const STEM_DECORATION_RE = /[✓✔✅✗✘❌×]|\n/g;
81
+ /** Split a stem text fragment into ticks, crosses, line breaks, and plain text. */
82
+ export function splitStemTextDecorations(text) {
83
+ if (!text)
84
+ return [];
85
+ const normalized = text.replace(/\r\n/g, '\n');
86
+ const parts = [];
87
+ let lastIndex = 0;
88
+ let match;
89
+ const regex = new RegExp(STEM_DECORATION_RE.source, 'g');
90
+ while ((match = regex.exec(normalized)) !== null) {
91
+ if (match.index > lastIndex) {
92
+ parts.push({ type: 'text', value: normalized.slice(lastIndex, match.index) });
93
+ }
94
+ const token = match[0];
95
+ if (token === '\n') {
96
+ parts.push({ type: 'br' });
97
+ }
98
+ else if (STEM_TICK_MARKS.has(token)) {
99
+ parts.push({ type: 'tick', value: token });
100
+ }
101
+ else if (STEM_CROSS_MARKS.has(token)) {
102
+ parts.push({ type: 'cross', value: token });
103
+ }
104
+ else {
105
+ parts.push({ type: 'text', value: token });
106
+ }
107
+ lastIndex = match.index + token.length;
108
+ }
109
+ if (lastIndex < normalized.length) {
110
+ parts.push({ type: 'text', value: normalized.slice(lastIndex) });
111
+ }
112
+ return parts;
113
+ }
78
114
  export function normalizeStudentText(value) {
79
115
  return value.toString().trim().toLowerCase().replace(/\s+/g, ' ');
80
116
  }
@@ -84,6 +120,59 @@ export function isBlankMatch(studentValue, alts) {
84
120
  return false;
85
121
  return alts.some((alt) => normalizeStudentText(alt) === user);
86
122
  }
123
+ export function parseWordBankEntry(raw) {
124
+ if (raw == null)
125
+ return null;
126
+ const options = Array.isArray(raw)
127
+ ? raw.map((item) => String(item ?? '').trim()).filter(Boolean)
128
+ : typeof raw === 'object' && Array.isArray(raw.options)
129
+ ? raw.options
130
+ .map((item) => String(item ?? '').trim())
131
+ .filter(Boolean)
132
+ : [];
133
+ return options.length >= 2 ? { options } : null;
134
+ }
135
+ export function normalizeWordBank(raw, requiredLength) {
136
+ if (!Array.isArray(raw))
137
+ return undefined;
138
+ let result = raw.map((entry) => parseWordBankEntry(entry));
139
+ if (typeof requiredLength === 'number' && requiredLength > 0) {
140
+ result = result.slice(0, requiredLength);
141
+ while (result.length < requiredLength)
142
+ result.push(null);
143
+ }
144
+ return result.some((entry) => entry && entry.options.length >= 2) ? result : undefined;
145
+ }
146
+ export function resolveBlankOptions(blankIndex, itemWordBank, groupWordBank) {
147
+ const entry = itemWordBank?.[blankIndex] ?? groupWordBank?.[blankIndex];
148
+ if (entry && entry.options.length >= 2)
149
+ return entry.options;
150
+ return undefined;
151
+ }
152
+ export function hasAnyBlankOptions(items, groupWordBank) {
153
+ if (normalizeWordBank(groupWordBank))
154
+ return true;
155
+ return items.some((item) => Boolean(normalizeWordBank(item.wordBank)));
156
+ }
157
+ export function toFillInBlankImageUrls(value) {
158
+ if (Array.isArray(value)) {
159
+ return value
160
+ .filter((url) => typeof url === 'string')
161
+ .map((url) => url.trim())
162
+ .filter((url) => url.length > 0);
163
+ }
164
+ if (typeof value === 'string' && value.trim()) {
165
+ return [value.trim()];
166
+ }
167
+ return [];
168
+ }
169
+ export function fromFillInBlankImageUrls(urls) {
170
+ if (urls.length === 0)
171
+ return undefined;
172
+ if (urls.length === 1)
173
+ return urls[0];
174
+ return urls;
175
+ }
87
176
  export function padStudentAnswers(raw, blankCount) {
88
177
  const values = Array.isArray(raw?.answers)
89
178
  ? raw.answers.map((item) => String(item ?? ''))
@@ -1,5 +1,5 @@
1
1
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
- import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
2
+ import { blankSlotCount, extractPlaceholderIndices, hasAnyBlankOptions, fromFillInBlankImageUrls, normalizeAnswersMatrix, normalizeWordBank, toFillInBlankImageUrls, } from './blank-utils';
3
3
  function isRecord(value) {
4
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
5
  }
@@ -39,16 +39,18 @@ export function mapFillInBlankGroupItem(item, index, fallbackAnswer) {
39
39
  return [];
40
40
  return alts.length > 0 ? alts : [''];
41
41
  });
42
+ const wordBank = normalizeWordBank(content.wordBank ?? record.wordBank, blankSlotCount(question));
42
43
  return {
43
44
  question,
44
45
  answers,
45
46
  caseSensitive: correctAnswer.caseSensitive === true
46
47
  || record.caseSensitive === true
47
48
  || fallback.caseSensitive === true,
48
- imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
49
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(content.imageUrl ?? record.imageUrl)),
49
50
  audioUrl: asString(content.audioUrl) || asString(record.audioUrl),
50
51
  showHints: content.showHints === true,
51
52
  hints: asStringArray(content.hints),
53
+ ...(wordBank ? { wordBank } : {}),
52
54
  isExample,
53
55
  points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
54
56
  questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
@@ -66,33 +68,49 @@ export function mapQuestionToFillInBlankGroupData(question) {
66
68
  const meta = asRecord(content.meta);
67
69
  if (Array.isArray(content.items)) {
68
70
  const items = content.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index]));
71
+ const groupWordBank = normalizeWordBank(meta.wordBank ?? content.wordBank);
72
+ const viewMode = meta.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
73
+ ? 'WITH_OPTIONS'
74
+ : undefined;
69
75
  return {
70
76
  instruction: asString(meta.instruction) || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
77
+ passage: asString(meta.passage),
71
78
  audioUrl: asString(meta.audioUrl),
72
- imageUrl: asString(meta.imageUrl),
79
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(meta.imageUrl)),
73
80
  showHints: meta.showHints === true,
74
81
  hints: asStringArray(meta.hints),
82
+ ...(viewMode ? { viewMode } : {}),
83
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
75
84
  items,
76
85
  explanation,
77
86
  points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
78
87
  };
79
88
  }
80
89
  if (Array.isArray(answer.items)) {
90
+ const items = answer.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index]));
91
+ const groupWordBank = normalizeWordBank(answer.wordBank);
92
+ const viewMode = answer.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
93
+ ? 'WITH_OPTIONS'
94
+ : undefined;
81
95
  return {
82
96
  instruction: asString(answer.instruction, FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION),
97
+ passage: asString(answer.passage),
83
98
  audioUrl: asString(answer.audioUrl),
84
- imageUrl: asString(answer.imageUrl),
99
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(answer.imageUrl)),
85
100
  showHints: answer.showHints === true,
86
101
  hints: asStringArray(answer.hints),
87
- items: answer.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index])),
102
+ ...(viewMode ? { viewMode } : {}),
103
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
104
+ items,
88
105
  explanation,
89
106
  points: typeof answer.points === 'number' ? answer.points : undefined,
90
107
  };
91
108
  }
92
109
  return {
93
110
  instruction: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
111
+ passage: '',
94
112
  audioUrl: '',
95
- imageUrl: '',
113
+ imageUrl: undefined,
96
114
  showHints: false,
97
115
  hints: [],
98
116
  items: [createEmptyFillInBlankGroupItem(1)],
@@ -1,5 +1,5 @@
1
1
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, FILL_IN_BLANK_ITEM_TYPE, } from '../../_shared/types/fill-in-blank-group.type';
2
- import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
2
+ import { blankSlotCount, extractPlaceholderIndices, hasAnyBlankOptions, fromFillInBlankImageUrls, normalizeAnswersMatrix, normalizeWordBank, toFillInBlankImageUrls, } from './blank-utils';
3
3
  function sumGradablePoints(items) {
4
4
  return items.reduce((total, item) => {
5
5
  if (item.isExample)
@@ -20,6 +20,9 @@ function toItemAnswer(item) {
20
20
  caseSensitive: item.caseSensitive === true,
21
21
  };
22
22
  }
23
+ function isContentEmpty(html) {
24
+ return !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
25
+ }
23
26
  /**
24
27
  * wrapOnSave nests creator fields under `answer`. getFormData() (Quiz Playground
25
28
  * Update save) returns the same fields at the root. Prefer nested items, then root.
@@ -50,15 +53,18 @@ export const transformFillInBlankGroup = (question) => {
50
53
  const hints = Array.isArray(item.hints)
51
54
  ? item.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
52
55
  : [];
56
+ const wordBank = normalizeWordBank(item.wordBank, blankSlotCount(item.question || ''));
57
+ const imageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(item.imageUrl));
53
58
  return {
54
59
  questionType: FILL_IN_BLANK_ITEM_TYPE,
55
60
  ...(item.isExample ? { isExample: true } : {}),
56
61
  content: {
57
62
  question: item.question || '',
58
- ...(item.imageUrl ? { imageUrl: item.imageUrl } : {}),
63
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
59
64
  ...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
60
65
  ...(item.showHints ? { showHints: true } : {}),
61
66
  ...(hints.length > 0 ? { hints } : {}),
67
+ ...(wordBank ? { wordBank } : {}),
62
68
  },
63
69
  correctAnswer,
64
70
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -68,14 +74,23 @@ export const transformFillInBlankGroup = (question) => {
68
74
  const groupHints = Array.isArray(answerData?.hints)
69
75
  ? answerData.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
70
76
  : [];
77
+ const passage = answerData?.passage?.trim();
78
+ const groupWordBank = normalizeWordBank(answerData?.wordBank);
79
+ const viewMode = answerData?.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
80
+ ? 'WITH_OPTIONS'
81
+ : undefined;
82
+ const groupImageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(answerData?.imageUrl));
71
83
  return {
72
84
  apiContent: {
73
85
  meta: {
74
86
  instruction: answerData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
87
+ ...(!isContentEmpty(passage) ? { passage } : {}),
75
88
  ...(answerData?.audioUrl ? { audioUrl: answerData.audioUrl } : {}),
76
- ...(answerData?.imageUrl ? { imageUrl: answerData.imageUrl } : {}),
89
+ ...(groupImageUrl !== undefined ? { imageUrl: groupImageUrl } : {}),
77
90
  ...(answerData?.showHints ? { showHints: true } : {}),
78
91
  ...(groupHints.length > 0 ? { hints: groupHints } : {}),
92
+ ...(viewMode ? { viewMode } : {}),
93
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
79
94
  },
80
95
  items: apiItems,
81
96
  },
@@ -1,25 +1,54 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Check, CircleHelp, Lightbulb, Star, X } from 'lucide-react';
3
+ import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2, X } from 'lucide-react';
4
4
  import { cn } from '../../../../shared/lib/utils';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
+ import { toMatchWordToPictureImageUrls } from '../../_shared/types/match-word-to-picture-group.type';
6
7
  function normalizeWord(value) {
7
8
  return value.trim().toLowerCase();
8
9
  }
9
- function ClientImageItem({ url, alt }) {
10
- const { previewUrl, isLoading } = usePresignedFileUrl(url);
11
- const src = previewUrl ||
12
- (typeof url === 'string' &&
13
- (url.startsWith('http') || url.startsWith('blob:') || url.startsWith('data:'))
14
- ? url
15
- : '');
10
+ function isPlayableAudioUrl(value) {
11
+ return (value.startsWith('http://')
12
+ || value.startsWith('https://')
13
+ || value.startsWith('blob:')
14
+ || value.startsWith('data:'));
15
+ }
16
+ function toPresignKey(value) {
17
+ const trimmed = value.trim();
18
+ if (isPlayableAudioUrl(trimmed)) {
19
+ return trimmed;
20
+ }
21
+ return trimmed.replace(/^\/+/, '');
22
+ }
23
+ function ClientAudio({ url }) {
24
+ const fileKey = toPresignKey(url);
25
+ const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
26
+ const src = previewUrl || (isPlayableAudioUrl(fileKey) ? fileKey : '');
27
+ if (isLoading) {
28
+ return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx("div", { className: "h-8 w-full max-w-xs animate-pulse rounded-md bg-slate-100" })] }));
29
+ }
30
+ if (!src) {
31
+ return null;
32
+ }
33
+ return (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx("audio", { controls: true, src: src, className: "h-8 max-w-full", preload: "metadata" })] }));
34
+ }
35
+ function isDisplayableImageUrl(value) {
36
+ return (value.startsWith('http://')
37
+ || value.startsWith('https://')
38
+ || value.startsWith('blob:')
39
+ || value.startsWith('data:'));
40
+ }
41
+ function ClientImageItem({ url, alt, large = false, }) {
42
+ const fileKey = toPresignKey(url);
43
+ const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
44
+ const src = previewUrl || (isDisplayableImageUrl(fileKey) ? fileKey : '');
16
45
  if (!url) {
17
46
  return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 \u1EA3nh" }));
18
47
  }
19
48
  if (!src && !isLoading) {
20
49
  return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-xs text-gray-400", children: "Kh\u00F4ng t\u1EA3i \u0111\u01B0\u1EE3c \u1EA3nh" }));
21
50
  }
22
- return (_jsx("div", { className: "relative h-28 w-28 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-full w-full items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-violet-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Picture', className: "h-full w-full object-contain" })) }));
51
+ return (_jsx("div", { className: cn('relative flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50', large ? 'max-h-80 w-full max-w-xl' : 'h-28 w-28'), children: isLoading ? (_jsx("div", { className: "flex h-28 w-full items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-violet-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Picture', className: cn('object-contain', large ? 'mx-auto max-h-80 w-auto' : 'h-full w-full') })) }));
23
52
  }
24
53
  export function MatchWordToPictureGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
25
54
  const items = questionData.items || [];
@@ -40,7 +69,7 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
40
69
  usedWords.add(normalizeWord(value));
41
70
  }
42
71
  });
43
- return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
72
+ return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, toMatchWordToPictureImageUrls(questionData.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap justify-center gap-3 rounded-lg border border-slate-100 bg-slate-50 p-3", children: toMatchWordToPictureImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh bài (${imageIndex + 1})`, large: true }, `mwtp-cover-${imageIndex}`))) })) : null, questionData.passage ? (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })) : null, wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
44
73
  const isUsed = usedWords.has(normalizeWord(word));
45
74
  return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
46
75
  ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
@@ -52,7 +81,7 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
52
81
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
53
82
  const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
54
83
  Boolean(item.correctAnswer);
55
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [_jsx(ClientImageItem, { url: item.imageUrl, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}` }), _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p" }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
84
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [toMatchWordToPictureImageUrls(item.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: toMatchWordToPictureImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}${imageIndex > 0 ? ` (${imageIndex + 1})` : ''}` }, `${item.questionNumber}-${imageIndex}`))) })) : null, _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsxs("p", { className: "text-sm font-semibold text-gray-800", children: [item.questionNumber || itemIndex + 1, ". Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p"] }), _jsxs("div", { className: "flex items-center gap-3", children: [item.isExample ? (_jsx("span", { className: "text-sm italic text-gray-800 underline decoration-gray-400", children: item.correctAnswer })) : (_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
56
85
  ? 'border-green-400 bg-green-50 text-green-700'
57
86
  : isReviewMode && selectedWord && !isCorrect
58
87
  ? 'border-red-400 bg-red-50 text-red-700'
@@ -60,6 +89,6 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
60
89
  const isUsedByOther = usedWords.has(normalizeWord(word)) &&
61
90
  normalizeWord(word) !== normalizeWord(selectedWord);
62
91
  return (_jsx("option", { value: word, disabled: isUsedByOther, children: word }, `${word}-${wordIndex}`));
63
- })] }), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
92
+ })] })), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
64
93
  }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
65
94
  }