@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.
- package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
- package/dist/components/exams/take/components/QuestionRenderer.js +13 -3
- package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.d.ts +14 -0
- package/dist/components/exams/take/components/question-renderers/ChooseThenAnswerGroupRenderer.js +32 -0
- package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.d.ts +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversAnswerTheQuestionRenderer.js +10 -2
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.d.ts +16 -0
- package/dist/components/exams/take/components/question-renderers/SortWordsIntoCategoriesRenderer.js +23 -0
- package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
- package/dist/components/exams/take/components/question-renderers/index.js +2 -0
- package/dist/components/exams/take/utils/answer-transformers.js +34 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +43 -4
- package/dist/components/exams/take/utils/question-transformers.js +71 -1
- package/dist/components/questions/_shared/config/question-types.config.js +12 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +7 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.js +19 -1
- package/dist/components/questions/_shared/types/answer-the-question.type.d.ts +3 -1
- package/dist/components/questions/_shared/types/answer-the-question.type.js +19 -1
- package/dist/components/questions/_shared/types/choose-then-answer-group.type.d.ts +47 -0
- package/dist/components/questions/_shared/types/choose-then-answer-group.type.js +2 -0
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
- package/dist/components/questions/_shared/types/index.d.ts +2 -0
- package/dist/components/questions/_shared/types/index.js +2 -0
- package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +10 -1
- package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +19 -0
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
- package/dist/components/questions/_shared/types/sort-words-into-categories.type.d.ts +43 -0
- package/dist/components/questions/_shared/types/sort-words-into-categories.type.js +1 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
- package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
- package/dist/components/questions/creator/question-type-registry.js +4 -0
- package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +30 -6
- package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.d.ts +2 -0
- package/dist/components/questions/types/answer-the-question/map-answer-the-question-data.js +56 -0
- package/dist/components/questions/types/answer-the-question/transform.js +9 -3
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +47 -3
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +8 -2
- package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +21 -2
- package/dist/components/questions/types/answer-the-question-group/transform.js +10 -1
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +34 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupCreator.js +174 -0
- package/dist/components/questions/types/choose-then-answer-group/index.d.ts +5 -0
- package/dist/components/questions/types/choose-then-answer-group/index.js +5 -0
- package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.d.ts +11 -0
- package/dist/components/questions/types/choose-then-answer-group/map-choose-then-answer-group-data.js +127 -0
- package/dist/components/questions/types/choose-then-answer-group/register.d.ts +3 -0
- package/dist/components/questions/types/choose-then-answer-group/register.js +37 -0
- package/dist/components/questions/types/choose-then-answer-group/transform.d.ts +2 -0
- package/dist/components/questions/types/choose-then-answer-group/transform.js +37 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
- package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +41 -12
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +23 -8
- package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +11 -2
- package/dist/components/questions/types/match-word-to-picture-group/transform.js +25 -9
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
- package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.d.ts +12 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesBoard.js +74 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +24 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesCreator.js +171 -0
- package/dist/components/questions/types/sort-words-into-categories/index.d.ts +4 -0
- package/dist/components/questions/types/sort-words-into-categories/index.js +4 -0
- package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.d.ts +11 -0
- package/dist/components/questions/types/sort-words-into-categories/map-sort-words-into-categories-data.js +153 -0
- package/dist/components/questions/types/sort-words-into-categories/register.d.ts +3 -0
- package/dist/components/questions/types/sort-words-into-categories/register.js +37 -0
- package/dist/components/questions/types/sort-words-into-categories/transform.d.ts +2 -0
- package/dist/components/questions/types/sort-words-into-categories/transform.js +33 -0
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
- package/dist/components/questions/viewer/AnswerTheQuestionViewer.d.ts +9 -0
- package/dist/components/questions/viewer/AnswerTheQuestionViewer.js +8 -0
- package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.d.ts +10 -0
- package/dist/components/questions/viewer/ChooseThenAnswerGroupViewer.js +39 -0
- package/dist/components/questions/viewer/QuestionViewer.js +13 -2
- package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.d.ts +10 -0
- package/dist/components/questions/viewer/SortWordsIntoCategoriesViewer.js +27 -0
- package/dist/components/questions/viewer/index.d.ts +3 -0
- package/dist/components/questions/viewer/index.js +3 -0
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.d.ts +2 -1
- package/dist/components/results/renderers/ReviewAnswerTheQuestionGroupRenderer.js +12 -3
- package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
- package/dist/components/results/renderers/review-question-body-movers.js +24 -2
- package/dist/components/results/review-data.js +28 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +4 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +126 -6
- package/dist/shared/lib/utils/question-transform-types.d.ts +1 -1
- package/dist/shared/lib/utils/question-transform.js +4 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/answer-the-question-group.d.ts +3 -0
- package/dist/shared/types/questions/choose-then-answer-group.d.ts +46 -0
- package/dist/shared/types/questions/choose-then-answer-group.js +1 -0
- package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
- package/dist/shared/types/questions/index.d.ts +2 -0
- package/dist/shared/types/questions/index.js +3 -0
- package/dist/shared/types/questions/match-word-to-picture-group.d.ts +7 -1
- package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
- package/dist/shared/types/questions/sort-words-into-categories.d.ts +37 -0
- package/dist/shared/types/questions/sort-words-into-categories.js +1 -0
- 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
99
|
+
imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(answer.imageUrl)),
|
|
85
100
|
showHints: answer.showHints === true,
|
|
86
101
|
hints: asStringArray(answer.hints),
|
|
87
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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
|
},
|
package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
(
|
|
13
|
-
|
|
14
|
-
|
|
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:
|
|
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 && (
|
|
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(
|
|
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
|
}
|