@tinyweb_dev/oe-exam-sdk 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/take/components/QuestionRenderer.js +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
- package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
- package/dist/components/exams/take/utils/question-transformers.js +10 -0
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
- package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
- package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
- package/dist/shared/lib/utils/question-reverse-transform.js +4 -0
- package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
- package/package.json +1 -1
|
@@ -55,7 +55,7 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
|
|
|
55
55
|
}
|
|
56
56
|
case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
|
|
57
57
|
const data = transformChooseTheCorrectAnswerGroup(questions);
|
|
58
|
-
return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
58
|
+
return (_jsx(MoversChooseBestAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl || part.audioUrl, example: data.example, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
59
59
|
}
|
|
60
60
|
case 'CHOOSE_CORRECT_ADJECTIVE': {
|
|
61
61
|
const data = transformChooseCorrectAdjective(questions);
|
package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js
CHANGED
|
@@ -42,7 +42,7 @@ function normalizeRenderableText(value) {
|
|
|
42
42
|
}
|
|
43
43
|
// Example Section Component
|
|
44
44
|
function ExampleSection({ example }) {
|
|
45
|
-
return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
|
|
45
|
+
return (_jsxs("div", { className: "rounded-lg border-2 border-orange-300 bg-white p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-gray-700", children: "Example:" }), _jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [example.questionNumber, ". ", normalizeRenderableText(example.questionText)] }), example.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: example.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: example.imageUrl, className: "mb-3" }), example.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${example.options.length}, minmax(0, 1fr))` }, children: example.options.map((option) => {
|
|
46
46
|
const isCorrect = option.id === example.correctOptionId;
|
|
47
47
|
return (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-40 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", isCorrect
|
|
48
48
|
? "bg-green-500 border-green-500 text-white"
|
|
@@ -99,7 +99,7 @@ function QuestionItem({ question, selectedOptionIds, onSelect, isReviewMode, id,
|
|
|
99
99
|
}
|
|
100
100
|
return selectedOptionIds.includes(optionId) || correctOptionIds.includes(optionId);
|
|
101
101
|
};
|
|
102
|
-
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
|
|
102
|
+
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("p", { className: "mb-3 text-[19px] font-bold text-[#001590]", children: [question.questionNumber, ". ", normalizeRenderableText(question.questionText)] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true, dense: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), question.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `relative flex flex-col items-center gap-1 rounded-[16px] border-[3px] p-4 pt-6 mt-3 transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsx("div", { className: "absolute -top-[14px] left-1/2 -translate-x-1/2 bg-blue-600 text-white w-[28px] h-[28px] rounded-full flex items-center justify-center font-bold text-[15px] shadow-sm", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "w-35 rounded object-contain mb-2" }), _jsx("div", { className: cn("w-7 h-7 rounded-full flex items-center justify-center border-[3px] transition-all mt-auto", showIndicatorMark(option.id)
|
|
103
103
|
? "bg-blue-600 border-blue-600 text-white"
|
|
104
104
|
: "border-gray-300 bg-white text-transparent"), children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "3.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "20 6 9 17 4 12" }) }) })] }, option.id))) })) : (_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("button", { type: "button", onClick: () => !isReviewMode && onSelect(option.id), disabled: isReviewMode, className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${getOptionStyle(option.id)} ${isReviewMode ? 'cursor-default' : 'cursor-pointer'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: normalizeRenderableText(option.text) }), _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 ${isMultipleAnswers ? 'rounded-md' : 'rounded-full'} ${getIndicatorStyle(option.id)}`, children: showIndicatorMark(option.id) && (_jsx("div", { className: `${isMultipleAnswers ? 'h-2.5 w-2.5 rounded-sm' : 'h-2 w-2 rounded-full'} bg-white` })) })] }, option.id))) }))] }));
|
|
105
105
|
}
|
|
@@ -161,6 +161,7 @@ export declare function transformChooseTheCorrectAnswerGroup(questions: ApiQuest
|
|
|
161
161
|
questions: ChooseBestAnswerQuestion[];
|
|
162
162
|
example?: import('../types').ChooseBestAnswerExample;
|
|
163
163
|
instruction: string;
|
|
164
|
+
audioUrl?: string;
|
|
164
165
|
};
|
|
165
166
|
export interface ChooseAdjectiveOption {
|
|
166
167
|
id: string;
|
|
@@ -411,12 +411,18 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
411
411
|
}
|
|
412
412
|
return urls.length === 1 ? urls[0] : urls;
|
|
413
413
|
};
|
|
414
|
+
const toAudioUrl = (value) => {
|
|
415
|
+
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
416
|
+
return trimmed !== '' ? trimmed : undefined;
|
|
417
|
+
};
|
|
418
|
+
const groupAudioUrl = toAudioUrl(content?.meta?.audioUrl);
|
|
414
419
|
let example;
|
|
415
420
|
if (exampleItem) {
|
|
416
421
|
const exampleIndex = items.indexOf(exampleItem);
|
|
417
422
|
const exOptions = mapOptions(exampleItem.content?.options, `${parent.id}-ex`);
|
|
418
423
|
const exCorrect = exampleItem.correctAnswer?.answer || parentCorrect[exampleIndex] || exOptions[0]?.id || '';
|
|
419
424
|
const exampleImageUrl = toQuestionImageUrl(exampleItem.content?.imageUrl);
|
|
425
|
+
const exampleAudioUrl = !groupAudioUrl ? toAudioUrl(exampleItem.content?.audioUrl) : undefined;
|
|
420
426
|
example = {
|
|
421
427
|
questionNumber: exampleItem.questionNumber || exampleIndex + 1,
|
|
422
428
|
questionText: exampleItem.content?.question || '',
|
|
@@ -424,6 +430,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
424
430
|
correctOptionId: exCorrect,
|
|
425
431
|
optionType: (exampleItem.content?.optionType || 'text'),
|
|
426
432
|
...(exampleImageUrl !== undefined ? { imageUrl: exampleImageUrl } : {}),
|
|
433
|
+
...(exampleAudioUrl ? { audioUrl: exampleAudioUrl } : {}),
|
|
427
434
|
};
|
|
428
435
|
}
|
|
429
436
|
const transformed = regularItems.map((item) => {
|
|
@@ -431,6 +438,7 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
431
438
|
const options = mapOptions(item.content?.options, `${parent.id}-item-${itemIndex}`);
|
|
432
439
|
const correctId = item.correctAnswer?.answer || parentCorrect[itemIndex] || '';
|
|
433
440
|
const itemImageUrl = toQuestionImageUrl(item.content?.imageUrl);
|
|
441
|
+
const itemAudioUrl = !groupAudioUrl ? toAudioUrl(item.content?.audioUrl) : undefined;
|
|
434
442
|
return {
|
|
435
443
|
id: `${parent.id}-item-${itemIndex}`,
|
|
436
444
|
questionNumber: item.questionNumber || itemIndex + 1,
|
|
@@ -440,12 +448,14 @@ export function transformChooseTheCorrectAnswerGroup(questions) {
|
|
|
440
448
|
correctOptionIds: correctId ? [correctId] : [],
|
|
441
449
|
optionType: (item.content?.optionType || 'text'),
|
|
442
450
|
...(itemImageUrl !== undefined ? { imageUrl: itemImageUrl } : {}),
|
|
451
|
+
...(itemAudioUrl ? { audioUrl: itemAudioUrl } : {}),
|
|
443
452
|
};
|
|
444
453
|
});
|
|
445
454
|
return {
|
|
446
455
|
questions: transformed,
|
|
447
456
|
example,
|
|
448
457
|
instruction: content?.meta?.instruction || 'Choose the correct answer.',
|
|
458
|
+
...(groupAudioUrl ? { audioUrl: groupAudioUrl } : {}),
|
|
449
459
|
};
|
|
450
460
|
}
|
|
451
461
|
export function transformChooseCorrectAdjective(questions) {
|
|
@@ -18,10 +18,12 @@ export interface ChooseTheCorrectAnswerGroupItemData {
|
|
|
18
18
|
points: number;
|
|
19
19
|
questionNumber: number;
|
|
20
20
|
imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
21
|
+
audioUrl?: string;
|
|
21
22
|
}
|
|
22
23
|
export interface ChooseTheCorrectAnswerGroupData {
|
|
23
24
|
instruction: string;
|
|
24
25
|
passage?: string;
|
|
26
|
+
audioUrl?: string;
|
|
25
27
|
items: ChooseTheCorrectAnswerGroupItemData[];
|
|
26
28
|
explanation?: string;
|
|
27
29
|
points?: number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { BookOpen, Check, CircleHelp, Lightbulb, Star } from 'lucide-react';
|
|
3
|
+
import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../../../shared/lib/utils';
|
|
5
5
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
6
|
import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
@@ -13,6 +13,15 @@ function ClientImageItem({ url, alt }) {
|
|
|
13
13
|
}
|
|
14
14
|
return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
|
|
15
15
|
}
|
|
16
|
+
function ClientAudio({ url }) {
|
|
17
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
18
|
+
const src = previewUrl ||
|
|
19
|
+
(typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
|
|
20
|
+
if (!src && !isLoading) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return (_jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-indigo-600" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-indigo-100" })) : (_jsx("audio", { controls: true, className: "h-9 w-full", src: src, preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
|
|
24
|
+
}
|
|
16
25
|
const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
|
|
17
26
|
export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
18
27
|
const items = questionData.items || [];
|
|
@@ -25,10 +34,11 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
|
|
|
25
34
|
next[itemIndex] = optionId;
|
|
26
35
|
onAnswerChange(next);
|
|
27
36
|
};
|
|
28
|
-
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
37
|
+
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && _jsx(ClientAudio, { url: questionData.audioUrl }), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none text-gray-800 leading-relaxed", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
29
38
|
const selectedId = userAnswers[itemIndex];
|
|
30
39
|
const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
31
|
-
|
|
40
|
+
const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
|
|
41
|
+
return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-4", children: [_jsx("p", { className: "text-sm font-semibold leading-relaxed text-gray-800", children: item.question || 'Câu hỏi chưa được nhập' }), showItemAudio && item.audioUrl && _jsx(ClientAudio, { url: item.audioUrl }), toChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), item.optionType === 'image' ? (_jsx("div", { className: "grid gap-3 sm:grid-cols-2", children: item.options.map((option, optionIndex) => {
|
|
32
42
|
const isCorrect = option.id === item.correctAnswer;
|
|
33
43
|
const isSelected = option.id === selectedId;
|
|
34
44
|
const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
|
|
@@ -8,6 +8,7 @@ import { Textarea } from '../../../../components/ui/textarea';
|
|
|
8
8
|
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
9
|
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
10
|
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
11
12
|
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
12
13
|
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
13
14
|
import { Eye, ImageIcon, Pencil, Plus, Star, Trash2, Type } from 'lucide-react';
|
|
@@ -51,6 +52,7 @@ function normalizeItems(rawItems) {
|
|
|
51
52
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
52
53
|
questionNumber: item.questionNumber || index + 1,
|
|
53
54
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
55
|
+
...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
|
|
54
56
|
};
|
|
55
57
|
});
|
|
56
58
|
}
|
|
@@ -67,6 +69,7 @@ function parsePointsValue(raw, allowZero) {
|
|
|
67
69
|
export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
68
70
|
const [instruction, setInstruction] = useState(initialData?.instruction || 'Choose the correct answer.');
|
|
69
71
|
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
72
|
+
const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
|
|
70
73
|
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
71
74
|
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
72
75
|
const [isClientMode, setIsClientMode] = useState(false);
|
|
@@ -77,9 +80,10 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
77
80
|
onChangeRef.current = onChange;
|
|
78
81
|
}, [onChange]);
|
|
79
82
|
const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
|
|
80
|
-
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
|
|
83
|
+
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl) => ({
|
|
81
84
|
instruction: nextInstruction,
|
|
82
85
|
...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
|
|
86
|
+
...(nextAudioUrl.trim() ? { audioUrl: nextAudioUrl.trim() } : {}),
|
|
83
87
|
items: nextItems,
|
|
84
88
|
explanation: nextExplanation,
|
|
85
89
|
points: sumPoints(nextItems),
|
|
@@ -96,7 +100,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
96
100
|
previousPayloadRef.current = serialized;
|
|
97
101
|
onUnsavedChangesChange?.(true);
|
|
98
102
|
debouncedOnChange(payload);
|
|
99
|
-
}, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
103
|
+
}, [instruction, passage, audioUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
100
104
|
const validate = () => {
|
|
101
105
|
const nextErrors = [];
|
|
102
106
|
if (!instruction.trim()) {
|
|
@@ -161,7 +165,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
161
165
|
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Ch\u1EC9nh s\u1EEDa"] }) }), _jsx(ChooseTheCorrectAnswerGroupClient, { questionData: buildPayload(), isReviewMode: true })] }));
|
|
162
166
|
}
|
|
163
167
|
const allErrors = [...errors, ...(externalErrors || [])];
|
|
164
|
-
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
168
|
+
return (_jsxs("div", { className: "space-y-4", children: [_jsx("div", { className: "flex justify-end", children: _jsxs(Button, { type: "button", variant: "outline", onClick: () => setIsClientMode(true), children: [_jsx(Eye, { className: "mr-2 h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] }) }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Nh\u00F3m ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer." })] }), _jsx("div", { className: "space-y-2", children: _jsx(FileUpload, { id: "choose-answer-group-audio", label: "Audio chung (tu\u1EF3 ch\u1ECDn)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage - Tu\u1EF3 ch\u1ECDn)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "rounded-xl border border-gray-200 bg-gray-50 p-4", children: [_jsx(PointsInput, { value: sumPoints(items), allowZero: true, readOnly: true }), _jsx("p", { className: "mt-2 text-xs text-gray-500", children: "T\u1ED5ng \u0111i\u1EC3m \u0111\u01B0\u1EE3c c\u1ED9ng t\u1EEB c\u00E1c c\u00E2u kh\u00F4ng ph\u1EA3i example." })] })] })] }), items.map((item, itemIndex) => (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between space-y-0", children: [_jsxs(CardTitle, { className: "text-base", children: ["C\u00E2u ", itemIndex + 1] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, children: _jsx(Trash2, { className: "h-4 w-4" }) })] }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium text-amber-800", children: [_jsx(Star, { className: "h-4 w-4" }), "C\u00E2u v\u00ED d\u1EE5"] }), _jsx(Switch, { checked: item.isExample === true, onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
165
169
|
isExample: checked,
|
|
166
170
|
points: checked ? 0 : (item.points || 1),
|
|
167
171
|
}) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "C\u00E2u h\u1ECFi" }), _jsx(Textarea, { value: item.question, onChange: (event) => updateItem(itemIndex, { question: event.target.value }), rows: 2, placeholder: "A place for someone to stay or live is called" })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { className: "flex items-center gap-2", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-teal-500" }), "H\u00ECnh \u1EA3nh (tu\u1EF3 ch\u1ECDn)"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
@@ -8,6 +8,13 @@ function asRecord(value) {
|
|
|
8
8
|
function asString(value, fallback = '') {
|
|
9
9
|
return typeof value === 'string' ? value : fallback;
|
|
10
10
|
}
|
|
11
|
+
function asOptionalUrl(value) {
|
|
12
|
+
if (typeof value !== 'string') {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return trimmed !== '' ? trimmed : undefined;
|
|
17
|
+
}
|
|
11
18
|
export function createEmptyChooseAnswerGroupItem(questionNumber) {
|
|
12
19
|
return {
|
|
13
20
|
question: '',
|
|
@@ -51,6 +58,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
51
58
|
];
|
|
52
59
|
const isExample = record.isExample === true;
|
|
53
60
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
|
|
61
|
+
const audioUrl = asOptionalUrl(content.audioUrl);
|
|
54
62
|
return {
|
|
55
63
|
question: asString(content.question) || asString(record.question),
|
|
56
64
|
optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
|
|
@@ -60,6 +68,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
60
68
|
points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
|
|
61
69
|
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
62
70
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
71
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
63
72
|
};
|
|
64
73
|
}
|
|
65
74
|
export function mapQuestionToChooseAnswerGroupData(question) {
|
|
@@ -73,18 +82,22 @@ export function mapQuestionToChooseAnswerGroupData(question) {
|
|
|
73
82
|
: [];
|
|
74
83
|
if (Array.isArray(content.items)) {
|
|
75
84
|
const items = content.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index]));
|
|
85
|
+
const audioUrl = asOptionalUrl(asRecord(content.meta).audioUrl);
|
|
76
86
|
return {
|
|
77
87
|
instruction: asString(asRecord(content.meta).instruction) || 'Choose the correct answer.',
|
|
78
88
|
passage: asString(asRecord(content.meta).passage),
|
|
89
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
79
90
|
items,
|
|
80
91
|
explanation,
|
|
81
92
|
points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
82
93
|
};
|
|
83
94
|
}
|
|
84
95
|
if (Array.isArray(answer.items)) {
|
|
96
|
+
const audioUrl = asOptionalUrl(answer.audioUrl);
|
|
85
97
|
return {
|
|
86
98
|
instruction: asString(answer.instruction, 'Choose the correct answer.'),
|
|
87
99
|
passage: asString(answer.passage),
|
|
100
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
88
101
|
items: answer.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index])),
|
|
89
102
|
explanation,
|
|
90
103
|
points: typeof answer.points === 'number' ? answer.points : undefined,
|
|
@@ -26,6 +26,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
26
26
|
}));
|
|
27
27
|
const correctAnswer = item.correctAnswer || options[0]?.id || 'opt-1';
|
|
28
28
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.imageUrl));
|
|
29
|
+
const audioUrl = typeof item.audioUrl === 'string' ? item.audioUrl.trim() : '';
|
|
29
30
|
return {
|
|
30
31
|
questionType: CHILD_QUESTION_TYPE,
|
|
31
32
|
...(item.isExample ? { isExample: true } : {}),
|
|
@@ -34,6 +35,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
34
35
|
options,
|
|
35
36
|
optionType: item.optionType || 'text',
|
|
36
37
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
38
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
37
39
|
},
|
|
38
40
|
correctAnswer: { answer: correctAnswer },
|
|
39
41
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
@@ -46,6 +48,9 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
46
48
|
if (answerData?.passage && answerData.passage.trim()) {
|
|
47
49
|
meta.passage = answerData.passage.trim();
|
|
48
50
|
}
|
|
51
|
+
if (answerData?.audioUrl && answerData.audioUrl.trim()) {
|
|
52
|
+
meta.audioUrl = answerData.audioUrl.trim();
|
|
53
|
+
}
|
|
49
54
|
return {
|
|
50
55
|
apiContent: {
|
|
51
56
|
meta,
|
|
@@ -43,7 +43,7 @@ function ReviewQuestionItem({ question, selectedOptionIds, isCorrect, correctOpt
|
|
|
43
43
|
}
|
|
44
44
|
return _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 border-gray-300 bg-white ${shapeClass}` });
|
|
45
45
|
};
|
|
46
|
-
return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
|
|
46
|
+
return (_jsxs("div", { className: "mb-4", children: [_jsxs("p", { className: "mb-2 text-lg font-bold text-gray-900", children: [question.questionNumber, ". ", question.questionText] }), isMultipleAnswers && (_jsx("p", { className: "mb-3 pl-2 text-sm font-medium text-blue-600", children: "Ch\u1ECDn nhi\u1EC1u \u0111\u00E1p \u00E1n" })), question.audioUrl && (_jsx("div", { className: "mb-3", children: _jsx(CambridgeYleAudioPlayer, { audioSrc: question.audioUrl, compact: true }) })), _jsx(ChooseBestAnswerImages, { imageUrl: question.imageUrl, className: "mb-3" }), isImageOptions ? (_jsx("div", { className: "grid gap-3 px-2", style: { gridTemplateColumns: `repeat(${question.options.length}, minmax(0, 1fr))` }, children: question.options.map((option) => (_jsxs("div", { className: `relative flex flex-col items-center gap-1 rounded-lg border-2 p-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsx("span", { className: "text-xs font-medium text-gray-600", children: option.label }), _jsx(ResolvedImage, { src: getChooseBestAnswerOptionImageSrc(option), alt: `Option ${option.label}`, className: "h-28 w-28 rounded object-contain" }), renderIndicator(option.id)] }, option.id))) })) : (
|
|
47
47
|
/* Options — text variant */
|
|
48
48
|
_jsx("div", { className: "flex flex-col gap-2 pl-2", children: question.options.map((option) => (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 cursor-default ${getOptionStyle(option.id)}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx("span", { className: "flex-1 text-sm text-gray-700", children: option.text }), renderIndicator(option.id)] }, option.id))) }))] }));
|
|
49
49
|
}
|
|
@@ -40,7 +40,7 @@ export function renderDedicatedReviewBody(ctx) {
|
|
|
40
40
|
}
|
|
41
41
|
case 'CHOOSE_THE_CORRECT_ANSWER_GROUP': {
|
|
42
42
|
const data = transformChooseTheCorrectAnswerGroup(apiQuestions);
|
|
43
|
-
return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
|
|
43
|
+
return (_jsx(ReviewChooseBestAnswerRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, audioUrl: data.audioUrl, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, correctOptionIdMap: correctOptionIdMap, example: data.example }));
|
|
44
44
|
}
|
|
45
45
|
case 'CHOOSE_CORRECT_ADJECTIVE': {
|
|
46
46
|
const data = transformChooseCorrectAdjective(apiQuestions);
|
|
@@ -242,6 +242,7 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
242
242
|
});
|
|
243
243
|
const isExample = item.isExample === true;
|
|
244
244
|
const imageUrl = normalizeChooseAnswerGroupImageUrl(itemContent.imageUrl);
|
|
245
|
+
const audioUrl = asString(itemContent.audioUrl).trim();
|
|
245
246
|
return {
|
|
246
247
|
question: asString(itemContent.question),
|
|
247
248
|
optionType: asString(itemContent.optionType, 'text') === 'image' ? 'image' : 'text',
|
|
@@ -255,13 +256,16 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
255
256
|
points: isExample ? 0 : asNumber(item.points, 1),
|
|
256
257
|
questionNumber: asNumber(item.questionNumber, index + 1),
|
|
257
258
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
259
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
258
260
|
};
|
|
259
261
|
});
|
|
262
|
+
const audioUrl = asString(asRecord(content.meta).audioUrl).trim();
|
|
260
263
|
return {
|
|
261
264
|
content: '',
|
|
262
265
|
answer: {
|
|
263
266
|
instruction: asString(asRecord(content.meta).instruction, 'Choose the correct answer.'),
|
|
264
267
|
passage: asString(asRecord(content.meta).passage),
|
|
268
|
+
...(audioUrl ? { audioUrl } : {}),
|
|
265
269
|
items,
|
|
266
270
|
explanation: asString(apiQuestion.explanation),
|
|
267
271
|
points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
|
|
@@ -8,6 +8,7 @@ export interface ChooseTheCorrectAnswerGroupItemContent {
|
|
|
8
8
|
options: ChooseTheCorrectAnswerGroupOption[];
|
|
9
9
|
optionType?: 'text' | 'image';
|
|
10
10
|
imageUrl?: string | string[];
|
|
11
|
+
audioUrl?: string;
|
|
11
12
|
}
|
|
12
13
|
export interface ChooseTheCorrectAnswerGroupItem {
|
|
13
14
|
questionType?: 'CHOOSE_THE_CORRECT_ANSWER';
|
|
@@ -23,6 +24,7 @@ export interface ChooseTheCorrectAnswerGroupContent {
|
|
|
23
24
|
meta?: {
|
|
24
25
|
instruction?: string;
|
|
25
26
|
passage?: string;
|
|
27
|
+
audioUrl?: string;
|
|
26
28
|
};
|
|
27
29
|
items: ChooseTheCorrectAnswerGroupItem[];
|
|
28
30
|
}
|