@tinyweb_dev/oe-exam-sdk 1.0.11 → 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 (32) 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.d.ts +5 -2
  4. package/dist/components/questions/_shared/components/compact/KidQuestionCard.js +4 -2
  5. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.d.ts +7 -0
  6. package/dist/components/questions/_shared/components/compact/SpeakingReplyMic.js +86 -0
  7. package/dist/components/questions/_shared/components/compact/index.d.ts +2 -0
  8. package/dist/components/questions/_shared/components/compact/index.js +2 -0
  9. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +14 -5
  10. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +2 -2
  11. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +35 -55
  12. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +4 -1
  13. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +4 -4
  14. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +2 -75
  15. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +32 -35
  16. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +2 -8
  17. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +4 -11
  18. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -9
  19. package/dist/components/questions/viewer/QuestionViewer.d.ts +3 -1
  20. package/dist/components/questions/viewer/QuestionViewer.js +36 -19
  21. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +1 -1
  22. package/dist/shared/index.d.ts +2 -0
  23. package/dist/shared/index.js +1 -0
  24. package/dist/shared/lib/i18n/messages/en/common.d.ts +38 -0
  25. package/dist/shared/lib/i18n/messages/en/common.js +39 -0
  26. package/dist/shared/lib/i18n/messages/en.d.ts +38 -0
  27. package/dist/shared/lib/i18n/messages/vi/common.d.ts +38 -0
  28. package/dist/shared/lib/i18n/messages/vi/common.js +39 -0
  29. package/dist/shared/lib/i18n/messages/vi.d.ts +38 -0
  30. package/dist/shared/lib/utils/media-urls.d.ts +2 -0
  31. package/dist/shared/lib/utils/media-urls.js +23 -0
  32. 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,10 @@
1
1
  import type { ReactNode } from 'react';
2
- export declare function KidQuestionCard({ icon, title, points, isExample, review, headerExtra, children, className, contentClassName, }: {
2
+ export declare function KidQuestionCard({ icon, hideIcon, title, titleClassName, points, isExample, review, headerExtra, children, className, contentClassName, }: {
3
3
  icon?: ReactNode;
4
- title: string;
4
+ hideIcon?: boolean;
5
+ title: ReactNode;
6
+ /** Defaults to `truncate`; pass e.g. `whitespace-normal` to let long titles wrap. */
7
+ titleClassName?: string;
5
8
  points?: number;
6
9
  isExample?: boolean;
7
10
  review?: 'correct' | 'wrong' | null;
@@ -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
- export function KidQuestionCard({ icon, title, 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: [_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: "truncate text-sm font-medium text-gray-700", 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 })] }));
6
+ export function KidQuestionCard({ icon, hideIcon = false, title, titleClassName, points, isExample = false, review, headerExtra, children, className, contentClassName, }) {
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 : [];