@tinyweb_dev/oe-exam-sdk 1.0.12 → 1.0.13

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 (31) hide show
  1. package/dist/components/questions/_shared/components/compact/KidPassage.d.ts +4 -0
  2. package/dist/components/questions/_shared/components/compact/KidPassage.js +21 -0
  3. package/dist/components/questions/_shared/components/compact/KidQuestionCard.js +3 -1
  4. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.d.ts +7 -0
  5. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.js +86 -0
  6. package/dist/components/questions/_shared/components/compact/index.d.ts +2 -0
  7. package/dist/components/questions/_shared/components/compact/index.js +2 -0
  8. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +14 -5
  9. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +2 -2
  10. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +35 -55
  11. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +4 -1
  12. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +4 -4
  13. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +2 -75
  14. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +15 -22
  15. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +2 -8
  16. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +4 -11
  17. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -9
  18. package/dist/components/questions/viewer/QuestionViewer.d.ts +3 -1
  19. package/dist/components/questions/viewer/QuestionViewer.js +36 -19
  20. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +1 -1
  21. package/dist/shared/index.d.ts +2 -0
  22. package/dist/shared/index.js +1 -0
  23. package/dist/shared/lib/i18n/messages/en/common.d.ts +38 -0
  24. package/dist/shared/lib/i18n/messages/en/common.js +39 -0
  25. package/dist/shared/lib/i18n/messages/en.d.ts +38 -0
  26. package/dist/shared/lib/i18n/messages/vi/common.d.ts +38 -0
  27. package/dist/shared/lib/i18n/messages/vi/common.js +39 -0
  28. package/dist/shared/lib/i18n/messages/vi.d.ts +38 -0
  29. package/dist/shared/lib/utils/media-urls.d.ts +2 -0
  30. package/dist/shared/lib/utils/media-urls.js +23 -0
  31. package/package.json +1 -1
@@ -0,0 +1,4 @@
1
+ export declare function KidPassage({ html, className, }: {
2
+ html: string;
3
+ className?: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState } from 'react';
4
+ import { BookOpen } from 'lucide-react';
5
+ import { useT } from '../../../../../shared/lib/i18n';
6
+ import { cn } from '../../../../../shared/lib/utils';
7
+ const COLLAPSE_CHAR_THRESHOLD = 480;
8
+ function plainTextLength(html) {
9
+ return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().length;
10
+ }
11
+ export function KidPassage({ html, className, }) {
12
+ const t = useT();
13
+ const isLong = plainTextLength(html) > COLLAPSE_CHAR_THRESHOLD;
14
+ const [expanded, setExpanded] = useState(!isLong);
15
+ if (!html) {
16
+ return null;
17
+ }
18
+ return (_jsxs("div", { className: cn('rounded-xl border border-gray-200 bg-white p-5 shadow-xs', expanded && isLong && 'md:sticky md:top-24 md:z-10', className), 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: t('question.viewer.readingPassage') })] }), _jsx("div", { className: cn('prose prose-sm max-w-none leading-relaxed text-gray-800', !expanded && 'max-h-40 overflow-hidden'), dangerouslySetInnerHTML: { __html: html } }), isLong ? (_jsx("button", { type: "button", onClick: () => setExpanded((value) => !value), className: "mt-3 text-sm font-semibold text-indigo-600 hover:text-indigo-800", children: expanded
19
+ ? t('question.viewer.collapsePassage')
20
+ : t('question.viewer.expandPassage') })) : null] }));
21
+ }
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check, CircleHelp, Star, X } from 'lucide-react';
4
+ import { useT } from '../../../../../shared/lib/i18n';
4
5
  import { cn } from '../../../../../shared/lib/utils';
5
6
  export function KidQuestionCard({ icon, hideIcon = false, title, titleClassName, points, isExample = false, review, headerExtra, children, className, contentClassName, }) {
6
- return (_jsxs("div", { className: cn('overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm', className), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [!hideIcon && (_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: icon ?? _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) })), _jsx("span", { className: cn('text-sm font-medium text-gray-700', titleClassName ?? 'truncate'), children: title }), isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "C\u00E2u v\u00ED d\u1EE5"] }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [headerExtra, typeof points === 'number' && (_jsxs("span", { className: "text-xs text-gray-500", children: [points, " \u0111i\u1EC3m"] })), review === 'correct' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700", children: [_jsx(Check, { className: "h-3 w-3" }), "\u0110\u00FAng"] })), review === 'wrong' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700", children: [_jsx(X, { className: "h-3 w-3" }), "Sai"] }))] })] }), _jsx("div", { className: cn('space-y-3 p-3', contentClassName), children: children })] }));
7
+ const t = useT();
8
+ return (_jsxs("div", { className: cn('overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm', className), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [!hideIcon && (_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: icon ?? _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) })), _jsx("span", { className: cn('text-sm font-medium text-gray-700', titleClassName ?? 'truncate'), children: title }), isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), t('question.viewer.example')] }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [headerExtra, typeof points === 'number' && (_jsx("span", { className: "text-xs text-gray-500", children: t('question.viewer.points', { count: points }) })), review === 'correct' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700", children: [_jsx(Check, { className: "h-3 w-3" }), t('question.viewer.correct')] })), review === 'wrong' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700", children: [_jsx(X, { className: "h-3 w-3" }), t('question.viewer.wrong')] }))] })] }), _jsx("div", { className: cn('space-y-3 p-3', contentClassName), children: children })] }));
7
9
  }
@@ -0,0 +1,7 @@
1
+ export declare function SpeakingReplyMic({ flattenIndex, audioKey, disabled, uploading, onRecorded, }: {
2
+ flattenIndex: number;
3
+ audioKey?: string | null;
4
+ disabled?: boolean;
5
+ uploading?: boolean;
6
+ onRecorded?: (flattenIndex: number, blob: Blob) => void;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { 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
+ export function SpeakingReplyMic({ flattenIndex, audioKey, disabled, uploading, onRecorded, }) {
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
+ if (disabled || uploading || !onRecorded) {
37
+ return;
38
+ }
39
+ setError('');
40
+ try {
41
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
42
+ streamRef.current = stream;
43
+ chunksRef.current = [];
44
+ const recorder = new MediaRecorder(stream);
45
+ recorderRef.current = recorder;
46
+ recorder.ondataavailable = (event) => {
47
+ if (event.data.size > 0) {
48
+ chunksRef.current.push(event.data);
49
+ }
50
+ };
51
+ recorder.onstop = () => {
52
+ const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
53
+ setBlobUrl((current) => {
54
+ if (current) {
55
+ URL.revokeObjectURL(current);
56
+ }
57
+ return URL.createObjectURL(blob);
58
+ });
59
+ setStatus('recorded');
60
+ stopStream();
61
+ onRecorded(flattenIndex, blob);
62
+ };
63
+ recorder.start();
64
+ setStatus('recording');
65
+ }
66
+ catch {
67
+ setError('Không thể mở microphone. Hãy cho phép quyền ghi âm.');
68
+ stopStream();
69
+ }
70
+ };
71
+ const stopRecording = () => {
72
+ if (recorderRef.current && recorderRef.current.state !== 'inactive') {
73
+ recorderRef.current.stop();
74
+ }
75
+ };
76
+ const canRecord = Boolean(onRecorded) && !disabled && !uploading;
77
+ 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: [canRecord ? (_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'
78
+ ? 'animate-pulse bg-red-500 hover:bg-red-600'
79
+ : '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("div", { className: "inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-300 text-white", children: _jsx(Mic, { className: "h-5 w-5" }) })), _jsx("p", { className: "text-center text-xs text-indigo-700", children: uploading
80
+ ? 'Đang tải lên bản ghi...'
81
+ : status === 'recording'
82
+ ? 'Đang ghi âm... Nhấn để dừng'
83
+ : audioKey || status === 'recorded'
84
+ ? 'Đã ghi. Nhấn micro để ghi lại'
85
+ : '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 }), audioKey ? (_jsx("div", { className: "w-full max-w-sm", children: _jsx(QuestionAudio, { url: audioKey }) })) : 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." })) : null] }));
86
+ }
@@ -5,6 +5,8 @@ export { CompactCreatorHeader } from './CompactCreatorHeader';
5
5
  export { CompactFieldCard } from './CompactFieldCard';
6
6
  export { CompactExplanationToggle } from './CompactExplanationToggle';
7
7
  export { KidInstruction } from './KidInstruction';
8
+ export { KidPassage } from './KidPassage';
8
9
  export { KidPrompt } from './KidPrompt';
9
10
  export { KidQuestionCard } from './KidQuestionCard';
10
11
  export { KidChoiceRow, optionLetter } from './KidChoiceRow';
12
+ export { SpeakingReplyMic } from './SpeakingReplyMic';
@@ -5,6 +5,8 @@ export { CompactCreatorHeader } from './CompactCreatorHeader';
5
5
  export { CompactFieldCard } from './CompactFieldCard';
6
6
  export { CompactExplanationToggle } from './CompactExplanationToggle';
7
7
  export { KidInstruction } from './KidInstruction';
8
+ export { KidPassage } from './KidPassage';
8
9
  export { KidPrompt } from './KidPrompt';
9
10
  export { KidQuestionCard } from './KidQuestionCard';
10
11
  export { KidChoiceRow, optionLetter } from './KidChoiceRow';
12
+ export { SpeakingReplyMic } from './SpeakingReplyMic';
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, CircleHelp, Lightbulb, Sparkles, Volume2 } from 'lucide-react';
3
+ import { CircleHelp, Lightbulb, Sparkles, Volume2 } from 'lucide-react';
4
+ import { useT } from '../../../../shared/lib/i18n';
4
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
5
6
  import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
6
- import { KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
7
+ import { KidInstruction, KidPassage, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
7
8
  function isAbsoluteMediaUrl(value) {
8
9
  return (value.startsWith('http://')
9
10
  || value.startsWith('https://')
@@ -41,16 +42,24 @@ function MediaPreview({ src, kind = 'image', alt }) {
41
42
  } }) }));
42
43
  }
43
44
  export function AnswerTheQuestionGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
45
+ const t = useT();
44
46
  const items = questionData.items || [];
45
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(KidInstruction, { children: questionData.instruction }), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: `Hình ảnh (${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 leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
47
+ const instruction = questionData.instruction?.trim()
48
+ || t('question.viewer.atqgDefaultInstruction');
49
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx(KidInstruction, { children: instruction }), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: t('question.viewer.imageAlt', { n: imageIndex + 1 }) }, `group-image-${imageIndex}`))) })), questionData.passage ? _jsx(KidPassage, { html: questionData.passage }) : null, items.map((item, itemIndex) => {
46
50
  const userValue = userAnswers[itemIndex] || '';
47
51
  const expectedAnswers = Array.isArray(item.expectedAnswers)
48
52
  ? item.expectedAnswers.filter((answer) => answer.trim() !== '')
49
53
  : [];
50
- return (_jsxs(KidQuestionCard, { icon: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }), title: `Câu ${item.questionNumber || itemIndex + 1}`, points: item.points || 0, isExample: item.isExample, headerExtra: _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] }), children: [toAnswerTheQuestionGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1} (${imageIndex + 1})` }, `${item.questionNumber}-${imageIndex}`))) })), _jsx(KidPrompt, { children: item.question || 'Câu hỏi chưa được nhập' }), isReviewMode || item.isExample || !onAnswerChange ? (userValue ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-700", children: userValue })) : null) : (_jsx("textarea", { value: userValue, onChange: (event) => {
54
+ const questionNumber = item.questionNumber || itemIndex + 1;
55
+ const answerId = `atqg-answer-${questionNumber}`;
56
+ return (_jsxs(KidQuestionCard, { icon: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }), title: t('question.viewer.questionN', { n: questionNumber }), points: item.points || 0, isExample: item.isExample, headerExtra: _jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-violet-100 px-2 py-0.5 text-xs font-semibold text-violet-700", children: [_jsx(Sparkles, { className: "h-3 w-3" }), "AI"] }), children: [toAnswerTheQuestionGroupImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, alt: t('question.viewer.itemImageAlt', {
57
+ n: questionNumber,
58
+ m: imageIndex + 1,
59
+ }) }, `${item.questionNumber}-${imageIndex}`))) })), _jsx(KidPrompt, { children: _jsx("label", { htmlFor: answerId, children: item.question || t('question.viewer.missingPrompt') }) }), isReviewMode || item.isExample || !onAnswerChange ? (userValue ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-700", children: userValue })) : null) : (_jsx("textarea", { id: answerId, name: answerId, value: userValue, onChange: (event) => {
51
60
  const next = items.map((_, index) => userAnswers[index] || '');
52
61
  next[itemIndex] = event.target.value;
53
62
  onAnswerChange(next);
54
- }, placeholder: "Nh\u1EADp c\u00E2u tr\u1EA3 l\u1EDDi...", rows: 2, className: "w-full rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-800 outline-none ring-blue-200 focus:border-blue-400 focus:ring-2" })), isReviewMode && expectedAnswers.length > 0 ? (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: "\u0110\u00E1p \u00E1n" }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] })) : isReviewMode && !userValue ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-500", children: "Ch\u01B0a c\u00F3 \u0111\u00E1p \u00E1n" })) : null] }, `${item.questionNumber}-${itemIndex}`));
63
+ }, placeholder: t('question.viewer.placeholder'), rows: 4, className: "w-full rounded-md border border-slate-200 px-3 py-1.5 text-sm text-slate-800 outline-none ring-blue-200 focus:border-blue-400 focus:ring-2" })), isReviewMode && expectedAnswers.length > 0 ? (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "text-xs font-medium text-gray-500", children: t('question.viewer.answerKey') }), _jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm text-gray-700", children: expectedAnswers.map((answer) => (_jsx("li", { children: answer }, answer))) })] })) : isReviewMode && !userValue ? (_jsx("div", { className: "rounded-md border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-500", children: t('question.viewer.noAnswer') })) : null] }, `${item.questionNumber}-${itemIndex}`));
55
64
  }), 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] }))] }));
56
65
  }
@@ -1,5 +1,5 @@
1
1
  import { fromAnswerTheQuestionGroupImageUrls, toAnswerTheQuestionGroupImageUrls, } from '../../_shared/types/answer-the-question-group.type';
2
- const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
2
+ const DEFAULT_INSTRUCTION = '';
3
3
  function isRecord(value) {
4
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
5
  }
@@ -70,7 +70,7 @@ export function mapQuestionToAnswerTheQuestionGroupData(question) {
70
70
  const audioUrl = asOptionalUrl(meta.audioUrl);
71
71
  const imageUrl = toAnswerTheQuestionGroupImageUrls(meta.imageUrl);
72
72
  return {
73
- instruction: asString(meta.instruction) || DEFAULT_INSTRUCTION,
73
+ instruction: asString(meta.instruction),
74
74
  passage: asString(meta.passage),
75
75
  ...(audioUrl ? { audioUrl } : {}),
76
76
  ...(imageUrl.length ? { imageUrl } : {}),
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check, Lightbulb, X } from 'lucide-react';
4
+ import { useT } from '../../../../shared/lib/i18n';
4
5
  import { cn } from '../../../../shared/lib/utils';
5
6
  import { KidInstruction, KidQuestionCard } from '../../_shared/components/compact';
6
7
  function emptyAnswer() {
@@ -10,33 +11,13 @@ function formatHalf(points) {
10
11
  const half = points * 0.5;
11
12
  return Number.isInteger(half) ? String(half) : half.toFixed(1);
12
13
  }
13
- function badgeClass(params) {
14
- if (params.isExample) {
15
- return 'bg-amber-500';
16
- }
17
- if (!params.isReviewMode || !params.hasAttempt) {
18
- return 'bg-indigo-600';
19
- }
20
- if (params.partialScore != null) {
21
- if (params.partialScore >= params.points - 1e-9) {
22
- return 'bg-emerald-500';
23
- }
24
- if (params.partialScore > 0) {
25
- return 'bg-amber-500';
26
- }
27
- return 'bg-red-500';
28
- }
29
- if (params.mappingCorrect || params.hasText) {
30
- return 'bg-amber-500';
31
- }
32
- return 'bg-red-500';
33
- }
34
14
  export function ChooseThenAnswerGroupClient({ questionData, isReviewMode = false, userAnswers = [], itemResults, onAnswerChange, }) {
15
+ const t = useT();
35
16
  const canEdit = Boolean(onAnswerChange) && !isReviewMode;
36
17
  const items = questionData.items || [];
37
18
  const hasAttempt = Boolean(itemResults?.length) ||
38
19
  userAnswers.some((answer) => Boolean(answer?.optionId || answer?.text?.trim()));
39
- return (_jsxs("div", { className: "space-y-2", children: [(questionData.title || questionData.instruction) && (_jsxs(KidInstruction, { children: [questionData.title ? (_jsx("p", { className: "mb-1 text-[11px] font-semibold text-indigo-500", children: questionData.title })) : null, questionData.instruction] })), items.map((item, itemIndex) => {
20
+ return (_jsxs("div", { className: "space-y-3", children: [(questionData.title || questionData.instruction) && (_jsxs(KidInstruction, { children: [questionData.title ? (_jsx("p", { className: "mb-1 text-[11px] font-semibold text-indigo-500", children: questionData.title })) : null, questionData.instruction] })), items.map((item, itemIndex) => {
40
21
  const stored = userAnswers[itemIndex] || emptyAnswer();
41
22
  const current = item.isExample
42
23
  ? { optionId: item.optionId, text: stored.text }
@@ -62,38 +43,37 @@ export function ChooseThenAnswerGroupClient({ questionData, isReviewMode = false
62
43
  text: next.text ?? current.text,
63
44
  });
64
45
  };
65
- return (_jsx(KidQuestionCard, { title: `Câu ${item.questionNumber || itemIndex + 1}`, points: points, isExample: item.isExample, children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("span", { className: cn('mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white', badgeClass({
66
- isExample: item.isExample,
67
- isReviewMode,
68
- hasAttempt,
69
- points,
70
- partialScore: result?.partialScore,
71
- mappingCorrect,
72
- hasText: hasStudentText,
73
- })), children: item.questionNumber || itemIndex + 1 }), _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsxs("p", { className: "flex flex-wrap items-center gap-x-1.5 gap-y-1.5 text-sm font-semibold leading-7 text-slate-800", children: [_jsx("span", { children: parts[0] || '' }), _jsx("span", { className: "inline-flex items-center gap-1", children: item.options.map((option, optionIndex) => {
74
- const selected = current.optionId === option.id;
75
- const isKey = option.id === expectedId;
76
- const pickedCorrect = showGradedReview && selected && isKey;
77
- const pickedWrong = showGradedReview && selected && !isKey;
78
- const keyOnly = showGradedReview && !selected && isKey;
79
- const officialCorrect = showAnswerKey && isKey;
80
- return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [optionIndex > 0 && (_jsx("span", { className: "text-sm font-medium text-slate-300", children: "|" })), _jsxs("button", { type: "button", disabled: !canEdit || item.isExample, onClick: () => setAnswer({ optionId: option.id }), className: cn('inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border px-2.5 py-1 text-sm font-semibold transition-colors', !showReview &&
81
- selected &&
82
- 'border-blue-500 bg-blue-500 text-white shadow-sm ring-2 ring-blue-100', !showReview &&
83
- !selected &&
84
- 'border-gray-200 bg-white text-slate-800 hover:border-blue-300 hover:bg-blue-50', showAnswerKey &&
85
- !isKey &&
86
- 'border-slate-200 bg-white text-slate-800', officialCorrect &&
87
- 'border-2 border-emerald-500 bg-white text-slate-800', pickedCorrect && 'border-emerald-600 bg-emerald-600 text-white', pickedWrong && 'border-red-500 bg-red-500 text-white', keyOnly && 'border-emerald-500 bg-white text-emerald-800', showGradedReview &&
88
- !selected &&
89
- !isKey &&
90
- 'border-slate-200 bg-slate-50 text-slate-400', canEdit &&
91
- !item.isExample &&
92
- 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-200', (!canEdit || item.isExample) && 'cursor-default'), children: [option.text || option.id, pickedCorrect ? _jsx(Check, { className: "h-3 w-3" }) : null, pickedWrong ? _jsx(X, { className: "h-3 w-3" }) : null] })] }, option.id));
93
- }) }), _jsx("span", { children: parts.slice(1).join('{0}') })] }), showReview && hasAttempt ? (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [_jsxs("span", { className: cn('rounded-full px-2 py-0.5 text-[11px] font-semibold', mappingCorrect
94
- ? 'bg-emerald-50 text-emerald-700'
95
- : 'bg-red-50 text-red-700'), children: ["Ch\u1ECDn ", mappingCorrect ? halfLabel : '0'] }), result?.aiCorrect !== undefined ? (_jsxs("span", { className: cn('rounded-full px-2 py-0.5 text-[11px] font-semibold', result.aiCorrect
96
- ? 'bg-emerald-50 text-emerald-700'
97
- : 'bg-red-50 text-red-700'), children: ["Vi\u1EBFt ", result.aiCorrect ? halfLabel : '0'] })) : null] })) : null, canEdit || hasStudentText || (isReviewMode && hasAttempt) ? (_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: "C\u00E2u tr\u1EA3 l\u1EDDi c\u1EE7a b\u1EA1n" }), isReviewMode && !hasStudentText ? (_jsx("p", { className: "rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm italic text-slate-400", children: "Ch\u01B0a vi\u1EBFt" })) : (_jsx("textarea", { value: current.text, disabled: !canEdit || item.isExample, onChange: (event) => setAnswer({ text: event.target.value }), placeholder: "Vi\u1EBFt c\u00E2u tr\u1EA3 l\u1EDDi t\u1EA1i \u0111\u00E2y...", rows: 2, className: "min-h-0 w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 disabled:bg-slate-50" }))] })) : null, isReviewMode && sampleAnswers.length > 0 ? (_jsxs("p", { className: "text-xs text-slate-500", children: [_jsx("span", { className: "font-semibold", children: "M\u1EABu:" }), " ", sampleAnswers.join(' · ')] })) : null] })] }) }, `${item.questionNumber}-${itemIndex}`));
46
+ return (_jsx(KidQuestionCard, { title: t('question.viewer.questionN', { n: item.questionNumber || itemIndex + 1 }), points: points, isExample: item.isExample, children: _jsx("div", { className: "space-y-3", children: _jsxs("div", { className: "min-w-0 space-y-2.5", children: [(parts[0] || parts.slice(1).join('{0}')) ? (_jsxs("p", { className: "text-sm font-semibold leading-7 text-slate-800", children: [_jsx("span", { children: parts[0] || '' }), _jsx("span", { className: "mx-1 inline-block min-w-[3rem] border-b-2 border-dashed border-slate-300 align-baseline" }), _jsx("span", { children: parts.slice(1).join('{0}') })] })) : null, _jsx("div", { className: "flex flex-wrap items-center gap-2.5", role: "group", "aria-label": t('question.viewer.questionN', {
47
+ n: item.questionNumber || itemIndex + 1,
48
+ }), children: item.options.map((option) => {
49
+ const selected = current.optionId === option.id;
50
+ const isKey = option.id === expectedId;
51
+ const pickedCorrect = showGradedReview && selected && isKey;
52
+ const pickedWrong = showGradedReview && selected && !isKey;
53
+ const keyOnly = showGradedReview && !selected && isKey;
54
+ const officialCorrect = showAnswerKey && isKey;
55
+ const optionId = `cta-${itemIndex}-opt-${option.id}`;
56
+ return (_jsxs("button", { id: optionId, name: `cta-${itemIndex}-option`, type: "button", disabled: !canEdit || item.isExample, onClick: () => setAnswer({ optionId: option.id }), className: cn('inline-flex min-w-[3.5rem] items-center justify-center gap-1 rounded-full border px-3 py-1.5 text-sm font-semibold transition-colors', !showReview &&
57
+ selected &&
58
+ 'border-blue-500 bg-blue-500 text-white shadow-sm ring-2 ring-blue-100', !showReview &&
59
+ !selected &&
60
+ 'border-gray-200 bg-white text-slate-800 hover:border-blue-300 hover:bg-blue-50', showAnswerKey &&
61
+ !isKey &&
62
+ 'border-slate-200 bg-white text-slate-800', officialCorrect &&
63
+ 'border-2 border-emerald-500 bg-white text-slate-800', pickedCorrect && 'border-emerald-600 bg-emerald-600 text-white', pickedWrong && 'border-red-500 bg-red-500 text-white', keyOnly && 'border-emerald-500 bg-white text-emerald-800', showGradedReview &&
64
+ !selected &&
65
+ !isKey &&
66
+ 'border-slate-200 bg-slate-50 text-slate-400', canEdit &&
67
+ !item.isExample &&
68
+ 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-200', (!canEdit || item.isExample) && 'cursor-default'), children: [option.text || option.id, pickedCorrect ? _jsx(Check, { className: "h-3 w-3" }) : null, pickedWrong ? _jsx(X, { className: "h-3 w-3" }) : null] }, option.id));
69
+ }) }), showReview && hasAttempt ? (_jsxs("div", { className: "flex flex-wrap gap-1.5", children: [_jsx("span", { className: cn('rounded-full px-2 py-0.5 text-[11px] font-semibold', mappingCorrect
70
+ ? 'bg-emerald-50 text-emerald-700'
71
+ : 'bg-red-50 text-red-700'), children: t('question.viewer.chooseScore', {
72
+ score: mappingCorrect ? halfLabel : '0',
73
+ }) }), result?.aiCorrect !== undefined ? (_jsx("span", { className: cn('rounded-full px-2 py-0.5 text-[11px] font-semibold', result.aiCorrect
74
+ ? 'bg-emerald-50 text-emerald-700'
75
+ : 'bg-red-50 text-red-700'), children: t('question.viewer.writeScore', {
76
+ score: result.aiCorrect ? halfLabel : '0',
77
+ }) })) : null] })) : null, canEdit || hasStudentText || (isReviewMode && hasAttempt) ? (_jsxs("label", { className: "block space-y-1", htmlFor: `cta-${itemIndex}-text`, children: [_jsx("span", { className: "text-xs font-medium text-slate-500", children: t('question.viewer.yourAnswer') }), isReviewMode && !hasStudentText ? (_jsx("p", { className: "rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-sm italic text-slate-400", children: t('question.viewer.notWritten') })) : (_jsx("textarea", { id: `cta-${itemIndex}-text`, name: `cta-${itemIndex}-text`, value: current.text, disabled: !canEdit || item.isExample, onChange: (event) => setAnswer({ text: event.target.value }), placeholder: t('question.viewer.writePlaceholder'), rows: 2, className: "min-h-0 w-full resize-none rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm leading-relaxed text-slate-800 outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 disabled:bg-slate-50" }))] })) : null, isReviewMode && sampleAnswers.length > 0 ? (_jsxs("p", { className: "text-xs text-slate-500", children: [_jsxs("span", { className: "font-semibold", children: [t('question.viewer.sample'), ":"] }), ' ', sampleAnswers.join(' · ')] })) : null] }) }) }, `${item.questionNumber}-${itemIndex}`));
98
78
  }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-lg border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
99
79
  }
@@ -6,6 +6,9 @@ export interface SpeakingCueCardClientProps {
6
6
  explanation?: string;
7
7
  };
8
8
  isReviewMode?: boolean;
9
+ audioKey?: string | null;
10
+ uploading?: boolean;
11
+ onRecorded?: (blob: Blob) => void;
9
12
  }
10
13
  export declare function mapQuestionToSpeakingCueCardData(question: Record<string, unknown> | null | undefined): SpeakingCueCardClientProps['questionData'];
11
- export declare function SpeakingCueCardClient({ questionData, isReviewMode, }: SpeakingCueCardClientProps): import("react").JSX.Element;
14
+ export declare function SpeakingCueCardClient({ questionData, isReviewMode, audioKey, uploading, onRecorded, }: SpeakingCueCardClientProps): import("react").JSX.Element;
@@ -1,8 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Mic, CheckCircle2, Info, CreditCard } from 'lucide-react';
3
+ import { CheckCircle2, Info, CreditCard } from 'lucide-react';
4
4
  import { RichTextHtml } from '../../../../components/shared/RichTextHtml';
5
- import { KidQuestionCard } from '../../_shared/components/compact';
5
+ import { KidQuestionCard, SpeakingReplyMic } from '../../_shared/components/compact';
6
6
  function asRecord(value) {
7
7
  return value && typeof value === 'object' && !Array.isArray(value)
8
8
  ? value
@@ -22,7 +22,7 @@ export function mapQuestionToSpeakingCueCardData(question) {
22
22
  explanation: typeof source.explanation === 'string' ? source.explanation : '',
23
23
  };
24
24
  }
25
- export function SpeakingCueCardClient({ questionData, isReviewMode = false, }) {
25
+ export function SpeakingCueCardClient({ questionData, isReviewMode = false, audioKey = null, uploading = false, onRecorded, }) {
26
26
  const answers = questionData.expectedAnswers || [];
27
- return (_jsxs("div", { className: "space-y-4", children: [questionData.isBackup && (_jsxs("div", { className: "inline-flex self-center items-center gap-1.5 px-3 py-1.5 rounded-md bg-amber-50 text-amber-700 border border-amber-200 text-xs font-bold shadow-sm", children: [_jsx("span", { children: "\uD83D\uDD04" }), " C\u00E2u h\u1ECFi d\u1EF1 ph\u00F2ng"] })), _jsxs(KidQuestionCard, { title: "Th\u1EBB g\u1EE3i \u00FD (Cue Card)", icon: _jsx(CreditCard, { className: "h-3.5 w-3.5" }), children: [_jsx("div", { className: "rounded-xl border border-purple-100 bg-purple-50/40 px-5 py-4", children: _jsx(RichTextHtml, { html: questionData.cueCardHtml, className: "text-sm text-slate-800", fallback: _jsx("p", { className: "text-sm text-gray-400", children: "Ch\u01B0a c\u00F3 n\u1ED9i dung cue card" }) }) }), _jsxs("div", { className: "flex items-center gap-3 rounded-lg border border-dashed border-gray-300 bg-gray-50/80 px-4 py-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-purple-50 ring-2 ring-purple-100/50", children: _jsx(Mic, { className: "w-4 h-4 text-purple-400" }) }), _jsx("p", { className: "text-xs text-gray-400", children: "H\u1ECDc sinh \u0111\u1ECDc th\u1EBB g\u1EE3i \u00FD, chu\u1EA9n b\u1ECB, r\u1ED3i tr\u1EA3 l\u1EDDi b\u1EB1ng gi\u1ECDng n\u00F3i" })] }), isReviewMode && answers.length > 0 && (_jsxs("div", { className: "rounded-xl border border-green-200 bg-green-50/80 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsx("h4", { className: "text-sm font-semibold text-green-800", children: "\u0110\u00E1p \u00E1n m\u1EABu" })] }), _jsx("div", { className: "space-y-2", children: answers.map((answer, index) => (_jsxs("div", { className: "flex items-center gap-2.5 rounded-lg bg-white/70 border border-green-100 px-3 py-2", children: [_jsx("span", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 text-xs font-bold", children: index + 1 }), _jsx("span", { className: "text-sm text-green-800", children: answer })] }, `${answer}-${index}`))) })] })), isReviewMode && questionData.explanation && (_jsxs("div", { className: "rounded-xl border border-amber-200 bg-amber-50 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Info, { className: "h-4 w-4 text-amber-600" }), _jsx("p", { className: "text-sm font-semibold text-amber-800", children: "Gi\u1EA3i th\u00EDch" })] }), _jsx("p", { className: "text-sm text-amber-700 leading-relaxed whitespace-pre-wrap", children: questionData.explanation })] }))] })] }));
27
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.isBackup && (_jsxs("div", { className: "inline-flex self-center items-center gap-1.5 px-3 py-1.5 rounded-md bg-amber-50 text-amber-700 border border-amber-200 text-xs font-bold shadow-sm", children: [_jsx("span", { children: "\uD83D\uDD04" }), " C\u00E2u h\u1ECFi d\u1EF1 ph\u00F2ng"] })), _jsxs(KidQuestionCard, { title: "Th\u1EBB g\u1EE3i \u00FD (Cue Card)", icon: _jsx(CreditCard, { className: "h-3.5 w-3.5" }), children: [_jsx("div", { className: "rounded-xl border border-purple-100 bg-purple-50/40 px-5 py-4", children: _jsx(RichTextHtml, { html: questionData.cueCardHtml, className: "text-sm text-slate-800", fallback: _jsx("p", { className: "text-sm text-gray-400", children: "Ch\u01B0a c\u00F3 n\u1ED9i dung cue card" }) }) }), isReviewMode && !onRecorded ? (audioKey ? (_jsx(SpeakingReplyMic, { flattenIndex: 0, audioKey: audioKey, disabled: true })) : (_jsx("p", { className: "text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 b\u1EA3n ghi." }))) : (_jsx(SpeakingReplyMic, { flattenIndex: 0, audioKey: audioKey, uploading: uploading, onRecorded: onRecorded ? (_index, blob) => onRecorded(blob) : undefined })), isReviewMode && answers.length > 0 && (_jsxs("div", { className: "rounded-xl border border-green-200 bg-green-50/80 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-3", children: [_jsx(CheckCircle2, { className: "h-4 w-4 text-green-600" }), _jsx("h4", { className: "text-sm font-semibold text-green-800", children: "\u0110\u00E1p \u00E1n m\u1EABu" })] }), _jsx("div", { className: "space-y-2", children: answers.map((answer, index) => (_jsxs("div", { className: "flex items-center gap-2.5 rounded-lg bg-white/70 border border-green-100 px-3 py-2", children: [_jsx("span", { className: "flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 text-xs font-bold", children: index + 1 }), _jsx("span", { className: "text-sm text-green-800", children: answer })] }, `${answer}-${index}`))) })] })), isReviewMode && questionData.explanation && (_jsxs("div", { className: "rounded-xl border border-amber-200 bg-amber-50 p-4", children: [_jsxs("div", { className: "flex items-center gap-2 mb-2", children: [_jsx(Info, { className: "h-4 w-4 text-amber-600" }), _jsx("p", { className: "text-sm font-semibold text-amber-800", children: "Gi\u1EA3i th\u00EDch" })] }), _jsx("p", { className: "text-sm text-amber-700 leading-relaxed whitespace-pre-wrap", children: questionData.explanation })] }))] })] }));
28
28
  }
@@ -1,9 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from 'react';
4
- import { Lightbulb, Mic, Square, Volume2 } from 'lucide-react';
3
+ import { Lightbulb, Mic, Volume2 } from 'lucide-react';
5
4
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
- import { KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
5
+ import { KidInstruction, KidPrompt, KidQuestionCard, SpeakingReplyMic, } from '../../_shared/components/compact';
7
6
  function QuestionAudio({ url }) {
8
7
  const { previewUrl, isLoading } = usePresignedFileUrl(url);
9
8
  const src = previewUrl ||
@@ -34,78 +33,6 @@ function findGradeDetail(details, flattenIndex) {
34
33
  item.itemId === `item-${flattenIndex}` ||
35
34
  item.itemId === `item-${flattenIndex + 1}`));
36
35
  }
37
- function SpeakingReplyMic({ flattenIndex, audioKey, disabled, uploading, onRecorded, }) {
38
- const [status, setStatus] = useState('idle');
39
- const [error, setError] = useState('');
40
- const [blobUrl, setBlobUrl] = useState('');
41
- const recorderRef = useRef(null);
42
- const chunksRef = useRef([]);
43
- const streamRef = useRef(null);
44
- useEffect(() => {
45
- return () => {
46
- recorderRef.current?.stop();
47
- streamRef.current?.getTracks().forEach((track) => track.stop());
48
- if (blobUrl) {
49
- URL.revokeObjectURL(blobUrl);
50
- }
51
- };
52
- }, [blobUrl]);
53
- const stopStream = () => {
54
- streamRef.current?.getTracks().forEach((track) => track.stop());
55
- streamRef.current = null;
56
- };
57
- const startRecording = async () => {
58
- if (disabled || uploading || !onRecorded) {
59
- return;
60
- }
61
- setError('');
62
- try {
63
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
64
- streamRef.current = stream;
65
- chunksRef.current = [];
66
- const recorder = new MediaRecorder(stream);
67
- recorderRef.current = recorder;
68
- recorder.ondataavailable = (event) => {
69
- if (event.data.size > 0) {
70
- chunksRef.current.push(event.data);
71
- }
72
- };
73
- recorder.onstop = () => {
74
- const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' });
75
- setBlobUrl((current) => {
76
- if (current) {
77
- URL.revokeObjectURL(current);
78
- }
79
- return URL.createObjectURL(blob);
80
- });
81
- setStatus('recorded');
82
- stopStream();
83
- onRecorded(flattenIndex, blob);
84
- };
85
- recorder.start();
86
- setStatus('recording');
87
- }
88
- catch {
89
- setError('Không thể mở microphone. Hãy cho phép quyền ghi âm.');
90
- stopStream();
91
- }
92
- };
93
- const stopRecording = () => {
94
- if (recorderRef.current && recorderRef.current.state !== 'inactive') {
95
- recorderRef.current.stop();
96
- }
97
- };
98
- const canRecord = Boolean(onRecorded) && !disabled && !uploading;
99
- 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: [canRecord ? (_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'
100
- ? 'animate-pulse bg-red-500 hover:bg-red-600'
101
- : '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("div", { className: "inline-flex h-12 w-12 items-center justify-center rounded-full bg-slate-300 text-white", children: _jsx(Mic, { className: "h-5 w-5" }) })), _jsx("p", { className: "text-center text-xs text-indigo-700", children: uploading
102
- ? 'Đang tải lên bản ghi...'
103
- : status === 'recording'
104
- ? 'Đang ghi âm... Nhấn để dừng'
105
- : audioKey || status === 'recorded'
106
- ? 'Đã ghi. Nhấn micro để ghi lại'
107
- : '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 }), audioKey ? (_jsx("div", { className: "w-full max-w-sm", children: _jsx(QuestionAudio, { url: audioKey }) })) : 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." })) : null] }));
108
- }
109
36
  export function SpontaneousQaGroupClient({ questionData, isReviewMode = false, userAnswer, onItemRecorded, uploadingIndex = null, gradeDetails, disabled = false, }) {
110
37
  const parts = questionData.parts || [];
111
38
  const audioKeys = Array.isArray(userAnswer?.audioKeys) ? userAnswer.audioKeys : [];
@@ -2,11 +2,18 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
4
4
  import { Check, X, Library } from 'lucide-react';
5
+ import { useT } from '../../../../shared/lib/i18n';
5
6
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
7
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
6
8
  import { cn } from '../../../../shared/lib/utils';
7
9
  import { KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
8
10
  import { detectWfsfBankMode, isOptionsWordEntry, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
9
11
  import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, primaryWfsfAnswers, } from '../../../../shared/lib/utils/wfsf-blank-match';
12
+ function wfsfFilledText(value) {
13
+ if (Array.isArray(value))
14
+ return String(value[0] ?? '');
15
+ return value == null ? '' : String(value);
16
+ }
10
17
  function normalizeWfsfBlankStyle(raw) {
11
18
  const v = String(raw || '').toLowerCase();
12
19
  if (v === 'letter' || v === 'box')
@@ -61,28 +68,14 @@ function ClientAudioItem({ url }) {
61
68
  return null;
62
69
  return (_jsx("div", { className: "w-full max-w-md", children: _jsx("audio", { controls: true, src: src, className: "w-full h-10 rounded-md" }) }));
63
70
  }
64
- const DEFAULT_WFSF_HEADER = 'Điền từ vào biểu mẫu có cấu trúc';
65
71
  export function WordFillStructuredFormClient({ questionData, isReviewMode = false, userAnswer, onAnswerChange, autoFillCorrectAnswers = false, instruction, questionNumber, explanation, }) {
66
72
  const { title = '', content = '', answers = {}, caseSensitive = false, blankStyle: rawBlankStyle, viewMode, wordBank = [], instruction: dataInstruction, } = questionData;
73
+ const t = useT();
67
74
  const blankStyle = normalizeWfsfBlankStyle(rawBlankStyle);
68
75
  const isLetterBlank = blankStyle === 'letter';
69
76
  const isPhraseBlank = blankStyle === 'phrase' || blankStyle === 'translate';
70
- const allImages = useMemo(() => {
71
- if (Array.isArray(questionData.imageUrls) && questionData.imageUrls.length > 0) {
72
- return questionData.imageUrls.filter(Boolean);
73
- }
74
- if (questionData.imageUrl)
75
- return [questionData.imageUrl];
76
- return [];
77
- }, [questionData.imageUrls, questionData.imageUrl]);
78
- const allAudios = useMemo(() => {
79
- if (Array.isArray(questionData.audioUrls) && questionData.audioUrls.length > 0) {
80
- return questionData.audioUrls.filter(Boolean);
81
- }
82
- if (questionData.audioUrl)
83
- return [questionData.audioUrl];
84
- return [];
85
- }, [questionData.audioUrls, questionData.audioUrl]);
77
+ const allImages = useMemo(() => toMediaUrlList(questionData.imageUrls?.length ? questionData.imageUrls : questionData.imageUrl), [questionData.imageUrls, questionData.imageUrl]);
78
+ const allAudios = useMemo(() => toMediaUrlList(questionData.audioUrls?.length ? questionData.audioUrls : questionData.audioUrl), [questionData.audioUrls, questionData.audioUrl]);
86
79
  const [filledAnswers, setFilledAnswers] = useState(() => {
87
80
  if (userAnswer)
88
81
  return { ...userAnswer };
@@ -133,16 +126,16 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
133
126
  return a.localeCompare(b);
134
127
  });
135
128
  }, [content]);
136
- const allFilled = expectedBlankKeys.every(k => filledAnswers[k]?.trim());
137
- const isAllCorrect = isReviewMode && expectedBlankKeys.every((k) => isWfsfBlankCorrect(filledAnswers[k] || '', answers[k], caseSensitive));
129
+ const allFilled = expectedBlankKeys.every((k) => wfsfFilledText(filledAnswers[k]).trim());
130
+ const isAllCorrect = isReviewMode && expectedBlankKeys.every((k) => isWfsfBlankCorrect(wfsfFilledText(filledAnswers[k]), answers[k], caseSensitive));
138
131
  // Header = page rubric. Pearson preview maps draft.instruction → explanation.
139
132
  const rawExplanation = String(explanation || questionData.explanation || '').trim();
140
133
  const headerInstruction = String(instruction || dataInstruction || rawExplanation || '').trim();
141
134
  // Header reads "Bài x: rubric" — the question type name is never shown.
142
135
  const numberLabel = typeof questionNumber === 'number' && questionNumber > 0
143
- ? `Bài ${questionNumber}`
136
+ ? t('question.viewer.partN', { n: questionNumber })
144
137
  : '';
145
- const headerTitle = numberLabel ? (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "font-bold", children: [numberLabel, headerInstruction ? ':' : ''] }), headerInstruction ? ` ${headerInstruction}` : ''] })) : (headerInstruction || DEFAULT_WFSF_HEADER);
138
+ const headerTitle = numberLabel ? (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "font-bold", children: [numberLabel, headerInstruction ? ':' : ''] }), headerInstruction ? ` ${headerInstruction}` : ''] })) : (headerInstruction || t('question.viewer.wfsfDefaultTitle'));
146
139
  // Do not re-show the same rubric under "Giải thích".
147
140
  const displayExplanation = rawExplanation && rawExplanation !== headerInstruction ? rawExplanation : '';
148
141
  const handleInputChange = useCallback((key, value) => {
@@ -159,7 +152,7 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
159
152
  setExpandedBlank(null);
160
153
  }, [isReviewMode, onAnswerChange]);
161
154
  const renderBlank = useCallback((key, reactKey) => {
162
- const filled = filledAnswers[key] || '';
155
+ const filled = wfsfFilledText(filledAnswers[key]);
163
156
  const isCorrectFill = isReviewMode && isWfsfBlankCorrect(filled, answers[key], caseSensitive);
164
157
  const isWrongFill = isReviewMode && !!filled && !isCorrectFill;
165
158
  const blankOptions = optionsByKey.get(key);
@@ -15,6 +15,7 @@ import { Textarea } from '../../../../components/ui/textarea';
15
15
  import { Switch } from '../../../../components/ui/switch';
16
16
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
17
17
  import { CompactCreatorHeader, CompactExplanationToggle, CompactFieldCard, CompactPreviewBanner, } from '../../_shared/components/compact';
18
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
18
19
  import { FileUpload } from '../../../../components/ui/file-upload';
19
20
  import { ImagePreview } from '../../../../components/ui/image-preview';
20
21
  import { WordFillStructuredFormClient } from './WordFillStructuredFormClient';
@@ -141,14 +142,7 @@ export function WordFillStructuredFormCreator({ initialData, onSave, onCancel, o
141
142
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
142
143
  const [blankStyle, setBlankStyle] = useState(initialData?.blankStyle ?? 'word');
143
144
  // ── Multiple Images & Audios ────────────────────────────────────────────────
144
- const [imageUrls, setImageUrls] = useState(() => {
145
- if (Array.isArray(initialData?.imageUrls) && initialData.imageUrls.length > 0) {
146
- return initialData.imageUrls.filter(Boolean);
147
- }
148
- if (initialData?.imageUrl)
149
- return [initialData.imageUrl];
150
- return [];
151
- });
145
+ const [imageUrls, setImageUrls] = useState(() => toMediaUrlList(initialData?.imageUrls?.length ? initialData.imageUrls : initialData?.imageUrl));
152
146
  const [audioUrls, setAudioUrls] = useState(() => {
153
147
  if (Array.isArray(initialData?.audioUrls) && initialData.audioUrls.length > 0) {
154
148
  return initialData.audioUrls.filter(Boolean);
@@ -1,3 +1,4 @@
1
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
1
2
  import { normalizeWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
2
3
  import { detectWfsfBankMode, normalizeOptionsWordBank, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
3
4
  function asRecord(value) {
@@ -79,19 +80,11 @@ export function mapQuestionToWfsfData(question) {
79
80
  ? normalizeOptionsWordBank(rawWordBank, blankKeys)
80
81
  : undefined;
81
82
  const groupPayload = asRecord(asRecord(source.questionGroup).payload);
82
- const rawImageUrls = apiContent.imageUrls || answer.imageUrls;
83
- const imageUrls = Array.isArray(rawImageUrls)
84
- ? rawImageUrls.filter(Boolean)
85
- : (apiContent.imageUrl || answer.imageUrl ? [String(apiContent.imageUrl || answer.imageUrl)] : []);
86
- const rawAudioUrls = apiContent.audioUrls || answer.audioUrls;
87
- const audioUrls = Array.isArray(rawAudioUrls)
88
- ? rawAudioUrls.filter(Boolean)
89
- : (apiContent.audioUrl || answer.audioUrl || source.audio_url
90
- ? [String(apiContent.audioUrl || answer.audioUrl || source.audio_url)]
91
- : []);
83
+ const imageUrls = toMediaUrlList(apiContent.imageUrls || answer.imageUrls || apiContent.imageUrl || answer.imageUrl);
84
+ const audioUrls = toMediaUrlList(apiContent.audioUrls || answer.audioUrls || apiContent.audioUrl || answer.audioUrl || source.audio_url);
92
85
  return {
93
86
  title: String(apiContent.title || answer.title || ''),
94
- imageUrl: imageUrls[0] || String(apiContent.imageUrl || answer.imageUrl || ''),
87
+ imageUrl: imageUrls[0] || '',
95
88
  imageUrls,
96
89
  audioUrl: audioUrls[0] || String(apiContent.audioUrl || answer.audioUrl || source.audio_url || ''),
97
90
  audioUrls,
@@ -1,3 +1,4 @@
1
+ import { toMediaUrlList } from '../../../../shared/lib/utils/media-urls';
1
2
  import { compactWfsfAnswersMap } from '../../../../shared/lib/utils/wfsf-blank-match';
2
3
  /**
3
4
  * Transform WORD_FILL_STRUCTURED_FORM creator data → API format
@@ -19,17 +20,11 @@ export const transformWordFillStructuredForm = (question) => {
19
20
  answerData.groupSubtitle ||
20
21
  answerData.groupContent ||
21
22
  answerData.groupImageUrl);
22
- const rawImageUrls = answerData.imageUrls;
23
- const imageUrls = Array.isArray(rawImageUrls)
24
- ? rawImageUrls.filter(Boolean)
25
- : (answerData.imageUrl ? [answerData.imageUrl] : []);
26
- const rawAudioUrls = answerData.audioUrls;
27
- const audioUrls = Array.isArray(rawAudioUrls)
28
- ? rawAudioUrls.filter(Boolean)
29
- : (answerData.audioUrl ? [answerData.audioUrl] : []);
23
+ const imageUrls = toMediaUrlList(answerData.imageUrls ?? answerData.imageUrl);
24
+ const audioUrls = toMediaUrlList(answerData.audioUrls ?? answerData.audioUrl);
30
25
  const apiContent = {
31
26
  title: answerData.title || '',
32
- imageUrl: imageUrls[0] || answerData.imageUrl || '',
27
+ imageUrl: imageUrls[0] || '',
33
28
  imageUrls,
34
29
  audioUrl: audioUrls[0] || answerData.audioUrl || '',
35
30
  audioUrls,
@@ -12,6 +12,8 @@ interface QuestionViewerProps {
12
12
  partialScore?: number;
13
13
  feedback?: string;
14
14
  }>;
15
+ onItemRecorded?: (flattenIndex: number, blob: Blob) => void;
16
+ uploadingIndex?: number | null;
15
17
  }
16
- export declare function QuestionViewer({ question, onSubmitAnswer, isReviewMode, userAnswer, gradeDetails, }: QuestionViewerProps): import("react").JSX.Element;
18
+ export declare function QuestionViewer({ question, onSubmitAnswer, isReviewMode, userAnswer, gradeDetails, onItemRecorded, uploadingIndex, }: QuestionViewerProps): import("react").JSX.Element;
17
19
  export {};
@@ -6,7 +6,7 @@ import { Input } from '../../../components/ui/input';
6
6
  import { Check, Volume2, X } from 'lucide-react';
7
7
  import { useRef, useState } from 'react';
8
8
  import { toast } from 'sonner';
9
- import { t } from '../../../shared/lib/i18n';
9
+ import { t, useT } from '../../../shared/lib/i18n';
10
10
  import { ReadAndColorObjectsClient } from '../types/read-and-color-objects/ReadAndColorObjectsClient';
11
11
  import { ArrangeLettersIntoWordsClient } from '../types/arrange-letters-into-words/ArrangeLettersIntoWordsClient';
12
12
  import { WriteShortParagraphClient } from '../types/write-short-paragraph/WriteShortParagraphClient';
@@ -38,7 +38,14 @@ import { MatchWordToPictureViewer } from './MatchWordToPictureViewer';
38
38
  import { MatchWordToPictureGroupViewer } from './MatchWordToPictureGroupViewer';
39
39
  import { MatchColumnsToMakeSentencesViewer } from './MatchColumnsToMakeSentencesViewer';
40
40
  import { WordOrderingGroupViewer } from './WordOrderingGroupViewer';
41
- export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false, userAnswer, gradeDetails, }) {
41
+ function readCueAudioKey(value) {
42
+ if (!value || typeof value !== 'object' || Array.isArray(value))
43
+ return null;
44
+ const key = value.audioKey;
45
+ return typeof key === 'string' && key.trim() !== '' ? key : null;
46
+ }
47
+ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false, userAnswer, gradeDetails, onItemRecorded, uploadingIndex = null, }) {
48
+ const translate = useT();
42
49
  const [selectedAnswer, setSelectedAnswer] = useState(userAnswer || null);
43
50
  const [playCount, setPlayCount] = useState(0);
44
51
  const audioRef = useRef(null);
@@ -214,6 +221,7 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
214
221
  next[index] = value;
215
222
  setFillBlankAnswers(next);
216
223
  setSelectedAnswer(next);
224
+ onSubmitAnswer?.(next);
217
225
  };
218
226
  const isCorrect = (index, value) => {
219
227
  const correctAnswer = blanks[index] ?? '';
@@ -517,11 +525,13 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
517
525
  case 'ANSWER_THE_QUESTION_GROUP':
518
526
  return (_jsx(AnswerTheQuestionGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
519
527
  case 'SPEAKING_CUE_CARD':
520
- return (_jsx(SpeakingCueCardClient, { questionData: mapQuestionToSpeakingCueCardData(question), isReviewMode: isReviewMode }));
528
+ return (_jsx(SpeakingCueCardClient, { questionData: mapQuestionToSpeakingCueCardData(question), isReviewMode: isReviewMode, audioKey: readCueAudioKey(userAnswer), uploading: uploadingIndex === 0, onRecorded: isReviewMode || !onItemRecorded
529
+ ? undefined
530
+ : (blob) => onItemRecorded(0, blob) }));
521
531
  case 'SPONTANEOUS_QA':
522
532
  return (_jsx(SpontaneousQAClient, { questionData: mapQuestionToSpontaneousQAData(question), isReviewMode: isReviewMode }));
523
533
  case 'SPONTANEOUS_QA_GROUP':
524
- return (_jsx(SpontaneousQaGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, disabled: !onSubmitAnswer }));
534
+ return (_jsx(SpontaneousQaGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, disabled: !onSubmitAnswer && !onItemRecorded }));
525
535
  case 'WORD_ORDER_AND_MATCH_GROUP':
526
536
  case 'WORD_ORDER_AND_MATCH':
527
537
  return (_jsx(WordOrderAndMatchGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, onSubmitAnswer: onSubmitAnswer }));
@@ -572,7 +582,7 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
572
582
  case 'CHOOSE_THEN_ANSWER_GROUP':
573
583
  return (_jsx(ChooseThenAnswerGroupViewer, { question: question, isReviewMode: isReviewMode, userAnswer: userAnswer, gradeDetails: gradeDetails, onSubmitAnswer: onSubmitAnswer }));
574
584
  default:
575
- return _jsxs("div", { children: ["Lo\u1EA1i c\u00E2u h\u1ECFi kh\u00F4ng \u0111\u01B0\u1EE3c h\u1ED7 tr\u1EE3: ", question.type] });
585
+ return _jsx("div", { children: t('question.viewer.unsupported', { type: question.type }) });
576
586
  }
577
587
  };
578
588
  /**
@@ -588,25 +598,32 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
588
598
  question.type === 'ANSWER_THE_QUESTION') {
589
599
  return null;
590
600
  }
591
- // Modern fill stems live entirely in the body; title is a short label.
592
- if (question.type === 'FILL_IN_BLANK' ||
593
- question.type === 'WORD_FILL_PARAGRAPH') {
594
- return 'Câu hỏi';
595
- }
601
+ const typeKey = `question.type.${question.type}`;
602
+ const typeLabel = translate(typeKey);
603
+ const typedTitle = typeLabel === typeKey ? translate('question.viewer.fallbackTitle') : typeLabel;
596
604
  if (typeof question.content === 'string') {
597
- return stripLightweightMarkdown(question.content) || 'Câu hỏi';
605
+ return stripLightweightMarkdown(question.content) || typedTitle;
598
606
  }
599
607
  const c = question.content;
600
- const raw = c?.title || c?.question || c?.paragraph || '';
601
- // Prefer explicit title; if question/paragraph is stem-like, avoid raw dump.
602
- if (c?.title)
603
- return stripLightweightMarkdown(c.title) || 'Câu hỏi';
608
+ const metaTitle = typeof c?.meta?.title === 'string' ? c.meta.title : '';
609
+ const explicitTitle = (typeof c?.title === 'string' && c.title) || metaTitle;
610
+ if (explicitTitle && explicitTitle.length <= 80 && !/\{\d+\}/.test(explicitTitle) && !/\*\*/.test(explicitTitle)) {
611
+ return stripLightweightMarkdown(explicitTitle) || typedTitle;
612
+ }
613
+ // Modern fill stems live entirely in the body; title is a short type label.
614
+ if (question.type === 'FILL_IN_BLANK' ||
615
+ question.type === 'WORD_FILL_PARAGRAPH') {
616
+ return typedTitle;
617
+ }
618
+ const raw = c?.question || c?.paragraph || '';
604
619
  if (typeof raw === 'string' && (/\{\d+\}/.test(raw) || /\*\*/.test(raw))) {
605
- return 'Câu hỏi';
620
+ return typedTitle;
606
621
  }
607
- return stripLightweightMarkdown(raw) || 'Câu hỏi';
622
+ const stripped = stripLightweightMarkdown(typeof raw === 'string' ? raw : '');
623
+ return stripped && stripped.length <= 80 ? stripped : typedTitle;
608
624
  };
609
- const cardTitle = resolveCardTitle();
625
+ const isGroupQuestion = question.type.endsWith('_GROUP') || Boolean(question.isGroup);
626
+ const cardTitle = isGroupQuestion ? null : resolveCardTitle();
610
627
  // WFSF and Crossword already have their own bordered shell — avoid double Card border.
611
628
  if (question.type === 'WORD_FILL_STRUCTURED_FORM' ||
612
629
  question.type === 'CROSSWORD_PUZZLE' ||
@@ -614,7 +631,7 @@ export function QuestionViewer({ question, onSubmitAnswer, isReviewMode = false,
614
631
  question.type === 'ANSWER_THE_QUESTION') {
615
632
  return (_jsx("div", { className: "space-y-4", children: renderQuestion() }));
616
633
  }
617
- return (_jsxs(Card, { children: [cardTitle ? (_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center justify-between", children: [_jsx("span", { className: "min-w-0 pr-2 text-base font-semibold leading-snug", children: cardTitle }), _jsxs("span", { className: "shrink-0 text-sm font-normal text-gray-500", children: [question.points, " \u0111i\u1EC3m"] })] }) })) : null, _jsx(CardContent, { className: cardTitle ? 'space-y-4' : 'space-y-4 pt-4', children: renderQuestion() })] }));
634
+ return (_jsxs(Card, { children: [cardTitle ? (_jsx(CardHeader, { children: _jsxs(CardTitle, { className: "flex items-center justify-between", children: [_jsx("span", { className: "min-w-0 pr-2 text-base font-semibold leading-snug", children: cardTitle }), _jsx("span", { className: "shrink-0 text-sm font-normal text-gray-500", children: translate('question.viewer.points', { count: question.points }) })] }) })) : null, _jsx(CardContent, { className: cardTitle ? 'space-y-4' : 'space-y-4 pt-4', children: renderQuestion() })] }));
618
635
  }
619
636
  /** Drop lightweight markdown markers for plain header labels (not full token parse). */
620
637
  function stripLightweightMarkdown(text) {
@@ -15,5 +15,5 @@ function readStudentAnswer(value) {
15
15
  };
16
16
  }
17
17
  export function SpontaneousQaGroupViewer({ question, isReviewMode = false, userAnswer, onItemRecorded, uploadingIndex, gradeDetails, disabled, }) {
18
- return (_jsx(SpontaneousQaGroupClient, { questionData: mapQuestionToSpontaneousQaGroupData(question), isReviewMode: isReviewMode, userAnswer: readStudentAnswer(userAnswer), onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, gradeDetails: isReviewMode ? undefined : gradeDetails, disabled: disabled }));
18
+ return (_jsx(SpontaneousQaGroupClient, { questionData: mapQuestionToSpontaneousQaGroupData(question), isReviewMode: isReviewMode, userAnswer: readStudentAnswer(userAnswer), onItemRecorded: isReviewMode ? undefined : onItemRecorded, uploadingIndex: uploadingIndex, gradeDetails: gradeDetails, disabled: disabled }));
19
19
  }
@@ -3,6 +3,8 @@ export * from './constants/QueryKeyConstant';
3
3
  export * from './constants/exam-level.enum';
4
4
  export * from './constants/routes';
5
5
  export * from './lib/utils';
6
+ export { useLocaleStore } from './lib/stores/localeStore';
7
+ export type { Locale as ExamSdkLocale } from './lib/stores/localeStore';
6
8
  export * from '../components/questions/_shared';
7
9
  export * from './types';
8
10
  export * from './lib/utils/question-transform';
@@ -3,6 +3,7 @@ export * from './constants/QueryKeyConstant';
3
3
  export * from './constants/exam-level.enum';
4
4
  export * from './constants/routes';
5
5
  export * from './lib/utils';
6
+ export { useLocaleStore } from './lib/stores/localeStore';
6
7
  export * from '../components/questions/_shared';
7
8
  export * from './types';
8
9
  export * from './lib/utils/question-transform';
@@ -120,5 +120,43 @@ declare const common: {
120
120
  readonly 'common.badge.source.createdByAdmin': "Created by admin";
121
121
  readonly 'common.badge.source.createdByTeacher': "Created by teacher";
122
122
  readonly 'common.badge.source.createdFromEBM': "Synced from EBM";
123
+ readonly 'question.viewer.fallbackTitle': "Question";
124
+ readonly 'question.viewer.points': "{count} pts";
125
+ readonly 'question.viewer.questionN': "Question {n}";
126
+ readonly 'question.viewer.partN': "Part {n}";
127
+ readonly 'question.viewer.example': "Example";
128
+ readonly 'question.viewer.correct': "Correct";
129
+ readonly 'question.viewer.wrong': "Incorrect";
130
+ readonly 'question.viewer.readingPassage': "Reading passage";
131
+ readonly 'question.viewer.placeholder': "Enter your answer…";
132
+ readonly 'question.viewer.yourAnswer': "Your answer";
133
+ readonly 'question.viewer.writePlaceholder': "Write your answer here…";
134
+ readonly 'question.viewer.answerKey': "Answer key";
135
+ readonly 'question.viewer.noAnswer': "No answer yet";
136
+ readonly 'question.viewer.notWritten': "Not written";
137
+ readonly 'question.viewer.sample': "Sample";
138
+ readonly 'question.viewer.chooseScore': "Choose {score}";
139
+ readonly 'question.viewer.writeScore': "Write {score}";
140
+ readonly 'question.viewer.expandPassage': "Show more";
141
+ readonly 'question.viewer.collapsePassage': "Show less";
142
+ readonly 'question.viewer.unsupported': "Unsupported question type: {type}";
143
+ readonly 'question.viewer.missingPrompt': "Question text is missing";
144
+ readonly 'question.viewer.imageAlt': "Image {n}";
145
+ readonly 'question.viewer.itemImageAlt': "Question {n} image {m}";
146
+ readonly 'question.viewer.atqgDefaultInstruction': "Read the text and answer the questions.";
147
+ readonly 'question.viewer.wfsfDefaultTitle': "Fill in the structured form";
148
+ readonly 'question.type.ANSWER_THE_QUESTION_GROUP': "Answer the questions";
149
+ readonly 'question.type.CHOOSE_THEN_ANSWER_GROUP': "Choose then answer";
150
+ readonly 'question.type.FILL_IN_BLANK': "Fill in the blank";
151
+ readonly 'question.type.FILL_IN_BLANK_GROUP': "Fill in the blanks";
152
+ readonly 'question.type.WORD_FILL_STRUCTURED_FORM': "Structured form";
153
+ readonly 'question.type.WORD_FILL_PARAGRAPH': "Fill the paragraph";
154
+ readonly 'question.type.MATCHING_WITH_LINES_GROUP': "Match the columns";
155
+ readonly 'question.type.MATCH_WORD_TO_PICTURE': "Match words to pictures";
156
+ readonly 'question.type.MATCH_WORD_TO_PICTURE_GROUP': "Match words to pictures";
157
+ readonly 'question.type.CROSSWORD_PUZZLE': "Crossword";
158
+ readonly 'question.type.FIND_WORDS_IN_MATRIX': "Find the words";
159
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER': "Choose the correct answer";
160
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': "Choose the correct answers";
123
161
  };
124
162
  export default common;
@@ -136,5 +136,44 @@ const common = {
136
136
  'common.badge.source.createdByAdmin': 'Created by admin',
137
137
  'common.badge.source.createdByTeacher': 'Created by teacher',
138
138
  'common.badge.source.createdFromEBM': 'Synced from EBM',
139
+ // Question viewer chrome (homework / examine)
140
+ 'question.viewer.fallbackTitle': 'Question',
141
+ 'question.viewer.points': '{count} pts',
142
+ 'question.viewer.questionN': 'Question {n}',
143
+ 'question.viewer.partN': 'Part {n}',
144
+ 'question.viewer.example': 'Example',
145
+ 'question.viewer.correct': 'Correct',
146
+ 'question.viewer.wrong': 'Incorrect',
147
+ 'question.viewer.readingPassage': 'Reading passage',
148
+ 'question.viewer.placeholder': 'Enter your answer…',
149
+ 'question.viewer.yourAnswer': 'Your answer',
150
+ 'question.viewer.writePlaceholder': 'Write your answer here…',
151
+ 'question.viewer.answerKey': 'Answer key',
152
+ 'question.viewer.noAnswer': 'No answer yet',
153
+ 'question.viewer.notWritten': 'Not written',
154
+ 'question.viewer.sample': 'Sample',
155
+ 'question.viewer.chooseScore': 'Choose {score}',
156
+ 'question.viewer.writeScore': 'Write {score}',
157
+ 'question.viewer.expandPassage': 'Show more',
158
+ 'question.viewer.collapsePassage': 'Show less',
159
+ 'question.viewer.unsupported': 'Unsupported question type: {type}',
160
+ 'question.viewer.missingPrompt': 'Question text is missing',
161
+ 'question.viewer.imageAlt': 'Image {n}',
162
+ 'question.viewer.itemImageAlt': 'Question {n} image {m}',
163
+ 'question.viewer.atqgDefaultInstruction': 'Read the text and answer the questions.',
164
+ 'question.viewer.wfsfDefaultTitle': 'Fill in the structured form',
165
+ 'question.type.ANSWER_THE_QUESTION_GROUP': 'Answer the questions',
166
+ 'question.type.CHOOSE_THEN_ANSWER_GROUP': 'Choose then answer',
167
+ 'question.type.FILL_IN_BLANK': 'Fill in the blank',
168
+ 'question.type.FILL_IN_BLANK_GROUP': 'Fill in the blanks',
169
+ 'question.type.WORD_FILL_STRUCTURED_FORM': 'Structured form',
170
+ 'question.type.WORD_FILL_PARAGRAPH': 'Fill the paragraph',
171
+ 'question.type.MATCHING_WITH_LINES_GROUP': 'Match the columns',
172
+ 'question.type.MATCH_WORD_TO_PICTURE': 'Match words to pictures',
173
+ 'question.type.MATCH_WORD_TO_PICTURE_GROUP': 'Match words to pictures',
174
+ 'question.type.CROSSWORD_PUZZLE': 'Crossword',
175
+ 'question.type.FIND_WORDS_IN_MATRIX': 'Find the words',
176
+ 'question.type.CHOOSE_THE_CORRECT_ANSWER': 'Choose the correct answer',
177
+ 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': 'Choose the correct answers',
139
178
  };
140
179
  export default common;
@@ -2297,5 +2297,43 @@ declare const en: {
2297
2297
  readonly 'common.badge.source.createdByAdmin': "Created by admin";
2298
2298
  readonly 'common.badge.source.createdByTeacher': "Created by teacher";
2299
2299
  readonly 'common.badge.source.createdFromEBM': "Synced from EBM";
2300
+ readonly 'question.viewer.fallbackTitle': "Question";
2301
+ readonly 'question.viewer.points': "{count} pts";
2302
+ readonly 'question.viewer.questionN': "Question {n}";
2303
+ readonly 'question.viewer.partN': "Part {n}";
2304
+ readonly 'question.viewer.example': "Example";
2305
+ readonly 'question.viewer.correct': "Correct";
2306
+ readonly 'question.viewer.wrong': "Incorrect";
2307
+ readonly 'question.viewer.readingPassage': "Reading passage";
2308
+ readonly 'question.viewer.placeholder': "Enter your answer…";
2309
+ readonly 'question.viewer.yourAnswer': "Your answer";
2310
+ readonly 'question.viewer.writePlaceholder': "Write your answer here…";
2311
+ readonly 'question.viewer.answerKey': "Answer key";
2312
+ readonly 'question.viewer.noAnswer': "No answer yet";
2313
+ readonly 'question.viewer.notWritten': "Not written";
2314
+ readonly 'question.viewer.sample': "Sample";
2315
+ readonly 'question.viewer.chooseScore': "Choose {score}";
2316
+ readonly 'question.viewer.writeScore': "Write {score}";
2317
+ readonly 'question.viewer.expandPassage': "Show more";
2318
+ readonly 'question.viewer.collapsePassage': "Show less";
2319
+ readonly 'question.viewer.unsupported': "Unsupported question type: {type}";
2320
+ readonly 'question.viewer.missingPrompt': "Question text is missing";
2321
+ readonly 'question.viewer.imageAlt': "Image {n}";
2322
+ readonly 'question.viewer.itemImageAlt': "Question {n} image {m}";
2323
+ readonly 'question.viewer.atqgDefaultInstruction': "Read the text and answer the questions.";
2324
+ readonly 'question.viewer.wfsfDefaultTitle': "Fill in the structured form";
2325
+ readonly 'question.type.ANSWER_THE_QUESTION_GROUP': "Answer the questions";
2326
+ readonly 'question.type.CHOOSE_THEN_ANSWER_GROUP': "Choose then answer";
2327
+ readonly 'question.type.FILL_IN_BLANK': "Fill in the blank";
2328
+ readonly 'question.type.FILL_IN_BLANK_GROUP': "Fill in the blanks";
2329
+ readonly 'question.type.WORD_FILL_STRUCTURED_FORM': "Structured form";
2330
+ readonly 'question.type.WORD_FILL_PARAGRAPH': "Fill the paragraph";
2331
+ readonly 'question.type.MATCHING_WITH_LINES_GROUP': "Match the columns";
2332
+ readonly 'question.type.MATCH_WORD_TO_PICTURE': "Match words to pictures";
2333
+ readonly 'question.type.MATCH_WORD_TO_PICTURE_GROUP': "Match words to pictures";
2334
+ readonly 'question.type.CROSSWORD_PUZZLE': "Crossword";
2335
+ readonly 'question.type.FIND_WORDS_IN_MATRIX': "Find the words";
2336
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER': "Choose the correct answer";
2337
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': "Choose the correct answers";
2300
2338
  };
2301
2339
  export default en;
@@ -120,5 +120,43 @@ declare const common: {
120
120
  readonly 'common.badge.source.createdByAdmin': "Admin tạo";
121
121
  readonly 'common.badge.source.createdByTeacher': "Giáo viên tạo";
122
122
  readonly 'common.badge.source.createdFromEBM': "Đồng bộ EBM";
123
+ readonly 'question.viewer.fallbackTitle': "Câu hỏi";
124
+ readonly 'question.viewer.points': "{count} điểm";
125
+ readonly 'question.viewer.questionN': "Câu {n}";
126
+ readonly 'question.viewer.partN': "Bài {n}";
127
+ readonly 'question.viewer.example': "Câu ví dụ";
128
+ readonly 'question.viewer.correct': "Đúng";
129
+ readonly 'question.viewer.wrong': "Sai";
130
+ readonly 'question.viewer.readingPassage': "Bài đọc";
131
+ readonly 'question.viewer.placeholder': "Nhập câu trả lời...";
132
+ readonly 'question.viewer.yourAnswer': "Câu trả lời của bạn";
133
+ readonly 'question.viewer.writePlaceholder': "Viết câu trả lời tại đây...";
134
+ readonly 'question.viewer.answerKey': "Đáp án";
135
+ readonly 'question.viewer.noAnswer': "Chưa có đáp án";
136
+ readonly 'question.viewer.notWritten': "Chưa viết";
137
+ readonly 'question.viewer.sample': "Mẫu";
138
+ readonly 'question.viewer.chooseScore': "Chọn {score}";
139
+ readonly 'question.viewer.writeScore': "Viết {score}";
140
+ readonly 'question.viewer.expandPassage': "Xem thêm";
141
+ readonly 'question.viewer.collapsePassage': "Thu gọn";
142
+ readonly 'question.viewer.unsupported': "Loại câu hỏi không được hỗ trợ: {type}";
143
+ readonly 'question.viewer.missingPrompt': "Câu hỏi chưa được nhập";
144
+ readonly 'question.viewer.imageAlt': "Hình ảnh ({n})";
145
+ readonly 'question.viewer.itemImageAlt': "Hình ảnh câu {n} ({m})";
146
+ readonly 'question.viewer.atqgDefaultInstruction': "Đọc đoạn văn và trả lời câu hỏi.";
147
+ readonly 'question.viewer.wfsfDefaultTitle': "Điền từ vào biểu mẫu có cấu trúc";
148
+ readonly 'question.type.ANSWER_THE_QUESTION_GROUP': "Trả lời câu hỏi";
149
+ readonly 'question.type.CHOOSE_THEN_ANSWER_GROUP': "Chọn rồi trả lời";
150
+ readonly 'question.type.FILL_IN_BLANK': "Điền từ";
151
+ readonly 'question.type.FILL_IN_BLANK_GROUP': "Điền từ";
152
+ readonly 'question.type.WORD_FILL_STRUCTURED_FORM': "Điền từ vào biểu mẫu";
153
+ readonly 'question.type.WORD_FILL_PARAGRAPH': "Điền từ vào đoạn";
154
+ readonly 'question.type.MATCHING_WITH_LINES_GROUP': "Nối cột";
155
+ readonly 'question.type.MATCH_WORD_TO_PICTURE': "Nối từ với tranh";
156
+ readonly 'question.type.MATCH_WORD_TO_PICTURE_GROUP': "Nối từ với tranh";
157
+ readonly 'question.type.CROSSWORD_PUZZLE': "Ô chữ";
158
+ readonly 'question.type.FIND_WORDS_IN_MATRIX': "Tìm từ";
159
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER': "Chọn đáp án đúng";
160
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': "Chọn đáp án đúng";
123
161
  };
124
162
  export default common;
@@ -136,5 +136,44 @@ const common = {
136
136
  'common.badge.source.createdByAdmin': 'Admin tạo',
137
137
  'common.badge.source.createdByTeacher': 'Giáo viên tạo',
138
138
  'common.badge.source.createdFromEBM': 'Đồng bộ EBM',
139
+ // Question viewer chrome (homework / examine)
140
+ 'question.viewer.fallbackTitle': 'Câu hỏi',
141
+ 'question.viewer.points': '{count} điểm',
142
+ 'question.viewer.questionN': 'Câu {n}',
143
+ 'question.viewer.partN': 'Bài {n}',
144
+ 'question.viewer.example': 'Câu ví dụ',
145
+ 'question.viewer.correct': 'Đúng',
146
+ 'question.viewer.wrong': 'Sai',
147
+ 'question.viewer.readingPassage': 'Bài đọc',
148
+ 'question.viewer.placeholder': 'Nhập câu trả lời...',
149
+ 'question.viewer.yourAnswer': 'Câu trả lời của bạn',
150
+ 'question.viewer.writePlaceholder': 'Viết câu trả lời tại đây...',
151
+ 'question.viewer.answerKey': 'Đáp án',
152
+ 'question.viewer.noAnswer': 'Chưa có đáp án',
153
+ 'question.viewer.notWritten': 'Chưa viết',
154
+ 'question.viewer.sample': 'Mẫu',
155
+ 'question.viewer.chooseScore': 'Chọn {score}',
156
+ 'question.viewer.writeScore': 'Viết {score}',
157
+ 'question.viewer.expandPassage': 'Xem thêm',
158
+ 'question.viewer.collapsePassage': 'Thu gọn',
159
+ 'question.viewer.unsupported': 'Loại câu hỏi không được hỗ trợ: {type}',
160
+ 'question.viewer.missingPrompt': 'Câu hỏi chưa được nhập',
161
+ 'question.viewer.imageAlt': 'Hình ảnh ({n})',
162
+ 'question.viewer.itemImageAlt': 'Hình ảnh câu {n} ({m})',
163
+ 'question.viewer.atqgDefaultInstruction': 'Đọc đoạn văn và trả lời câu hỏi.',
164
+ 'question.viewer.wfsfDefaultTitle': 'Điền từ vào biểu mẫu có cấu trúc',
165
+ 'question.type.ANSWER_THE_QUESTION_GROUP': 'Trả lời câu hỏi',
166
+ 'question.type.CHOOSE_THEN_ANSWER_GROUP': 'Chọn rồi trả lời',
167
+ 'question.type.FILL_IN_BLANK': 'Điền từ',
168
+ 'question.type.FILL_IN_BLANK_GROUP': 'Điền từ',
169
+ 'question.type.WORD_FILL_STRUCTURED_FORM': 'Điền từ vào biểu mẫu',
170
+ 'question.type.WORD_FILL_PARAGRAPH': 'Điền từ vào đoạn',
171
+ 'question.type.MATCHING_WITH_LINES_GROUP': 'Nối cột',
172
+ 'question.type.MATCH_WORD_TO_PICTURE': 'Nối từ với tranh',
173
+ 'question.type.MATCH_WORD_TO_PICTURE_GROUP': 'Nối từ với tranh',
174
+ 'question.type.CROSSWORD_PUZZLE': 'Ô chữ',
175
+ 'question.type.FIND_WORDS_IN_MATRIX': 'Tìm từ',
176
+ 'question.type.CHOOSE_THE_CORRECT_ANSWER': 'Chọn đáp án đúng',
177
+ 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': 'Chọn đáp án đúng',
139
178
  };
140
179
  export default common;
@@ -2297,5 +2297,43 @@ declare const vi: {
2297
2297
  readonly 'common.badge.source.createdByAdmin': "Admin tạo";
2298
2298
  readonly 'common.badge.source.createdByTeacher': "Giáo viên tạo";
2299
2299
  readonly 'common.badge.source.createdFromEBM': "Đồng bộ EBM";
2300
+ readonly 'question.viewer.fallbackTitle': "Câu hỏi";
2301
+ readonly 'question.viewer.points': "{count} điểm";
2302
+ readonly 'question.viewer.questionN': "Câu {n}";
2303
+ readonly 'question.viewer.partN': "Bài {n}";
2304
+ readonly 'question.viewer.example': "Câu ví dụ";
2305
+ readonly 'question.viewer.correct': "Đúng";
2306
+ readonly 'question.viewer.wrong': "Sai";
2307
+ readonly 'question.viewer.readingPassage': "Bài đọc";
2308
+ readonly 'question.viewer.placeholder': "Nhập câu trả lời...";
2309
+ readonly 'question.viewer.yourAnswer': "Câu trả lời của bạn";
2310
+ readonly 'question.viewer.writePlaceholder': "Viết câu trả lời tại đây...";
2311
+ readonly 'question.viewer.answerKey': "Đáp án";
2312
+ readonly 'question.viewer.noAnswer': "Chưa có đáp án";
2313
+ readonly 'question.viewer.notWritten': "Chưa viết";
2314
+ readonly 'question.viewer.sample': "Mẫu";
2315
+ readonly 'question.viewer.chooseScore': "Chọn {score}";
2316
+ readonly 'question.viewer.writeScore': "Viết {score}";
2317
+ readonly 'question.viewer.expandPassage': "Xem thêm";
2318
+ readonly 'question.viewer.collapsePassage': "Thu gọn";
2319
+ readonly 'question.viewer.unsupported': "Loại câu hỏi không được hỗ trợ: {type}";
2320
+ readonly 'question.viewer.missingPrompt': "Câu hỏi chưa được nhập";
2321
+ readonly 'question.viewer.imageAlt': "Hình ảnh ({n})";
2322
+ readonly 'question.viewer.itemImageAlt': "Hình ảnh câu {n} ({m})";
2323
+ readonly 'question.viewer.atqgDefaultInstruction': "Đọc đoạn văn và trả lời câu hỏi.";
2324
+ readonly 'question.viewer.wfsfDefaultTitle': "Điền từ vào biểu mẫu có cấu trúc";
2325
+ readonly 'question.type.ANSWER_THE_QUESTION_GROUP': "Trả lời câu hỏi";
2326
+ readonly 'question.type.CHOOSE_THEN_ANSWER_GROUP': "Chọn rồi trả lời";
2327
+ readonly 'question.type.FILL_IN_BLANK': "Điền từ";
2328
+ readonly 'question.type.FILL_IN_BLANK_GROUP': "Điền từ";
2329
+ readonly 'question.type.WORD_FILL_STRUCTURED_FORM': "Điền từ vào biểu mẫu";
2330
+ readonly 'question.type.WORD_FILL_PARAGRAPH': "Điền từ vào đoạn";
2331
+ readonly 'question.type.MATCHING_WITH_LINES_GROUP': "Nối cột";
2332
+ readonly 'question.type.MATCH_WORD_TO_PICTURE': "Nối từ với tranh";
2333
+ readonly 'question.type.MATCH_WORD_TO_PICTURE_GROUP': "Nối từ với tranh";
2334
+ readonly 'question.type.CROSSWORD_PUZZLE': "Ô chữ";
2335
+ readonly 'question.type.FIND_WORDS_IN_MATRIX': "Tìm từ";
2336
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER': "Chọn đáp án đúng";
2337
+ readonly 'question.type.CHOOSE_THE_CORRECT_ANSWER_GROUP': "Chọn đáp án đúng";
2300
2338
  };
2301
2339
  export default vi;
@@ -0,0 +1,2 @@
1
+ /** Normalize creator / bank `imageUrl` that may be a string, string[], or Array#toString join. */
2
+ export declare function toMediaUrlList(value: unknown): string[];
@@ -0,0 +1,23 @@
1
+ /** Normalize creator / bank `imageUrl` that may be a string, string[], or Array#toString join. */
2
+ export function toMediaUrlList(value) {
3
+ if (Array.isArray(value)) {
4
+ return value.flatMap((item) => toMediaUrlList(item));
5
+ }
6
+ if (typeof value !== 'string') {
7
+ return [];
8
+ }
9
+ const trimmed = value.trim();
10
+ if (!trimmed) {
11
+ return [];
12
+ }
13
+ if (trimmed.includes(',') && /https?:\/\//i.test(trimmed)) {
14
+ const parts = trimmed
15
+ .split(',')
16
+ .map((part) => part.trim())
17
+ .filter(Boolean);
18
+ if (parts.length > 1 && parts.every((part) => /^https?:\/\//i.test(part) || !part.includes(' '))) {
19
+ return parts;
20
+ }
21
+ }
22
+ return [trimmed];
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",