@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.
Files changed (20) hide show
  1. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -2
  2. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.js +5 -1
  3. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.d.ts +7 -0
  4. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseAnswerGroupImageUpload.js +4 -1
  5. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +9 -5
  6. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +45 -58
  7. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupItemEditor.d.ts +6 -0
  8. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupItemEditor.js +145 -0
  9. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +6 -15
  10. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -4
  11. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.d.ts +1 -1
  12. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +79 -3
  13. package/dist/components/questions/types/spontaneous-qa-group/index.d.ts +1 -0
  14. package/dist/components/questions/types/spontaneous-qa-group/index.js +1 -0
  15. package/dist/components/questions/types/spontaneous-qa-group/map-spontaneous-qa-group-data.d.ts +2 -0
  16. package/dist/components/questions/types/spontaneous-qa-group/map-spontaneous-qa-group-data.js +74 -0
  17. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +4 -79
  18. package/dist/shared/lib/utils/question-reverse-transform.js +4 -4
  19. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +1 -1
  20. 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?: string;
21
+ audioUrl?: ChooseTheCorrectAnswerGroupImageUrl;
22
22
  }
23
23
  export interface ChooseTheCorrectAnswerGroupData {
24
24
  instruction: string;
25
25
  passage?: string;
26
- audioUrl?: string;
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
- return typeof option.text === 'string' && option.text.trim() !== '';
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
- const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
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 && _jsx(ClientAudio, { url: questionData.audioUrl }), 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) => {
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("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) => {
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: OPTION_LABELS[optionIndex] || optionIndex + 1 }), optionImageSrc ? (_jsx(ClientImageItem, { url: optionImageSrc, alt: `Đáp án ${OPTION_LABELS[optionIndex] || optionIndex + 1}` })) : (_jsx("span", { className: "text-xs text-gray-500", children: `Đáp án ${OPTION_LABELS[optionIndex] || optionIndex + 1}` })), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
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: OPTION_LABELS[optionIndex] || optionIndex + 1 }), _jsx("span", { className: "flex-1 text-gray-800", children: option.text || `Đáp án ${OPTION_LABELS[optionIndex]}` }), isReviewMode && isCorrect && _jsx(Check, { className: "h-4 w-4 text-green-600" })] }, option.id || optionIndex));
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, Type, Volume2 } from 'lucide-react';
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 { ChooseAnswerGroupImageUploadItem, ChooseAnswerGroupOptionImageField, } from './ChooseAnswerGroupImageUpload';
16
- import { fromChooseAnswerGroupImageUrls, getChooseAnswerGroupOptionImageSrc, isChooseAnswerGroupOptionFilled, toChooseAnswerGroupImageUrls, toEditableChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
17
- const OPTION_LABELS = ['A', 'B', 'C', 'D', 'E', 'F'];
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
- ...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
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 [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
65
- const [imageUrl, setImageUrl] = useState(toChooseAnswerGroupImageUrls(initialData?.imageUrl)[0] || '');
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, nextAudioUrl = audioUrl, nextImageUrl = imageUrl) => ({
84
- instruction: nextInstruction,
85
- ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
86
- ...(nextAudioUrl.trim() ? { audioUrl: nextAudioUrl.trim() } : {}),
87
- ...(nextImageUrl.trim() ? { imageUrl: nextImageUrl.trim() } : {}),
88
- items: nextItems,
89
- explanation: nextExplanation,
90
- points: sumPoints(nextItems),
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, audioUrl, imageUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
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: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(Label, { htmlFor: "group-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", title: "Th\u00EAm audio chung", onClick: () => setIsGroupAudioVisible(true), className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${audioUrl || isGroupAudioVisible
174
- ? 'bg-violet-600 text-white shadow-sm'
175
- : '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 chung", onClick: () => setIsGroupImageVisible(true), className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${imageUrl || isGroupImageVisible
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" }) })] })] }), _jsx(Input, { id: "group-instruction", value: instruction, onChange: (event) => setInstruction(event.target.value), placeholder: "Choose the correct answer.", className: "h-8 border-gray-200" })] }), isGroupAudioVisible && (_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", label: "", accept: "audio/*", value: audioUrl, onChange: setAudioUrl, maxSize: 20, placeholder: "Upload file audio ho\u1EB7c paste URL", autoUpload: true, prefix: "questions", showPlayButton: true }) })] })), isGroupImageVisible && (_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(ImageIcon, { 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-image", label: "", accept: "image/*", value: imageUrl, onChange: setImageUrl, maxSize: 5, placeholder: "Upload \u1EA3nh nh\u00F3m ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }) })] })), !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) => (_jsxs(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(HelpCircle, { className: "h-3.5 w-3.5" }), title: `Câu ${itemIndex + 1}`, points: !item.isExample ? {
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: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "C\u00E2u h\u1ECFi" }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("button", { type: "button", title: "Th\u00EAm audio", onClick: () => setVisibleItemAudio((prev) => ({ ...prev, [itemIndex]: true })), className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${item.audioUrl || visibleItemAudio[itemIndex]
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 = asOptionalUrl(content.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 = asOptionalUrl(asRecord(content.meta).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 = asOptionalUrl(answer.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 = typeof item.audioUrl === 'string' ? item.audioUrl.trim() : '';
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
- if (answerData?.audioUrl && answerData.audioUrl.trim()) {
52
- meta.audioUrl = answerData.audioUrl.trim();
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 { CircleHelp, Lightbulb } from 'lucide-react';
4
- export function SpontaneousQaGroupClient({ questionData, }) {
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
  }
@@ -1,2 +1,3 @@
1
1
  export * from './SpontaneousQaGroupCreator';
2
2
  export * from './SpontaneousQaGroupClient';
3
+ export * from './map-spontaneous-qa-group-data';
@@ -1,2 +1,3 @@
1
1
  export * from './SpontaneousQaGroupCreator';
2
2
  export * from './SpontaneousQaGroupClient';
3
+ export * from './map-spontaneous-qa-group-data';
@@ -0,0 +1,2 @@
1
+ import type { SpontaneousQaGroupData } from '../../_shared/types/spontaneous-qa-group.type';
2
+ export declare function mapQuestionToSpontaneousQaGroupData(question: Record<string, unknown>): SpontaneousQaGroupData;
@@ -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 { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
3
- function isRecord(value) {
4
- return typeof value === 'object' && value !== null && !Array.isArray(value);
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
- const { instruction, parts } = resolveGroup(question);
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 = asString(itemContent.audioUrl).trim();
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 = asString(asRecord(content.meta).audioUrl).trim();
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),
@@ -24,7 +24,7 @@ export interface ChooseTheCorrectAnswerGroupContent {
24
24
  meta?: {
25
25
  instruction?: string;
26
26
  passage?: string;
27
- audioUrl?: string;
27
+ audioUrl?: string | string[];
28
28
  imageUrl?: string | string[];
29
29
  };
30
30
  items: ChooseTheCorrectAnswerGroupItem[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",