@tinyweb_dev/oe-exam-sdk 1.0.0 → 1.0.2
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/api/exam-taking.types.d.ts +1 -0
- package/dist/components/exams/ExamPreviewDialog.js +4 -2
- package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +13 -20
- package/dist/components/exams/take/exam-taking.utils.d.ts +3 -2
- package/dist/components/exams/take/exam-taking.utils.js +3 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +11 -3
- package/dist/components/exams/take/utils/question-transformers.js +45 -2
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
- package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
- package/dist/components/questions/_shared/types/question-group.type.js +4 -1
- package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
- package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
- package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
- package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
- package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
- package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
- package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
- package/dist/components/questions/types/fill-in-blank/register.js +5 -0
- package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
- package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +2 -10
- package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -1
- package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
- package/dist/components/questions/viewer/QuestionViewer.js +12 -68
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
- package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
- package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
- package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
- package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
- package/dist/shared/constants/routes.d.ts +2 -2
- package/dist/shared/constants/routes.js +3 -3
- package/dist/shared/lib/rich-text.d.ts +4 -0
- package/dist/shared/lib/rich-text.js +25 -0
- package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform.js +1 -1
- package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
- package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
- package/package.json +1 -1
- package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
|
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { CheckCircle2, Eye, GraduationCap } from 'lucide-react';
|
|
5
5
|
import { Button } from '../../components/ui/button';
|
|
6
|
-
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
|
6
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog';
|
|
7
7
|
import { useT } from '../../shared/lib/i18n';
|
|
8
8
|
import { cn } from '../../shared/lib/utils';
|
|
9
9
|
import { ExamPreviewMode } from './exam-preview.mode';
|
|
@@ -129,7 +129,9 @@ export function ExamPreviewDialog({ open, onOpenChange, examId, api, title, defa
|
|
|
129
129
|
scrollToTop();
|
|
130
130
|
}
|
|
131
131
|
};
|
|
132
|
-
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }),
|
|
132
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "max-h-[95vh] max-w-7xl overflow-y-auto", children: [_jsxs(DialogHeader, { className: "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border-b pb-3 pr-8", children: [_jsxs(DialogTitle, { className: "flex items-center gap-2 text-lg font-bold", children: [_jsx(Eye, { className: "h-5 w-5 text-blue-600" }), t('admin.exams.preview.title', { name: heading })] }), _jsx(DialogDescription, { className: "sr-only", children: previewMode === ExamPreviewMode.ANSWERS
|
|
133
|
+
? t('admin.exams.preview.footerNoteAnswers')
|
|
134
|
+
: t('admin.exams.preview.footerNoteStudent') }), _jsxs("div", { className: "inline-flex items-center rounded-lg border border-gray-200 bg-gray-100 p-1 shadow-inner", children: [_jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.STUDENT), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.STUDENT
|
|
133
135
|
? 'bg-white text-blue-700 shadow-sm ring-1 ring-black/5'
|
|
134
136
|
: 'text-gray-600 hover:bg-gray-200/70 hover:text-gray-900'), children: [_jsx(GraduationCap, { className: "h-4 w-4" }), _jsx("span", { children: t('admin.exams.preview.mode.student') })] }), _jsxs("button", { type: "button", onClick: () => setPreviewMode(ExamPreviewMode.ANSWERS), className: cn('inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-all', previewMode === ExamPreviewMode.ANSWERS
|
|
135
137
|
? 'bg-emerald-600 text-white shadow-sm'
|
|
@@ -42,8 +42,8 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
|
|
|
42
42
|
};
|
|
43
43
|
switch (questionType) {
|
|
44
44
|
case 'FILL_IN_BLANK': {
|
|
45
|
-
const data = transformFillInBlank(questions);
|
|
46
|
-
return (_jsx(MoversListenAndWriteRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.groupImageUrl || data.imageUrl, audioUrl: data.audioUrl || part.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle }));
|
|
45
|
+
const data = transformFillInBlank(questions, { suggestedAnswers: part.suggestedAnswers });
|
|
46
|
+
return (_jsx(MoversListenAndWriteRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.groupImageUrl || data.imageUrl, audioUrl: data.audioUrl || part.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle, imageLabels: data.imageLabels }));
|
|
47
47
|
}
|
|
48
48
|
case 'WRITE_CORRECT_VERB_FORM': {
|
|
49
49
|
const data = transformWriteCorrectVerbForm(questions);
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FillBlankQuestion } from '../../types';
|
|
2
|
+
import { type ImageLabelData } from '../../../../questions/_shared/types/question-group.type';
|
|
2
3
|
interface MoversListenAndWriteRendererProps {
|
|
3
4
|
partNumber: number;
|
|
4
5
|
partName?: string;
|
|
@@ -16,6 +17,7 @@ interface MoversListenAndWriteRendererProps {
|
|
|
16
17
|
showHints?: boolean;
|
|
17
18
|
groupContent?: string;
|
|
18
19
|
groupTitle?: string;
|
|
20
|
+
imageLabels?: ImageLabelData[];
|
|
19
21
|
}
|
|
20
|
-
export declare function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode, showHints, groupContent, groupTitle, }: MoversListenAndWriteRendererProps): import("react").JSX.Element;
|
|
22
|
+
export declare function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode, showHints, groupContent, groupTitle, imageLabels, }: MoversListenAndWriteRendererProps): import("react").JSX.Element;
|
|
21
23
|
export {};
|
package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js
CHANGED
|
@@ -6,6 +6,8 @@ import CambridgeYleInstructionBanner from '../../../../../components/themes/camb
|
|
|
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
|
+
import { ImageLabelWordBank } from '../../../../questions/groups/ImageLabelWordBank';
|
|
10
|
+
import { hasImageLabels } from '../../../../questions/_shared/types/question-group.type';
|
|
9
11
|
/** Preserve author newlines in fill-blank stems by mapping `\n` → `<br />`. */
|
|
10
12
|
function renderTextWithLineBreaks(text) {
|
|
11
13
|
const lines = text.split('\n');
|
|
@@ -23,9 +25,10 @@ function blankInputClassName({ isExampleQ, isReviewMode, isCorrect, isWrong, })
|
|
|
23
25
|
}
|
|
24
26
|
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
27
|
}
|
|
26
|
-
export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, }) {
|
|
28
|
+
export function MoversListenAndWriteRenderer({ partNumber, partName, questionCount, instruction, imageUrl, audioUrl, title, exampleText, exampleHighlight, questions, answers, onAnswerChange, isReviewMode = false, showHints = false, groupContent, groupTitle, imageLabels, }) {
|
|
29
|
+
const hasImageLabelBank = hasImageLabels(imageLabels);
|
|
27
30
|
const hasGroupContent = !!(audioUrl || imageUrl || groupContent || exampleText);
|
|
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) => {
|
|
31
|
+
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 }), hasImageLabelBank && (_jsx(ImageLabelWordBank, { items: imageLabels, className: "rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm" }))] }), 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) => {
|
|
29
32
|
const isExampleQ = question.isExample || false;
|
|
30
33
|
const isDisabled = isReviewMode || isExampleQ;
|
|
31
34
|
const blankCount = countBlanks(question.segments);
|
|
@@ -6,6 +6,7 @@ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-y
|
|
|
6
6
|
import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
7
|
import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
8
8
|
import { cn } from '../../../../../shared/lib/utils';
|
|
9
|
+
import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, } from '../../../../../shared/lib/utils/wfsf-blank-match';
|
|
9
10
|
/**
|
|
10
11
|
* Replace `{key}` placeholders with inline blank controls.
|
|
11
12
|
* WITH_OPTIONS blanks become <select>; free-type stay <input>.
|
|
@@ -51,14 +52,6 @@ function buildHtmlWithInputPlaceholders(html, wordBank) {
|
|
|
51
52
|
`;
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
|
-
function normalizeStructuredAnswer(value, caseSensitive) {
|
|
55
|
-
const normalized = value
|
|
56
|
-
.trim()
|
|
57
|
-
.replace(/[\p{P}]/gu, '')
|
|
58
|
-
.replace(/\s+/g, ' ')
|
|
59
|
-
.trim();
|
|
60
|
-
return caseSensitive ? normalized : normalized.toLowerCase();
|
|
61
|
-
}
|
|
62
55
|
// Stable empty object to avoid new {} on every render (breaks React.memo)
|
|
63
56
|
const emptyObj = {};
|
|
64
57
|
/**
|
|
@@ -96,33 +89,33 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
96
89
|
const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
|
|
97
90
|
const hint = container.querySelector(`[data-blank-hint="${key}"]`);
|
|
98
91
|
const userAnswer = initialAnswersRef.current[key] || '';
|
|
99
|
-
const correctAnswer = question.answers[key]
|
|
92
|
+
const correctAnswer = question.answers[key];
|
|
93
|
+
const correctLabel = formatWfsfCorrectLabel(correctAnswer);
|
|
100
94
|
const hasUserAnswer = userAnswer.trim().length > 0;
|
|
101
95
|
const isCorrect = hasUserAnswer
|
|
102
|
-
&&
|
|
103
|
-
=== normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
|
|
96
|
+
&& isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
|
|
104
97
|
// Restore saved answer value on mount
|
|
105
|
-
const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect &&
|
|
98
|
+
const shouldReplaceInputWithReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
|
|
106
99
|
input.value = shouldReplaceInputWithReviewText
|
|
107
|
-
? `${hasUserAnswer ? userAnswer : '(trống)'} → ${
|
|
100
|
+
? `${hasUserAnswer ? userAnswer : '(trống)'} → ${correctLabel}`
|
|
108
101
|
: userAnswer;
|
|
109
102
|
input.disabled = isReviewMode;
|
|
110
103
|
const isSelect = input.tagName === 'SELECT';
|
|
111
104
|
// Set classes from blankStyle (letter = square box)
|
|
112
105
|
const blankStyle = question.blankStyle || 'word';
|
|
113
106
|
const isLetter = blankStyle === 'letter';
|
|
114
|
-
const isPhrase = blankStyle === 'phrase';
|
|
107
|
+
const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
|
|
115
108
|
if (isLetter && !isSelect && 'maxLength' in input) {
|
|
116
|
-
|
|
109
|
+
const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
|
|
110
|
+
input.maxLength = Math.max(1, maxAltLen || 1);
|
|
117
111
|
}
|
|
118
112
|
input.className = cn('structured-blank-input inline-block bg-slate-50/40 text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isSelect
|
|
119
113
|
? 'min-w-[5.5rem] max-w-[10rem] rounded border px-2 py-1 text-center'
|
|
120
114
|
: isLetter
|
|
121
115
|
? 'h-7 w-7 rounded border-2 px-0 text-center uppercase'
|
|
122
116
|
: isPhrase
|
|
123
|
-
? 'min-w-[
|
|
124
|
-
:
|
|
125
|
-
'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
|
|
117
|
+
? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-b-2 px-2 py-0.5 text-center'
|
|
118
|
+
: 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-b-2 px-1 py-0.5 text-center', isReviewMode
|
|
126
119
|
? isCorrect
|
|
127
120
|
? 'border-emerald-500 bg-emerald-50 text-emerald-800 font-bold'
|
|
128
121
|
: hasUserAnswer
|
|
@@ -130,7 +123,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
130
123
|
: 'border-amber-500 bg-amber-50 text-amber-800 font-bold'
|
|
131
124
|
: 'border-slate-400 text-slate-800 focus:border-teal-600');
|
|
132
125
|
if (!isSelect && !isLetter && 'size' in input) {
|
|
133
|
-
input.size = isPhrase ?
|
|
126
|
+
input.size = isPhrase ? 28 : 10;
|
|
134
127
|
}
|
|
135
128
|
// letter inline box — host FE may not scan Tailwind h-7/w-7
|
|
136
129
|
if (isLetter) {
|
|
@@ -174,7 +167,7 @@ const StructuredFormQuestion = memo(function StructuredFormQuestion({ question,
|
|
|
174
167
|
}, [processedHtml, isReviewMode]);
|
|
175
168
|
return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-gray-800", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-gray-200", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-48 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: cn('structured-form-content rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 text-sm leading-relaxed text-gray-800', '[&_.structured-blank-wrapper]:inline-flex [&_.structured-blank-wrapper]:align-baseline [&_.structured-blank-wrapper]:items-baseline [&_.structured-blank-hint]:max-w-[180px]',
|
|
176
169
|
// Conversation: speaker column hug name (not equal 50/50 split)
|
|
177
|
-
'[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", val] }, key))) })] })] }))] }));
|
|
170
|
+
'[&_table.dialogue]:w-full [&_table.dialogue]:border-collapse [&_table.dialogue]:table-fixed', '[&_table.dialogue_td.speaker]:w-[1%] [&_table.dialogue_td.speaker]:whitespace-nowrap', '[&_table.dialogue_td.speaker]:align-top [&_table.dialogue_td.speaker]:pr-3', '[&_table.dialogue_td.speaker]:font-semibold [&_table.dialogue_td.speaker]:text-slate-900', '[&_table.dialogue_td]:align-top [&_table.dialogue_td]:py-1.5') }), isReviewMode && (_jsxs("div", { className: "space-y-2 rounded-lg border border-slate-200 bg-slate-50 p-3", children: [_jsxs("div", { className: "flex flex-wrap gap-2 text-[11px] font-medium", children: [_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-1 text-green-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-green-500" }), "\u0110\u00FAng"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-1 text-red-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-red-500" }), "Sai"] }), _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-1 text-amber-700", children: [_jsx("span", { className: "h-2 w-2 rounded-full bg-amber-500" }), "Ch\u01B0a tr\u1EA3 l\u1EDDi"] })] }), _jsxs("div", { children: [_jsx("span", { className: "text-[10px] font-medium uppercase tracking-wider text-slate-500", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-green-300 bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-800", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] })] }))] }));
|
|
178
171
|
});
|
|
179
172
|
export function MoversWordFillStructuredFormRenderer({ partNumber, partName, questionCount, instruction, audioUrl, groupTitle, groupContent, groupImageUrl, questions, answers, onAnswerChange, isReviewMode = false, }) {
|
|
180
173
|
const answersRef = useRef(answers);
|
|
@@ -11,7 +11,7 @@ export interface TemplateSection {
|
|
|
11
11
|
viewMode?: string;
|
|
12
12
|
wordBankConfig?: string[];
|
|
13
13
|
}
|
|
14
|
-
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
|
|
14
|
+
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
|
|
15
15
|
export type WfsfWordBankEntry = {
|
|
16
16
|
options: string[];
|
|
17
17
|
} | {
|
|
@@ -21,7 +21,7 @@ export type WfsfWordBankEntry = {
|
|
|
21
21
|
export interface WordFillStructuredFormQuestion {
|
|
22
22
|
id: string;
|
|
23
23
|
content: string;
|
|
24
|
-
answers: Record<string, string>;
|
|
24
|
+
answers: Record<string, string | string[]>;
|
|
25
25
|
title?: string;
|
|
26
26
|
instruction?: string;
|
|
27
27
|
caseSensitive?: boolean;
|
|
@@ -48,6 +48,7 @@ export interface ExamTakingPart {
|
|
|
48
48
|
wordBankConfig?: string[];
|
|
49
49
|
startIndex?: number;
|
|
50
50
|
endIndex?: number;
|
|
51
|
+
suggestedAnswers?: string[];
|
|
51
52
|
}
|
|
52
53
|
export declare function unwrap<T>(value: ApiData<T> | T): T;
|
|
53
54
|
export declare function unwrapAttempt(value: ExamTakingAttempt | {
|
|
@@ -28,6 +28,7 @@ export function groupQuestions(result) {
|
|
|
28
28
|
viewMode: typeof template?.group?.viewMode === 'string' ? template.group.viewMode : undefined,
|
|
29
29
|
startIndex: template?.startIndex,
|
|
30
30
|
endIndex: template?.endIndex,
|
|
31
|
+
suggestedAnswers: template?.suggestedAnswers,
|
|
31
32
|
questions: questions.sort((a, b) => a.question_number - b.question_number),
|
|
32
33
|
};
|
|
33
34
|
});
|
|
@@ -117,6 +118,8 @@ export function transformWordFillStructuredForm(questions) {
|
|
|
117
118
|
return 'letter';
|
|
118
119
|
if (v === 'phrase' || v === 'sentence')
|
|
119
120
|
return 'phrase';
|
|
121
|
+
if (v === 'translate')
|
|
122
|
+
return 'translate';
|
|
120
123
|
return 'word';
|
|
121
124
|
};
|
|
122
125
|
const normalizeWordBank = (raw) => {
|
|
@@ -63,6 +63,7 @@ export interface QuestionPart {
|
|
|
63
63
|
/** Zero-based exam-order start index of this part/section (for question numbering). */
|
|
64
64
|
startIndex?: number;
|
|
65
65
|
endIndex?: number;
|
|
66
|
+
suggestedAnswers?: string[];
|
|
66
67
|
}
|
|
67
68
|
/** Nested template group payload ({ items, config, type }) from the API template. */
|
|
68
69
|
export interface TemplateGroupConfig {
|
|
@@ -108,6 +109,7 @@ export interface TemplatePart {
|
|
|
108
109
|
category?: string;
|
|
109
110
|
/** Max points for this part from exams.template.parts */
|
|
110
111
|
points?: number;
|
|
112
|
+
suggestedAnswers?: string[];
|
|
111
113
|
}
|
|
112
114
|
/**
|
|
113
115
|
* Group questions by part_id AND questionGroup.id
|
|
@@ -122,7 +124,9 @@ export interface TemplatePart {
|
|
|
122
124
|
* @param templateParts - Optional template parts with name/instructions metadata
|
|
123
125
|
*/
|
|
124
126
|
export declare function groupQuestionsByPart(questions: ApiQuestion[], templateParts?: TemplatePart[]): QuestionPart[];
|
|
125
|
-
export declare function transformFillInBlank(questions: ApiQuestion[]
|
|
127
|
+
export declare function transformFillInBlank(questions: ApiQuestion[], options?: {
|
|
128
|
+
suggestedAnswers?: string[];
|
|
129
|
+
}): {
|
|
126
130
|
questions: FillBlankQuestion[];
|
|
127
131
|
title: string;
|
|
128
132
|
imageUrl?: string;
|
|
@@ -134,6 +138,10 @@ export declare function transformFillInBlank(questions: ApiQuestion[]): {
|
|
|
134
138
|
groupContent?: string;
|
|
135
139
|
groupTitle?: string;
|
|
136
140
|
groupImageUrl?: string;
|
|
141
|
+
imageLabels?: Array<{
|
|
142
|
+
imageUrl: string;
|
|
143
|
+
label: string;
|
|
144
|
+
}>;
|
|
137
145
|
};
|
|
138
146
|
export declare function transformWriteCorrectVerbForm(questions: ApiQuestion[]): {
|
|
139
147
|
questions: FillBlankQuestion[];
|
|
@@ -387,7 +395,7 @@ export declare function transformWriteSentences(questions: ApiQuestion[]): {
|
|
|
387
395
|
title?: string;
|
|
388
396
|
groupContent?: string;
|
|
389
397
|
};
|
|
390
|
-
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase';
|
|
398
|
+
export type WfsfBlankStyle = 'letter' | 'word' | 'phrase' | 'translate';
|
|
391
399
|
export type WfsfWordBankEntry = {
|
|
392
400
|
options: string[];
|
|
393
401
|
} | {
|
|
@@ -400,7 +408,7 @@ export interface WordFillStructuredFormQuestion {
|
|
|
400
408
|
title: string;
|
|
401
409
|
imageUrl?: string;
|
|
402
410
|
content: string;
|
|
403
|
-
answers: Record<string, string>;
|
|
411
|
+
answers: Record<string, string | string[]>;
|
|
404
412
|
caseSensitive: boolean;
|
|
405
413
|
/** letter = square boxes; word / phrase = underline width */
|
|
406
414
|
blankStyle: WfsfBlankStyle;
|
|
@@ -41,7 +41,9 @@ export function groupQuestionsByPart(questions, templateParts) {
|
|
|
41
41
|
// a single screen with the passage on the left and ALL questions on the
|
|
42
42
|
// right — so keep it whole and pass the sections through for the renderer.
|
|
43
43
|
const partLevelGroup = templatePart?.group;
|
|
44
|
-
const
|
|
44
|
+
const sharedGroupType = String(partLevelGroup?.type || '').toUpperCase();
|
|
45
|
+
const hasDocumentGroup = Boolean(templatePart?.isGroup) &&
|
|
46
|
+
(sharedGroupType === 'DOCUMENT' || sharedGroupType === 'IMAGE_LABEL' || sharedGroupType === 'ARTICLES');
|
|
45
47
|
// Mixed multi-section part (e.g. Listening SECTION 2: CHOOSE + MATCH +
|
|
46
48
|
// WORD_FILL): keep it whole and pass the sections through so the grouped
|
|
47
49
|
// renderer can stack each sub-section on one screen. Without this the part
|
|
@@ -68,6 +70,7 @@ export function groupQuestionsByPart(questions, templateParts) {
|
|
|
68
70
|
sections: templatePart?.sections,
|
|
69
71
|
startIndex: templatePart?.startIndex,
|
|
70
72
|
endIndex: templatePart?.endIndex,
|
|
73
|
+
suggestedAnswers: templatePart?.suggestedAnswers,
|
|
71
74
|
});
|
|
72
75
|
continue;
|
|
73
76
|
}
|
|
@@ -96,6 +99,7 @@ export function groupQuestionsByPart(questions, templateParts) {
|
|
|
96
99
|
group: templatePart?.group,
|
|
97
100
|
startIndex: templatePart?.startIndex,
|
|
98
101
|
endIndex: templatePart?.endIndex,
|
|
102
|
+
suggestedAnswers: templatePart?.suggestedAnswers,
|
|
99
103
|
});
|
|
100
104
|
continue;
|
|
101
105
|
}
|
|
@@ -138,6 +142,7 @@ export function groupQuestionsByPart(questions, templateParts) {
|
|
|
138
142
|
group: section.group,
|
|
139
143
|
startIndex: section.startIndex,
|
|
140
144
|
endIndex: section.endIndex,
|
|
145
|
+
suggestedAnswers: section.suggestedAnswers ?? templatePart?.suggestedAnswers,
|
|
141
146
|
});
|
|
142
147
|
}
|
|
143
148
|
}
|
|
@@ -150,7 +155,42 @@ export function groupQuestionsByPart(questions, templateParts) {
|
|
|
150
155
|
return (a.questions[0]?.question_number || 0) - (b.questions[0]?.question_number || 0);
|
|
151
156
|
});
|
|
152
157
|
}
|
|
153
|
-
|
|
158
|
+
// ============================================
|
|
159
|
+
// FILL_IN_BLANK Transformer
|
|
160
|
+
// ============================================
|
|
161
|
+
function asImageLabelItems(value) {
|
|
162
|
+
if (!Array.isArray(value))
|
|
163
|
+
return undefined;
|
|
164
|
+
const items = value
|
|
165
|
+
.map((item) => {
|
|
166
|
+
if (!item || typeof item !== 'object')
|
|
167
|
+
return null;
|
|
168
|
+
const imageUrl = String(item.imageUrl ?? '').trim();
|
|
169
|
+
const label = String(item.label ?? '').trim();
|
|
170
|
+
if (!imageUrl && !label)
|
|
171
|
+
return null;
|
|
172
|
+
return { imageUrl, label };
|
|
173
|
+
})
|
|
174
|
+
.filter((item) => item !== null);
|
|
175
|
+
return items.length > 0 ? items : undefined;
|
|
176
|
+
}
|
|
177
|
+
function collectFillInBlankImageLabels(questions, suggestedAnswers) {
|
|
178
|
+
for (const question of questions) {
|
|
179
|
+
const payload = question.questionGroup?.payload ||
|
|
180
|
+
question.question_group?.payload;
|
|
181
|
+
const fromGroup = asImageLabelItems(payload?.imageLabels);
|
|
182
|
+
if (fromGroup)
|
|
183
|
+
return fromGroup;
|
|
184
|
+
const fromContent = asImageLabelItems(question.content?.imageLabels);
|
|
185
|
+
if (fromContent)
|
|
186
|
+
return fromContent;
|
|
187
|
+
}
|
|
188
|
+
if (suggestedAnswers?.length) {
|
|
189
|
+
return suggestedAnswers.map((label) => ({ imageUrl: '', label: String(label) }));
|
|
190
|
+
}
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
export function transformFillInBlank(questions, options) {
|
|
154
194
|
const firstQuestion = questions[0];
|
|
155
195
|
const content = firstQuestion?.content;
|
|
156
196
|
const groupPayload = firstQuestion?.questionGroup?.payload;
|
|
@@ -187,6 +227,7 @@ export function transformFillInBlank(questions) {
|
|
|
187
227
|
groupContent: groupPayload?.content || groupPayload?.groupContent,
|
|
188
228
|
groupTitle: groupPayload?.title,
|
|
189
229
|
groupImageUrl: groupPayload?.mainImageUrl || groupPayload?.imageUrl,
|
|
230
|
+
imageLabels: collectFillInBlankImageLabels(questions, options?.suggestedAnswers),
|
|
190
231
|
};
|
|
191
232
|
}
|
|
192
233
|
export function transformWriteCorrectVerbForm(questions) {
|
|
@@ -1168,6 +1209,8 @@ function normalizeWfsfBlankStyle(raw) {
|
|
|
1168
1209
|
return 'letter';
|
|
1169
1210
|
if (v === 'phrase' || v === 'sentence')
|
|
1170
1211
|
return 'phrase';
|
|
1212
|
+
if (v === 'translate')
|
|
1213
|
+
return 'translate';
|
|
1171
1214
|
return 'word';
|
|
1172
1215
|
}
|
|
1173
1216
|
function normalizeWfsfWordBank(raw) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { QuestionGroupConfig, BasicGroupData, ArticleData } from '../types/question-group.type';
|
|
1
|
+
import type { QuestionGroupConfig, BasicGroupData, ArticleData, ImageLabelData } from '../types/question-group.type';
|
|
2
2
|
/**
|
|
3
3
|
* Derives which group number a question at `questionIndexInPart` belongs to,
|
|
4
4
|
* given the part's group configuration array.
|
|
@@ -23,6 +23,8 @@ export interface UseBasicQuestionGroupOptions {
|
|
|
23
23
|
initialData?: BasicGroupData;
|
|
24
24
|
/** Number of articles for ARTICLES group type (creates empty articles array when > 0) */
|
|
25
25
|
articleCount?: number;
|
|
26
|
+
/** Number of image+label items for IMAGE_LABEL group type */
|
|
27
|
+
imageLabelCount?: number;
|
|
26
28
|
}
|
|
27
29
|
export interface UseBasicQuestionGroupReturn {
|
|
28
30
|
/** Is this question part of a group (i.e. groups array is non-empty)? */
|
|
@@ -36,11 +38,13 @@ export interface UseBasicQuestionGroupReturn {
|
|
|
36
38
|
groupContent: string;
|
|
37
39
|
imageUrl: string;
|
|
38
40
|
articles: ArticleData[];
|
|
41
|
+
imageLabels: ImageLabelData[];
|
|
39
42
|
setGroupTitle: (value: string) => void;
|
|
40
43
|
setGroupSubtitle: (value: string) => void;
|
|
41
44
|
setGroupContent: (value: string) => void;
|
|
42
45
|
setImageUrl: (value: string) => void;
|
|
43
46
|
setArticles: (articles: ArticleData[]) => void;
|
|
47
|
+
setImageLabels: (imageLabels: ImageLabelData[]) => void;
|
|
44
48
|
}
|
|
45
49
|
/**
|
|
46
50
|
* `useBasicQuestionGroup`
|
|
@@ -53,4 +57,4 @@ export interface UseBasicQuestionGroupReturn {
|
|
|
53
57
|
* the same group), falls back to `initialData`, and writes every change back to
|
|
54
58
|
* the store so sibling questions instantly see the updated values.
|
|
55
59
|
*/
|
|
56
|
-
export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
|
|
60
|
+
export declare function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount, imageLabelCount, }: UseBasicQuestionGroupOptions): UseBasicQuestionGroupReturn;
|
|
@@ -54,7 +54,7 @@ export function getIsFirstInGroup(questionIndexInPart, groups) {
|
|
|
54
54
|
* the same group), falls back to `initialData`, and writes every change back to
|
|
55
55
|
* the store so sibling questions instantly see the updated values.
|
|
56
56
|
*/
|
|
57
|
-
export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, }) {
|
|
57
|
+
export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, initialData, articleCount = 0, imageLabelCount = 0, }) {
|
|
58
58
|
const setSharedPassageData = useExamQuestionStore((s) => s.setSharedPassageData);
|
|
59
59
|
const getSharedPassageData = useExamQuestionStore((s) => s.getSharedPassageData);
|
|
60
60
|
const isGrouped = Boolean(groups && groups.length > 0);
|
|
@@ -67,6 +67,9 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
67
67
|
const defaultArticles = articleCount > 0
|
|
68
68
|
? Array.from({ length: articleCount }, () => ({ name: '', content: '', imageUrl: '' }))
|
|
69
69
|
: [];
|
|
70
|
+
const defaultImageLabels = imageLabelCount > 0
|
|
71
|
+
? Array.from({ length: imageLabelCount }, () => ({ imageUrl: '', label: '' }))
|
|
72
|
+
: [];
|
|
70
73
|
const resolveInitial = () => {
|
|
71
74
|
if (isGrouped && partId) {
|
|
72
75
|
const stored = getSharedPassageData(partId, currentGroupNumber);
|
|
@@ -77,6 +80,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
77
80
|
content: stored.groupContent ?? stored.passage ?? initialData?.content ?? '',
|
|
78
81
|
imageUrl: stored.imageUrl ?? initialData?.imageUrl ?? '',
|
|
79
82
|
articles: stored.articles ?? initialData?.articles ?? defaultArticles,
|
|
83
|
+
imageLabels: stored.imageLabels ?? initialData?.imageLabels ?? defaultImageLabels,
|
|
80
84
|
};
|
|
81
85
|
}
|
|
82
86
|
}
|
|
@@ -86,6 +90,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
86
90
|
content: initialData?.content ?? '',
|
|
87
91
|
imageUrl: initialData?.imageUrl ?? '',
|
|
88
92
|
articles: initialData?.articles ?? defaultArticles,
|
|
93
|
+
imageLabels: initialData?.imageLabels ?? defaultImageLabels,
|
|
89
94
|
};
|
|
90
95
|
};
|
|
91
96
|
const initial = resolveInitial();
|
|
@@ -94,6 +99,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
94
99
|
const [groupContent, setGroupContentState] = useState(initial.content ?? '');
|
|
95
100
|
const [imageUrl, setImageUrlState] = useState(initial.imageUrl ?? '');
|
|
96
101
|
const [articles, setArticlesState] = useState(initial.articles ?? defaultArticles);
|
|
102
|
+
const [imageLabels, setImageLabelsState] = useState(initial.imageLabels ?? defaultImageLabels);
|
|
97
103
|
// Track the last rendered group number so we can re-sync when navigating
|
|
98
104
|
const prevGroupNumberRef = useRef(currentGroupNumber);
|
|
99
105
|
// Track whether we have already seeded from initialData for the current group
|
|
@@ -121,7 +127,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
121
127
|
const newContent = initialData.content || null;
|
|
122
128
|
const newImage = initialData.imageUrl || null;
|
|
123
129
|
const newArticles = initialData.articles || null;
|
|
124
|
-
|
|
130
|
+
const newImageLabels = initialData.imageLabels || null;
|
|
131
|
+
if (newTitle === null && newSubtitle === null && newContent === null && newImage === null && newArticles === null && newImageLabels === null)
|
|
125
132
|
return;
|
|
126
133
|
if (newTitle !== null)
|
|
127
134
|
setGroupTitleState(newTitle);
|
|
@@ -133,6 +140,8 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
133
140
|
setImageUrlState(newImage);
|
|
134
141
|
if (newArticles !== null)
|
|
135
142
|
setArticlesState(newArticles);
|
|
143
|
+
if (newImageLabels !== null)
|
|
144
|
+
setImageLabelsState(newImageLabels);
|
|
136
145
|
setSharedPassageData(partId, currentGroupNumber, {
|
|
137
146
|
passageTitle: newTitle ?? '',
|
|
138
147
|
passageSubtitle: newSubtitle ?? '',
|
|
@@ -140,6 +149,7 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
140
149
|
passage: newContent ?? '',
|
|
141
150
|
imageUrl: newImage ?? '',
|
|
142
151
|
articles: newArticles ?? defaultArticles,
|
|
152
|
+
imageLabels: newImageLabels ?? defaultImageLabels,
|
|
143
153
|
});
|
|
144
154
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
155
|
}, [initialData, partId, currentGroupNumber, isGrouped]);
|
|
@@ -153,12 +163,13 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
153
163
|
if (!isGrouped || !partId)
|
|
154
164
|
return;
|
|
155
165
|
const stored = getSharedPassageData(partId, currentGroupNumber);
|
|
156
|
-
if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles)) {
|
|
166
|
+
if (stored && (stored.passageTitle || stored.passageSubtitle || stored.groupContent || stored.passage || stored.imageUrl || stored.articles || stored.imageLabels)) {
|
|
157
167
|
setGroupTitleState(stored.passageTitle ?? '');
|
|
158
168
|
setGroupSubtitleState(stored.passageSubtitle ?? '');
|
|
159
169
|
setGroupContentState(stored.groupContent ?? stored.passage ?? '');
|
|
160
170
|
setImageUrlState(stored.imageUrl ?? '');
|
|
161
171
|
setArticlesState(stored.articles ?? defaultArticles);
|
|
172
|
+
setImageLabelsState(stored.imageLabels ?? defaultImageLabels);
|
|
162
173
|
}
|
|
163
174
|
else {
|
|
164
175
|
setGroupTitleState(initialData?.title ?? '');
|
|
@@ -166,22 +177,26 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
166
177
|
setGroupContentState(initialData?.content ?? '');
|
|
167
178
|
setImageUrlState(initialData?.imageUrl ?? '');
|
|
168
179
|
setArticlesState(initialData?.articles ?? defaultArticles);
|
|
180
|
+
setImageLabelsState(initialData?.imageLabels ?? defaultImageLabels);
|
|
169
181
|
}
|
|
170
182
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
171
183
|
}, [currentGroupNumber, partId, isGrouped]);
|
|
172
184
|
// ------------------------------------------------------------------
|
|
173
185
|
// Helpers: write state + persist to store
|
|
174
186
|
// ------------------------------------------------------------------
|
|
175
|
-
const persistToStore = (title, subtitle, content, image, arts) => {
|
|
187
|
+
const persistToStore = (title, subtitle, content, image, arts, labels) => {
|
|
176
188
|
if (!isGrouped || !partId)
|
|
177
189
|
return;
|
|
190
|
+
const existing = getSharedPassageData(partId, currentGroupNumber);
|
|
178
191
|
setSharedPassageData(partId, currentGroupNumber, {
|
|
192
|
+
...existing,
|
|
179
193
|
passageTitle: title,
|
|
180
194
|
passageSubtitle: subtitle,
|
|
181
195
|
groupContent: content,
|
|
182
196
|
passage: content,
|
|
183
197
|
imageUrl: image,
|
|
184
198
|
articles: arts ?? articles,
|
|
199
|
+
imageLabels: labels ?? imageLabels,
|
|
185
200
|
});
|
|
186
201
|
};
|
|
187
202
|
const setGroupTitle = (value) => {
|
|
@@ -202,7 +217,11 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
202
217
|
};
|
|
203
218
|
const setArticles = (value) => {
|
|
204
219
|
setArticlesState(value);
|
|
205
|
-
persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value);
|
|
220
|
+
persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, value, imageLabels);
|
|
221
|
+
};
|
|
222
|
+
const setImageLabels = (value) => {
|
|
223
|
+
setImageLabelsState(value);
|
|
224
|
+
persistToStore(groupTitle, groupSubtitle, groupContent, imageUrl, articles, value);
|
|
206
225
|
};
|
|
207
226
|
return {
|
|
208
227
|
isGrouped,
|
|
@@ -213,10 +232,12 @@ export function useBasicQuestionGroup({ partId, groups, questionIndexInPart, ini
|
|
|
213
232
|
groupContent,
|
|
214
233
|
imageUrl,
|
|
215
234
|
articles,
|
|
235
|
+
imageLabels,
|
|
216
236
|
setGroupTitle,
|
|
217
237
|
setGroupSubtitle,
|
|
218
238
|
setGroupContent,
|
|
219
239
|
setImageUrl,
|
|
220
240
|
setArticles,
|
|
241
|
+
setImageLabels,
|
|
221
242
|
};
|
|
222
243
|
}
|
|
@@ -40,6 +40,11 @@ export interface FillInBlankData {
|
|
|
40
40
|
groupImageUrl?: string;
|
|
41
41
|
groupNumber?: number;
|
|
42
42
|
groups?: FillInBlankGroup[];
|
|
43
|
+
/** Picture + label bank when groupType === "IMAGE_LABEL" */
|
|
44
|
+
imageLabels?: Array<{
|
|
45
|
+
imageUrl: string;
|
|
46
|
+
label: string;
|
|
47
|
+
}>;
|
|
43
48
|
blanks?: BlankItem[];
|
|
44
49
|
}
|
|
45
50
|
export interface FillInBlankCreatorProps {
|
|
@@ -55,6 +60,10 @@ export interface FillInBlankCreatorProps {
|
|
|
55
60
|
partId?: string;
|
|
56
61
|
/** Template questionConfig — controls which fields are shown (e.g. "audio") */
|
|
57
62
|
questionConfig?: string[];
|
|
63
|
+
/** Template group.type (e.g. IMAGE_LABEL) */
|
|
64
|
+
groupType?: string;
|
|
65
|
+
/** Template suggestedAnswers — prefill IMAGE_LABEL labels when empty */
|
|
66
|
+
suggestedAnswers?: string[];
|
|
58
67
|
}
|
|
59
68
|
export interface FillInBlankClientProps {
|
|
60
69
|
questionData: FillInBlankData;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Different question types may use different group shapes:
|
|
8
8
|
* - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
|
|
9
|
+
* - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
|
|
9
10
|
* - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
|
|
10
11
|
*/
|
|
11
12
|
/** One entry in a part's group configuration array */
|
|
@@ -24,6 +25,14 @@ export interface ArticleData {
|
|
|
24
25
|
/** Hình ảnh – S3 key hoặc URL */
|
|
25
26
|
imageUrl: string;
|
|
26
27
|
}
|
|
28
|
+
/** One item in an IMAGE_LABEL word bank (e.g. GET Primary vocabulary box) */
|
|
29
|
+
export interface ImageLabelData {
|
|
30
|
+
/** Hình ảnh – S3 key hoặc URL */
|
|
31
|
+
imageUrl: string;
|
|
32
|
+
/** Nhãn / từ hiển thị dưới ảnh */
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function hasImageLabels(items?: ImageLabelData[] | null): boolean;
|
|
27
36
|
/**
|
|
28
37
|
* Shared context fields for a group of ANSWER_THE_QUESTION,
|
|
29
38
|
* CHOOSE_THE_CORRECT_ANSWER, or FILL_IN_BLANK questions.
|
|
@@ -41,4 +50,6 @@ export interface BasicGroupData {
|
|
|
41
50
|
imageUrl?: string;
|
|
42
51
|
/** Mảng bài viết – dùng cho groupType "ARTICLES" (optional) */
|
|
43
52
|
articles?: ArticleData[];
|
|
53
|
+
/** Mảng ảnh + nhãn – dùng cho groupType "IMAGE_LABEL" (optional) */
|
|
54
|
+
imageLabels?: ImageLabelData[];
|
|
44
55
|
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Different question types may use different group shapes:
|
|
8
8
|
* - BasicGroupData → ANSWER_THE_QUESTION, CHOOSE_THE_CORRECT_ANSWER, FILL_IN_BLANK
|
|
9
|
+
* - IMAGE_LABEL → FILL_IN_BLANK word bank (image + label items)
|
|
9
10
|
* - (future) → MATCH_BY_WRITING_ANSWER, LISTENING_*, etc.
|
|
10
11
|
*/
|
|
11
|
-
export {
|
|
12
|
+
export function hasImageLabels(items) {
|
|
13
|
+
return Array.isArray(items) && items.some((item) => Boolean(item?.imageUrl?.trim() || item?.label?.trim()));
|
|
14
|
+
}
|
|
@@ -13,6 +13,13 @@ export interface SpontaneousQAData {
|
|
|
13
13
|
/** Points per question */
|
|
14
14
|
points?: number;
|
|
15
15
|
}
|
|
16
|
+
export interface SpontaneousQAClientProps {
|
|
17
|
+
questionData: SpontaneousQAData & {
|
|
18
|
+
isBackup?: boolean;
|
|
19
|
+
expectedAnswers?: string[];
|
|
20
|
+
};
|
|
21
|
+
isReviewMode?: boolean;
|
|
22
|
+
}
|
|
16
23
|
export interface SpontaneousQACreatorProps {
|
|
17
24
|
initialData?: SpontaneousQAData;
|
|
18
25
|
onSave?: (data: SpontaneousQAData) => void;
|