@tinyweb_dev/oe-exam-sdk 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/take/components/QuestionRenderer.js +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
- package/dist/components/exams/take/types.d.ts +12 -5
- package/dist/components/exams/take/utils/answer-transformers.js +1 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
- package/dist/components/exams/take/utils/question-transformers.js +29 -53
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
- package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
- package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
- package/dist/components/questions/types/fill-in-blank/register.js +19 -6
- package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
- package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
- package/dist/components/results/renderers/review-question-body-dedicated.js +3 -3
- package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
- package/dist/shared/lib/utils/fill-in-blank.js +96 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +68 -3
- package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
- package/package.json +1 -1
|
@@ -55,7 +55,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
|
|
|
55
55
|
}
|
|
56
56
|
case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
|
|
57
57
|
const data = transformChooseTheCorrectAnswerGroup(questions);
|
|
58
|
-
return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
58
|
+
return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
59
59
|
}
|
|
60
60
|
case 'CHOOSE_CORRECT_ADJECTIVE': {
|
|
61
61
|
const data = transformChooseCorrectAdjective(questions);
|
package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js
CHANGED
|
@@ -42,7 +42,7 @@ function normalizeRenderableText(value) {
|
|
|
42
42
|
}
|
|
43
43
|
// Example Section Component
|
|
44
44
|
function ExampleSection({ example }) {
|
|
45
|
-
return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
|
|
45
|
+
return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), example.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: example.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
|
|
46
46
|
const isCorrect = option.id === example.correctOptionId;
|
|
47
47
|
return (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-40 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", isCorrect
|
|
48
48
|
? "bg-green-500 border-green-500 text-white"
|
|
@@ -99,7 +99,7 @@ function QuestionItem({ question, selectedOptionIds, onSelect, isReviewMode, id,
|
|
|
99
99
|
}
|
|
100
100
|
return selectedOptionIds.includes(optionId) || correctOptionIds.includes(optionId);
|
|
101
101
|
};
|
|
102
|
-
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
|
|
102
|
+
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
|
|
103
103
|
? "bg-blue-600 border-blue-600 text-white"
|
|
104
104
|
: "border-gray-300 bg-white text-transparent"), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }) })] }, option.id))) })) : (_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: normalizeRenderableText(option.text) }), _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 ${isMultipleAnswers ? 'rounded-md' : 'rounded-full'} ${getIndicatorStyle(option.id)}`, children: showIndicatorMark(option.id) && (_jsx("div", { className: `${isMultipleAnswers ? 'h-2.5 w-2.5 rounded-sm' : 'h-2 w-2 rounded-full'} bg-white` })) })] }, option.id))) }))] }));
|
|
105
105
|
}
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts
CHANGED
|
@@ -10,8 +10,8 @@ interface MoversListenAndWriteRendererProps {
|
|
|
10
10
|
exampleText?: string;
|
|
11
11
|
exampleHighlight?: string;
|
|
12
12
|
questions: FillBlankQuestion[];
|
|
13
|
-
answers: Record<string,
|
|
14
|
-
onAnswerChange: (questionId: string, value: string) => void;
|
|
13
|
+
answers: Record<string, unknown>;
|
|
14
|
+
onAnswerChange: (questionId: string, value: string[]) => void;
|
|
15
15
|
isReviewMode?: boolean;
|
|
16
16
|
showHints?: boolean;
|
|
17
17
|
groupContent?: string;
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js
CHANGED
|
@@ -5,22 +5,68 @@ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-y
|
|
|
5
5
|
import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
6
6
|
import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
7
7
|
import CambridgeYleExampleText from '../../../../../components/themes/cambridge-yle/CambridgeYleExampleText';
|
|
8
|
-
|
|
8
|
+
import { countBlanks, formatAcceptedAnswers, getPrimaryAnswer, isBlankCorrect, toBlankValues, } from '../../../../../shared/lib/utils/fill-in-blank';
|
|
9
|
+
/** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
|
|
9
10
|
function renderTextWithLineBreaks(text) {
|
|
10
11
|
const lines = text.split('\n');
|
|
11
12
|
return lines.map((line, index) => (_jsxs(Fragment, { children: [index > 0 ? _jsx("br", {}) : null, line] }, index)));
|
|
12
13
|
}
|
|
14
|
+
function blankInputClassName({ isExampleQ, isReviewMode, isCorrect, isWrong, }) {
|
|
15
|
+
if (isExampleQ)
|
|
16
|
+
return 'border-amber-300 bg-amber-100/50 text-amber-800 disabled:opacity-100';
|
|
17
|
+
if (isReviewMode) {
|
|
18
|
+
if (isCorrect)
|
|
19
|
+
return 'border-emerald-400 bg-emerald-50 text-emerald-800';
|
|
20
|
+
if (isWrong)
|
|
21
|
+
return 'border-rose-400 bg-rose-50 text-rose-800';
|
|
22
|
+
return 'border-slate-200 bg-slate-50 text-slate-500';
|
|
23
|
+
}
|
|
24
|
+
return 'border-blue-200 bg-blue-50/30 text-blue-900 focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-100';
|
|
25
|
+
}
|
|
13
26
|
export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, }) {
|
|
14
27
|
const hasGroupContent = !!(audioUrl || imageUrl || groupContent || exampleText);
|
|
15
28
|
return (_jsxs("div", { className: "flex flex-col h-full gap-4 sm:gap-6 overflow-y-auto custom-scrollbar relative", children: [_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: isReviewMode })] }), hasGroupContent && (_jsxs("div", { className: "flex flex-col gap-2 sm:gap-3 p-3 sm:px-6 sm:pt-0 sticky top-0 z-20 bg-white/95 backdrop-blur pb-2 shadow-sm border-b border-gray-100", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), _jsxs("div", { className: "max-h-[35vh] overflow-y-auto custom-scrollbar pr-2 flex flex-col gap-3", children: [imageUrl && (_jsx("div", { className: "flex items-center justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[200px] rounded-lg object-contain shadow-md" }) })), groupContent && (_jsxs("div", { className: "rounded-lg bg-amber-50 p-3 text-sm leading-relaxed text-gray-700 shadow-inner", children: [groupTitle && _jsx("h3", { className: "mb-2 font-semibold text-gray-800", children: groupTitle }), groupContent] }))] }), exampleText && (_jsx(CambridgeYleExampleText, { text: exampleText, highlightedWord: exampleHighlight || '' }))] })), _jsxs("div", { className: "flex flex-col gap-4 p-3 sm:gap-6 sm:pb-6 sm:px-6", children: [title && (_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title })), _jsx("div", { className: "flex flex-col gap-6 sm:gap-8", children: questions.map((question) => {
|
|
16
29
|
const isExampleQ = question.isExample || false;
|
|
17
30
|
const isDisabled = isReviewMode || isExampleQ;
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
const blankCount = countBlanks(question.segments);
|
|
32
|
+
// Examples are pre-filled with the first accepted answer; everyone
|
|
33
|
+
// else shows what the student typed.
|
|
34
|
+
const displayValues = isExampleQ
|
|
35
|
+
? question.acceptedAnswers.map(getPrimaryAnswer)
|
|
36
|
+
: toBlankValues(answers[question.id], blankCount);
|
|
37
|
+
const blankStatuses = displayValues.map((value, index) => {
|
|
38
|
+
const hasValue = !!value.trim();
|
|
39
|
+
const matches = isBlankCorrect(value, question.acceptedAnswers[index]);
|
|
40
|
+
return {
|
|
41
|
+
hasValue,
|
|
42
|
+
isCorrect: isReviewMode && !isExampleQ && hasValue && matches,
|
|
43
|
+
isWrong: isReviewMode && !isExampleQ && hasValue && !matches,
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
const hasAnswer = blankStatuses.some((status) => status.hasValue);
|
|
47
|
+
const isCorrect = isReviewMode &&
|
|
48
|
+
!isExampleQ &&
|
|
49
|
+
hasAnswer &&
|
|
50
|
+
blankStatuses.every((status) => status.isCorrect);
|
|
51
|
+
const isWrong = isReviewMode && !isExampleQ && hasAnswer && !isCorrect;
|
|
52
|
+
const setBlankValue = (blankIndex, value) => {
|
|
53
|
+
const next = toBlankValues(answers[question.id], blankCount);
|
|
54
|
+
next[blankIndex] = value;
|
|
55
|
+
onAnswerChange(question.id, next);
|
|
56
|
+
};
|
|
57
|
+
const renderBlankInput = (blankIndex) => {
|
|
58
|
+
const status = blankStatuses[blankIndex] || {
|
|
59
|
+
hasValue: false,
|
|
60
|
+
isCorrect: false,
|
|
61
|
+
isWrong: false,
|
|
62
|
+
};
|
|
63
|
+
return (_jsx("span", { className: "mx-1 inline-block w-[7.5rem] max-w-[40%] min-w-[5.5rem] align-baseline sm:w-[8.5rem]", children: _jsx("input", { type: "text", value: displayValues[blankIndex] || '', onChange: (e) => setBlankValue(blankIndex, e.target.value), disabled: isDisabled, placeholder: isExampleQ ? '' : '...', className: `w-full rounded-md border-2 px-2 py-1 text-center text-sm font-semibold outline-none transition-all sm:text-base ${blankInputClassName({
|
|
64
|
+
isExampleQ,
|
|
65
|
+
isReviewMode,
|
|
66
|
+
isCorrect: status.isCorrect,
|
|
67
|
+
isWrong: status.isWrong,
|
|
68
|
+
})} ${isDisabled ? 'disabled:cursor-not-allowed disabled:opacity-75' : ''}` }) }));
|
|
69
|
+
};
|
|
24
70
|
return (_jsxs("div", { id: `question-${question.id}`, className: `flex flex-col gap-4 rounded-2xl border p-4 sm:p-6 transition-all duration-200 hover:shadow-md ${isExampleQ
|
|
25
71
|
? 'border-amber-300 bg-amber-50/60 shadow-xs'
|
|
26
72
|
: 'border-slate-200/80 bg-white shadow-xs hover:border-blue-300'}`, children: [_jsxs("div", { className: "flex items-center justify-between border-b border-slate-100 pb-3", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: `inline-flex items-center justify-center rounded-full px-3.5 py-1 text-sm font-bold text-white shadow-xs ${isExampleQ
|
|
@@ -31,12 +77,10 @@ export function MoversListenAndWriteRenderer({ partNumber, partName, questionCou
|
|
|
31
77
|
: isWrong
|
|
32
78
|
? 'bg-rose-500'
|
|
33
79
|
: 'bg-[#001590]'
|
|
34
|
-
: 'bg-[#001590]'}`, children: isExampleQ ? `
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
: 'border-rose-400 bg-rose-50 text-rose-800'
|
|
40
|
-
: 'border-blue-200 bg-blue-50/30 text-blue-900 focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-100'} ${isDisabled ? 'disabled:cursor-not-allowed disabled:opacity-75' : ''}` }) }), question.textAfter && (_jsx("span", { className: "font-medium", children: renderTextWithLineBreaks(question.textAfter) })), question.verbHint && (_jsxs("span", { className: "ml-1 italic text-slate-500 font-normal", children: ["(", question.verbHint, ")"] }))] }), showHints && question.hints && question.hints.length > 0 && !isReviewMode && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100", children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: "G\u1EE3i \u00FD:" }), question.hints.map((hint, idx) => (_jsx("button", { type: "button", onClick: () => onAnswerChange(question.id, hint), className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 shadow-2xs transition-all hover:bg-blue-600 hover:text-white hover:border-blue-600 active:scale-95", children: hint }, idx)))] })), isReviewMode && !isExampleQ && question.correctAnswer && (_jsx("div", { className: "mt-1 rounded-lg bg-emerald-50/80 p-2.5 text-sm border border-emerald-200/80", children: _jsxs("span", { className: "font-semibold text-emerald-800", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("span", { className: "font-bold underline", children: question.correctAnswer })] }) }))] }, question.id));
|
|
80
|
+
: 'bg-[#001590]'}`, children: isExampleQ ? `Và dụ` : `${question.questionNumber}.` }), isExampleQ && (_jsx("span", { className: "rounded-md bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: "Example" }))] }), isReviewMode && !isExampleQ && (_jsx("div", { children: isCorrect ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 border border-emerald-200", children: "\u00E2\u0153\u201C \u00C4\u0090\u00C3\u00BAng" })) : isWrong ? (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-rose-50 px-2.5 py-0.5 text-xs font-semibold text-rose-700 border border-rose-200", children: "\u00E2\u0153\u2022 Ch\u00C6\u00B0a \u00C4\u2018\u00C3\u00BAng" })) : (_jsx("span", { className: "inline-flex items-center gap-1 rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600", children: "Ch\u00C6\u00B0a tr\u00E1\u00BA\u00A3 l\u00E1\u00BB\u009Di" })) }))] }), question.audioUrl && (_jsx("div", { className: "w-full my-1", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), question.imageUrl && (_jsx("div", { className: "flex items-center justify-center rounded-xl bg-slate-50/80 p-3 border border-slate-100 max-w-md mx-auto w-full", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: `Question ${question.questionNumber} illustration`, className: "max-h-[200px] sm:max-h-[220px] w-auto rounded-lg object-contain drop-shadow-xs" }) })), _jsxs("div", { className: "text-base sm:text-lg leading-relaxed text-slate-800 pt-1", children: [question.segments.map((segment, segmentIndex) => segment.type === 'text' ? (_jsx("span", { className: "font-medium", children: renderTextWithLineBreaks(segment.value) }, `text-${segmentIndex}`)) : (_jsx(Fragment, { children: renderBlankInput(segment.index) }, `blank-${segment.index}-${segmentIndex}`))), question.verbHint && (_jsxs("span", { className: "ml-1 italic text-slate-500 font-normal", children: ["(", question.verbHint, ")"] }))] }), showHints && question.hints && question.hints.length > 0 && !isReviewMode && (_jsxs("div", { className: "flex flex-wrap items-center gap-2 pt-2 border-t border-slate-100", children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: "G\u00E1\u00BB\u00A3i \u00C3\u00BD:" }), question.hints.map((hint, idx) => (_jsx("button", { type: "button", onClick: () => {
|
|
81
|
+
const current = toBlankValues(answers[question.id], blankCount);
|
|
82
|
+
const emptyIndex = current.findIndex((value) => !value.trim());
|
|
83
|
+
setBlankValue(emptyIndex >= 0 ? emptyIndex : 0, hint);
|
|
84
|
+
}, className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700 shadow-2xs transition-all hover:bg-blue-600 hover:text-white hover:border-blue-600 active:scale-95", children: hint }, idx)))] })), isReviewMode && !isExampleQ && question.acceptedAnswers.some((alts) => alts.length > 0) && (_jsx("div", { className: "mt-1 rounded-lg bg-emerald-50/80 p-2.5 text-sm border border-emerald-200/80", children: _jsxs("span", { className: "font-semibold text-emerald-800", children: ["\u00C4\u0090\u00C3\u00A1p \u00C3\u00A1n \u00C4\u2018\u00C3\u00BAng:", ' ', _jsx("span", { className: "font-bold underline", children: question.acceptedAnswers.map(formatAcceptedAnswers).filter(Boolean).join(' | ') })] }) }))] }, question.id));
|
|
41
85
|
}) })] })] }));
|
|
42
86
|
}
|
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
* Types for Movers Exam UI
|
|
3
3
|
*/
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
5
|
+
import type { FillBlankSegment } from '../../../shared/lib/utils/fill-in-blank';
|
|
6
|
+
export type { FillBlankSegment };
|
|
7
|
+
/**
|
|
8
|
+
* A fill-in-the-blank stem, already split into segments. Every blank in
|
|
9
|
+
* `segments` gets its own input, and the student answer is always one string
|
|
10
|
+
* per blank, index-aligned with `acceptedAnswers`.
|
|
11
|
+
*/
|
|
5
12
|
export interface FillBlankQuestion {
|
|
6
13
|
id: string;
|
|
7
14
|
questionNumber: number;
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
segments: FillBlankSegment[];
|
|
16
|
+
/** acceptedAnswers[i] = equivalent correct answers for blank i. */
|
|
17
|
+
acceptedAnswers: string[][];
|
|
10
18
|
verbHint?: string;
|
|
11
|
-
correctAnswer?: string;
|
|
12
19
|
imageUrl?: string;
|
|
13
20
|
audioUrl?: string;
|
|
14
21
|
hints?: string[];
|
|
@@ -25,8 +32,8 @@ export interface MoversListenAndWriteRendererProps {
|
|
|
25
32
|
exampleText?: string;
|
|
26
33
|
exampleHighlight?: string;
|
|
27
34
|
questions: FillBlankQuestion[];
|
|
28
|
-
answers: Record<string,
|
|
29
|
-
onAnswerChange: (questionId: string, value: string) => void;
|
|
35
|
+
answers: Record<string, unknown>;
|
|
36
|
+
onAnswerChange: (questionId: string, value: string[]) => void;
|
|
30
37
|
isReviewMode?: boolean;
|
|
31
38
|
showHints?: boolean;
|
|
32
39
|
}
|
|
@@ -216,7 +216,7 @@ function wrapAnswer(answer, questionType) {
|
|
|
216
216
|
// For array answers (word fill with word bank), wrap in { answers: array }
|
|
217
217
|
// WORD_FILL_PARAGRAPH stores answers as array of strings
|
|
218
218
|
if (Array.isArray(answer)) {
|
|
219
|
-
if (questionType === 'WORD_FILL_PARAGRAPH') {
|
|
219
|
+
if (questionType === 'WORD_FILL_PARAGRAPH' || questionType === 'FILL_IN_BLANK') {
|
|
220
220
|
return { answers: answer };
|
|
221
221
|
}
|
|
222
222
|
// Fallback for other question types with array answers
|
|
@@ -161,6 +161,7 @@ export declare function transformChooseTheCorrectAnswerGroup(questions: ApiQuest
|
|
|
161
161
|
questions: ChooseBestAnswerQuestion[];
|
|
162
162
|
example?: import('../types').ChooseBestAnswerExample;
|
|
163
163
|
instruction: string;
|
|
164
|
+
audioUrl?: string;
|
|
164
165
|
};
|
|
165
166
|
export interface ChooseAdjectiveOption {
|
|
166
167
|
id: string;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Maps backend question types to frontend renderer props.
|
|
5
5
|
* Each transformer handles specific question type data structure.
|
|
6
6
|
*/
|
|
7
|
+
import { countBlanks, normalizeAcceptedAnswers, parseFillBlankStem, } from '../../../../shared/lib/utils/fill-in-blank';
|
|
7
8
|
/**
|
|
8
9
|
* Group questions by part_id AND questionGroup.id
|
|
9
10
|
* This ensures questions with different passages (different questionGroups)
|
|
@@ -154,55 +155,20 @@ export function transformFillInBlank(questions) {
|
|
|
154
155
|
const content = firstQuestion?.content;
|
|
155
156
|
const groupPayload = firstQuestion?.questionGroup?.payload;
|
|
156
157
|
const transformed = [];
|
|
157
|
-
/** Take UI shows plain textBefore/textAfter — strip `**` so markers never leak. */
|
|
158
|
-
const stripStemBoldMarkers = (s) => s.replace(/\*\*/g, '');
|
|
159
158
|
for (const q of questions) {
|
|
160
159
|
const qContent = q.content;
|
|
161
|
-
const isExample = q.is_example || false;
|
|
162
160
|
// Support both: content.question (new) and content.blanks[0].text (legacy)
|
|
163
161
|
const text = qContent.question || qContent.blanks?.[0]?.text || '';
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const parts = line.split(/\{\d+\}/);
|
|
175
|
-
const textBefore = stripStemBoldMarkers(parts[0]?.trim() || '');
|
|
176
|
-
const textAfter = stripStemBoldMarkers(parts[1]?.trim() || '');
|
|
177
|
-
transformed.push({
|
|
178
|
-
id: `${q.id}-blank-${blankIndex}`,
|
|
179
|
-
questionNumber: q.question_number,
|
|
180
|
-
textBefore,
|
|
181
|
-
textAfter,
|
|
182
|
-
imageUrl: qContent.imageUrl,
|
|
183
|
-
hints: qContent.hints,
|
|
184
|
-
isExample,
|
|
185
|
-
correctAnswer: isExample ? q.correct_answer?.answers?.[blankIndex] : undefined,
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
else {
|
|
190
|
-
// Single placeholder or no placeholder.
|
|
191
|
-
// Use [\s\S] so stems with newlines still match (`.` does not cross `\n`).
|
|
192
|
-
const match = text.match(/^([\s\S]*?)\{0\}([\s\S]*)$/);
|
|
193
|
-
const textBefore = stripStemBoldMarkers(match ? match[1].trim() : text);
|
|
194
|
-
const textAfter = stripStemBoldMarkers(match ? match[2].trim() : '');
|
|
195
|
-
transformed.push({
|
|
196
|
-
id: q.id,
|
|
197
|
-
questionNumber: q.question_number,
|
|
198
|
-
textBefore,
|
|
199
|
-
textAfter,
|
|
200
|
-
imageUrl: qContent.imageUrl,
|
|
201
|
-
hints: qContent.hints,
|
|
202
|
-
isExample,
|
|
203
|
-
correctAnswer: isExample ? q.correct_answer?.answers?.[0] : undefined,
|
|
204
|
-
});
|
|
205
|
-
}
|
|
162
|
+
const segments = parseFillBlankStem(text);
|
|
163
|
+
transformed.push({
|
|
164
|
+
id: q.id,
|
|
165
|
+
questionNumber: q.question_number,
|
|
166
|
+
segments,
|
|
167
|
+
acceptedAnswers: normalizeAcceptedAnswers(q.correct_answer?.answers, countBlanks(segments)),
|
|
168
|
+
imageUrl: qContent.imageUrl,
|
|
169
|
+
hints: qContent.hints,
|
|
170
|
+
isExample: q.is_example || false,
|
|
171
|
+
});
|
|
206
172
|
}
|
|
207
173
|
return {
|
|
208
174
|
questions: transformed,
|
|
@@ -228,17 +194,17 @@ export function transformWriteCorrectVerbForm(questions) {
|
|
|
228
194
|
const content = firstQuestion?.content;
|
|
229
195
|
const transformed = questions.map((q) => {
|
|
230
196
|
const qContent = q.content;
|
|
231
|
-
const
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
197
|
+
const segments = parseFillBlankStem(qContent.sentence || '');
|
|
198
|
+
// Stored as { correctAnswer: "doesn't like" }; older rows used { answers: { 'blank-1': … } }.
|
|
199
|
+
const correct = q.correct_answer;
|
|
200
|
+
const rawAccepted = Array.isArray(correct?.answers)
|
|
201
|
+
? correct.answers
|
|
202
|
+
: (correct?.answers?.['blank-1'] ?? correct?.correctAnswer ?? correct?.answer);
|
|
237
203
|
return {
|
|
238
204
|
id: q.id,
|
|
239
205
|
questionNumber: q.question_number,
|
|
240
|
-
|
|
241
|
-
|
|
206
|
+
segments,
|
|
207
|
+
acceptedAnswers: normalizeAcceptedAnswers(rawAccepted, countBlanks(segments)),
|
|
242
208
|
verbHint: qContent.verbHint, // Pass verbHint per question
|
|
243
209
|
};
|
|
244
210
|
});
|
|
@@ -445,12 +411,18 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
445
411
|
}
|
|
446
412
|
return urls.length === 1 ? urls[0] : urls;
|
|
447
413
|
};
|
|
414
|
+
const toAudioUrl = (value) => {
|
|
415
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
416
|
+
return trimmed !== '' ? trimmed : undefined;
|
|
417
|
+
};
|
|
418
|
+
const groupAudioUrl = toAudioUrl(content?.meta?.audioUrl);
|
|
448
419
|
let example;
|
|
449
420
|
if (exampleItem) {
|
|
450
421
|
const exampleIndex = items.indexOf(exampleItem);
|
|
451
422
|
const exOptions = mapOptions(exampleItem.content?.options, `${parent.id}-ex`);
|
|
452
423
|
const exCorrect = exampleItem.correctAnswer?.answer || parentCorrect[exampleIndex] || exOptions[0]?.id || '';
|
|
453
424
|
const exampleImageUrl = toQuestionImageUrl(exampleItem.content?.imageUrl);
|
|
425
|
+
const exampleAudioUrl = !groupAudioUrl ? toAudioUrl(exampleItem.content?.audioUrl) : undefined;
|
|
454
426
|
example = {
|
|
455
427
|
questionNumber: exampleItem.questionNumber || exampleIndex + 1,
|
|
456
428
|
questionText: exampleItem.content?.question || '',
|
|
@@ -458,6 +430,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
458
430
|
correctOptionId: exCorrect,
|
|
459
431
|
optionType: (exampleItem.content?.optionType || 'text'),
|
|
460
432
|
...(exampleImageUrl !== undefined ? { imageUrl: exampleImageUrl } : {}),
|
|
433
|
+
...(exampleAudioUrl ? { audioUrl: exampleAudioUrl } : {}),
|
|
461
434
|
};
|
|
462
435
|
}
|
|
463
436
|
const transformed = regularItems.map((item) => {
|
|
@@ -465,6 +438,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
465
438
|
const options = mapOptions(item.content?.options, `${parent.id}-item-${itemIndex}`);
|
|
466
439
|
const correctId = item.correctAnswer?.answer || parentCorrect[itemIndex] || '';
|
|
467
440
|
const itemImageUrl = toQuestionImageUrl(item.content?.imageUrl);
|
|
441
|
+
const itemAudioUrl = !groupAudioUrl ? toAudioUrl(item.content?.audioUrl) : undefined;
|
|
468
442
|
return {
|
|
469
443
|
id: `${parent.id}-item-${itemIndex}`,
|
|
470
444
|
questionNumber: item.questionNumber || itemIndex + 1,
|
|
@@ -474,12 +448,14 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
474
448
|
correctOptionIds: correctId ? [correctId] : [],
|
|
475
449
|
optionType: (item.content?.optionType || 'text'),
|
|
476
450
|
...(itemImageUrl !== undefined ? { imageUrl: itemImageUrl } : {}),
|
|
451
|
+
...(itemAudioUrl ? { audioUrl: itemAudioUrl } : {}),
|
|
477
452
|
};
|
|
478
453
|
});
|
|
479
454
|
return {
|
|
480
455
|
questions: transformed,
|
|
481
456
|
example,
|
|
482
457
|
instruction: content?.meta?.instruction || 'Choose the correct answer.',
|
|
458
|
+
...(groupAudioUrl ? { audioUrl: groupAudioUrl } : {}),
|
|
483
459
|
};
|
|
484
460
|
}
|
|
485
461
|
export function transformChooseCorrectAdjective(questions) {
|
|
@@ -18,10 +18,12 @@ export interface ChooseTheCorrectAnswerGroupItemData {
|
|
|
18
18
|
points: number;
|
|
19
19
|
questionNumber: number;
|
|
20
20
|
imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
21
|
+
audioUrl?: string;
|
|
21
22
|
}
|
|
22
23
|
export interface ChooseTheCorrectAnswerGroupData {
|
|
23
24
|
instruction: string;
|
|
24
25
|
passage?: string;
|
|
26
|
+
audioUrl?: string;
|
|
25
27
|
items: ChooseTheCorrectAnswerGroupItemData[];
|
|
26
28
|
explanation?: string;
|
|
27
29
|
points?: number;
|
|
@@ -3,20 +3,32 @@ export type { ValidationRef };
|
|
|
3
3
|
export interface BlankItem {
|
|
4
4
|
id: string;
|
|
5
5
|
text: string;
|
|
6
|
+
/** @deprecated Prefer correctAnswers — kept for legacy load */
|
|
6
7
|
correctAnswer: string;
|
|
8
|
+
/** Equivalent correct answers for this blank */
|
|
9
|
+
correctAnswers?: string[];
|
|
7
10
|
}
|
|
8
11
|
export interface FillInBlankGroup {
|
|
9
12
|
number: number;
|
|
10
13
|
questionCount: number;
|
|
11
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Main data interface - supports multiple blanks in a single question
|
|
17
|
+
* Question format: "He {0} 10 years old and she {1} 12."
|
|
18
|
+
* Answers format (canonical): [["is", "'s"], ["are"]]
|
|
19
|
+
* - answers[i] = list of equivalent correct answers for placeholder {i}
|
|
20
|
+
* Legacy format still accepted when loading: ["is", "are"] → [["is"], ["are"]]
|
|
21
|
+
*/
|
|
12
22
|
export interface FillInBlankData {
|
|
13
23
|
question: string;
|
|
14
|
-
answers
|
|
24
|
+
/** Per-blank list of equivalent correct answers */
|
|
25
|
+
answers: string[][];
|
|
15
26
|
caseSensitive?: boolean;
|
|
16
27
|
explanation?: string;
|
|
17
28
|
points?: number;
|
|
18
29
|
isExample?: boolean;
|
|
19
30
|
imageUrl?: string;
|
|
31
|
+
audioUrl?: string;
|
|
20
32
|
showHints?: boolean;
|
|
21
33
|
hints?: string[];
|
|
22
34
|
isGroup?: boolean;
|
|
@@ -41,9 +53,12 @@ export interface FillInBlankCreatorProps {
|
|
|
41
53
|
groups?: FillInBlankGroup[];
|
|
42
54
|
questionIndexInPart?: number;
|
|
43
55
|
partId?: string;
|
|
56
|
+
/** Template questionConfig — controls which fields are shown (e.g. "audio") */
|
|
57
|
+
questionConfig?: string[];
|
|
44
58
|
}
|
|
45
59
|
export interface FillInBlankClientProps {
|
|
46
60
|
questionData: FillInBlankData;
|
|
61
|
+
questionConfig?: string[];
|
|
47
62
|
onSubmit?: (answers: string[]) => void;
|
|
48
63
|
isReviewMode?: boolean;
|
|
49
64
|
userAnswer?: string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { BookOpen, Check, CircleHelp, Lightbulb, Star } from 'lucide-react';
|
|
3
|
+
import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../../../shared/lib/utils';
|
|
5
5
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
6
|
import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
@@ -13,6 +13,15 @@ function ClientImageItem({ url, alt }) {
|
|
|
13
13
|
}
|
|
14
14
|
return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
|
|
15
15
|
}
|
|
16
|
+
function ClientAudio({ url }) {
|
|
17
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
18
|
+
const src = previewUrl ||
|
|
19
|
+
(typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
|
|
20
|
+
if (!src && !isLoading) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return (_jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-indigo-600" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-indigo-100" })) : (_jsx("audio", { controls: true, className: "h-9 w-full", src: src, preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
|
|
24
|
+
}
|
|
16
25
|
const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
17
26
|
export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
18
27
|
const items = questionData.items || [];
|
|
@@ -25,10 +34,11 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
|
|
|
25
34
|
next[itemIndex] = optionId;
|
|
26
35
|
onAnswerChange(next);
|
|
27
36
|
};
|
|
28
|
-
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 })), 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 text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
37
|
+
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 })), questionData.audioUrl && _jsx(ClientAudio, { url: questionData.audioUrl }), 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 text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
29
38
|
const selectedId = userAnswers[itemIndex];
|
|
30
39
|
const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
31
|
-
|
|
40
|
+
const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
|
|
41
|
+
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-blue-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: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), showItemAudio && item.audioUrl && _jsx(ClientAudio, { url: item.audioUrl }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
|
|
32
42
|
const isCorrect = option.id === item.correctAnswer;
|
|
33
43
|
const isSelected = option.id === selectedId;
|
|
34
44
|
const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
|
|
@@ -8,6 +8,7 @@ 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 { FileUpload } from '../../../../components/ui/file-upload';
|
|
11
12
|
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
12
13
|
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
13
14
|
import { Eye, ImageIcon, Pencil, Plus, Star, Trash2, Type } from 'lucide-react';
|
|
@@ -51,6 +52,7 @@ function normalizeItems(rawItems) {
|
|
|
51
52
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
52
53
|
questionNumber: item.questionNumber || index + 1,
|
|
53
54
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
55
|
+
...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
|
|
54
56
|
};
|
|
55
57
|
});
|
|
56
58
|
}
|
|
@@ -67,6 +69,7 @@ function parsePointsValue(raw, allowZero) {
|
|
|
67
69
|
export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
68
70
|
const [instruction, setInstruction] = useState(initialData?.instruction || 'Choose the correct answer.');
|
|
69
71
|
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
72
|
+
const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
|
|
70
73
|
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
71
74
|
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
72
75
|
const [isClientMode, setIsClientMode] = useState(false);
|
|
@@ -77,9 +80,10 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
77
80
|
onChangeRef.current = onChange;
|
|
78
81
|
}, [onChange]);
|
|
79
82
|
const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
|
|
80
|
-
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
|
|
83
|
+
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl) => ({
|
|
81
84
|
instruction: nextInstruction,
|
|
82
85
|
...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
|
|
86
|
+
...(nextAudioUrl.trim() ? { audioUrl: nextAudioUrl.trim() } : {}),
|
|
83
87
|
items: nextItems,
|
|
84
88
|
explanation: nextExplanation,
|
|
85
89
|
points: sumPoints(nextItems),
|
|
@@ -96,7 +100,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
96
100
|
previousPayloadRef.current = serialized;
|
|
97
101
|
onUnsavedChangesChange?.(true);
|
|
98
102
|
debouncedOnChange(payload);
|
|
99
|
-
}, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
103
|
+
}, [instruction, passage, audioUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
100
104
|
const validate = () => {
|
|
101
105
|
const nextErrors = [];
|
|
102
106
|
if (!instruction.trim()) {
|
|
@@ -161,7 +165,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
161
165
|
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EC9nh s\u1EEDa"] }) }), _jsx(ChooseTheCorrectAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
|
|
162
166
|
}
|
|
163
167
|
const allErrors = [...errors, ...(externalErrors || [])];
|
|
164
|
-
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
168
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsx("div", { className: "space-y-2", children: _jsx(FileUpload, { id: "choose-answer-group-audio", label: "Audio chung (tu\u1EF3 ch\u1ECDn)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
165
169
|
isExample: checked,
|
|
166
170
|
points: checked ? 0 : (item.points || 1),
|
|
167
171
|
}) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "A place for someone to stay or live is called" })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { className: "flex items-center gap-2", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-teal-500" }), "H\u00ECnh \u1EA3nh (tu\u1EF3 ch\u1ECDn)"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
@@ -8,6 +8,13 @@ function asRecord(value) {
|
|
|
8
8
|
function asString(value, fallback = '') {
|
|
9
9
|
return typeof value === 'string' ? value : fallback;
|
|
10
10
|
}
|
|
11
|
+
function asOptionalUrl(value) {
|
|
12
|
+
if (typeof value !== 'string') {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return trimmed !== '' ? trimmed : undefined;
|
|
17
|
+
}
|
|
11
18
|
export function createEmptyChooseAnswerGroupItem(questionNumber) {
|
|
12
19
|
return {
|
|
13
20
|
question: '',
|
|
@@ -51,6 +58,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
51
58
|
];
|
|
52
59
|
const isExample = record.isExample === true;
|
|
53
60
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
|
|
61
|
+
const audioUrl = asOptionalUrl(content.audioUrl);
|
|
54
62
|
return {
|
|
55
63
|
question: asString(content.question) || asString(record.question),
|
|
56
64
|
optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
|
|
@@ -60,6 +68,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
60
68
|
points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
|
|
61
69
|
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
62
70
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
71
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
63
72
|
};
|
|
64
73
|
}
|
|
65
74
|
export function mapQuestionToChooseAnswerGroupData(question) {
|
|
@@ -73,18 +82,22 @@ export function mapQuestionToChooseAnswerGroupData(question) {
|
|
|
73
82
|
: [];
|
|
74
83
|
if (Array.isArray(content.items)) {
|
|
75
84
|
const items = content.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index]));
|
|
85
|
+
const audioUrl = asOptionalUrl(asRecord(content.meta).audioUrl);
|
|
76
86
|
return {
|
|
77
87
|
instruction: asString(asRecord(content.meta).instruction) || 'Choose the correct answer.',
|
|
78
88
|
passage: asString(asRecord(content.meta).passage),
|
|
89
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
79
90
|
items,
|
|
80
91
|
explanation,
|
|
81
92
|
points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
82
93
|
};
|
|
83
94
|
}
|
|
84
95
|
if (Array.isArray(answer.items)) {
|
|
96
|
+
const audioUrl = asOptionalUrl(answer.audioUrl);
|
|
85
97
|
return {
|
|
86
98
|
instruction: asString(answer.instruction, 'Choose the correct answer.'),
|
|
87
99
|
passage: asString(answer.passage),
|
|
100
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
88
101
|
items: answer.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index])),
|
|
89
102
|
explanation,
|
|
90
103
|
points: typeof answer.points === 'number' ? answer.points : undefined,
|