@tinyweb_dev/oe-exam-sdk 0.2.3 → 0.2.5
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 +11 -3
- package/dist/components/exams/take/components/question-renderers/ChooseBestAnswerImages.d.ts +10 -0
- package/dist/components/exams/take/components/question-renderers/ChooseBestAnswerImages.js +38 -0
- package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +5 -11
- package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureRenderer.d.ts +16 -0
- package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureRenderer.js +75 -0
- package/dist/components/exams/take/components/question-renderers/MoversTrueFalseCorrectGroupRenderer.d.ts +18 -0
- package/dist/components/exams/take/components/question-renderers/MoversTrueFalseCorrectGroupRenderer.js +92 -0
- package/dist/components/exams/take/components/question-renderers/MoversWritingRenderer.d.ts +2 -1
- package/dist/components/exams/take/components/question-renderers/MoversWritingRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
- package/dist/components/exams/take/components/question-renderers/index.js +2 -0
- package/dist/components/exams/take/exam-taking.utils.d.ts +1 -0
- package/dist/components/exams/take/exam-taking.utils.js +9 -1
- package/dist/components/exams/take/types.d.ts +3 -2
- package/dist/components/exams/take/utils/answer-transformers.js +12 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +29 -1
- package/dist/components/exams/take/utils/question-transformers.js +98 -0
- package/dist/components/questions/_shared/config/question-types.config.js +12 -0
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +9 -0
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.js +49 -1
- package/dist/components/questions/_shared/types/index.d.ts +2 -0
- package/dist/components/questions/_shared/types/index.js +2 -0
- package/dist/components/questions/_shared/types/match-word-to-picture.type.d.ts +62 -0
- package/dist/components/questions/_shared/types/match-word-to-picture.type.js +1 -0
- package/dist/components/questions/_shared/types/true-false-correct-group.type.d.ts +42 -0
- package/dist/components/questions/_shared/types/true-false-correct-group.type.js +2 -0
- package/dist/components/questions/_shared/types/write-a-short-letter.type.d.ts +4 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +2 -0
- package/dist/components/questions/components/QuestionTypeSelector.js +1 -0
- package/dist/components/questions/creator/question-type-registry.js +4 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.d.ts +13 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.js +32 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.d.ts +1 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +42 -6
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +40 -20
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.d.ts +1 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +5 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +4 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureClient.d.ts +2 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureClient.js +31 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureCreator.d.ts +2 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureCreator.js +267 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureGroupClient.d.ts +2 -0
- package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureGroupClient.js +65 -0
- package/dist/components/questions/types/match-word-to-picture/WordBankEditor.d.ts +8 -0
- package/dist/components/questions/types/match-word-to-picture/WordBankEditor.js +15 -0
- package/dist/components/questions/types/match-word-to-picture/index.d.ts +6 -0
- package/dist/components/questions/types/match-word-to-picture/index.js +6 -0
- package/dist/components/questions/types/match-word-to-picture/map-match-word-to-picture-data.d.ts +2 -0
- package/dist/components/questions/types/match-word-to-picture/map-match-word-to-picture-data.js +53 -0
- package/dist/components/questions/types/match-word-to-picture/map-match-word-to-picture-group-data.d.ts +4 -0
- package/dist/components/questions/types/match-word-to-picture/map-match-word-to-picture-group-data.js +75 -0
- package/dist/components/questions/types/match-word-to-picture/register.d.ts +2 -0
- package/dist/components/questions/types/match-word-to-picture/register.js +55 -0
- package/dist/components/questions/types/match-word-to-picture/transform.d.ts +11 -0
- package/dist/components/questions/types/match-word-to-picture/transform.js +43 -0
- package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupClient.d.ts +2 -0
- package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupClient.js +25 -0
- package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupCreator.js +144 -0
- package/dist/components/questions/types/true-false-correct-group/index.d.ts +5 -0
- package/dist/components/questions/types/true-false-correct-group/index.js +5 -0
- package/dist/components/questions/types/true-false-correct-group/map-true-false-correct-group-data.d.ts +4 -0
- package/dist/components/questions/types/true-false-correct-group/map-true-false-correct-group-data.js +79 -0
- package/dist/components/questions/types/true-false-correct-group/register.d.ts +2 -0
- package/dist/components/questions/types/true-false-correct-group/register.js +36 -0
- package/dist/components/questions/types/true-false-correct-group/transform.d.ts +2 -0
- package/dist/components/questions/types/true-false-correct-group/transform.js +49 -0
- package/dist/components/questions/types/write-a-short-letter/WriteAShortLetterClient.js +27 -3
- package/dist/components/questions/types/write-a-short-letter/WriteAShortLetterCreator.js +8 -2
- package/dist/components/questions/types/write-a-short-letter/register.js +4 -0
- package/dist/components/questions/types/write-a-short-letter/transform.js +4 -0
- package/dist/components/questions/viewer/AnswerTheQuestionGroupViewer.d.ts +8 -0
- package/dist/components/questions/viewer/AnswerTheQuestionGroupViewer.js +11 -0
- package/dist/components/questions/viewer/ChooseTheCorrectAnswerGroupViewer.d.ts +2 -1
- package/dist/components/questions/viewer/ChooseTheCorrectAnswerGroupViewer.js +2 -2
- package/dist/components/questions/viewer/MatchWordToPictureGroupViewer.d.ts +9 -0
- package/dist/components/questions/viewer/MatchWordToPictureGroupViewer.js +11 -0
- package/dist/components/questions/viewer/MatchWordToPictureViewer.d.ts +9 -0
- package/dist/components/questions/viewer/MatchWordToPictureViewer.js +8 -0
- package/dist/components/questions/viewer/QuestionViewer.js +58 -9
- package/dist/components/questions/viewer/TrueFalseCorrectGroupViewer.d.ts +9 -0
- package/dist/components/questions/viewer/TrueFalseCorrectGroupViewer.js +14 -0
- package/dist/components/questions/viewer/index.d.ts +4 -0
- package/dist/components/questions/viewer/index.js +4 -0
- package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +4 -9
- package/dist/components/results/renderers/review-question-body-movers.js +39 -3
- package/dist/components/results/review-data.js +22 -0
- package/dist/components/themes/english-certification/EcWritingTask.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +4 -0
- package/dist/shared/lib/hooks/usePresignedFileUrl.d.ts +3 -1
- package/dist/shared/lib/hooks/usePresignedFileUrl.js +30 -12
- package/dist/shared/lib/utils/question-reverse-transform.js +111 -2
- package/dist/shared/lib/utils/question-transform.js +4 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +1 -0
- package/dist/shared/types/questions/index.d.ts +2 -0
- package/dist/shared/types/questions/index.js +4 -0
- package/dist/shared/types/questions/match-word-to-picture.d.ts +35 -0
- package/dist/shared/types/questions/match-word-to-picture.js +5 -0
- package/dist/shared/types/questions/true-false-correct-group.d.ts +39 -0
- package/dist/shared/types/questions/true-false-correct-group.js +1 -0
- package/package.json +7 -2
|
@@ -2,21 +2,57 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { BookOpen, Check, CircleHelp, Lightbulb, Star } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../../../shared/lib/utils';
|
|
5
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
+
import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
7
|
+
function ClientImageItem({ url, alt }) {
|
|
8
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
9
|
+
const src = previewUrl ||
|
|
10
|
+
(typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
|
|
11
|
+
if (!src && !isLoading) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
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
|
+
}
|
|
5
16
|
const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
6
|
-
export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], }) {
|
|
17
|
+
export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
7
18
|
const items = questionData.items || [];
|
|
19
|
+
const handleSelect = (itemIndex, optionId) => {
|
|
20
|
+
const item = items[itemIndex];
|
|
21
|
+
if (isReviewMode || item?.isExample || !onAnswerChange) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const next = items.map((_, index) => userAnswers[index] ?? '');
|
|
25
|
+
next[itemIndex] = optionId;
|
|
26
|
+
onAnswerChange(next);
|
|
27
|
+
};
|
|
8
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) => {
|
|
9
29
|
const selectedId = userAnswers[itemIndex];
|
|
10
|
-
|
|
30
|
+
const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
31
|
+
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' }), 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
|
+
const isCorrect = option.id === item.correctAnswer;
|
|
33
|
+
const isSelected = option.id === selectedId;
|
|
34
|
+
const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
|
|
35
|
+
return (_jsxs("button", { type: "button", disabled: !canSelect, onClick: () => handleSelect(itemIndex, option.id), className: cn('flex flex-col items-center gap-2 rounded-lg border-2 px-3 py-2.5 text-sm text-left', isReviewMode && isCorrect
|
|
36
|
+
? 'border-green-400 bg-green-50'
|
|
37
|
+
: isSelected
|
|
38
|
+
? 'border-blue-400 bg-blue-50'
|
|
39
|
+
: 'border-gray-200 bg-white', canSelect && 'cursor-pointer hover:border-blue-300', !canSelect && 'cursor-default'), children: [_jsx("span", { className: cn('flex h-7 w-7 items-center justify-center rounded-md text-xs font-bold', isReviewMode && isCorrect
|
|
40
|
+
? 'bg-green-500 text-white'
|
|
41
|
+
: isSelected
|
|
42
|
+
? 'bg-blue-500 text-white'
|
|
43
|
+
: 'bg-gray-100 text-gray-600'), children: OPTION_LABELS[optionIndex] || optionIndex + 1 }), optionImageSrc ? (_jsx(ClientImageItem, { url: optionImageSrc, alt: `Đáp án ${OPTION_LABELS[optionIndex] || optionIndex + 1}` })) : (_jsx("span", { className: "text-xs text-gray-500", children: `Đáp án ${OPTION_LABELS[optionIndex] || optionIndex + 1}` })), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
|
|
44
|
+
}) })) : (_jsx("div", { className: "space-y-2", children: item.options.map((option, optionIndex) => {
|
|
11
45
|
const isCorrect = option.id === item.correctAnswer;
|
|
12
46
|
const isSelected = option.id === selectedId;
|
|
13
|
-
return (_jsxs("
|
|
47
|
+
return (_jsxs("button", { type: "button", disabled: !canSelect, onClick: () => handleSelect(itemIndex, option.id), className: cn('flex w-full items-center gap-3 rounded-lg border-2 px-3 py-2.5 text-sm text-left', isReviewMode && isCorrect
|
|
14
48
|
? 'border-green-400 bg-green-50'
|
|
15
49
|
: isSelected
|
|
16
50
|
? 'border-blue-400 bg-blue-50'
|
|
17
|
-
: 'border-gray-200 bg-white'), children: [_jsx("span", { className: cn('flex h-7 w-7 items-center justify-center rounded-md text-xs font-bold', isReviewMode && isCorrect
|
|
51
|
+
: 'border-gray-200 bg-white', canSelect && 'cursor-pointer hover:border-blue-300', !canSelect && 'cursor-default'), children: [_jsx("span", { className: cn('flex h-7 w-7 items-center justify-center rounded-md text-xs font-bold', isReviewMode && isCorrect
|
|
18
52
|
? 'bg-green-500 text-white'
|
|
19
|
-
:
|
|
20
|
-
|
|
53
|
+
: isSelected
|
|
54
|
+
? 'bg-blue-500 text-white'
|
|
55
|
+
: 'bg-gray-100 text-gray-600'), children: OPTION_LABELS[optionIndex] || optionIndex + 1 }), _jsx("span", { className: "flex-1 text-gray-800", children: option.text || `Đáp án ${OPTION_LABELS[optionIndex]}` }), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
|
|
56
|
+
}) }))] })] }, `${item.questionNumber}-${itemIndex}`));
|
|
21
57
|
}), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
|
|
22
58
|
}
|
|
@@ -10,8 +10,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../../../components
|
|
|
10
10
|
import { Switch } from '../../../../components/ui/switch';
|
|
11
11
|
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
12
12
|
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
13
|
-
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
13
|
+
import { Eye, ImageIcon, Pencil, Plus, Star, Trash2, Type } from 'lucide-react';
|
|
14
14
|
import { ChooseTheCorrectAnswerGroupClient } from './ChooseTheCorrectAnswerGroupClient';
|
|
15
|
+
import { ChooseAnswerGroupImageUploadItem, ChooseAnswerGroupOptionImageField, } from './ChooseAnswerGroupImageUpload';
|
|
16
|
+
import { fromChooseAnswerGroupImageUrls, getChooseAnswerGroupOptionImageSrc, isChooseAnswerGroupOptionFilled, toChooseAnswerGroupImageUrls, toEditableChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
15
17
|
const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
16
18
|
function createEmptyItem(questionNumber) {
|
|
17
19
|
return {
|
|
@@ -32,20 +34,25 @@ function normalizeItems(rawItems) {
|
|
|
32
34
|
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
33
35
|
return [createEmptyItem(1)];
|
|
34
36
|
}
|
|
35
|
-
return rawItems.map((item, index) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
return rawItems.map((item, index) => {
|
|
38
|
+
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.imageUrl));
|
|
39
|
+
return {
|
|
40
|
+
question: item.question || '',
|
|
41
|
+
optionType: item.optionType === 'image' ? 'image' : 'text',
|
|
42
|
+
options: Array.isArray(item.options) && item.options.length >= 2
|
|
43
|
+
? item.options.map((option, optionIndex) => ({
|
|
44
|
+
id: option.id || `opt-${optionIndex + 1}`,
|
|
45
|
+
text: option.text || '',
|
|
46
|
+
...(option.imageUrl ? { imageUrl: option.imageUrl } : {}),
|
|
47
|
+
}))
|
|
48
|
+
: createEmptyItem(index + 1).options,
|
|
49
|
+
correctAnswer: item.correctAnswer || 'opt-1',
|
|
50
|
+
isExample: item.isExample === true,
|
|
51
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
52
|
+
questionNumber: item.questionNumber || index + 1,
|
|
53
|
+
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
54
|
+
};
|
|
55
|
+
});
|
|
49
56
|
}
|
|
50
57
|
function sumPoints(items) {
|
|
51
58
|
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
@@ -102,11 +109,11 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
102
109
|
if (!item.question.trim()) {
|
|
103
110
|
nextErrors.push(`Câu ${index + 1}: nội dung câu hỏi là bắt buộc.`);
|
|
104
111
|
}
|
|
105
|
-
const filledOptions = item.options.filter((option) => option.
|
|
112
|
+
const filledOptions = item.options.filter((option) => (isChooseAnswerGroupOptionFilled(option, item.optionType)));
|
|
106
113
|
if (filledOptions.length < 2) {
|
|
107
114
|
nextErrors.push(`Câu ${index + 1}: cần ít nhất 2 đáp án.`);
|
|
108
115
|
}
|
|
109
|
-
if (!item.options.some((option) => option.id === item.correctAnswer && option.
|
|
116
|
+
if (!item.options.some((option) => (option.id === item.correctAnswer && isChooseAnswerGroupOptionFilled(option, item.optionType)))) {
|
|
110
117
|
nextErrors.push(`Câu ${index + 1}: hãy chọn đáp án đúng.`);
|
|
111
118
|
}
|
|
112
119
|
});
|
|
@@ -128,12 +135,12 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
128
135
|
const updateItem = (index, patch) => {
|
|
129
136
|
setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
|
130
137
|
};
|
|
131
|
-
const
|
|
138
|
+
const updateOption = (itemIndex, optionIndex, patch) => {
|
|
132
139
|
setItems((current) => current.map((item, index) => {
|
|
133
140
|
if (index !== itemIndex) {
|
|
134
141
|
return item;
|
|
135
142
|
}
|
|
136
|
-
const options = item.options.map((option, currentOptionIndex) => (currentOptionIndex === optionIndex ? { ...option,
|
|
143
|
+
const options = item.options.map((option, currentOptionIndex) => (currentOptionIndex === optionIndex ? { ...option, ...patch } : option));
|
|
137
144
|
return { ...item, options };
|
|
138
145
|
}));
|
|
139
146
|
};
|
|
@@ -157,7 +164,20 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
157
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, {
|
|
158
165
|
isExample: checked,
|
|
159
166
|
points: checked ? 0 : (item.points || 1),
|
|
160
|
-
}) })] }), _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" })] }),
|
|
167
|
+
}) })] }), _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: () => {
|
|
168
|
+
const nextUrls = [...toEditableChooseAnswerGroupImageUrls(item.imageUrl), ''];
|
|
169
|
+
updateItem(itemIndex, { imageUrl: nextUrls });
|
|
170
|
+
}, className: "gap-1.5 border-teal-200 text-teal-600 hover:bg-teal-50 hover:text-teal-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm h\u00ECnh \u1EA3nh"] })] }), toEditableChooseAnswerGroupImageUrls(item.imageUrl).length === 0 ? (_jsx("div", { className: "rounded-lg border border-dashed border-gray-200 bg-gray-50/50 p-3 text-xs text-gray-500", children: "Ch\u01B0a c\u00F3 h\u00ECnh \u1EA3nh. Nh\u1EA5n \"Th\u00EAm h\u00ECnh \u1EA3nh\" \u0111\u1EC3 upload \u1EA3nh minh ho\u1EA1 cho c\u00E2u h\u1ECFi." })) : (_jsx("div", { className: "space-y-3", children: toEditableChooseAnswerGroupImageUrls(item.imageUrl).map((imageUrl, imageIndex) => (_jsx(ChooseAnswerGroupImageUploadItem, { index: imageIndex, itemIndex: itemIndex, imageUrl: imageUrl, onChange: (url) => {
|
|
171
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
172
|
+
nextUrls[imageIndex] = url;
|
|
173
|
+
updateItem(itemIndex, { imageUrl: nextUrls });
|
|
174
|
+
}, onRemove: () => {
|
|
175
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl)
|
|
176
|
+
.filter((_, idx) => idx !== imageIndex);
|
|
177
|
+
updateItem(itemIndex, { imageUrl: fromChooseAnswerGroupImageUrls(nextUrls) });
|
|
178
|
+
} }, `${itemIndex}-${imageIndex}`))) }))] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { children: "\u0110\u00E1p \u00E1n" }), _jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: item.optionType === 'image', onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
179
|
+
optionType: checked ? 'image' : 'text',
|
|
180
|
+
}) }), _jsx(ImageIcon, { className: `h-3.5 w-3.5 ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}`, children: "\u1EA2nh" })] })] }), item.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("input", { type: "radio", name: `correct-${itemIndex}`, checked: item.correctAnswer === option.id, onChange: () => updateItem(itemIndex, { correctAnswer: option.id }), className: "mt-2" }), _jsx("span", { className: "mt-2 w-6 text-sm font-semibold text-gray-600", children: OPTION_LABELS[optionIndex] || optionIndex + 1 }), item.optionType === 'image' ? (_jsx(ChooseAnswerGroupOptionImageField, { itemIndex: itemIndex, optionIndex: optionIndex, imageUrl: getChooseAnswerGroupOptionImageSrc(option), onChange: (url) => updateOption(itemIndex, optionIndex, { imageUrl: url, text: '' }) })) : (_jsx(Input, { value: option.text, onChange: (event) => updateOption(itemIndex, optionIndex, { text: event.target.value }), placeholder: `Đáp án ${OPTION_LABELS[optionIndex]}` }))] }, option.id)))] }), !item.isExample && (_jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(itemIndex, {
|
|
161
181
|
points: parsePointsValue(event.target.value, true),
|
|
162
182
|
}) }))] })] }, `item-${itemIndex}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Th\u00EAm c\u00E2u h\u1ECFi"] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] }), allErrors.length > 0 && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: allErrors.map((error) => (_jsx("p", { children: error }, error))) }))] }));
|
|
163
183
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ChooseTheCorrectAnswerGroupData, type ChooseTheCorrectAnswerGroupItemData } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
2
2
|
export declare function createEmptyChooseAnswerGroupItem(questionNumber: number): ChooseTheCorrectAnswerGroupItemData;
|
|
3
3
|
export declare function mapChooseAnswerGroupItem(item: unknown, index: number, fallbackAnswer?: string): ChooseTheCorrectAnswerGroupItemData;
|
|
4
4
|
export declare function mapQuestionToChooseAnswerGroupData(question: Record<string, unknown> | null | undefined): ChooseTheCorrectAnswerGroupData;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fromChooseAnswerGroupImageUrls, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
1
2
|
function isRecord(value) {
|
|
2
3
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
4
|
}
|
|
@@ -36,9 +37,11 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
const optionRecord = asRecord(option);
|
|
40
|
+
const imageUrl = asString(optionRecord.imageUrl);
|
|
39
41
|
return {
|
|
40
42
|
id: asString(optionRecord.id, `opt-${optionIndex + 1}`),
|
|
41
43
|
text: asString(optionRecord.text),
|
|
44
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
42
45
|
};
|
|
43
46
|
})
|
|
44
47
|
: [
|
|
@@ -47,6 +50,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
47
50
|
{ id: 'opt-3', text: '' },
|
|
48
51
|
];
|
|
49
52
|
const isExample = record.isExample === true;
|
|
53
|
+
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
|
|
50
54
|
return {
|
|
51
55
|
question: asString(content.question) || asString(record.question),
|
|
52
56
|
optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
|
|
@@ -55,6 +59,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
55
59
|
isExample,
|
|
56
60
|
points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
|
|
57
61
|
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
62
|
+
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
58
63
|
};
|
|
59
64
|
}
|
|
60
65
|
export function mapQuestionToChooseAnswerGroupData(question) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fromChooseAnswerGroupImageUrls, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
1
2
|
const CHILD_QUESTION_TYPE = 'CHOOSE_THE_CORRECT_ANSWER';
|
|
2
3
|
function toOptionId(option, index) {
|
|
3
4
|
return option?.id && option.id.trim() !== '' ? option.id : `opt-${index + 1}`;
|
|
@@ -21,8 +22,10 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
21
22
|
const options = (item.options || []).map((option, optionIndex) => ({
|
|
22
23
|
id: toOptionId(option, optionIndex),
|
|
23
24
|
text: option.text || '',
|
|
25
|
+
...(option.imageUrl ? { imageUrl: option.imageUrl } : {}),
|
|
24
26
|
}));
|
|
25
27
|
const correctAnswer = item.correctAnswer || options[0]?.id || 'opt-1';
|
|
28
|
+
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.imageUrl));
|
|
26
29
|
return {
|
|
27
30
|
questionType: CHILD_QUESTION_TYPE,
|
|
28
31
|
...(item.isExample ? { isExample: true } : {}),
|
|
@@ -30,6 +33,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
30
33
|
question: item.question || '',
|
|
31
34
|
options,
|
|
32
35
|
optionType: item.optionType || 'text',
|
|
36
|
+
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
33
37
|
},
|
|
34
38
|
correctAnswer: { answer: correctAnswer },
|
|
35
39
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { MatchWordToPictureClientProps } from '../../_shared/types/match-word-to-picture.type';
|
|
2
|
+
export declare function MatchWordToPictureClient({ questionData, onSubmit, isReviewMode, userAnswer, autoFillCorrectAnswers, explanation, }: MatchWordToPictureClientProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Card, CardContent } from '../../../../components/ui/card';
|
|
4
|
+
import { Label } from '../../../../components/ui/label';
|
|
5
|
+
import { BookOpen, CheckCircle2, XCircle } from 'lucide-react';
|
|
6
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
7
|
+
import { MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION, } from '../../_shared/types/match-word-to-picture.type';
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
export function MatchWordToPictureClient({ questionData, onSubmit, isReviewMode = false, userAnswer, autoFillCorrectAnswers = false, explanation, }) {
|
|
10
|
+
const [answer, setAnswer] = useState(userAnswer || '');
|
|
11
|
+
const { previewUrl: questionImageUrl } = usePresignedFileUrl(questionData.imageUrl || '');
|
|
12
|
+
const wordBank = questionData.wordBank || [];
|
|
13
|
+
if (!questionData.imageUrl) {
|
|
14
|
+
return (_jsx(Card, { children: _jsx(CardContent, { className: "p-6", children: _jsx("p", { className: "text-gray-500", children: "Kh\u00F4ng c\u00F3 d\u1EEF li\u1EC7u c\u00E2u h\u1ECFi" }) }) }));
|
|
15
|
+
}
|
|
16
|
+
const isExample = questionData.isExample || false;
|
|
17
|
+
const displayedAnswer = autoFillCorrectAnswers || isExample
|
|
18
|
+
? questionData.correctAnswer
|
|
19
|
+
: answer;
|
|
20
|
+
const isCorrect = isReviewMode && userAnswer !== undefined
|
|
21
|
+
? userAnswer.trim().toLowerCase() === questionData.correctAnswer.trim().toLowerCase()
|
|
22
|
+
: undefined;
|
|
23
|
+
return (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg", children: [_jsx("div", { className: "h-1 bg-gradient-to-r from-violet-400 to-indigo-500" }), _jsxs(CardContent, { className: "space-y-6 p-6", children: [_jsx("p", { className: "text-sm font-semibold uppercase tracking-wide text-violet-700", children: questionData.instruction || MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION }), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => (_jsx("span", { className: "rounded-full border border-violet-200 bg-white px-3 py-1 text-sm font-semibold text-slate-700", children: word }, `${word}-${index}`))) })] })), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5", children: [isExample && (_jsxs("div", { className: "mb-2 inline-flex items-center gap-1.5 rounded-full border border-amber-300 bg-amber-100 px-3 py-1", children: [_jsx(BookOpen, { className: "h-3.5 w-3.5 text-amber-600" }), _jsx("span", { className: "text-xs font-semibold uppercase tracking-wide text-amber-700", children: "Example" })] })), _jsxs("div", { className: "flex items-center gap-4", children: [_jsx("div", { className: "relative h-24 w-24 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm", children: _jsx("img", { src: questionImageUrl || questionData.imageUrl, alt: questionData.correctAnswer || 'Picture', className: "h-full w-full object-contain" }) }), _jsxs("div", { className: "flex-1 space-y-2", children: [_jsx(Label, { className: "text-base font-semibold text-gray-800", children: "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p" }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("select", { value: displayedAnswer, onChange: (event) => {
|
|
24
|
+
setAnswer(event.target.value);
|
|
25
|
+
onSubmit?.(event.target.value);
|
|
26
|
+
}, disabled: isReviewMode || isExample, className: `h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium ${isReviewMode
|
|
27
|
+
? isCorrect
|
|
28
|
+
? 'border-green-400 bg-green-50 text-green-700'
|
|
29
|
+
: 'border-red-400 bg-red-50 text-red-700'
|
|
30
|
+
: 'border-violet-300 focus:border-violet-500 focus:ring-violet-200'}`, children: [_jsx("option", { value: "", children: "-- Ch\u1ECDn t\u1EEB --" }), wordBank.map((word, index) => (_jsx("option", { value: word, children: word }, `${word}-${index}`)))] }), isReviewMode && isCorrect !== undefined && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(CheckCircle2, { className: "h-5 w-5 text-green-500" })) : (_jsx(XCircle, { className: "h-5 w-5 text-red-500" })) }))] })] })] }), isReviewMode && isCorrect === false && (_jsxs("div", { className: "mt-3 flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-2", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsxs("span", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: questionData.correctAnswer })] })] }))] }), explanation && isReviewMode && (_jsxs("div", { className: "rounded-xl border border-blue-200 bg-blue-50 p-4", children: [_jsx("p", { className: "mb-1 text-sm font-semibold text-blue-800", children: "Gi\u1EA3i th\u00EDch:" }), _jsx("p", { className: "text-sm text-blue-700", children: explanation })] }))] })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { ImagePreview } from '../../../../components/ui/image-preview';
|
|
4
|
+
import { useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { Button } from '../../../../components/ui/button';
|
|
6
|
+
import { Label } from '../../../../components/ui/label';
|
|
7
|
+
import { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
+
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
|
+
import { BookOpen, Eye, HelpCircle, ImageIcon, Pencil } from 'lucide-react';
|
|
11
|
+
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
12
|
+
import { Checkbox } from '../../../../components/ui/checkbox';
|
|
13
|
+
import { MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION, } from '../../_shared/types/match-word-to-picture.type';
|
|
14
|
+
import { MatchWordToPictureClient } from './MatchWordToPictureClient';
|
|
15
|
+
import { WordBankEditor } from './WordBankEditor';
|
|
16
|
+
import { useDebouncedCallback, usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
17
|
+
import { useExamQuestionStore } from '../../../../shared/lib/stores/examQuestionStore';
|
|
18
|
+
function getGroupNumberForIndex(questionIndex, groups) {
|
|
19
|
+
if (!groups || groups.length === 0) {
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
let currentIndex = 0;
|
|
23
|
+
for (const group of groups) {
|
|
24
|
+
if (questionIndex < currentIndex + group.questionCount) {
|
|
25
|
+
return group.number;
|
|
26
|
+
}
|
|
27
|
+
currentIndex += group.questionCount;
|
|
28
|
+
}
|
|
29
|
+
return groups[groups.length - 1]?.number || 1;
|
|
30
|
+
}
|
|
31
|
+
function normalizeWordBank(wordBank) {
|
|
32
|
+
if (!Array.isArray(wordBank)) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
return wordBank.filter((word) => typeof word === 'string');
|
|
36
|
+
}
|
|
37
|
+
function MatchWordToPictureContent({ initialData, onChange, onUnsavedChangesChange, externalErrors, groups, questionIndexInPart = 0, partId = '', validationRef, }) {
|
|
38
|
+
const setSharedPassageData = useExamQuestionStore((state) => state.setSharedPassageData);
|
|
39
|
+
const getSharedPassageData = useExamQuestionStore((state) => state.getSharedPassageData);
|
|
40
|
+
const initialGroupNum = getGroupNumberForIndex(questionIndexInPart, groups);
|
|
41
|
+
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
|
42
|
+
const [correctAnswer, setCorrectAnswer] = useState(initialData?.correctAnswer || '');
|
|
43
|
+
const [points, setPoints] = useState(initialData?.points || 1);
|
|
44
|
+
const [isExample, setIsExample] = useState(initialData?.isExample || false);
|
|
45
|
+
const [groupNumber, setGroupNumber] = useState(initialData?.groupNumber || initialGroupNum);
|
|
46
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
47
|
+
const [wordBank, setWordBank] = useState(() => {
|
|
48
|
+
if (partId) {
|
|
49
|
+
const storedData = getSharedPassageData(partId, initialGroupNum);
|
|
50
|
+
if (Array.isArray(storedData?.wordBank)) {
|
|
51
|
+
return storedData.wordBank;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return initialData?.wordBank || [];
|
|
55
|
+
});
|
|
56
|
+
const [instruction, setInstruction] = useState(() => {
|
|
57
|
+
if (partId) {
|
|
58
|
+
const storedData = getSharedPassageData(partId, initialGroupNum);
|
|
59
|
+
if (storedData?.instruction) {
|
|
60
|
+
return storedData.instruction;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return initialData?.instruction || MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION;
|
|
64
|
+
});
|
|
65
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
66
|
+
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
67
|
+
const [imagePreviewUrl, setImagePreviewUrl] = useState('');
|
|
68
|
+
const [validationErrors, setValidationErrors] = useState({});
|
|
69
|
+
const savedStateRef = useRef('');
|
|
70
|
+
const previousQuestionIndexRef = useRef(questionIndexInPart);
|
|
71
|
+
const isInitializingRef = useRef(false);
|
|
72
|
+
const hasInitializedRef = useRef(false);
|
|
73
|
+
const onChangeRef = useRef(onChange);
|
|
74
|
+
const previousPayloadRef = useRef('');
|
|
75
|
+
const onUnsavedChangesChangeRef = useRef(onUnsavedChangesChange);
|
|
76
|
+
const currentGroupNumber = groupNumber || 1;
|
|
77
|
+
const { previewUrl: generatedPreviewUrl } = usePresignedFileUrl(imageUrl);
|
|
78
|
+
const displayPreviewUrl = imagePreviewUrl || generatedPreviewUrl || '';
|
|
79
|
+
const usableWords = wordBank.map((word) => word.trim()).filter((word) => word.length > 0);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
onUnsavedChangesChangeRef.current = onUnsavedChangesChange;
|
|
82
|
+
}, [onUnsavedChangesChange]);
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
onChangeRef.current = onChange;
|
|
85
|
+
}, [onChange]);
|
|
86
|
+
const debouncedOnChange = useDebouncedCallback((data) => {
|
|
87
|
+
onChangeRef.current?.(data);
|
|
88
|
+
}, 300);
|
|
89
|
+
const getCurrentStateString = () => JSON.stringify({ imageUrl, correctAnswer, points, isExample, wordBank, instruction });
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (isInitializingRef.current)
|
|
92
|
+
return;
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
savedStateRef.current = getCurrentStateString();
|
|
95
|
+
setHasUnsavedChanges(false);
|
|
96
|
+
}, 150);
|
|
97
|
+
return () => clearTimeout(timer);
|
|
98
|
+
}, [questionIndexInPart]);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (isInitializingRef.current || !savedStateRef.current)
|
|
101
|
+
return;
|
|
102
|
+
const hasChanges = getCurrentStateString() !== savedStateRef.current;
|
|
103
|
+
if (hasChanges !== hasUnsavedChanges) {
|
|
104
|
+
setHasUnsavedChanges(hasChanges);
|
|
105
|
+
onUnsavedChangesChangeRef.current?.(hasChanges);
|
|
106
|
+
}
|
|
107
|
+
}, [imageUrl, correctAnswer, points, isExample, wordBank, instruction, hasUnsavedChanges]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!validationRef)
|
|
110
|
+
return;
|
|
111
|
+
validationRef.current = {
|
|
112
|
+
triggerValidation: async () => {
|
|
113
|
+
const errors = {};
|
|
114
|
+
let isValid = true;
|
|
115
|
+
if (!imageUrl.trim()) {
|
|
116
|
+
errors.imageUrl = 'Vui lòng upload hình ảnh';
|
|
117
|
+
isValid = false;
|
|
118
|
+
}
|
|
119
|
+
if (usableWords.length === 0) {
|
|
120
|
+
errors.wordBank = 'Vui lòng thêm word bank';
|
|
121
|
+
isValid = false;
|
|
122
|
+
}
|
|
123
|
+
if (!correctAnswer.trim()) {
|
|
124
|
+
errors.correctAnswer = 'Vui lòng chọn đáp án';
|
|
125
|
+
isValid = false;
|
|
126
|
+
}
|
|
127
|
+
else if (usableWords.length > 0 &&
|
|
128
|
+
!usableWords.some((word) => word.toLowerCase() === correctAnswer.trim().toLowerCase())) {
|
|
129
|
+
errors.correctAnswer = 'Đáp án phải nằm trong word bank';
|
|
130
|
+
isValid = false;
|
|
131
|
+
}
|
|
132
|
+
setValidationErrors(errors);
|
|
133
|
+
return isValid;
|
|
134
|
+
},
|
|
135
|
+
getFormData: () => ({
|
|
136
|
+
imageUrl,
|
|
137
|
+
correctAnswer,
|
|
138
|
+
points,
|
|
139
|
+
groupNumber: currentGroupNumber,
|
|
140
|
+
isExample,
|
|
141
|
+
wordBank,
|
|
142
|
+
instruction,
|
|
143
|
+
explanation,
|
|
144
|
+
groups,
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}, [
|
|
148
|
+
validationRef,
|
|
149
|
+
imageUrl,
|
|
150
|
+
correctAnswer,
|
|
151
|
+
points,
|
|
152
|
+
currentGroupNumber,
|
|
153
|
+
isExample,
|
|
154
|
+
wordBank,
|
|
155
|
+
instruction,
|
|
156
|
+
explanation,
|
|
157
|
+
groups,
|
|
158
|
+
usableWords,
|
|
159
|
+
]);
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
const questionIndexChanged = questionIndexInPart !== previousQuestionIndexRef.current;
|
|
162
|
+
const shouldSyncForFirstInit = !hasInitializedRef.current && !!initialData?.imageUrl;
|
|
163
|
+
if (!shouldSyncForFirstInit && !questionIndexChanged)
|
|
164
|
+
return;
|
|
165
|
+
isInitializingRef.current = true;
|
|
166
|
+
previousQuestionIndexRef.current = questionIndexInPart;
|
|
167
|
+
hasInitializedRef.current = true;
|
|
168
|
+
const groupNum = getGroupNumberForIndex(questionIndexInPart, groups);
|
|
169
|
+
setImageUrl(initialData?.imageUrl || '');
|
|
170
|
+
setCorrectAnswer(initialData?.correctAnswer || '');
|
|
171
|
+
setPoints(initialData?.points || 1);
|
|
172
|
+
setIsExample(initialData?.isExample || false);
|
|
173
|
+
setGroupNumber(groupNum);
|
|
174
|
+
setExplanation(initialData?.explanation || '');
|
|
175
|
+
let sharedWordBank = [];
|
|
176
|
+
let sharedInstruction = MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION;
|
|
177
|
+
if (partId) {
|
|
178
|
+
const storedData = getSharedPassageData(partId, groupNum);
|
|
179
|
+
if (Array.isArray(storedData?.wordBank)) {
|
|
180
|
+
sharedWordBank = storedData.wordBank;
|
|
181
|
+
sharedInstruction = storedData.instruction || MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION;
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
sharedWordBank = initialData?.wordBank || [];
|
|
185
|
+
sharedInstruction = initialData?.instruction || MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION;
|
|
186
|
+
if (sharedWordBank.length > 0 || sharedInstruction) {
|
|
187
|
+
setSharedPassageData(partId, groupNum, {
|
|
188
|
+
wordBank: sharedWordBank,
|
|
189
|
+
instruction: sharedInstruction,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
sharedWordBank = initialData?.wordBank || [];
|
|
196
|
+
sharedInstruction = initialData?.instruction || MATCH_WORD_TO_PICTURE_DEFAULT_INSTRUCTION;
|
|
197
|
+
}
|
|
198
|
+
setWordBank(normalizeWordBank(sharedWordBank));
|
|
199
|
+
setInstruction(sharedInstruction);
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
isInitializingRef.current = false;
|
|
202
|
+
}, 100);
|
|
203
|
+
}, [initialData, groups, questionIndexInPart, partId, getSharedPassageData, setSharedPassageData]);
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
if (isInitializingRef.current)
|
|
206
|
+
return;
|
|
207
|
+
const payload = {
|
|
208
|
+
imageUrl,
|
|
209
|
+
correctAnswer,
|
|
210
|
+
points: points || 1,
|
|
211
|
+
groupNumber: currentGroupNumber,
|
|
212
|
+
isExample,
|
|
213
|
+
wordBank,
|
|
214
|
+
instruction,
|
|
215
|
+
explanation,
|
|
216
|
+
groups,
|
|
217
|
+
};
|
|
218
|
+
const payloadString = JSON.stringify(payload);
|
|
219
|
+
if (previousPayloadRef.current === payloadString)
|
|
220
|
+
return;
|
|
221
|
+
previousPayloadRef.current = payloadString;
|
|
222
|
+
if (partId) {
|
|
223
|
+
setSharedPassageData(partId, currentGroupNumber, { wordBank, instruction });
|
|
224
|
+
}
|
|
225
|
+
debouncedOnChange(payload);
|
|
226
|
+
}, [
|
|
227
|
+
imageUrl,
|
|
228
|
+
correctAnswer,
|
|
229
|
+
points,
|
|
230
|
+
currentGroupNumber,
|
|
231
|
+
isExample,
|
|
232
|
+
wordBank,
|
|
233
|
+
instruction,
|
|
234
|
+
explanation,
|
|
235
|
+
groups,
|
|
236
|
+
partId,
|
|
237
|
+
setSharedPassageData,
|
|
238
|
+
debouncedOnChange,
|
|
239
|
+
]);
|
|
240
|
+
if (isClientMode) {
|
|
241
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { children: _jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx(CardTitle, { children: "Match word to picture (Preview)" }), _jsx("p", { className: "mt-1 text-sm text-gray-500", children: "Ch\u1EBF \u0111\u1ED9 xem tr\u01B0\u1EDBc - Hi\u1EC3n th\u1ECB \u0111\u00E1p \u00E1n \u0111\u00FAng" })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Quay l\u1EA1i Edit Mode"] })] }) }) }), _jsx(MatchWordToPictureClient, { questionData: {
|
|
242
|
+
imageUrl: displayPreviewUrl || imageUrl,
|
|
243
|
+
correctAnswer,
|
|
244
|
+
points,
|
|
245
|
+
groupNumber: currentGroupNumber,
|
|
246
|
+
isExample,
|
|
247
|
+
wordBank: usableWords,
|
|
248
|
+
instruction,
|
|
249
|
+
}, isReviewMode: true, autoFillCorrectAnswers: true, explanation: explanation })] }));
|
|
250
|
+
}
|
|
251
|
+
return (_jsxs("div", { className: "space-y-4", children: [groups && groups.length > 0 && (_jsx(WordBankEditor, { wordBank: wordBank, instruction: instruction, onWordBankChange: setWordBank, onInstructionChange: setInstruction })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-violet-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "N\u1ED1i t\u1EEB v\u1EDBi tranh" }), _jsx("p", { className: "text-xs text-gray-500", children: "Upload tranh v\u00E0 ch\u1ECDn t\u1EEB trong word bank" })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: !imageUrl, className: "gap-2 border-violet-200 text-violet-600 hover:bg-violet-50", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] }) }), _jsxs(CardContent, { className: "space-y-4 px-4 pb-4", children: [_jsxs("div", { className: "flex items-center gap-6", children: [_jsx(PointsInput, { value: points || 1, onChange: (e) => setPoints(parseFloat(e.target.value) || 1) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "mwtp-isExample", checked: isExample || false, onCheckedChange: (checked) => setIsExample(!!checked) }), _jsx(Label, { htmlFor: "mwtp-isExample", className: "cursor-pointer text-sm font-medium text-gray-700", children: "\u0110\u00E1nh d\u1EA5u l\u00E0 c\u00E2u h\u1ECFi Example" })] })] }), (!groups || groups.length === 0) && (_jsx(WordBankEditor, { wordBank: wordBank, instruction: instruction, onWordBankChange: setWordBank, onInstructionChange: setInstruction })), _jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-violet-500" }), "H\u00ECnh \u1EA3nh ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(FileUpload, { id: "mwtp-question-image", label: "", accept: "image/*", value: imageUrl || '', onChange: (url) => {
|
|
252
|
+
setImageUrl(url);
|
|
253
|
+
if (validationErrors.imageUrl) {
|
|
254
|
+
setValidationErrors((prev) => ({ ...prev, imageUrl: undefined }));
|
|
255
|
+
}
|
|
256
|
+
}, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" })), validationErrors.imageUrl && _jsx("p", { className: "text-sm text-red-600", children: validationErrors.imageUrl })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "mwtp-correctAnswer", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(HelpCircle, { className: "h-4 w-4 text-green-500" }), "\u0110\u00E1p \u00E1n \u0111\u00FAng ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsxs("select", { id: "mwtp-correctAnswer", value: correctAnswer, onChange: (event) => {
|
|
257
|
+
setCorrectAnswer(event.target.value);
|
|
258
|
+
if (validationErrors.correctAnswer) {
|
|
259
|
+
setValidationErrors((prev) => ({ ...prev, correctAnswer: undefined }));
|
|
260
|
+
}
|
|
261
|
+
}, className: `flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm ${validationErrors.correctAnswer
|
|
262
|
+
? 'border-red-400 focus:border-red-500'
|
|
263
|
+
: 'border-gray-200 focus:border-green-300'}`, children: [_jsx("option", { value: "", children: "-- Ch\u1ECDn t\u1EEB trong word bank --" }), usableWords.map((word, index) => (_jsx("option", { value: word, children: word }, `${word}-${index}`)))] }), validationErrors.correctAnswer && (_jsx("p", { className: "text-sm text-red-600", children: validationErrors.correctAnswer })), validationErrors.wordBank && _jsx("p", { className: "text-sm text-red-600", children: validationErrors.wordBank })] }), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc", children: externalErrors.map((error, index) => (_jsx("li", { children: error }, index))) })] }))] })] }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", 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-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { id: "mwtp-explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] resize-none border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200" }) })] })] }));
|
|
264
|
+
}
|
|
265
|
+
export function MatchWordToPictureCreator(props) {
|
|
266
|
+
return _jsx(MatchWordToPictureContent, { ...props });
|
|
267
|
+
}
|
package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureGroupClient.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { MatchWordToPictureGroupClientProps } from '../../_shared/types/match-word-to-picture.type';
|
|
2
|
+
export declare function MatchWordToPictureGroupClient({ questionData, isReviewMode, userAnswers, onAnswerChange, }: MatchWordToPictureGroupClientProps): import("react").JSX.Element;
|