@tinyweb_dev/oe-exam-sdk 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -2
- package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.js +5 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.d.ts +7 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.js +4 -1
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +9 -5
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +45 -58
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupItemEditor.d.ts +6 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupItemEditor.js +145 -0
- package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +6 -15
- package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -4
- package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.d.ts +1 -1
- package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +79 -3
- package/dist/components/questions/types/spontaneous-qa-group/index.d.ts +1 -0
- package/dist/components/questions/types/spontaneous-qa-group/index.js +1 -0
- package/dist/components/questions/types/spontaneous-qa-group/map-spontaneous-qa-group-data.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa-group/map-spontaneous-qa-group-data.js +74 -0
- package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +4 -79
- package/dist/shared/lib/utils/question-reverse-transform.js +4 -4
- package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +1 -1
- package/package.json +1 -1
|
@@ -18,12 +18,12 @@ export interface ChooseTheCorrectAnswerGroupItemData {
|
|
|
18
18
|
points: number;
|
|
19
19
|
questionNumber: number;
|
|
20
20
|
imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
21
|
-
audioUrl?:
|
|
21
|
+
audioUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
22
22
|
}
|
|
23
23
|
export interface ChooseTheCorrectAnswerGroupData {
|
|
24
24
|
instruction: string;
|
|
25
25
|
passage?: string;
|
|
26
|
-
audioUrl?:
|
|
26
|
+
audioUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
27
27
|
imageUrl?: ChooseTheCorrectAnswerGroupImageUrl;
|
|
28
28
|
items: ChooseTheCorrectAnswerGroupItemData[];
|
|
29
29
|
explanation?: string;
|
|
@@ -11,7 +11,11 @@ export function isChooseAnswerGroupOptionFilled(option, optionType = 'text') {
|
|
|
11
11
|
if (optionType === 'image') {
|
|
12
12
|
return getChooseAnswerGroupOptionImageSrc(option) !== '';
|
|
13
13
|
}
|
|
14
|
-
|
|
14
|
+
if (typeof option.text !== 'string') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const text = option.text.trim();
|
|
18
|
+
return text !== '' && text !== '<p></p>' && text !== '<p><br></p>';
|
|
15
19
|
}
|
|
16
20
|
export function toChooseAnswerGroupImageUrls(value) {
|
|
17
21
|
if (Array.isArray(value)) {
|
|
@@ -5,6 +5,13 @@ export declare function ChooseAnswerGroupImageUploadItem({ index, itemIndex, ima
|
|
|
5
5
|
onChange: (url: string) => void;
|
|
6
6
|
onRemove: () => void;
|
|
7
7
|
}): import("react").JSX.Element;
|
|
8
|
+
export declare function ChooseAnswerGroupAudioUploadItem({ index, itemIndex, audioUrl, onChange, onRemove, }: {
|
|
9
|
+
index: number;
|
|
10
|
+
itemIndex: number;
|
|
11
|
+
audioUrl: string;
|
|
12
|
+
onChange: (url: string) => void;
|
|
13
|
+
onRemove: () => void;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
8
15
|
export declare function ChooseAnswerGroupOptionImageField({ itemIndex, optionIndex, imageUrl, onChange, }: {
|
|
9
16
|
itemIndex: number;
|
|
10
17
|
optionIndex: number;
|
|
@@ -5,7 +5,7 @@ import { Button } from '../../../../components/ui/button';
|
|
|
5
5
|
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
6
6
|
import { ImagePreview } from '../../../../components/ui/image-preview';
|
|
7
7
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
8
|
-
import { ImageIcon, Trash2 } from 'lucide-react';
|
|
8
|
+
import { ImageIcon, Trash2, Volume2 } from 'lucide-react';
|
|
9
9
|
function resolveDisplayUrl(imageUrl, previewOverride, fetchedPreviewUrl) {
|
|
10
10
|
if (previewOverride) {
|
|
11
11
|
return previewOverride;
|
|
@@ -24,6 +24,9 @@ export function ChooseAnswerGroupImageUploadItem({ index, itemIndex, imageUrl, o
|
|
|
24
24
|
const displayUrl = resolveDisplayUrl(imageUrl, previewOverride, fetchedPreviewUrl);
|
|
25
25
|
return (_jsxs("div", { className: "flex items-center gap-2 rounded-md border border-gray-200 bg-white p-1.5", children: [_jsx("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-md border border-gray-100 bg-gray-50", children: displayUrl ? (_jsx(ImagePreview, { src: displayUrl, alt: `Hình ảnh ${index + 1}`, className: "h-12 w-12 rounded-md object-contain" })) : (_jsx(ImageIcon, { className: "h-4 w-4 text-gray-300" })) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(FileUpload, { id: `choose-answer-group-image-${itemIndex}-${index}`, label: "", accept: "image/*", value: imageUrl, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: onRemove, title: "Xo\u00E1 \u1EA3nh n\u00E0y", className: "h-7 w-7 shrink-0 text-gray-400 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }));
|
|
26
26
|
}
|
|
27
|
+
export function ChooseAnswerGroupAudioUploadItem({ index, itemIndex, audioUrl, onChange, onRemove, }) {
|
|
28
|
+
return (_jsxs("div", { className: "flex items-center gap-2 rounded-md border border-gray-200 bg-white p-1.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-indigo-50", children: _jsx(Volume2, { className: "h-3.5 w-3.5 text-indigo-500" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(FileUpload, { id: `choose-answer-group-audio-${itemIndex}-${index}`, label: "", accept: "audio/*", value: audioUrl, onChange: onChange, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: onRemove, title: "Xo\u00E1 audio n\u00E0y", className: "h-7 w-7 shrink-0 text-gray-400 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }));
|
|
29
|
+
}
|
|
27
30
|
export function ChooseAnswerGroupOptionImageField({ itemIndex, optionIndex, imageUrl, onChange, }) {
|
|
28
31
|
const [previewOverride, setPreviewOverride] = useState('');
|
|
29
32
|
const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(imageUrl);
|
|
@@ -3,6 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
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
|
+
import { RichTextHtml } from '../../../../components/shared/RichTextHtml';
|
|
7
|
+
import { isRichTextEmpty } from '../../../../shared/lib/rich-text';
|
|
6
8
|
import { getChooseAnswerGroupOptionImageSrc, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
7
9
|
function ClientImageItem({ url, alt }) {
|
|
8
10
|
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
@@ -22,7 +24,9 @@ function ClientAudio({ url }) {
|
|
|
22
24
|
}
|
|
23
25
|
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
26
|
}
|
|
25
|
-
|
|
27
|
+
function optionLetter(index) {
|
|
28
|
+
return String.fromCharCode(65 + index);
|
|
29
|
+
}
|
|
26
30
|
export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
27
31
|
const items = questionData.items || [];
|
|
28
32
|
const handleSelect = (itemIndex, optionId) => {
|
|
@@ -34,10 +38,10 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
|
|
|
34
38
|
next[itemIndex] = optionId;
|
|
35
39
|
onAnswerChange(next);
|
|
36
40
|
};
|
|
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
|
|
41
|
+
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 })), toChooseAnswerGroupImageUrls(questionData.audioUrl).map((url, audioIndex) => (_jsx(ClientAudio, { url: url }, `group-audio-${audioIndex}`))), toChooseAnswerGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh nhóm (${imageIndex + 1})` }, `group-image-${imageIndex}`))) })), 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) => {
|
|
38
42
|
const selectedId = userAnswers[itemIndex];
|
|
39
43
|
const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
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-3 py-2", 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-3", children: [_jsx(
|
|
44
|
+
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-3 py-2", 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-3", children: [!isRichTextEmpty(item.question) && (_jsx(RichTextHtml, { html: item.question, className: "text-sm font-semibold leading-relaxed text-gray-800" })), toChooseAnswerGroupImageUrls(item.audioUrl).map((url, audioIndex) => (_jsx(ClientAudio, { url: url }, `${item.questionNumber}-audio-${audioIndex}`))), 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) => {
|
|
41
45
|
const isCorrect = option.id === item.correctAnswer;
|
|
42
46
|
const isSelected = option.id === selectedId;
|
|
43
47
|
const optionImageSrc = getChooseAnswerGroupOptionImageSrc(option);
|
|
@@ -49,7 +53,7 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
|
|
|
49
53
|
? 'bg-green-500 text-white'
|
|
50
54
|
: isSelected
|
|
51
55
|
? 'bg-blue-500 text-white'
|
|
52
|
-
: 'bg-gray-100 text-gray-600'), children:
|
|
56
|
+
: 'bg-gray-100 text-gray-600'), children: optionLetter(optionIndex) }), optionImageSrc ? (_jsx(ClientImageItem, { url: optionImageSrc, alt: `Đáp án ${optionLetter(optionIndex)}` })) : (_jsx("span", { className: "text-xs text-gray-500", children: `Đáp án ${optionLetter(optionIndex)}` })), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
|
|
53
57
|
}) })) : (_jsx("div", { className: "space-y-2", children: item.options.map((option, optionIndex) => {
|
|
54
58
|
const isCorrect = option.id === item.correctAnswer;
|
|
55
59
|
const isSelected = option.id === selectedId;
|
|
@@ -61,7 +65,7 @@ export function ChooseTheCorrectAnswerGroupClient({ questionData, isReviewMode =
|
|
|
61
65
|
? 'bg-green-500 text-white'
|
|
62
66
|
: isSelected
|
|
63
67
|
? 'bg-blue-500 text-white'
|
|
64
|
-
: 'bg-gray-100 text-gray-600'), children:
|
|
68
|
+
: 'bg-gray-100 text-gray-600'), children: optionLetter(optionIndex) }), isRichTextEmpty(option.text) ? (_jsx("span", { className: "flex-1 text-gray-800", children: `Đáp án ${optionLetter(optionIndex)}` })) : (_jsx(RichTextHtml, { html: option.text, className: "flex-1 text-gray-800" })), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
|
|
65
69
|
}) }))] })] }, `${item.questionNumber}-${itemIndex}`));
|
|
66
70
|
}), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
|
|
67
71
|
}
|
|
@@ -3,18 +3,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { Button } from '../../../../components/ui/button';
|
|
5
5
|
import { Label } from '../../../../components/ui/label';
|
|
6
|
-
import { Input } from '../../../../components/ui/input';
|
|
7
6
|
import { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
-
import { Switch } from '../../../../components/ui/switch';
|
|
9
|
-
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
10
7
|
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
11
8
|
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
12
|
-
import { BookOpen, HelpCircle, ImageIcon, Plus, Trash2,
|
|
9
|
+
import { BookOpen, HelpCircle, ImageIcon, Plus, Trash2, Volume2 } from 'lucide-react';
|
|
13
10
|
import { CompactCreatorHeader, CompactExplanationToggle, CompactFieldCard, CompactPreviewBanner, } from '../../_shared/components/compact';
|
|
14
11
|
import { ChooseTheCorrectAnswerGroupClient } from './ChooseTheCorrectAnswerGroupClient';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
12
|
+
import { ChooseAnswerGroupAudioUploadItem, ChooseAnswerGroupImageUploadItem, } from './ChooseAnswerGroupImageUpload';
|
|
13
|
+
import { ChooseTheCorrectAnswerGroupItemEditor } from './ChooseTheCorrectAnswerGroupItemEditor';
|
|
14
|
+
import { fromChooseAnswerGroupImageUrls, isChooseAnswerGroupOptionFilled, toChooseAnswerGroupImageUrls, toEditableChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
18
15
|
function createEmptyItem(questionNumber) {
|
|
19
16
|
return {
|
|
20
17
|
question: '',
|
|
@@ -22,7 +19,6 @@ function createEmptyItem(questionNumber) {
|
|
|
22
19
|
options: [
|
|
23
20
|
{ id: 'opt-1', text: '' },
|
|
24
21
|
{ id: 'opt-2', text: '' },
|
|
25
|
-
{ id: 'opt-3', text: '' },
|
|
26
22
|
],
|
|
27
23
|
correctAnswer: 'opt-1',
|
|
28
24
|
isExample: false,
|
|
@@ -36,6 +32,7 @@ function normalizeItems(rawItems) {
|
|
|
36
32
|
}
|
|
37
33
|
return rawItems.map((item, index) => {
|
|
38
34
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.imageUrl));
|
|
35
|
+
const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.audioUrl));
|
|
39
36
|
return {
|
|
40
37
|
question: item.question || '',
|
|
41
38
|
optionType: item.optionType === 'image' ? 'image' : 'text',
|
|
@@ -51,7 +48,7 @@ function normalizeItems(rawItems) {
|
|
|
51
48
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
52
49
|
questionNumber: item.questionNumber || index + 1,
|
|
53
50
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
54
|
-
...(
|
|
51
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
55
52
|
};
|
|
56
53
|
});
|
|
57
54
|
}
|
|
@@ -61,34 +58,35 @@ function sumPoints(items) {
|
|
|
61
58
|
export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
62
59
|
const [instruction, setInstruction] = useState(initialData?.instruction || 'Choose the correct answer.');
|
|
63
60
|
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
64
|
-
const [
|
|
65
|
-
const [
|
|
61
|
+
const [imageUrls, setImageUrls] = useState(() => (toEditableChooseAnswerGroupImageUrls(initialData?.imageUrl)));
|
|
62
|
+
const [audioUrls, setAudioUrls] = useState(() => (toEditableChooseAnswerGroupImageUrls(initialData?.audioUrl)));
|
|
66
63
|
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
67
64
|
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
68
65
|
const [isClientMode, setIsClientMode] = useState(false);
|
|
69
66
|
const [errors, setErrors] = useState([]);
|
|
70
|
-
const [isGroupAudioVisible, setIsGroupAudioVisible] = useState(() => Boolean(initialData?.audioUrl));
|
|
71
|
-
const [isGroupImageVisible, setIsGroupImageVisible] = useState(() => toChooseAnswerGroupImageUrls(initialData?.imageUrl).length > 0);
|
|
72
67
|
const [isPassageVisible, setIsPassageVisible] = useState(() => {
|
|
73
68
|
const html = initialData?.passage;
|
|
74
69
|
return Boolean(html && html !== '<p></p>' && html !== '<p><br></p>' && html.trim());
|
|
75
70
|
});
|
|
76
|
-
const [visibleItemAudio, setVisibleItemAudio] = useState({});
|
|
77
71
|
const onChangeRef = useRef(onChange);
|
|
78
72
|
const previousPayloadRef = useRef('');
|
|
79
73
|
useEffect(() => {
|
|
80
74
|
onChangeRef.current = onChange;
|
|
81
75
|
}, [onChange]);
|
|
82
76
|
const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
|
|
83
|
-
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
77
|
+
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation, nextAudioUrls = audioUrls, nextImageUrls = imageUrls) => {
|
|
78
|
+
const nextAudioUrl = fromChooseAnswerGroupImageUrls(nextAudioUrls);
|
|
79
|
+
const nextImageUrl = fromChooseAnswerGroupImageUrls(nextImageUrls);
|
|
80
|
+
return {
|
|
81
|
+
instruction: nextInstruction,
|
|
82
|
+
...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
|
|
83
|
+
...(nextAudioUrl !== undefined ? { audioUrl: nextAudioUrl } : {}),
|
|
84
|
+
...(nextImageUrl !== undefined ? { imageUrl: nextImageUrl } : {}),
|
|
85
|
+
items: nextItems,
|
|
86
|
+
explanation: nextExplanation,
|
|
87
|
+
points: sumPoints(nextItems),
|
|
88
|
+
};
|
|
89
|
+
};
|
|
92
90
|
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
93
91
|
onChangeRef.current?.(payload);
|
|
94
92
|
}, 300);
|
|
@@ -101,7 +99,7 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
101
99
|
previousPayloadRef.current = serialized;
|
|
102
100
|
onUnsavedChangesChange?.(true);
|
|
103
101
|
debouncedOnChange(payload);
|
|
104
|
-
}, [instruction, passage,
|
|
102
|
+
}, [instruction, passage, audioUrls, imageUrls, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
105
103
|
const validate = () => {
|
|
106
104
|
const nextErrors = [];
|
|
107
105
|
if (!instruction.trim()) {
|
|
@@ -111,9 +109,6 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
111
109
|
nextErrors.push('Cần ít nhất một câu hỏi.');
|
|
112
110
|
}
|
|
113
111
|
items.forEach((item, index) => {
|
|
114
|
-
if (!item.question.trim()) {
|
|
115
|
-
nextErrors.push(`Câu ${index + 1}: nội dung câu hỏi là bắt buộc.`);
|
|
116
|
-
}
|
|
117
112
|
const filledOptions = item.options.filter((option) => (isChooseAnswerGroupOptionFilled(option, item.optionType)));
|
|
118
113
|
if (filledOptions.length < 2) {
|
|
119
114
|
nextErrors.push(`Câu ${index + 1}: cần ít nhất 2 đáp án.`);
|
|
@@ -140,15 +135,6 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
140
135
|
const updateItem = (index, patch) => {
|
|
141
136
|
setItems((current) => current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
|
142
137
|
};
|
|
143
|
-
const updateOption = (itemIndex, optionIndex, patch) => {
|
|
144
|
-
setItems((current) => current.map((item, index) => {
|
|
145
|
-
if (index !== itemIndex) {
|
|
146
|
-
return item;
|
|
147
|
-
}
|
|
148
|
-
const options = item.options.map((option, currentOptionIndex) => (currentOptionIndex === optionIndex ? { ...option, ...patch } : option));
|
|
149
|
-
return { ...item, options };
|
|
150
|
-
}));
|
|
151
|
-
};
|
|
152
138
|
const addItem = () => {
|
|
153
139
|
setItems((current) => [...current, createEmptyItem(current.length + 1)]);
|
|
154
140
|
};
|
|
@@ -170,11 +156,29 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
170
156
|
value: sumPoints(items),
|
|
171
157
|
onValueChange: () => undefined,
|
|
172
158
|
allowZero: true,
|
|
173
|
-
}, preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [
|
|
174
|
-
|
|
175
|
-
|
|
159
|
+
}, preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "group-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer.", rows: 2, className: "min-h-[56px] border-gray-200 pb-11" }), _jsxs("div", { className: "absolute bottom-1.5 right-1.5 z-10 flex items-center gap-1 rounded-md border border-indigo-100 bg-white/95 p-0.5 shadow-sm", children: [_jsx("button", { type: "button", title: "Th\u00EAm h\u00ECnh \u1EA3nh", onClick: () => {
|
|
160
|
+
const hasEmpty = imageUrls.some((url) => !url.trim());
|
|
161
|
+
if (imageUrls.length === 0 || !hasEmpty) {
|
|
162
|
+
setImageUrls([...imageUrls, '']);
|
|
163
|
+
}
|
|
164
|
+
}, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${imageUrls.length > 0
|
|
176
165
|
? 'bg-indigo-600 text-white shadow-sm'
|
|
177
|
-
: 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100'}`, children: _jsx(ImageIcon, { className: "h-4 w-4" }) })
|
|
166
|
+
: 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100'}`, children: _jsx(ImageIcon, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Th\u00EAm audio", onClick: () => {
|
|
167
|
+
const hasEmpty = audioUrls.some((url) => !url.trim());
|
|
168
|
+
if (audioUrls.length === 0 || !hasEmpty) {
|
|
169
|
+
setAudioUrls([...audioUrls, '']);
|
|
170
|
+
}
|
|
171
|
+
}, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${audioUrls.length > 0
|
|
172
|
+
? 'bg-violet-600 text-white shadow-sm'
|
|
173
|
+
: 'bg-violet-50 text-violet-600 hover:bg-violet-100'}`, children: _jsx(Volume2, { className: "h-4 w-4" }) })] })] })] }), imageUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [imageUrls.map((url, imageIndex) => (_jsx(ChooseAnswerGroupImageUploadItem, { index: imageIndex, itemIndex: -1, imageUrl: url, onChange: (nextUrl) => {
|
|
174
|
+
setImageUrls((current) => current.map((item, idx) => (idx === imageIndex ? nextUrl : item)));
|
|
175
|
+
}, onRemove: () => {
|
|
176
|
+
setImageUrls((current) => current.filter((_, idx) => idx !== imageIndex));
|
|
177
|
+
} }, `group-image-${imageIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setImageUrls((current) => (current.length ? [...current, ''] : [''])), className: "h-8 gap-1.5 border-dashed border-indigo-300 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm \u1EA3nh"] })] })), audioUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [audioUrls.map((url, audioIndex) => (_jsx(ChooseAnswerGroupAudioUploadItem, { index: audioIndex, itemIndex: -1, audioUrl: url, onChange: (nextUrl) => {
|
|
178
|
+
setAudioUrls((current) => current.map((item, idx) => (idx === audioIndex ? nextUrl : item)));
|
|
179
|
+
}, onRemove: () => {
|
|
180
|
+
setAudioUrls((current) => current.filter((_, idx) => idx !== audioIndex));
|
|
181
|
+
} }, `group-audio-${audioIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setAudioUrls((current) => (current.length ? [...current, ''] : [''])), className: "h-8 gap-1.5 border-dashed border-violet-300 text-violet-600 hover:bg-violet-50 hover:text-violet-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm audio"] })] })), !isPassageVisible ? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsPassageVisible(true), className: "h-8 gap-1.5 border-gray-200 px-2.5 text-xs text-gray-600 hover:bg-gray-50", children: [_jsx(BookOpen, { className: "h-3.5 w-3.5" }), "Th\u00EAm n\u1ED9i dung b\u00E0i \u0111\u1ECDc"] })) : (_jsxs("div", { className: "relative space-y-1.5 pr-10", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsPassageVisible(false), className: "absolute right-0 top-0 z-10 h-7 px-2 text-xs text-gray-500 hover:text-gray-700", children: "\u1EA8n" }), _jsxs(Label, { className: "text-sm font-semibold text-gray-800", children: ["N\u1ED9i dung b\u00E0i \u0111\u1ECDc ", _jsx("span", { className: "text-xs font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, variant: "compact", minHeightClassName: "min-h-[72px]" })] }))] }), items.map((item, itemIndex) => (_jsx(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(HelpCircle, { className: "h-3.5 w-3.5" }), title: `Câu ${itemIndex + 1}`, points: !item.isExample ? {
|
|
178
182
|
value: item.points,
|
|
179
183
|
allowZero: true,
|
|
180
184
|
onValueChange: (value) => updateItem(itemIndex, { points: value }),
|
|
@@ -184,22 +188,5 @@ export function ChooseTheCorrectAnswerGroupCreator({ initialData, onChange, exte
|
|
|
184
188
|
isExample: checked,
|
|
185
189
|
points: checked ? 0 : (item.points || 1),
|
|
186
190
|
}),
|
|
187
|
-
}, extraActions: _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, className: "h-8 w-8 text-gray-400 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }) }), children:
|
|
188
|
-
? 'bg-violet-600 text-white shadow-sm'
|
|
189
|
-
: 'bg-violet-50 text-violet-600 hover:bg-violet-100'}`, children: _jsx(Volume2, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Th\u00EAm h\u00ECnh \u1EA3nh", onClick: () => {
|
|
190
|
-
const nextUrls = [...toEditableChooseAnswerGroupImageUrls(item.imageUrl), ''];
|
|
191
|
-
updateItem(itemIndex, { imageUrl: nextUrls });
|
|
192
|
-
}, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${toEditableChooseAnswerGroupImageUrls(item.imageUrl).length > 0
|
|
193
|
-
? 'bg-indigo-600 text-white shadow-sm'
|
|
194
|
-
: 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100'}`, children: _jsx(ImageIcon, { className: "h-4 w-4" }) })] })] }), _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", className: "min-h-[56px] border-gray-200" })] }), (item.audioUrl || visibleItemAudio[itemIndex]) && (_jsxs("div", { className: "flex items-center gap-2 rounded-md border border-gray-200 bg-white p-1.5", children: [_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-indigo-50", children: _jsx(Volume2, { className: "h-3.5 w-3.5 text-indigo-500" }) }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(FileUpload, { id: `choose-answer-item-audio-${itemIndex}`, label: "", 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 }) })] })), toEditableChooseAnswerGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "space-y-2", children: toEditableChooseAnswerGroupImageUrls(item.imageUrl).map((imageUrl, imageIndex) => (_jsx(ChooseAnswerGroupImageUploadItem, { index: imageIndex, itemIndex: itemIndex, imageUrl: imageUrl, onChange: (url) => {
|
|
195
|
-
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
196
|
-
nextUrls[imageIndex] = url;
|
|
197
|
-
updateItem(itemIndex, { imageUrl: nextUrls });
|
|
198
|
-
}, onRemove: () => {
|
|
199
|
-
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl)
|
|
200
|
-
.filter((_, idx) => idx !== imageIndex);
|
|
201
|
-
updateItem(itemIndex, { imageUrl: fromChooseAnswerGroupImageUrls(nextUrls) });
|
|
202
|
-
} }, `${itemIndex}-${imageIndex}`))) })), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "\u0110\u00E1p \u00E1n" }), _jsxs("div", { className: "flex h-8 items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: item.optionType === 'image', onCheckedChange: (checked) => updateItem(itemIndex, {
|
|
203
|
-
optionType: checked ? 'image' : 'text',
|
|
204
|
-
}) }), _jsx(ImageIcon, { className: `h-3.5 w-3.5 ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}`, children: "\u1EA2nh" })] })] }), item.options.map((option, optionIndex) => (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("input", { type: "radio", name: `correct-${itemIndex}`, checked: item.correctAnswer === option.id, onChange: () => updateItem(itemIndex, { correctAnswer: option.id }), className: "mt-2" }), _jsx("span", { className: "mt-2 w-6 text-sm font-semibold text-gray-600", children: OPTION_LABELS[optionIndex] || optionIndex + 1 }), item.optionType === 'image' ? (_jsx(ChooseAnswerGroupOptionImageField, { itemIndex: itemIndex, optionIndex: optionIndex, imageUrl: getChooseAnswerGroupOptionImageSrc(option), onChange: (url) => updateOption(itemIndex, optionIndex, { imageUrl: url, text: '' }) })) : (_jsx(Input, { value: option.text, onChange: (event) => updateOption(itemIndex, optionIndex, { text: event.target.value }), placeholder: `Đáp án ${OPTION_LABELS[optionIndex]}`, className: "h-8 border-gray-200" }))] }, option.id)))] })] }, `item-${itemIndex}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, className: "h-8 gap-1.5 border-dashed border-green-200 px-2.5 text-xs text-green-600 hover:bg-green-50 hover:text-green-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm c\u00E2u h\u1ECFi"] }), _jsx(CompactExplanationToggle, { value: explanation, onValueChange: setExplanation, defaultVisible: Boolean(initialData?.explanation) }), allErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: allErrors.map((error) => (_jsx("p", { children: error }, error))) }))] }));
|
|
191
|
+
}, extraActions: _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removeItem(itemIndex), disabled: items.length <= 1, className: "h-8 w-8 text-gray-400 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }) }), children: _jsx(ChooseTheCorrectAnswerGroupItemEditor, { item: item, itemIndex: itemIndex, onChange: (patch) => updateItem(itemIndex, patch) }) }, `item-${itemIndex}`))), _jsxs(Button, { type: "button", variant: "outline", onClick: addItem, className: "h-8 gap-1.5 border-dashed border-green-200 px-2.5 text-xs text-green-600 hover:bg-green-50 hover:text-green-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm c\u00E2u h\u1ECFi"] }), _jsx(CompactExplanationToggle, { value: explanation, onValueChange: setExplanation, defaultVisible: Boolean(initialData?.explanation) }), allErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: allErrors.map((error) => (_jsx("p", { children: error }, error))) }))] }));
|
|
205
192
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ChooseTheCorrectAnswerGroupItemData } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
2
|
+
export declare function ChooseTheCorrectAnswerGroupItemEditor({ item, itemIndex, onChange, }: {
|
|
3
|
+
item: ChooseTheCorrectAnswerGroupItemData;
|
|
4
|
+
itemIndex: number;
|
|
5
|
+
onChange: (patch: Partial<ChooseTheCorrectAnswerGroupItemData>) => void;
|
|
6
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useRef, useState } from 'react';
|
|
4
|
+
import { Button } from '../../../../components/ui/button';
|
|
5
|
+
import { Label } from '../../../../components/ui/label';
|
|
6
|
+
import { Input } from '../../../../components/ui/input';
|
|
7
|
+
import { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
+
import { Switch } from '../../../../components/ui/switch';
|
|
9
|
+
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
10
|
+
import { hasRichTextFormatting, htmlToPlainEditorText } from '../../../../shared/lib/rich-text';
|
|
11
|
+
import { CheckCircle2, ImageIcon, Pilcrow, Plus, Trash2, Type, Volume2 } from 'lucide-react';
|
|
12
|
+
import { ChooseAnswerGroupAudioUploadItem, ChooseAnswerGroupImageUploadItem, ChooseAnswerGroupOptionImageField, } from './ChooseAnswerGroupImageUpload';
|
|
13
|
+
import { fromChooseAnswerGroupImageUrls, getChooseAnswerGroupOptionImageSrc, toEditableChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
|
|
14
|
+
function optionLetter(index) {
|
|
15
|
+
return String.fromCharCode(65 + index);
|
|
16
|
+
}
|
|
17
|
+
function nextOptionId(options) {
|
|
18
|
+
const used = new Set(options.map((option) => option.id));
|
|
19
|
+
let next = 1;
|
|
20
|
+
while (used.has(`opt-${next}`)) {
|
|
21
|
+
next += 1;
|
|
22
|
+
}
|
|
23
|
+
return `opt-${next}`;
|
|
24
|
+
}
|
|
25
|
+
function shouldStartInTipTapMode(item) {
|
|
26
|
+
if (hasRichTextFormatting(item.question)) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return item.options.some((option) => hasRichTextFormatting(option.text));
|
|
30
|
+
}
|
|
31
|
+
function emptyOptions(count) {
|
|
32
|
+
return Array.from({ length: count }, (_, index) => ({
|
|
33
|
+
id: `opt-${index + 1}`,
|
|
34
|
+
text: '',
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
function ChooseAnswerTextInput({ value, onChange, isTipTapMode, placeholder, compact = false, className, }) {
|
|
38
|
+
if (isTipTapMode) {
|
|
39
|
+
return (_jsx(RichTextEditor, { value: value, onChange: onChange, variant: "compact", minHeightClassName: compact ? `min-h-[56px] ${className ?? ''}` : `min-h-[72px] ${className ?? ''}` }));
|
|
40
|
+
}
|
|
41
|
+
if (compact) {
|
|
42
|
+
return (_jsx(Input, { value: htmlToPlainEditorText(value), onChange: (event) => onChange(event.target.value), placeholder: placeholder, className: `h-10 border-gray-200 bg-white ${className ?? ''}` }));
|
|
43
|
+
}
|
|
44
|
+
return (_jsx(Textarea, { value: htmlToPlainEditorText(value), onChange: (event) => onChange(event.target.value), placeholder: placeholder, rows: 2, className: `min-h-[56px] border-gray-200 bg-white ${className ?? ''}` }));
|
|
45
|
+
}
|
|
46
|
+
export function ChooseTheCorrectAnswerGroupItemEditor({ item, itemIndex, onChange, }) {
|
|
47
|
+
const [isTipTapMode, setIsTipTapMode] = useState(() => shouldStartInTipTapMode(item));
|
|
48
|
+
const textOptionsCacheRef = useRef(null);
|
|
49
|
+
const imageOptionsCacheRef = useRef(null);
|
|
50
|
+
const imageUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
51
|
+
const audioUrls = toEditableChooseAnswerGroupImageUrls(item.audioUrl);
|
|
52
|
+
const hasCorrectAnswer = item.options.some((option) => option.id === item.correctAnswer);
|
|
53
|
+
const updateOption = (optionIndex, patch) => {
|
|
54
|
+
onChange({
|
|
55
|
+
options: item.options.map((option, index) => (index === optionIndex ? { ...option, ...patch } : option)),
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const switchOptionType = (nextType) => {
|
|
59
|
+
if (nextType === item.optionType) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (item.optionType === 'text') {
|
|
63
|
+
textOptionsCacheRef.current = { options: item.options, correctAnswer: item.correctAnswer };
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
imageOptionsCacheRef.current = { options: item.options, correctAnswer: item.correctAnswer };
|
|
67
|
+
}
|
|
68
|
+
const targetCache = nextType === 'text' ? textOptionsCacheRef.current : imageOptionsCacheRef.current;
|
|
69
|
+
if (targetCache) {
|
|
70
|
+
onChange({
|
|
71
|
+
optionType: nextType,
|
|
72
|
+
options: targetCache.options,
|
|
73
|
+
correctAnswer: targetCache.correctAnswer,
|
|
74
|
+
});
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const nextOptions = emptyOptions(item.options.length);
|
|
78
|
+
onChange({
|
|
79
|
+
optionType: nextType,
|
|
80
|
+
options: nextOptions,
|
|
81
|
+
correctAnswer: nextOptions[0]?.id || 'opt-1',
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
return (_jsxs("div", { className: "space-y-2.5", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs(Label, { className: "text-sm font-semibold text-gray-800", children: ["C\u00E2u h\u1ECFi ", _jsx("span", { className: "text-xs font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsxs("button", { type: "button", onClick: () => setIsTipTapMode((enabled) => !enabled), title: isTipTapMode ? 'Chuyển về ô nhập thường' : 'Bật định dạng chữ (in đậm, danh sách, bảng)', className: `inline-flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors select-none ${isTipTapMode
|
|
85
|
+
? 'border-indigo-300 bg-indigo-50 text-indigo-700'
|
|
86
|
+
: 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Pilcrow, { className: "h-3.5 w-3.5" }), "\u0110\u1ECBnh d\u1EA1ng"] })] }), _jsxs("div", { className: "relative", children: [_jsx(ChooseAnswerTextInput, { value: item.question, onChange: (question) => onChange({ question }), isTipTapMode: isTipTapMode, placeholder: "Nh\u1EADp c\u00E2u h\u1ECFi...", className: "pb-11" }), _jsxs("div", { className: "absolute bottom-1.5 right-1.5 z-10 flex items-center gap-1 rounded-md border border-indigo-100 bg-white/95 p-0.5 shadow-sm", children: [_jsx("button", { type: "button", title: "Th\u00EAm h\u00ECnh \u1EA3nh", onClick: () => {
|
|
87
|
+
const current = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
88
|
+
const hasEmpty = current.some((url) => !url.trim());
|
|
89
|
+
if (current.length === 0 || !hasEmpty) {
|
|
90
|
+
onChange({ imageUrl: [...current, ''] });
|
|
91
|
+
}
|
|
92
|
+
}, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${imageUrls.length > 0
|
|
93
|
+
? 'bg-indigo-600 text-white shadow-sm'
|
|
94
|
+
: 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100'}`, children: _jsx(ImageIcon, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Th\u00EAm audio", onClick: () => {
|
|
95
|
+
const current = toEditableChooseAnswerGroupImageUrls(item.audioUrl);
|
|
96
|
+
const hasEmpty = current.some((url) => !url.trim());
|
|
97
|
+
if (current.length === 0 || !hasEmpty) {
|
|
98
|
+
onChange({ audioUrl: [...current, ''] });
|
|
99
|
+
}
|
|
100
|
+
}, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${audioUrls.length > 0
|
|
101
|
+
? 'bg-violet-600 text-white shadow-sm'
|
|
102
|
+
: 'bg-violet-50 text-violet-600 hover:bg-violet-100'}`, children: _jsx(Volume2, { className: "h-4 w-4" }) })] })] })] }), imageUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [imageUrls.map((imageUrl, imageIndex) => (_jsx(ChooseAnswerGroupImageUploadItem, { index: imageIndex, itemIndex: itemIndex, imageUrl: imageUrl, onChange: (url) => {
|
|
103
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
104
|
+
nextUrls[imageIndex] = url;
|
|
105
|
+
onChange({ imageUrl: nextUrls });
|
|
106
|
+
}, onRemove: () => {
|
|
107
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.imageUrl)
|
|
108
|
+
.filter((_, idx) => idx !== imageIndex);
|
|
109
|
+
onChange({ imageUrl: fromChooseAnswerGroupImageUrls(nextUrls) });
|
|
110
|
+
} }, `${itemIndex}-image-${imageIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
111
|
+
const current = toEditableChooseAnswerGroupImageUrls(item.imageUrl);
|
|
112
|
+
onChange({ imageUrl: current.length ? [...current, ''] : [''] });
|
|
113
|
+
}, className: "h-8 gap-1.5 border-dashed border-indigo-300 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm \u1EA3nh"] })] })), audioUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [audioUrls.map((audioUrl, audioIndex) => (_jsx(ChooseAnswerGroupAudioUploadItem, { index: audioIndex, itemIndex: itemIndex, audioUrl: audioUrl, onChange: (url) => {
|
|
114
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.audioUrl);
|
|
115
|
+
nextUrls[audioIndex] = url;
|
|
116
|
+
onChange({ audioUrl: nextUrls });
|
|
117
|
+
}, onRemove: () => {
|
|
118
|
+
const nextUrls = toEditableChooseAnswerGroupImageUrls(item.audioUrl)
|
|
119
|
+
.filter((_, idx) => idx !== audioIndex);
|
|
120
|
+
onChange({ audioUrl: fromChooseAnswerGroupImageUrls(nextUrls) });
|
|
121
|
+
} }, `${itemIndex}-audio-${audioIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
122
|
+
const current = toEditableChooseAnswerGroupImageUrls(item.audioUrl);
|
|
123
|
+
onChange({ audioUrl: current.length ? [...current, ''] : [''] });
|
|
124
|
+
}, className: "h-8 gap-1.5 border-dashed border-violet-300 text-violet-600 hover:bg-violet-50 hover:text-violet-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm audio"] })] })), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs(Label, { className: "text-sm font-semibold text-gray-800", children: ["\u0110\u00E1p \u00E1n ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", item.options.length, " \u0111\u00E1p \u00E1n)"] })] }), _jsxs("div", { className: "flex h-8 items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 px-2.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: item.optionType === 'image', className: item.optionType === 'image' ? '!bg-indigo-600' : undefined, onCheckedChange: (checked) => switchOptionType(checked ? 'image' : 'text') }), _jsx(ImageIcon, { className: `h-3.5 w-3.5 ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${item.optionType === 'image' ? 'text-indigo-600' : 'text-gray-400'}`, children: "\u1EA2nh" })] })] }), item.options.map((option, optionIndex) => {
|
|
125
|
+
const isCorrect = option.id === item.correctAnswer;
|
|
126
|
+
const letter = optionLetter(optionIndex);
|
|
127
|
+
return (_jsx("div", { className: `group relative rounded-md px-3 py-2.5 transition-all duration-200 ${isCorrect
|
|
128
|
+
? 'border-2 border-green-400 bg-gradient-to-r from-green-50 to-emerald-50 shadow-sm shadow-green-100'
|
|
129
|
+
: 'border border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'}`, children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: `flex h-8 w-8 flex-shrink-0 cursor-pointer items-center justify-center rounded-full text-xs font-bold leading-none transition-all ${isCorrect
|
|
130
|
+
? 'bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-sm'
|
|
131
|
+
: 'bg-gray-100 text-gray-800 hover:bg-indigo-100 hover:text-indigo-600'}`, onClick: () => onChange({ correctAnswer: option.id }), children: letter }), _jsx("div", { className: "flex-1 space-y-2", children: item.optionType === 'image' ? (_jsx(ChooseAnswerGroupOptionImageField, { itemIndex: itemIndex, optionIndex: optionIndex, imageUrl: getChooseAnswerGroupOptionImageSrc(option), onChange: (url) => updateOption(optionIndex, { imageUrl: url, text: '' }) })) : (_jsx(ChooseAnswerTextInput, { value: option.text, onChange: (text) => updateOption(optionIndex, { text }), isTipTapMode: isTipTapMode, compact: true, placeholder: `Nhập đáp án ${letter}...` })) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => onChange({ correctAnswer: option.id }), className: `h-7 w-7 ${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-3.5 w-3.5" }) }), item.options.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => {
|
|
132
|
+
const nextOptions = item.options.filter((_, index) => index !== optionIndex);
|
|
133
|
+
onChange({
|
|
134
|
+
options: nextOptions,
|
|
135
|
+
correctAnswer: isCorrect
|
|
136
|
+
? (nextOptions[0]?.id || 'opt-1')
|
|
137
|
+
: item.correctAnswer,
|
|
138
|
+
});
|
|
139
|
+
}, className: "h-7 w-7 text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] }) }, option.id));
|
|
140
|
+
}), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => {
|
|
141
|
+
onChange({
|
|
142
|
+
options: [...item.options, { id: nextOptionId(item.options), text: '' }],
|
|
143
|
+
});
|
|
144
|
+
}, className: "h-8 w-full gap-1.5 border-dashed border-green-200 px-2.5 text-xs text-green-600 hover:bg-green-50 hover:text-green-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm \u0111\u00E1p \u00E1n"] }), !hasCorrectAnswer && (_jsx("div", { className: "flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800", children: _jsx("span", { children: "Ch\u01B0a ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng. Click ch\u1EEF c\u00E1i ho\u1EB7c \u2713 \u0111\u1EC3 ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng." }) }))] })] }));
|
|
145
|
+
}
|
|
@@ -18,13 +18,6 @@ function asOptionId(value) {
|
|
|
18
18
|
}
|
|
19
19
|
return '';
|
|
20
20
|
}
|
|
21
|
-
function asOptionalUrl(value) {
|
|
22
|
-
if (typeof value !== 'string') {
|
|
23
|
-
return undefined;
|
|
24
|
-
}
|
|
25
|
-
const trimmed = value.trim();
|
|
26
|
-
return trimmed !== '' ? trimmed : undefined;
|
|
27
|
-
}
|
|
28
21
|
export function createEmptyChooseAnswerGroupItem(questionNumber) {
|
|
29
22
|
return {
|
|
30
23
|
question: '',
|
|
@@ -32,7 +25,6 @@ export function createEmptyChooseAnswerGroupItem(questionNumber) {
|
|
|
32
25
|
options: [
|
|
33
26
|
{ id: 'opt-1', text: '' },
|
|
34
27
|
{ id: 'opt-2', text: '' },
|
|
35
|
-
{ id: 'opt-3', text: '' },
|
|
36
28
|
],
|
|
37
29
|
correctAnswer: 'opt-1',
|
|
38
30
|
isExample: false,
|
|
@@ -64,11 +56,10 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
64
56
|
: [
|
|
65
57
|
{ id: 'opt-1', text: '' },
|
|
66
58
|
{ id: 'opt-2', text: '' },
|
|
67
|
-
{ id: 'opt-3', text: '' },
|
|
68
59
|
];
|
|
69
60
|
const isExample = record.isExample === true;
|
|
70
61
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.imageUrl));
|
|
71
|
-
const audioUrl =
|
|
62
|
+
const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(content.audioUrl));
|
|
72
63
|
return {
|
|
73
64
|
question: asString(content.question) || asString(record.question),
|
|
74
65
|
optionType: asString(content.optionType) === 'image' ? 'image' : 'text',
|
|
@@ -78,7 +69,7 @@ export function mapChooseAnswerGroupItem(item, index, fallbackAnswer) {
|
|
|
78
69
|
points: isExample ? 0 : typeof record.points === 'number' ? record.points : 1,
|
|
79
70
|
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
80
71
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
81
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
72
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
82
73
|
};
|
|
83
74
|
}
|
|
84
75
|
export function mapQuestionToChooseAnswerGroupData(question) {
|
|
@@ -92,12 +83,12 @@ export function mapQuestionToChooseAnswerGroupData(question) {
|
|
|
92
83
|
: [];
|
|
93
84
|
if (Array.isArray(content.items)) {
|
|
94
85
|
const items = content.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index]));
|
|
95
|
-
const audioUrl =
|
|
86
|
+
const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(asRecord(content.meta).audioUrl));
|
|
96
87
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(asRecord(content.meta).imageUrl));
|
|
97
88
|
return {
|
|
98
89
|
instruction: asString(asRecord(content.meta).instruction) || 'Choose the correct answer.',
|
|
99
90
|
passage: asString(asRecord(content.meta).passage),
|
|
100
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
91
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
101
92
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
102
93
|
items,
|
|
103
94
|
explanation,
|
|
@@ -105,12 +96,12 @@ export function mapQuestionToChooseAnswerGroupData(question) {
|
|
|
105
96
|
};
|
|
106
97
|
}
|
|
107
98
|
if (Array.isArray(answer.items)) {
|
|
108
|
-
const audioUrl =
|
|
99
|
+
const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answer.audioUrl));
|
|
109
100
|
const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answer.imageUrl));
|
|
110
101
|
return {
|
|
111
102
|
instruction: asString(answer.instruction, 'Choose the correct answer.'),
|
|
112
103
|
passage: asString(answer.passage),
|
|
113
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
104
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
114
105
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
115
106
|
items: answer.items.map((item, index) => mapChooseAnswerGroupItem(item, index, fallbackAnswers[index])),
|
|
116
107
|
explanation,
|
|
@@ -26,7 +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 =
|
|
29
|
+
const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(item.audioUrl));
|
|
30
30
|
return {
|
|
31
31
|
questionType: CHILD_QUESTION_TYPE,
|
|
32
32
|
...(item.isExample ? { isExample: true } : {}),
|
|
@@ -35,7 +35,7 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
35
35
|
options,
|
|
36
36
|
optionType: item.optionType || 'text',
|
|
37
37
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
38
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
38
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
39
39
|
},
|
|
40
40
|
correctAnswer: { answer: correctAnswer },
|
|
41
41
|
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
@@ -48,8 +48,9 @@ export const transformChooseTheCorrectAnswerGroup = (question) => {
|
|
|
48
48
|
if (answerData?.passage && answerData.passage.trim()) {
|
|
49
49
|
meta.passage = answerData.passage.trim();
|
|
50
50
|
}
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const groupAudioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answerData?.audioUrl));
|
|
52
|
+
if (groupAudioUrl !== undefined) {
|
|
53
|
+
meta.audioUrl = groupAudioUrl;
|
|
53
54
|
}
|
|
54
55
|
const groupImageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answerData?.imageUrl));
|
|
55
56
|
if (groupImageUrl !== undefined) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { SpontaneousQaGroupClientProps } from '../../_shared/types/spontaneous-qa-group.type';
|
|
2
|
-
export declare function SpontaneousQaGroupClient({ questionData, }: SpontaneousQaGroupClientProps): import("react").JSX.Element;
|
|
2
|
+
export declare function SpontaneousQaGroupClient({ questionData, isReviewMode, }: SpontaneousQaGroupClientProps): import("react").JSX.Element;
|
|
@@ -1,9 +1,85 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { CircleHelp, Lightbulb, Mic, Square, Volume2 } from 'lucide-react';
|
|
5
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
+
function QuestionAudio({ url }) {
|
|
7
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
8
|
+
const src = previewUrl ||
|
|
9
|
+
(url.startsWith('http') || url.startsWith('blob:') ? url : '');
|
|
10
|
+
if (!src && !isLoading) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return (_jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-blue-100 bg-blue-50/60 px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-blue-500" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-blue-100" })) : (_jsx("audio", { controls: true, src: src, className: "h-8 w-full", preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
|
|
14
|
+
}
|
|
15
|
+
function SpeakingReplyMic() {
|
|
16
|
+
const [status, setStatus] = useState('idle');
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
const [blobUrl, setBlobUrl] = useState('');
|
|
19
|
+
const recorderRef = useRef(null);
|
|
20
|
+
const chunksRef = useRef([]);
|
|
21
|
+
const streamRef = useRef(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
return () => {
|
|
24
|
+
recorderRef.current?.stop();
|
|
25
|
+
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
26
|
+
if (blobUrl) {
|
|
27
|
+
URL.revokeObjectURL(blobUrl);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}, [blobUrl]);
|
|
31
|
+
const stopStream = () => {
|
|
32
|
+
streamRef.current?.getTracks().forEach((track) => track.stop());
|
|
33
|
+
streamRef.current = null;
|
|
34
|
+
};
|
|
35
|
+
const startRecording = async () => {
|
|
36
|
+
setError('');
|
|
37
|
+
try {
|
|
38
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
39
|
+
streamRef.current = stream;
|
|
40
|
+
chunksRef.current = [];
|
|
41
|
+
const recorder = new MediaRecorder(stream);
|
|
42
|
+
recorderRef.current = recorder;
|
|
43
|
+
recorder.ondataavailable = (event) => {
|
|
44
|
+
if (event.data.size > 0) {
|
|
45
|
+
chunksRef.current.push(event.data);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
recorder.onstop = () => {
|
|
49
|
+
const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
|
|
50
|
+
setBlobUrl((current) => {
|
|
51
|
+
if (current) {
|
|
52
|
+
URL.revokeObjectURL(current);
|
|
53
|
+
}
|
|
54
|
+
return URL.createObjectURL(blob);
|
|
55
|
+
});
|
|
56
|
+
setStatus('recorded');
|
|
57
|
+
stopStream();
|
|
58
|
+
};
|
|
59
|
+
recorder.start();
|
|
60
|
+
setStatus('recording');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
setError('Không thể mở microphone. Hãy cho phép quyền ghi âm.');
|
|
64
|
+
stopStream();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const stopRecording = () => {
|
|
68
|
+
if (recorderRef.current && recorderRef.current.state !== 'inactive') {
|
|
69
|
+
recorderRef.current.stop();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-2 rounded-lg border border-dashed border-indigo-200 bg-indigo-50/60 px-4 py-3", children: [_jsx("button", { type: "button", onClick: status === 'recording' ? stopRecording : startRecording, title: status === 'recording' ? 'Dừng ghi âm' : 'Nhấn micro để trả lời', className: `inline-flex h-12 w-12 items-center justify-center rounded-full text-white shadow-sm transition-colors ${status === 'recording'
|
|
73
|
+
? 'animate-pulse bg-red-500 hover:bg-red-600'
|
|
74
|
+
: 'bg-indigo-600 hover:bg-indigo-700'}`, children: status === 'recording' ? _jsx(Square, { className: "h-4 w-4 fill-current" }) : _jsx(Mic, { className: "h-5 w-5" }) }), _jsx("p", { className: "text-center text-xs text-indigo-700", children: status === 'recording'
|
|
75
|
+
? 'Đang ghi âm... Nhấn để dừng'
|
|
76
|
+
: status === 'recorded'
|
|
77
|
+
? 'Đã ghi. Nhấn micro để ghi lại'
|
|
78
|
+
: 'Nhấn micro để trả lời bằng giọng nói' }), error && _jsx("p", { className: "text-center text-xs text-red-600", children: error }), status === 'recorded' && blobUrl && (_jsx("audio", { controls: true, src: blobUrl, className: "h-8 w-full max-w-sm", preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
|
|
79
|
+
}
|
|
80
|
+
export function SpontaneousQaGroupClient({ questionData, isReviewMode = false, }) {
|
|
5
81
|
const parts = questionData.parts || [];
|
|
6
|
-
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 })), parts.map((part, partIndex) => (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [_jsx("p", { className: "text-sm font-semibold text-slate-800", children: part.partTitle || `PART ${part.partNumber || partIndex + 1}` }), part.topic && (_jsx("p", { className: "mt-1 text-sm text-slate-600", children: part.topic }))] }), _jsx("div", { className: "space-y-3 p-3", children: part.questions.map((question, questionIndex) => (_jsxs("div", { className: "rounded-lg border border-gray-200 bg-white p-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", 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-blue-100 text-blue-700", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", question.questionNumber || questionIndex + 1] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [question.points || 0, " \u0111i\u1EC3m"] })] }), _jsx("p", { className: "text-sm font-semibold text-gray-800", children: question.questionText || 'Câu hỏi chưa được nhập' }), question.expectedAnswers.filter((item) => item.trim() !== '').length > 0 && (_jsxs("div", { className: "mt-2 rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold text-amber-700", children: [_jsx(Lightbulb, { className: "h-3 w-3" }), "\u0110\u00E1p \u00E1n m\u1EABu"] }), _jsx("ul", { className: "list-disc space-y-1 pl-4 text-xs text-amber-800", children: question.expectedAnswers
|
|
82
|
+
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 })), parts.map((part, partIndex) => (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white px-3 py-2", children: [_jsx("p", { className: "text-sm font-semibold text-slate-800", children: part.partTitle || `PART ${part.partNumber || partIndex + 1}` }), part.topic && (_jsx("p", { className: "mt-1 text-sm text-slate-600", children: part.topic }))] }), _jsx("div", { className: "space-y-3 p-3", children: part.questions.map((question, questionIndex) => (_jsxs("div", { className: "rounded-lg border border-gray-200 bg-white p-3", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between", 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-blue-100 text-blue-700", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", question.questionNumber || questionIndex + 1] })] }), _jsxs("span", { className: "text-xs text-gray-500", children: [question.points || 0, " \u0111i\u1EC3m"] })] }), _jsx("p", { className: "text-sm font-semibold text-gray-800", children: question.questionText || 'Câu hỏi chưa được nhập' }), question.audioUrl ? _jsx("div", { className: "mt-2", children: _jsx(QuestionAudio, { url: question.audioUrl }) }) : null, _jsx("div", { className: "mt-3", children: _jsx(SpeakingReplyMic, {}) }), isReviewMode && question.expectedAnswers.filter((item) => item.trim() !== '').length > 0 && (_jsxs("div", { className: "mt-2 rounded-lg border border-amber-100 bg-amber-50 px-3 py-2", children: [_jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold text-amber-700", children: [_jsx(Lightbulb, { className: "h-3 w-3" }), "\u0110\u00E1p \u00E1n m\u1EABu"] }), _jsx("ul", { className: "list-disc space-y-1 pl-4 text-xs text-amber-800", children: question.expectedAnswers
|
|
7
83
|
.filter((item) => item.trim() !== '')
|
|
8
84
|
.map((item) => (_jsx("li", { children: item }, item))) })] }))] }, `${part.partNumber}-${question.questionNumber}-${questionIndex}`))) })] }, `part-${part.partNumber}-${partIndex}`)))] }));
|
|
9
85
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function isRecord(value) {
|
|
2
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
3
|
+
}
|
|
4
|
+
function asRecord(value) {
|
|
5
|
+
return isRecord(value) ? value : {};
|
|
6
|
+
}
|
|
7
|
+
function asString(value, fallback = '') {
|
|
8
|
+
return typeof value === 'string' ? value : fallback;
|
|
9
|
+
}
|
|
10
|
+
function asNumber(value, fallback) {
|
|
11
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
12
|
+
}
|
|
13
|
+
function normalizeExpectedAnswers(value) {
|
|
14
|
+
if (!Array.isArray(value)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
.filter((item) => typeof item === 'string')
|
|
19
|
+
.map((item) => item.trim())
|
|
20
|
+
.filter((item) => item !== '');
|
|
21
|
+
}
|
|
22
|
+
function mapQuestion(item, index) {
|
|
23
|
+
const record = asRecord(item);
|
|
24
|
+
const content = asRecord(record.content);
|
|
25
|
+
const correctAnswer = asRecord(record.correctAnswer);
|
|
26
|
+
return {
|
|
27
|
+
questionText: asString(content.questionText) ||
|
|
28
|
+
asString(content.question) ||
|
|
29
|
+
asString(record.questionText),
|
|
30
|
+
inputType: asString(content.inputType) === 'AUDIO' ? 'AUDIO' : 'TEXT',
|
|
31
|
+
audioUrl: asString(content.audioUrl),
|
|
32
|
+
expectedAnswers: normalizeExpectedAnswers(correctAnswer.expectedAnswers),
|
|
33
|
+
points: asNumber(record.points, 1),
|
|
34
|
+
questionNumber: asNumber(record.questionNumber, index + 1),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function mapPart(item, index) {
|
|
38
|
+
const record = asRecord(item);
|
|
39
|
+
const questions = Array.isArray(record.questions) ? record.questions : [];
|
|
40
|
+
return {
|
|
41
|
+
partNumber: asNumber(record.partNumber, index + 1),
|
|
42
|
+
partTitle: asString(record.partTitle, `PART ${index + 1}`),
|
|
43
|
+
topic: asString(record.topic),
|
|
44
|
+
questions: questions.length > 0
|
|
45
|
+
? questions.map((question, questionIndex) => mapQuestion(question, questionIndex))
|
|
46
|
+
: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function mapQuestionToSpontaneousQaGroupData(question) {
|
|
50
|
+
const content = asRecord(question.content);
|
|
51
|
+
const list = asRecord(question.spontaneousQAList ?? content.spontaneousQAList ?? content);
|
|
52
|
+
const itemsRaw = Array.isArray(list.items)
|
|
53
|
+
? list.items
|
|
54
|
+
: Array.isArray(content.items)
|
|
55
|
+
? content.items
|
|
56
|
+
: [];
|
|
57
|
+
const hasNestedParts = itemsRaw.some((item) => Array.isArray(asRecord(item).questions));
|
|
58
|
+
const parts = hasNestedParts
|
|
59
|
+
? itemsRaw.map((item, index) => mapPart(item, index))
|
|
60
|
+
: [mapPart({
|
|
61
|
+
partNumber: 1,
|
|
62
|
+
partTitle: '',
|
|
63
|
+
topic: '',
|
|
64
|
+
questions: itemsRaw,
|
|
65
|
+
}, 0)];
|
|
66
|
+
return {
|
|
67
|
+
instruction: asString(asRecord(list.meta).instruction) ||
|
|
68
|
+
asString(asRecord(content.meta).instruction) ||
|
|
69
|
+
'Talk about the topic.',
|
|
70
|
+
parts,
|
|
71
|
+
explanation: asString(question.explanation),
|
|
72
|
+
points: parts.reduce((total, part) => (total + part.questions.reduce((partTotal, item) => partTotal + (item.points || 0), 0)), 0),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -1,82 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
function asRecord(value) {
|
|
7
|
-
return isRecord(value) ? value : {};
|
|
8
|
-
}
|
|
9
|
-
function asString(value, fallback = '') {
|
|
10
|
-
return typeof value === 'string' ? value : fallback;
|
|
11
|
-
}
|
|
12
|
-
function mapItem(item, index) {
|
|
13
|
-
const record = asRecord(item);
|
|
14
|
-
const content = asRecord(record.content);
|
|
15
|
-
const correctAnswer = asRecord(record.correctAnswer);
|
|
16
|
-
const expected = Array.isArray(correctAnswer.expectedAnswers)
|
|
17
|
-
? correctAnswer.expectedAnswers.map((value) => String(value ?? ''))
|
|
18
|
-
: [];
|
|
19
|
-
return {
|
|
20
|
-
questionText: asString(content.questionText) ||
|
|
21
|
-
asString(content.question) ||
|
|
22
|
-
asString(record.questionText),
|
|
23
|
-
audioUrl: asString(content.audioUrl) || undefined,
|
|
24
|
-
expectedAnswers: expected,
|
|
25
|
-
isExample: record.isExample === true,
|
|
26
|
-
points: typeof record.points === 'number' ? record.points : 1,
|
|
27
|
-
questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
function isNestedPart(item) {
|
|
31
|
-
const record = asRecord(item);
|
|
32
|
-
return Array.isArray(record.questions);
|
|
33
|
-
}
|
|
34
|
-
function resolveGroup(question) {
|
|
35
|
-
const content = asRecord(question.content);
|
|
36
|
-
const list = asRecord(question.spontaneousQAList ?? content.spontaneousQAList ?? content);
|
|
37
|
-
const itemsRaw = Array.isArray(list.items)
|
|
38
|
-
? list.items
|
|
39
|
-
: Array.isArray(content.items)
|
|
40
|
-
? content.items
|
|
41
|
-
: [];
|
|
42
|
-
const instruction = asString(asRecord(list.meta).instruction) ||
|
|
43
|
-
asString(asRecord(content.meta).instruction) ||
|
|
44
|
-
asString(question.explanation) ||
|
|
45
|
-
'Talk about the topic.';
|
|
46
|
-
if (itemsRaw.some(isNestedPart)) {
|
|
47
|
-
return {
|
|
48
|
-
instruction,
|
|
49
|
-
parts: itemsRaw.map((item, index) => {
|
|
50
|
-
const record = asRecord(item);
|
|
51
|
-
const questions = Array.isArray(record.questions)
|
|
52
|
-
? record.questions.map(mapItem)
|
|
53
|
-
: [];
|
|
54
|
-
return {
|
|
55
|
-
partNumber: typeof record.partNumber === 'number' ? record.partNumber : index + 1,
|
|
56
|
-
partTitle: asString(record.partTitle) || `PART ${index + 1}`,
|
|
57
|
-
topic: asString(record.topic),
|
|
58
|
-
questions,
|
|
59
|
-
};
|
|
60
|
-
}),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
instruction,
|
|
65
|
-
parts: [
|
|
66
|
-
{
|
|
67
|
-
partNumber: 1,
|
|
68
|
-
partTitle: '',
|
|
69
|
-
topic: '',
|
|
70
|
-
questions: itemsRaw.map(mapItem),
|
|
71
|
-
},
|
|
72
|
-
],
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
function QaPromptCard({ item, index, isReviewMode = false, }) {
|
|
76
|
-
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-gray-50 px-4 py-2.5", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-2 p-4", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: item.questionText || 'Câu hỏi chưa được nhập' }), item.audioUrl ? (_jsxs("p", { className: "text-xs text-gray-500", children: ["Audio: ", item.audioUrl] })) : null, isReviewMode && item.expectedAnswers.length > 0 ? (_jsxs("div", { className: "rounded-lg border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800", children: [_jsx("p", { className: "mb-1 font-medium", children: "G\u1EE3i \u00FD \u0111\u00E1p \u00E1n" }), _jsx("ul", { className: "list-disc space-y-1 pl-4", children: item.expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] })) : null] })] }));
|
|
77
|
-
}
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { SpontaneousQaGroupClient } from '../types/spontaneous-qa-group/SpontaneousQaGroupClient';
|
|
4
|
+
import { mapQuestionToSpontaneousQaGroupData } from '../types/spontaneous-qa-group/map-spontaneous-qa-group-data';
|
|
78
5
|
export function SpontaneousQaGroupViewer({ question, isReviewMode = false, }) {
|
|
79
|
-
|
|
80
|
-
const showPartChrome = parts.length > 1 || parts.some((part) => part.partTitle || part.topic);
|
|
81
|
-
return (_jsxs("div", { className: "space-y-5", children: [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: instruction })) : null, parts.map((part) => (_jsxs("section", { className: "space-y-3", children: [showPartChrome ? (_jsxs("div", { children: [part.partTitle ? (_jsx("h3", { className: "text-base font-bold tracking-wide text-sky-600", children: part.partTitle })) : null, part.topic ? (_jsx("p", { className: "text-sm font-semibold text-slate-600", children: part.topic })) : null] })) : null, _jsx("div", { className: "space-y-3", children: part.questions.map((item, index) => (_jsx(QaPromptCard, { item: item, index: index, isReviewMode: isReviewMode }, `${part.partNumber}-${item.questionNumber}-${index}`))) })] }, `part-${part.partNumber}`)))] }));
|
|
6
|
+
return (_jsx(SpontaneousQaGroupClient, { questionData: mapQuestionToSpontaneousQaGroupData(question), isReviewMode: isReviewMode }));
|
|
82
7
|
}
|
|
@@ -231,7 +231,7 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
231
231
|
});
|
|
232
232
|
const isExample = item.isExample === true;
|
|
233
233
|
const imageUrl = normalizeChooseAnswerGroupImageUrl(itemContent.imageUrl);
|
|
234
|
-
const audioUrl =
|
|
234
|
+
const audioUrl = normalizeChooseAnswerGroupImageUrl(itemContent.audioUrl);
|
|
235
235
|
return {
|
|
236
236
|
question: asString(itemContent.question),
|
|
237
237
|
optionType: asString(itemContent.optionType, 'text') === 'image' ? 'image' : 'text',
|
|
@@ -251,17 +251,17 @@ const reverseChooseTheCorrectAnswerGroup = (apiQuestion) => {
|
|
|
251
251
|
points: isExample ? 0 : asNumber(item.points, 1),
|
|
252
252
|
questionNumber: asNumber(item.questionNumber, index + 1),
|
|
253
253
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
254
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
254
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
255
255
|
};
|
|
256
256
|
});
|
|
257
|
-
const audioUrl =
|
|
257
|
+
const audioUrl = normalizeChooseAnswerGroupImageUrl(asRecord(content.meta).audioUrl);
|
|
258
258
|
const imageUrl = normalizeChooseAnswerGroupImageUrl(asRecord(content.meta).imageUrl);
|
|
259
259
|
return {
|
|
260
260
|
content: '',
|
|
261
261
|
answer: {
|
|
262
262
|
instruction: asString(asRecord(content.meta).instruction, 'Choose the correct answer.'),
|
|
263
263
|
passage: asString(asRecord(content.meta).passage),
|
|
264
|
-
...(audioUrl ? { audioUrl } : {}),
|
|
264
|
+
...(audioUrl !== undefined ? { audioUrl } : {}),
|
|
265
265
|
...(imageUrl !== undefined ? { imageUrl } : {}),
|
|
266
266
|
items,
|
|
267
267
|
explanation: asString(apiQuestion.explanation),
|