@tinyweb_dev/oe-exam-sdk 0.2.7 → 0.2.9
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 +5 -4
- 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/index.d.ts +1 -0
- package/dist/components/index.js +1 -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/ChooseTheCorrectAnswerClient.js +6 -4
- package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +16 -4
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +12 -3
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +8 -4
- 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 +3 -2
- package/dist/components/results/renderers/review-question-body-dedicated.js +1 -1
- package/dist/components/shared/RichTextEditor.d.ts +4 -2
- package/dist/components/shared/RichTextEditor.js +34 -11
- package/dist/components/shared/RichTextHtml.d.ts +8 -0
- package/dist/components/shared/RichTextHtml.js +9 -0
- package/dist/shared/lib/rich-text.d.ts +3 -0
- package/dist/shared/lib/rich-text.js +24 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +4 -0
- package/dist/shared/lib/validation/choose-correct-answer.validation.js +4 -2
- 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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { cn } from '../../../../../shared/lib/utils';
|
|
4
4
|
import { ResolvedImage } from '../../../../../components/common/ResolvedImage';
|
|
5
|
+
import { RichTextHtml } from '../../../../../components/shared/RichTextHtml';
|
|
5
6
|
import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
|
|
6
7
|
import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
8
|
import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
@@ -42,14 +43,14 @@ function normalizeRenderableText(value) {
|
|
|
42
43
|
}
|
|
43
44
|
// Example Section Component
|
|
44
45
|
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("
|
|
46
|
+
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("div", { className: "mb-3 flex items-start gap-1.5 text-[19px] font-bold text-[#001590]", children: [_jsxs("span", { children: [example.questionNumber, "."] }), _jsx(RichTextHtml, { html: normalizeRenderableText(example.questionText), className: "flex-1 font-bold text-[#001590]" })] }), 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
47
|
const isCorrect = option.id === example.correctOptionId;
|
|
47
48
|
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
49
|
? "bg-green-500 border-green-500 text-white"
|
|
49
50
|
: "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));
|
|
50
51
|
}) })) : (_jsx("div", { className: "flex flex-col gap-2 pl-2", children: example.options.map((option) => {
|
|
51
52
|
const isCorrect = option.id === example.correctOptionId;
|
|
52
|
-
return (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx(
|
|
53
|
+
return (_jsxs("div", { className: `flex items-center gap-3 rounded-lg border-2 px-3 py-2 text-left transition-colors ${isCorrect ? 'border-green-500 bg-green-50' : 'border-blue-200 bg-white'}`, children: [_jsxs("span", { className: "text-sm font-medium text-gray-600", children: [option.label, "."] }), _jsx(RichTextHtml, { html: normalizeRenderableText(option.text), className: "flex-1 text-sm text-gray-700" }), _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 rounded-full ${isCorrect ? 'border-green-500 bg-green-500' : 'border-gray-300 bg-white'}`, children: isCorrect && (_jsx("div", { className: "h-2 w-2 rounded-full bg-white" })) })] }, option.id));
|
|
53
54
|
}) }))] }));
|
|
54
55
|
}
|
|
55
56
|
// Single Question Component
|
|
@@ -99,9 +100,9 @@ function QuestionItem({ question, selectedOptionIds, onSelect, isReviewMode, id,
|
|
|
99
100
|
}
|
|
100
101
|
return selectedOptionIds.includes(optionId) || correctOptionIds.includes(optionId);
|
|
101
102
|
};
|
|
102
|
-
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("
|
|
103
|
+
return (_jsxs("div", { id: id, className: "mb-6", children: [_jsxs("div", { className: "mb-3 flex items-start gap-1.5 text-[19px] font-bold text-[#001590]", children: [_jsxs("span", { children: [question.questionNumber, "."] }), _jsx(RichTextHtml, { html: normalizeRenderableText(question.questionText), className: "flex-1 font-bold text-[#001590]" })] }), 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
104
|
? "bg-blue-600 border-blue-600 text-white"
|
|
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(
|
|
105
|
+
: "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(RichTextHtml, { html: normalizeRenderableText(option.text), className: "flex-1 text-sm text-gray-700" }), _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
106
|
}
|
|
106
107
|
// Article Card for ARTICLES groupType (e.g., GN QE RWL Part 2)
|
|
107
108
|
function ArticleCard({ article, label, }) {
|
|
@@ -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 = toAudioUrl(exampleItem.content?.audioUrl);
|
|
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 = toAudioUrl(item.content?.audioUrl);
|
|
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) {
|
package/dist/components/index.js
CHANGED
|
@@ -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;
|
package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerClient.js
CHANGED
|
@@ -4,6 +4,8 @@ import { useState, useEffect, useMemo } from 'react';
|
|
|
4
4
|
import { Check, X, CircleHelp, Lightbulb, ClipboardList, Users, BookOpen } from 'lucide-react';
|
|
5
5
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
6
|
import { cn } from '../../../../shared/lib/utils';
|
|
7
|
+
import { RichTextHtml } from '../../../../components/shared/RichTextHtml';
|
|
8
|
+
import { isRichTextEmpty, richTextToPlainText } from '../../../../shared/lib/rich-text';
|
|
7
9
|
import { normalizeChooseAnswerCorrectAnswer, normalizeChooseAnswerOptions, optionToString } from './normalize';
|
|
8
10
|
// Helper component to resolve presigned URL for each image option
|
|
9
11
|
function OptionImage({ src, alt, className }) {
|
|
@@ -82,7 +84,7 @@ export function ChooseTheCorrectAnswerClient({ questionData, onSubmit, isReviewM
|
|
|
82
84
|
const optText = optionToString(opt);
|
|
83
85
|
return questionData.optionType === 'image'
|
|
84
86
|
? `Đáp án ${answerLetter}`
|
|
85
|
-
: `${answerLetter}. ${optText}`;
|
|
87
|
+
: `${answerLetter}. ${richTextToPlainText(optText)}`;
|
|
86
88
|
}).join(', ');
|
|
87
89
|
};
|
|
88
90
|
// Visibility toggles based on questionConfig (from template)
|
|
@@ -109,7 +111,7 @@ export function ChooseTheCorrectAnswerClient({ questionData, onSubmit, isReviewM
|
|
|
109
111
|
.split('\n')
|
|
110
112
|
.map((line) => `<p class="last:mb-0">${line}</p>`)
|
|
111
113
|
.join(''),
|
|
112
|
-
} })), questionData.groupImageUrl && (_jsxs("div", { className: "mt-5 relative overflow-hidden rounded-xl border border-slate-200/60 bg-slate-50 shadow-sm transition-all hover:shadow-md", children: [_jsx("div", { className: "flex items-center justify-between border-b border-slate-200/60 bg-slate-100/50 px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("span", { className: "ml-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: "T\u00E0i li\u1EC7u \u0111\u00EDnh k\u00E8m" })] }) }), _jsx("div", { className: "flex items-center justify-center p-4 min-h-[160px]", children: _jsx(OptionImage, { src: questionData.groupImageUrl, alt: "Group illustration", className: "max-h-64 w-auto rounded-lg object-contain" }) })] }))] }))] })] })), _jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-5 py-3.5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-xs", children: _jsx(CircleHelp, { className: "h-4 w-4" }) }), _jsx("span", { className: "text-sm font-medium text-gray-700", children: isMultipleAnswers ? 'Câu hỏi nhiều đáp án' : 'Chọn đáp án đúng' })] }), questionData.points !== undefined && (_jsx("div", { className: "flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: _jsxs("span", { children: [questionData.points, " \u0111i\u1EC3m"] }) }))] }), _jsxs("div", { className: "p-5 space-y-4", children: [(questionData.askerName || questionData.answererName) && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-gray-50 p-2.5 text-xs text-gray-600", children: [_jsx(Users, { className: "h-4 w-4 text-gray-400" }), questionData.askerName && (_jsxs("span", { children: [_jsx("strong", { children: "Ng\u01B0\u1EDDi h\u1ECFi:" }), " ", questionData.askerName] })), questionData.askerName && questionData.answererName && _jsx("span", { children: "\u2022" }), questionData.answererName && (_jsxs("span", { children: [_jsx("strong", { children: "Ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi:" }), " ", questionData.answererName] }))] })), showQuestion && (_jsx("div", { className: "rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 border border-blue-100", children: _jsx("h3", { className: "text-base font-semibold text-gray-800 leading-relaxed", children: questionData.question
|
|
114
|
+
} })), questionData.groupImageUrl && (_jsxs("div", { className: "mt-5 relative overflow-hidden rounded-xl border border-slate-200/60 bg-slate-50 shadow-sm transition-all hover:shadow-md", children: [_jsx("div", { className: "flex items-center justify-between border-b border-slate-200/60 bg-slate-100/50 px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("span", { className: "ml-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: "T\u00E0i li\u1EC7u \u0111\u00EDnh k\u00E8m" })] }) }), _jsx("div", { className: "flex items-center justify-center p-4 min-h-[160px]", children: _jsx(OptionImage, { src: questionData.groupImageUrl, alt: "Group illustration", className: "max-h-64 w-auto rounded-lg object-contain" }) })] }))] }))] })] })), _jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xs", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-5 py-3.5", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-xs", children: _jsx(CircleHelp, { className: "h-4 w-4" }) }), _jsx("span", { className: "text-sm font-medium text-gray-700", children: isMultipleAnswers ? 'Câu hỏi nhiều đáp án' : 'Chọn đáp án đúng' })] }), questionData.points !== undefined && (_jsx("div", { className: "flex items-center gap-1.5 rounded-full bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: _jsxs("span", { children: [questionData.points, " \u0111i\u1EC3m"] }) }))] }), _jsxs("div", { className: "p-5 space-y-4", children: [(questionData.askerName || questionData.answererName) && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg bg-gray-50 p-2.5 text-xs text-gray-600", children: [_jsx(Users, { className: "h-4 w-4 text-gray-400" }), questionData.askerName && (_jsxs("span", { children: [_jsx("strong", { children: "Ng\u01B0\u1EDDi h\u1ECFi:" }), " ", questionData.askerName] })), questionData.askerName && questionData.answererName && _jsx("span", { children: "\u2022" }), questionData.answererName && (_jsxs("span", { children: [_jsx("strong", { children: "Ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi:" }), " ", questionData.answererName] }))] })), showQuestion && (_jsx("div", { className: "rounded-lg bg-gradient-to-r from-blue-50 to-indigo-50 p-4 border border-blue-100", children: isRichTextEmpty(questionData.question) ? (_jsx("h3", { className: "text-base font-semibold text-gray-800 leading-relaxed", children: "C\u00E2u h\u1ECFi ch\u01B0a \u0111\u01B0\u1EE3c nh\u1EADp" })) : (_jsx(RichTextHtml, { html: questionData.question, className: "text-base font-semibold text-gray-800 leading-relaxed" })) })), showImage && (imagePreviewUrl || cleanImageUrl) && (_jsxs("div", { className: "overflow-hidden rounded-lg border border-gray-200 bg-gradient-to-br from-slate-50 to-gray-100", children: [_jsx("div", { className: "border-b border-gray-100 px-3 py-1.5", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "h-2 w-2 rounded-full bg-red-400" }), _jsx("div", { className: "h-2 w-2 rounded-full bg-yellow-400" }), _jsx("div", { className: "h-2 w-2 rounded-full bg-green-400" }), _jsx("span", { className: "ml-2 text-xs font-medium text-gray-500", children: "H\u00ECnh \u1EA3nh" })] }) }), _jsx("div", { className: "flex items-center justify-center p-4", children: isLoading ? (_jsx("div", { className: "flex h-48 items-center justify-center", children: _jsx("div", { className: "h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-blue-500" }) })) : (
|
|
113
115
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
114
116
|
_jsx("img", { src: imagePreviewUrl || cleanImageUrl, alt: "Question illustration", className: "max-h-56 w-auto object-contain rounded-md", onError: (e) => {
|
|
115
117
|
const target = e.target;
|
|
@@ -136,11 +138,11 @@ export function ChooseTheCorrectAnswerClient({ questionData, onSubmit, isReviewM
|
|
|
136
138
|
: 'bg-gray-200 text-gray-600'
|
|
137
139
|
: isSelected
|
|
138
140
|
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white shadow-sm'
|
|
139
|
-
: 'bg-gray-100 text-gray-600 group-hover:bg-gray-200'), children: letter }), questionData.optionType === 'image' ? (_jsx("div", { className: "flex-1 flex items-center justify-center py-1", children: _jsx(OptionImage, { src: optionText, alt: `Đáp án ${letter}` }) })) : (_jsx(
|
|
141
|
+
: 'bg-gray-100 text-gray-600 group-hover:bg-gray-200'), children: letter }), questionData.optionType === 'image' ? (_jsx("div", { className: "flex-1 flex items-center justify-center py-1", children: _jsx(OptionImage, { src: optionText, alt: `Đáp án ${letter}` }) })) : (_jsx(RichTextHtml, { html: optionText, className: cn('flex-1 text-sm font-medium', isReviewMode && isCorrectAnswer
|
|
140
142
|
? 'text-green-800'
|
|
141
143
|
: isReviewMode && isSelected && !isCorrectAnswer
|
|
142
144
|
? 'text-red-800'
|
|
143
|
-
: 'text-gray-700')
|
|
145
|
+
: 'text-gray-700') })), isReviewMode && (isCorrectAnswer || isSelected) && (_jsx("div", { className: cn('flex h-6 w-6 items-center justify-center rounded-full', isCorrectAnswer ? 'bg-green-500' : 'bg-red-500'), children: isCorrectAnswer
|
|
144
146
|
? _jsx(Check, { className: "h-4 w-4 text-white" })
|
|
145
147
|
: _jsx(X, { className: "h-4 w-4 text-white" }) }))] }, index));
|
|
146
148
|
})] })), !isReviewMode && !hasAnswered && (_jsxs("div", { className: "rounded-lg border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-3 flex items-center gap-2", children: [_jsx("div", { className: "flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-amber-400 text-white", children: _jsx("span", { className: "text-xs font-bold", children: "!" }) }), _jsx("span", { className: "text-sm text-amber-800", children: isMultipleAnswers ? 'Vui lòng chọn ít nhất một đáp án' : 'Vui lòng chọn một đáp án' })] }))] })] }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "overflow-hidden rounded-xl border border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 shadow-sm", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-blue-100 bg-white/50 px-4 py-3", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-sm", children: _jsx(Lightbulb, { className: "h-4 w-4" }) }), _jsx("h4", { className: "text-sm font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" })] }), _jsx("div", { className: "p-4", children: _jsx("p", { className: "text-sm text-gray-700 leading-relaxed", children: questionData.explanation }) })] })), isReviewMode && (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-3", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-slate-500 to-gray-600 text-white shadow-sm", children: _jsx(ClipboardList, { className: "h-4 w-4" }) }), _jsx("h4", { className: "text-sm font-semibold text-gray-800", children: "T\u1ED5ng k\u1EBFt" })] }), _jsxs("div", { className: "p-4 space-y-3", children: [_jsxs("div", { className: "flex items-center justify-between rounded-lg bg-gray-50 p-3", children: [_jsx("span", { className: "text-sm text-gray-600", children: "\u0110\u00E1p \u00E1n c\u1EE7a b\u1EA1n:" }), _jsx("span", { className: cn('text-sm font-semibold', isAnswerCorrect ? 'text-green-600' : hasAnswered ? 'text-red-600' : 'text-gray-500'), children: formatAnswerLabel(selectedAnswer) })] }), _jsxs("div", { className: "flex items-center justify-between rounded-lg bg-green-50 p-3 border border-green-100", children: [_jsx("span", { className: "text-sm text-gray-600", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng:" }), _jsx("span", { className: "text-sm font-semibold text-green-600", children: formatAnswerLabel(normalizedCorrectAnswers) })] }), _jsxs("div", { className: cn('flex items-center justify-between rounded-lg p-3 border', isAnswerCorrect
|
package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js
CHANGED
|
@@ -14,6 +14,8 @@ import { usePresignedFileUrl, useDebouncedCallback } from '../../../../shared/li
|
|
|
14
14
|
import { useReactHookForm } from '../../../../shared/lib/hooks/useReactHookForm';
|
|
15
15
|
import { Pencil, Eye, Plus, Trash2, HelpCircle, CheckCircle2, Lightbulb, MessageSquare, ImageIcon, Type, Users, BookOpen, Star, ListChecks } from 'lucide-react';
|
|
16
16
|
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
17
|
+
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
18
|
+
import { isRichTextEmpty } from '../../../../shared/lib/rich-text';
|
|
17
19
|
import { ChooseTheCorrectAnswerClient } from './ChooseTheCorrectAnswerClient';
|
|
18
20
|
import { normalizeChooseAnswerCorrectAnswer, normalizeChooseAnswerOptions, pickRawChooseAnswer, } from './normalize';
|
|
19
21
|
import { chooseCorrectAnswerFormSchema, } from '../../../../shared/lib/validation/choose-correct-answer.validation';
|
|
@@ -410,7 +412,9 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
|
|
|
410
412
|
: undefined })] }));
|
|
411
413
|
}
|
|
412
414
|
// Creator UI
|
|
413
|
-
return (_jsxs("div", { className: "space-y-4", children: [isGrouped && effectiveGroupType === 'ARTICLES' && (_jsx(ArticlesGroupCard, { groupNumber: currentGroupNumber, articleCount: articleCount, articles: articles, onArticlesChange: setArticles, title: groupTitle, onTitleChange: setGroupTitle, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'articles'}` })), isGrouped && effectiveGroupType === 'BASIC' && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'group'}` })), isGrouped && effectiveGroupType === 'DOCUMENT' && (_jsx(DocumentGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'document'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100", children: _jsx(HelpCircle, { className: "h-4 w-4 text-gray-600" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "Ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("p", { className: "text-xs text-gray-500", children: "T\u1EA1o c\u00E2u h\u1ECFi tr\u1EAFc nghi\u1EC7m v\u1EDBi nhi\u1EC1u l\u1EF1a ch\u1ECDn" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: watchedOptions.filter((o) =>
|
|
415
|
+
return (_jsxs("div", { className: "space-y-4", children: [isGrouped && effectiveGroupType === 'ARTICLES' && (_jsx(ArticlesGroupCard, { groupNumber: currentGroupNumber, articleCount: articleCount, articles: articles, onArticlesChange: setArticles, title: groupTitle, onTitleChange: setGroupTitle, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'articles'}` })), isGrouped && effectiveGroupType === 'BASIC' && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'group'}` })), isGrouped && effectiveGroupType === 'DOCUMENT' && (_jsx(DocumentGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, groupConfig: groupType ? groupConfig : undefined, isReadOnly: !isFirstInGroup, uploadIdPrefix: `choose-correct-answer-${partId ?? 'document'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100", children: _jsx(HelpCircle, { className: "h-4 w-4 text-gray-600" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "Ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("p", { className: "text-xs text-gray-500", children: "T\u1EA1o c\u00E2u h\u1ECFi tr\u1EAFc nghi\u1EC7m v\u1EDBi nhi\u1EC1u l\u1EF1a ch\u1ECDn" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: watchedOptions.filter((o) => watchedOptionType === 'image'
|
|
416
|
+
? typeof o === 'string' && o.trim()
|
|
417
|
+
: !isRichTextEmpty(o)).length < 2, className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] })] }) }), _jsxs(CardContent, { className: "space-y-3 px-4 pb-4", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-y-2", children: [_jsx(PointsInput, { ...form.register('points', { valueAsNumber: true }), error: form.hasError('points') ? form.getFieldError('points') : undefined }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("button", { type: "button", style: { paddingBlock: 'calc(var(--spacing) * 1)' }, onClick: () => form.setValue('isExample', !watchedIsExample), className: `inline-flex items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${watchedIsExample
|
|
414
418
|
? 'border-amber-300 bg-amber-50 text-amber-700'
|
|
415
419
|
: 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Star, { className: `h-4 w-4 ${watchedIsExample ? 'fill-amber-400 text-amber-400' : ''}` }), "Example"] }), _jsxs("button", { type: "button", style: { paddingBlock: 'calc(var(--spacing) * 1)' }, disabled: Boolean(multipleAnswers), onClick: () => {
|
|
416
420
|
const enabled = !watchedMultipleAnswers;
|
|
@@ -422,7 +426,7 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
|
|
|
422
426
|
form.setValue('correctAnswer', enabled ? normalized : (normalized[0] ?? -1), { shouldValidate: true });
|
|
423
427
|
}, className: `inline-flex items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${watchedMultipleAnswers
|
|
424
428
|
? 'border-violet-300 bg-violet-50 text-violet-700'
|
|
425
|
-
: 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'} ${Boolean(multipleAnswers) ? 'cursor-not-allowed opacity-50' : ''}`, children: [_jsx(ListChecks, { className: "h-4 w-4" }), "Nhi\u1EC1u \u0111\u00E1p \u00E1n"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(
|
|
429
|
+
: 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'} ${Boolean(multipleAnswers) ? 'cursor-not-allowed opacity-50' : ''}`, children: [_jsx(ListChecks, { className: "h-4 w-4" }), "Nhi\u1EC1u \u0111\u00E1p \u00E1n"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(RichTextEditor, { value: watchedQuestion, onChange: (html) => form.setValue('question', html, { shouldValidate: true }), variant: "compact", minHeightClassName: "min-h-[100px]" }), form.hasError('question') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('question') }))] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && (_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-indigo-500" }), "H\u00ECnh \u1EA3nh ", _jsx("span", { className: "text-sm font-normal text-gray-500" })] }), _jsx(FileUpload, { id: "question-image", label: "", accept: "image/*", value: watchedImageUrl || '', onChange: (url) => {
|
|
426
430
|
form.setValue('imageUrl', url, { shouldValidate: true });
|
|
427
431
|
}, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), form.hasError('imageUrl') && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: form.getFieldError('imageUrl') })), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, index) => (_jsx("li", { children: error }, index))) })] })] })), form.formState.errors.options?.message && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.formState.errors.options.message }) }))] })] }), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('options')) && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-indigo-100", children: _jsx(Lightbulb, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["C\u00E1c l\u1EF1a ch\u1ECDn ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", watchedOptions.length, " \u0111\u00E1p \u00E1n)"] })] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_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 ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: watchedOptionType === 'image', disabled: true, className: watchedOptionType === 'image' ? '!bg-indigo-600 !opacity-100' : '!bg-indigo-600 !opacity-100', onCheckedChange: (checked) => {
|
|
428
432
|
const newType = checked ? 'image' : 'text';
|
|
@@ -488,7 +492,11 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
|
|
|
488
492
|
setOptionPreviewUrls((prev) => ({ ...prev, [index]: presignedUrl }));
|
|
489
493
|
}, maxSize: 5, placeholder: `Upload ảnh đáp án ${optionLetter}...`, autoUpload: true, prefix: "questions/options" }), (optionPreviewUrls[index] || option) && (_jsx(OptionImagePreview, { src: optionPreviewUrls[index] || option, alt: `Đáp án ${optionLetter}` }))] })) : (
|
|
490
494
|
/* Text Input Mode */
|
|
491
|
-
_jsx(
|
|
495
|
+
_jsx(RichTextEditor, { value: option, onChange: (html) => {
|
|
496
|
+
const newOptions = [...watchedOptions];
|
|
497
|
+
newOptions[index] = html;
|
|
498
|
+
form.setValue('options', newOptions, { shouldValidate: true });
|
|
499
|
+
}, variant: "compact", minHeightClassName: "min-h-[72px]" })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), watchedMultipleAnswers ? 'Đáp án đúng đã chọn' : 'Đáp án đúng'] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
492
500
|
if (watchedMultipleAnswers) {
|
|
493
501
|
const currentAnswers = Array.isArray(form.getValues('correctAnswer'))
|
|
494
502
|
? form.getValues('correctAnswer')
|
|
@@ -543,7 +551,11 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
|
|
|
543
551
|
setOptionPreviewUrls((prev) => ({ ...prev, [index]: presignedUrl }));
|
|
544
552
|
}, maxSize: 5, placeholder: `Upload ảnh đáp án ${optionLetter}...`, autoUpload: true, prefix: "questions/options" }), (optionPreviewUrls[index] || option) && (_jsx(OptionImagePreview, { src: optionPreviewUrls[index] || option, alt: `Đáp án ${optionLetter}` }))] })) : (
|
|
545
553
|
/* Text Input Mode */
|
|
546
|
-
_jsx(
|
|
554
|
+
_jsx(RichTextEditor, { value: option, onChange: (html) => {
|
|
555
|
+
const newOptions = [...watchedOptions];
|
|
556
|
+
newOptions[index] = html;
|
|
557
|
+
form.setValue('options', newOptions, { shouldValidate: true });
|
|
558
|
+
}, variant: "compact", minHeightClassName: "min-h-[72px]" })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), "\u0110\u00E1p \u00E1n \u0111\u00FAng"] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
547
559
|
form.setValue('correctAnswer', index, { shouldValidate: true });
|
|
548
560
|
}, className: isCorrect ? 'text-green-600 hover:text-green-700' : 'text-gray-400 hover:text-green-600', title: "Ch\u1ECDn l\u00E0m \u0111\u00E1p \u00E1n \u0111\u00FAng", children: _jsx(CheckCircle2, { className: "h-4 w-4" }) }), watchedOptions.length > 2 && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
|
|
549
561
|
const currentOptions = form.getValues('options');
|
|
@@ -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,10 @@ 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
|
-
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) => {
|
|
40
|
+
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' }), 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
41
|
const isCorrect = option.id === item.correctAnswer;
|
|
33
42
|
const isSelected = option.id === selectedId;
|
|
34
43
|
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,10 +165,10 @@ 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
|
-
}) })] }), _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: () => {
|
|
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" })] }), _jsx("div", { className: "space-y-2", children: _jsx(FileUpload, { id: `choose-answer-item-audio-${itemIndex}`, label: "Audio c\u00E2u h\u1ECFi (tu\u1EF3 ch\u1ECDn)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(itemIndex, { audioUrl: url }), maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _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
172
|
const nextUrls = [...toEditableChooseAnswerGroupImageUrls(item.imageUrl), ''];
|
|
169
173
|
updateItem(itemIndex, { imageUrl: nextUrls });
|
|
170
174
|
}, 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) => {
|
|
@@ -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,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Check, X } from 'lucide-react';
|
|
4
4
|
import { ResolvedImage } from '../../../components/common/ResolvedImage';
|
|
5
|
+
import { RichTextHtml } from '../../../components/shared/RichTextHtml';
|
|
5
6
|
import CambridgeYlePartBanner from '../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
|
|
6
7
|
import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
8
|
import CambridgeYleAudioPlayer from '../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
@@ -43,9 +44,9 @@ function ReviewQuestionItem({ question, selectedOptionIds, isCorrect, correctOpt
|
|
|
43
44
|
}
|
|
44
45
|
return _jsx("div", { className: `flex h-5 w-5 items-center justify-center border-2 border-gray-300 bg-white ${shapeClass}` });
|
|
45
46
|
};
|
|
46
|
-
return (_jsxs("div", { className: "mb-4", children: [_jsxs("
|
|
47
|
+
return (_jsxs("div", { className: "mb-4", children: [_jsxs("div", { className: "mb-2 flex items-start gap-1.5 text-lg font-bold text-gray-900", children: [_jsxs("span", { children: [question.questionNumber, "."] }), _jsx(RichTextHtml, { html: question.questionText, className: "flex-1 font-bold text-gray-900" })] }), 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
48
|
/* Options — text variant */
|
|
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(
|
|
49
|
+
_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(RichTextHtml, { html: option.text, className: "flex-1 text-sm text-gray-700" }), renderIndicator(option.id)] }, option.id))) }))] }));
|
|
49
50
|
}
|
|
50
51
|
// Article Card for ARTICLES groupType (e.g., GN QE RWL Part 2)
|
|
51
52
|
function ArticleCard({ article, label, }) {
|
|
@@ -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);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
|
|
2
|
+
interface RichTextEditorProps {
|
|
3
3
|
value?: string;
|
|
4
4
|
onChange: (html: string) => void;
|
|
5
5
|
label?: React.ReactNode;
|
|
6
6
|
disabled?: boolean;
|
|
7
7
|
minHeightClassName?: string;
|
|
8
|
+
variant?: 'default' | 'compact';
|
|
8
9
|
}
|
|
9
|
-
export declare function RichTextEditor({ value, onChange, label, disabled, minHeightClassName, }: RichTextEditorProps): React.JSX.Element;
|
|
10
|
+
export declare function RichTextEditor({ value, onChange, label, disabled, minHeightClassName, variant, }: RichTextEditorProps): React.JSX.Element;
|
|
11
|
+
export {};
|
|
@@ -3,16 +3,43 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
5
5
|
import StarterKit from '@tiptap/starter-kit';
|
|
6
|
+
import Underline from '@tiptap/extension-underline';
|
|
7
|
+
import TextAlign from '@tiptap/extension-text-align';
|
|
8
|
+
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
|
9
|
+
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Code, Columns3, Italic, List, ListOrdered, Minus, Quote, Redo2, RemoveFormatting, Rows3, SquareCode, Strikethrough, TableIcon, Trash2, Underline as UnderlineIcon, Undo2, } from 'lucide-react';
|
|
6
10
|
const EMPTY_CONTENT = '<p></p>';
|
|
7
|
-
const HEADING_LEVELS = [1, 2, 3];
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
const HEADING_LEVELS = [1, 2, 3, 4];
|
|
12
|
+
const EDITOR_EXTENSIONS = [
|
|
13
|
+
StarterKit.configure({
|
|
14
|
+
heading: { levels: [...HEADING_LEVELS] },
|
|
15
|
+
}),
|
|
16
|
+
Underline,
|
|
17
|
+
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
18
|
+
Table.configure({ resizable: false }),
|
|
19
|
+
TableRow,
|
|
20
|
+
TableHeader,
|
|
21
|
+
TableCell,
|
|
22
|
+
];
|
|
23
|
+
const EDITOR_TABLE_CLASS = '[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-slate-300 [&_td]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1';
|
|
24
|
+
function toolbarButtonClass(isActive) {
|
|
25
|
+
return `inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 transition-colors disabled:opacity-30 ${isActive ? 'bg-indigo-600 text-white' : 'text-slate-600 hover:bg-slate-100'}`;
|
|
10
26
|
}
|
|
11
|
-
|
|
27
|
+
function ToolbarGroup({ children }) {
|
|
28
|
+
return (_jsx("div", { className: "flex items-center gap-0.5 rounded-md bg-white px-0.5 py-0.5 shadow-sm ring-1 ring-slate-200/80", children: children }));
|
|
29
|
+
}
|
|
30
|
+
function ToolbarButton({ editor, disabled, active, title, onClick, children, }) {
|
|
31
|
+
return (_jsx("button", { type: "button", title: title, disabled: disabled || !editor, onClick: onClick, className: toolbarButtonClass(!!active), children: children }));
|
|
32
|
+
}
|
|
33
|
+
function EditorToolbar({ editor, disabled, }) {
|
|
34
|
+
const iconClass = 'h-4 w-4';
|
|
35
|
+
return (_jsxs("div", { className: "relative flex flex-wrap items-center gap-1 rounded-t-md border-b border-slate-200 bg-slate-100 px-1.5 py-1", children: [_jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "Ho\u00E0n t\u00E1c", onClick: () => editor?.chain().focus().undo().run(), children: _jsx(Undo2, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "L\u00E0m l\u1EA1i", onClick: () => editor?.chain().focus().redo().run(), children: _jsx(Redo2, { className: iconClass, strokeWidth: 2 }) })] }), _jsx(ToolbarGroup, { children: HEADING_LEVELS.map((level) => (_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('heading', { level }), title: `Heading ${level}`, onClick: () => editor?.chain().focus().toggleHeading({ level }).run(), children: _jsxs("span", { className: "text-[11px] font-bold", children: ["H", level] }) }, level))) }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('bold'), title: "\u0110\u1EADm", onClick: () => editor?.chain().focus().toggleBold().run(), children: _jsx(Bold, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('italic'), title: "Nghi\u00EAng", onClick: () => editor?.chain().focus().toggleItalic().run(), children: _jsx(Italic, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('underline'), title: "G\u1EA1ch ch\u00E2n", onClick: () => editor?.chain().focus().toggleUnderline().run(), children: _jsx(UnderlineIcon, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('strike'), title: "G\u1EA1ch ngang", onClick: () => editor?.chain().focus().toggleStrike().run(), children: _jsx(Strikethrough, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('code'), title: "Code", onClick: () => editor?.chain().focus().toggleCode().run(), children: _jsx(Code, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('bulletList'), title: "Danh s\u00E1ch", onClick: () => editor?.chain().focus().toggleBulletList().run(), children: _jsx(List, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('orderedList'), title: "Danh s\u00E1ch s\u1ED1", onClick: () => editor?.chain().focus().toggleOrderedList().run(), children: _jsx(ListOrdered, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('blockquote'), title: "Tr\u00EDch d\u1EABn", onClick: () => editor?.chain().focus().toggleBlockquote().run(), children: _jsx(Quote, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive('codeBlock'), title: "Kh\u1ED1i code", onClick: () => editor?.chain().focus().toggleCodeBlock().run(), children: _jsx(SquareCode, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'left' }), title: "C\u0103n tr\u00E1i", onClick: () => editor?.chain().focus().setTextAlign('left').run(), children: _jsx(AlignLeft, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'center' }), title: "C\u0103n gi\u1EEFa", onClick: () => editor?.chain().focus().setTextAlign('center').run(), children: _jsx(AlignCenter, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'right' }), title: "C\u0103n ph\u1EA3i", onClick: () => editor?.chain().focus().setTextAlign('right').run(), children: _jsx(AlignRight, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, active: editor?.isActive({ textAlign: 'justify' }), title: "C\u0103n \u0111\u1EC1u", onClick: () => editor?.chain().focus().setTextAlign('justify').run(), children: _jsx(AlignJustify, { className: iconClass, strokeWidth: 2 }) })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "\u0110\u01B0\u1EDDng k\u1EBB ngang", onClick: () => editor?.chain().focus().setHorizontalRule().run(), children: _jsx(Minus, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "Ch\u00E8n b\u1EA3ng", onClick: () => editor?.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), children: _jsx(TableIcon, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().addRowAfter(), title: "Th\u00EAm h\u00E0ng", onClick: () => editor?.chain().focus().addRowAfter().run(), children: _jsx(Rows3, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().addColumnAfter(), title: "Th\u00EAm c\u1ED9t", onClick: () => editor?.chain().focus().addColumnAfter().run(), children: _jsx(Columns3, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled || !editor?.can().deleteTable(), title: "X\u00F3a b\u1EA3ng", onClick: () => editor?.chain().focus().deleteTable().run(), children: _jsx(Trash2, { className: iconClass, strokeWidth: 2 }) }), _jsx(ToolbarButton, { editor: editor, disabled: disabled, title: "X\u00F3a \u0111\u1ECBnh d\u1EA1ng", onClick: () => editor?.chain().focus().unsetAllMarks().clearNodes().run(), children: _jsx(RemoveFormatting, { className: iconClass, strokeWidth: 2 }) })] })] }));
|
|
36
|
+
}
|
|
37
|
+
export function RichTextEditor({ value, onChange, label, disabled = false, minHeightClassName = 'min-h-[250px]', variant = 'default', }) {
|
|
12
38
|
const [mounted, setMounted] = useState(false);
|
|
39
|
+
const isCompact = variant === 'compact';
|
|
13
40
|
const editor = useEditor({
|
|
14
41
|
immediatelyRender: false,
|
|
15
|
-
extensions:
|
|
42
|
+
extensions: EDITOR_EXTENSIONS,
|
|
16
43
|
editable: !disabled,
|
|
17
44
|
content: value || EMPTY_CONTENT,
|
|
18
45
|
onUpdate: ({ editor: currentEditor }) => {
|
|
@@ -20,7 +47,7 @@ export function RichTextEditor({ value, onChange, label, disabled = false, minHe
|
|
|
20
47
|
},
|
|
21
48
|
editorProps: {
|
|
22
49
|
attributes: {
|
|
23
|
-
class: `tiptap-editor focus:outline-none ${minHeightClassName} p-4 bg-white
|
|
50
|
+
class: `tiptap-editor focus:outline-none ${minHeightClassName} ${isCompact ? 'p-2' : 'p-4'} bg-white rounded-b-md prose prose-sm max-w-none ${EDITOR_TABLE_CLASS}`,
|
|
24
51
|
},
|
|
25
52
|
},
|
|
26
53
|
});
|
|
@@ -39,9 +66,5 @@ export function RichTextEditor({ value, onChange, label, disabled = false, minHe
|
|
|
39
66
|
if (!mounted) {
|
|
40
67
|
return null;
|
|
41
68
|
}
|
|
42
|
-
return (_jsxs("div", { className:
|
|
43
|
-
?.chain()
|
|
44
|
-
.focus()
|
|
45
|
-
.toggleHeading({ level })
|
|
46
|
-
.run(), disabled: disabled, className: `${getToolbarButtonClass(!!editor?.isActive('heading', { level }))} font-bold`, children: ["H", level] }, level))), _jsx("div", { className: "mx-1 h-6 w-px self-center bg-gray-300" }), _jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleBulletList().run(), disabled: disabled, className: getToolbarButtonClass(!!editor?.isActive('bulletList')), children: "\u2022 List" }), _jsx("button", { type: "button", onClick: () => editor?.chain().focus().toggleOrderedList().run(), disabled: disabled, className: getToolbarButtonClass(!!editor?.isActive('orderedList')), children: "1. List" })] }), _jsx(EditorContent, { editor: editor })] })] }));
|
|
69
|
+
return (_jsxs("div", { className: `flex w-full flex-col ${isCompact ? 'gap-0' : 'gap-4'}`, children: [label ? _jsx("div", { children: label }) : null, _jsxs("div", { className: "flex flex-col overflow-hidden rounded-md border border-gray-300 shadow-sm", children: [_jsx(EditorToolbar, { editor: editor, disabled: disabled }), _jsx(EditorContent, { editor: editor })] })] }));
|
|
47
70
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
interface RichTextHtmlProps {
|
|
3
|
+
html?: string | null;
|
|
4
|
+
className?: string;
|
|
5
|
+
fallback?: ReactNode;
|
|
6
|
+
}
|
|
7
|
+
export declare function RichTextHtml({ html, className, fallback }: RichTextHtmlProps): import("react").JSX.Element;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from '../../shared/lib/utils';
|
|
3
|
+
const RICH_TEXT_CLASS = 'rich-text-html max-w-none [&_p]:m-0 [&_p+p]:mt-1 [&_h1]:mb-1 [&_h1]:mt-0 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:mb-1 [&_h2]:mt-0 [&_h2]:text-lg [&_h2]:font-bold [&_h3]:mb-1 [&_h3]:mt-0 [&_h3]:text-base [&_h3]:font-bold [&_h4]:mb-1 [&_h4]:mt-0 [&_h4]:text-sm [&_h4]:font-bold [&_ul]:my-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:my-1 [&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 [&_blockquote]:pl-3 [&_pre]:my-1 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-slate-100 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-slate-100 [&_code]:px-1 [&_u]:underline [&_s]:line-through [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-slate-300 [&_td]:border-slate-300 [&_th]:bg-slate-50 [&_th]:px-2 [&_th]:py-1 [&_td]:px-2 [&_td]:py-1 [&_hr]:my-2';
|
|
4
|
+
export function RichTextHtml({ html, className, fallback = null }) {
|
|
5
|
+
if (!html) {
|
|
6
|
+
return fallback ? _jsx(_Fragment, { children: fallback }) : null;
|
|
7
|
+
}
|
|
8
|
+
return (_jsx("div", { className: cn(RICH_TEXT_CLASS, className), dangerouslySetInnerHTML: { __html: html } }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const HTML_TAG_RE = /<[^>]*>/g;
|
|
2
|
+
const HTML_ENTITIES = {
|
|
3
|
+
' ': ' ',
|
|
4
|
+
'&': '&',
|
|
5
|
+
'<': '<',
|
|
6
|
+
'>': '>',
|
|
7
|
+
'"': '"',
|
|
8
|
+
''': "'",
|
|
9
|
+
};
|
|
10
|
+
/** Strip HTML tags/entities so empty TipTap documents (`<p></p>`) count as blank. */
|
|
11
|
+
export function richTextToPlainText(value) {
|
|
12
|
+
if (!value)
|
|
13
|
+
return '';
|
|
14
|
+
return value
|
|
15
|
+
.replace(HTML_TAG_RE, ' ')
|
|
16
|
+
.replace(/ |&|<|>|"|'/gi, (entity) => {
|
|
17
|
+
return HTML_ENTITIES[entity.toLowerCase()] ?? entity;
|
|
18
|
+
})
|
|
19
|
+
.replace(/\s+/g, ' ')
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
export function isRichTextEmpty(value) {
|
|
23
|
+
return richTextToPlainText(value) === '';
|
|
24
|
+
}
|
|
@@ -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),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { isRichTextEmpty } from '../../lib/rich-text';
|
|
2
3
|
// Validation schema for Choose the Correct Answer form
|
|
3
4
|
export const chooseCorrectAnswerFormSchema = z.object({
|
|
4
5
|
question: z.string().optional(),
|
|
@@ -35,7 +36,8 @@ export const chooseCorrectAnswerFormSchema = z.object({
|
|
|
35
36
|
})
|
|
36
37
|
.pipe(z.number().min(1, 'Điểm số phải >= 1')),
|
|
37
38
|
}).superRefine((data, ctx) => {
|
|
38
|
-
const
|
|
39
|
+
const isOptionFilled = (opt) => data.optionType === 'image' ? opt.trim() !== '' : !isRichTextEmpty(opt);
|
|
40
|
+
const filledOptions = data.options.filter((opt) => isOptionFilled(opt));
|
|
39
41
|
if (filledOptions.length < 2) {
|
|
40
42
|
ctx.addIssue({
|
|
41
43
|
code: z.ZodIssueCode.custom,
|
|
@@ -47,7 +49,7 @@ export const chooseCorrectAnswerFormSchema = z.object({
|
|
|
47
49
|
}
|
|
48
50
|
// Validate each option based on type
|
|
49
51
|
data.options.forEach((opt, index) => {
|
|
50
|
-
if (opt
|
|
52
|
+
if (!isOptionFilled(opt)) {
|
|
51
53
|
ctx.addIssue({
|
|
52
54
|
code: z.ZodIssueCode.custom,
|
|
53
55
|
message: data.optionType === 'image'
|
|
@@ -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
|
}
|