@tinyweb_dev/oe-exam-sdk 1.0.9 → 1.0.11

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 (124) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +7 -7
  3. package/dist/components/exams/take/components/question-renderers/MoversCrossOutWordGroupRenderer.js +24 -10
  4. package/dist/components/exams/take/components/question-renderers/MoversCrosswordPuzzleRenderer.js +1 -1
  5. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -1
  6. package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureGroupRenderer.d.ts +14 -0
  7. package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureGroupRenderer.js +36 -0
  8. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +4 -0
  9. package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
  10. package/dist/components/exams/take/components/question-renderers/index.js +1 -0
  11. package/dist/components/exams/take/components/speaking/renderers/SpeakingSpontaneousQaGroupRenderer.js +12 -2
  12. package/dist/components/exams/take/hooks/useSpontaneousQaGroupRendererSetup.js +2 -1
  13. package/dist/components/exams/take/utils/answer-transformers.js +17 -1
  14. package/dist/components/exams/take/utils/cross-out-word-group.transformer.js +4 -6
  15. package/dist/components/exams/take/utils/question-transformers.d.ts +32 -3
  16. package/dist/components/exams/take/utils/question-transformers.js +51 -2
  17. package/dist/components/questions/_shared/components/compact/KidChoiceRow.d.ts +12 -0
  18. package/dist/components/questions/_shared/components/compact/KidChoiceRow.js +33 -0
  19. package/dist/components/questions/_shared/components/compact/KidInstruction.d.ts +5 -0
  20. package/dist/components/questions/_shared/components/compact/KidInstruction.js +9 -0
  21. package/dist/components/questions/_shared/components/compact/KidPrompt.d.ts +5 -0
  22. package/dist/components/questions/_shared/components/compact/KidPrompt.js +9 -0
  23. package/dist/components/questions/_shared/components/compact/KidQuestionCard.d.ts +12 -0
  24. package/dist/components/questions/_shared/components/compact/KidQuestionCard.js +7 -0
  25. package/dist/components/questions/_shared/components/compact/index.d.ts +4 -0
  26. package/dist/components/questions/_shared/components/compact/index.js +4 -0
  27. package/dist/components/questions/_shared/types/crossword-puzzle.type.d.ts +5 -1
  28. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +1 -0
  29. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +14 -0
  30. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +2 -2
  31. package/dist/components/questions/_shared/types/spontaneous-qa-group.type.d.ts +17 -0
  32. package/dist/components/questions/components/QuestionSearchDropdown.js +1 -0
  33. package/dist/components/questions/types/answer-the-question/AnswerTheQuestionClient.js +18 -19
  34. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +8 -7
  35. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +5 -4
  36. package/dist/components/questions/types/arrange-letters-into-words/ArrangeLettersIntoWordsClient.js +17 -11
  37. package/dist/components/questions/types/choose-correct-adjective/ChooseCorrectAdjectiveClient.js +15 -42
  38. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerClient.js +15 -36
  39. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +9 -30
  40. package/dist/components/questions/types/choose-then-answer-group/ChooseThenAnswerGroupClient.js +8 -7
  41. package/dist/components/questions/types/cross-out-word-group/CrossOutWordGroupClient.js +50 -32
  42. package/dist/components/questions/types/cross-out-word-group/CrossOutWordGroupCreator.js +24 -44
  43. package/dist/components/questions/types/cross-out-word-group/index.d.ts +1 -0
  44. package/dist/components/questions/types/cross-out-word-group/index.js +1 -0
  45. package/dist/components/questions/types/cross-out-word-group/map-cross-out-word-group-data.js +9 -9
  46. package/dist/components/questions/types/cross-out-word-group/tokenize.d.ts +2 -0
  47. package/dist/components/questions/types/cross-out-word-group/tokenize.js +11 -0
  48. package/dist/components/questions/types/cross-out-word-group/transform.js +2 -4
  49. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.d.ts +2 -1
  50. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleClient.js +8 -6
  51. package/dist/components/questions/types/crossword-puzzle/CrosswordPuzzleCreator.js +68 -11
  52. package/dist/components/questions/types/crossword-puzzle/transform.js +59 -30
  53. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +26 -31
  54. package/dist/components/questions/types/fill-in-blank-group/FillInBlankAnswerKey.d.ts +6 -0
  55. package/dist/components/questions/types/fill-in-blank-group/FillInBlankAnswerKey.js +17 -0
  56. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +32 -28
  57. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +109 -26
  58. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +21 -11
  59. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +1 -0
  60. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +4 -1
  61. package/dist/components/questions/types/fill-missing-words-in-grid/FillMissingWordsInGridClient.js +32 -31
  62. package/dist/components/questions/types/find-words-in-matrix/FindWordsInMatrixClient.js +71 -28
  63. package/dist/components/questions/types/find-words-in-matrix/FindWordsInMatrixCreator.js +6 -2
  64. package/dist/components/questions/types/find-words-in-matrix/transform.js +5 -0
  65. package/dist/components/questions/types/label-the-picture/LabelThePictureClient.js +4 -3
  66. package/dist/components/questions/types/listen-and-choose-from-answer-group/ListenAndChooseFromAnswerGroupClient.js +11 -30
  67. package/dist/components/questions/types/listen-and-speak-answer/ListenAndSpeakAnswerClient.js +7 -6
  68. package/dist/components/questions/types/listen-and-speak-compare-images/ListenAndSpeakCompareImagesClient.js +2 -1
  69. package/dist/components/questions/types/listen-and-speak-image-group/ListenAndSpeakImageGroupClient.js +14 -13
  70. package/dist/components/questions/types/listen-and-speak-info-exchange/ListenAndSpeakInfoExchangeClient.js +4 -3
  71. package/dist/components/questions/types/listen-and-speak-odd-one-out/ListenAndSpeakOddOneOutClient.js +4 -3
  72. package/dist/components/questions/types/listen-and-speak-question-list/ListenAndSpeakQuestionListClient.js +11 -10
  73. package/dist/components/questions/types/listen-and-speak-with-story-images/ListenAndSpeakWithStoryImagesClient.js +4 -3
  74. package/dist/components/questions/types/match-by-writing-answer/MatchByWritingAnswerClient.js +13 -11
  75. package/dist/components/questions/types/match-columns-to-make-sentences/MatchColumnsToMakeSentencesClient.js +17 -14
  76. package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureClient.js +16 -12
  77. package/dist/components/questions/types/match-word-to-picture/MatchWordToPictureGroupClient.js +14 -14
  78. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +24 -35
  79. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +16 -3
  80. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.d.ts +2 -1
  81. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.js +25 -6
  82. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +1 -1
  83. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +22 -10
  84. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +60 -18
  85. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +2 -1
  86. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +24 -5
  87. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +8 -4
  88. package/dist/components/questions/types/matching-with-lines-group/transform.js +5 -4
  89. package/dist/components/questions/types/read-passage-and-answer-questions/ReadPassageAndAnswerQuestionsClient.js +8 -26
  90. package/dist/components/questions/types/sort-words-into-categories/SortWordsIntoCategoriesClient.js +2 -1
  91. package/dist/components/questions/types/speaking-conversation/SpeakingConversationPreviewClient.js +6 -5
  92. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +2 -1
  93. package/dist/components/questions/types/speaking-describe-image/SpeakingDescribeImageClient.js +5 -4
  94. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.d.ts +1 -1
  95. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupClient.js +54 -11
  96. package/dist/components/questions/types/spontaneous-qa-group/SpontaneousQaGroupCreator.js +11 -7
  97. package/dist/components/questions/types/spontaneous-qa-group/creator-helpers.js +8 -2
  98. package/dist/components/questions/types/spontaneous-qa-group/flatten.d.ts +1 -0
  99. package/dist/components/questions/types/spontaneous-qa-group/flatten.js +4 -2
  100. package/dist/components/questions/types/spontaneous-qa-group/map-spontaneous-qa-group-data.js +5 -3
  101. package/dist/components/questions/types/spontaneous-qa-group/register.js +5 -2
  102. package/dist/components/questions/types/spontaneous-qa-group/transform.js +6 -2
  103. package/dist/components/questions/types/true-false/TrueFalseClient.js +5 -54
  104. package/dist/components/questions/types/true-false-correct-group/TrueFalseCorrectGroupClient.js +21 -23
  105. package/dist/components/questions/types/true-false-group/TrueFalseGroupClient.js +8 -17
  106. package/dist/components/questions/types/word-fill-paragraph/WordFillParagraphClient.js +76 -81
  107. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +17 -16
  108. package/dist/components/questions/types/word-order-and-match-group/WordOrderAndMatchGroupClient.js +31 -36
  109. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +15 -14
  110. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +9 -8
  111. package/dist/components/questions/types/write-correct-verb-form/WriteCorrectVerbFormClient.js +16 -13
  112. package/dist/components/questions/viewer/CrossOutWordGroupViewer.js +1 -4
  113. package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +6 -2
  114. package/dist/components/questions/viewer/FillInBlankGroupViewer.js +13 -7
  115. package/dist/components/questions/viewer/QuestionViewer.js +2 -2
  116. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.d.ts +7 -1
  117. package/dist/components/questions/viewer/SpontaneousQaGroupViewer.js +14 -2
  118. package/dist/components/results/renderers/review-question-body-movers.js +7 -3
  119. package/dist/components/results/review-data.js +20 -1
  120. package/dist/shared/lib/utils/question-reverse-transform.js +48 -7
  121. package/dist/shared/types/questions/crossword-puzzle.d.ts +1 -0
  122. package/dist/shared/types/questions/find-words-in-matrix.d.ts +1 -0
  123. package/dist/shared/types/questions/matching-with-lines-group.d.ts +2 -2
  124. package/package.json +1 -1
@@ -1,47 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, Check, CircleHelp, Lightbulb, Star, Volume2, X } from 'lucide-react';
3
+ import { BookOpen, Check, ChevronDown, Lightbulb, Volume2, X } from 'lucide-react';
4
4
  import { cn } from '../../../../shared/lib/utils';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
- import { toMatchWordToPictureImageUrls } from '../../_shared/types/match-word-to-picture-group.type';
6
+ import { toMatchWordToPictureImageUrls, toMatchWordToPictureStorageKey, } from '../../_shared/types/match-word-to-picture-group.type';
7
+ import { KidInstruction, KidPrompt, KidQuestionCard, } from '../../_shared/components/compact';
7
8
  function normalizeWord(value) {
8
9
  return value.trim().toLowerCase();
9
10
  }
10
- function isPlayableAudioUrl(value) {
11
+ function isPlayableUrl(value) {
11
12
  return (value.startsWith('http://')
12
13
  || value.startsWith('https://')
13
14
  || value.startsWith('blob:')
14
15
  || value.startsWith('data:'));
15
16
  }
16
- function toPresignKey(value) {
17
- const trimmed = value.trim();
18
- if (isPlayableAudioUrl(trimmed)) {
19
- return trimmed;
20
- }
21
- return trimmed.replace(/^\/+/, '');
22
- }
23
17
  function ClientAudio({ url }) {
24
- const fileKey = toPresignKey(url);
18
+ const fileKey = toMatchWordToPictureStorageKey(url);
25
19
  const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
26
- const src = previewUrl || (isPlayableAudioUrl(fileKey) ? fileKey : '');
20
+ const src = previewUrl || (isPlayableUrl(fileKey) ? fileKey : '');
27
21
  if (isLoading) {
28
22
  return (_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("div", { className: "h-8 w-full max-w-xs animate-pulse rounded-md bg-slate-100" })] }));
29
23
  }
30
- if (!src) {
24
+ if (!src)
31
25
  return null;
32
- }
33
- return (_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("audio", { controls: true, src: src, className: "h-8 max-w-full", preload: "metadata" })] }));
34
- }
35
- function isDisplayableImageUrl(value) {
36
- return (value.startsWith('http://')
37
- || value.startsWith('https://')
38
- || value.startsWith('blob:')
39
- || value.startsWith('data:'));
26
+ return (_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("audio", { controls: true, src: src, className: "h-8 max-w-full", preload: "metadata", children: _jsx("track", { kind: "captions" }) })] }));
40
27
  }
41
28
  function ClientImageItem({ url, alt, large = false, }) {
42
- const fileKey = toPresignKey(url);
29
+ const fileKey = toMatchWordToPictureStorageKey(url);
43
30
  const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
44
- const src = previewUrl || (isDisplayableImageUrl(fileKey) ? fileKey : '');
31
+ const src = previewUrl || (isPlayableUrl(fileKey) ? fileKey : '');
45
32
  if (!url) {
46
33
  return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400", children: "Ch\u01B0a c\u00F3 \u1EA3nh" }));
47
34
  }
@@ -50,9 +37,16 @@ function ClientImageItem({ url, alt, large = false, }) {
50
37
  }
51
38
  return (_jsx("div", { className: cn('relative flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50', large ? 'max-h-80 w-full max-w-xl' : 'h-28 w-28'), children: isLoading ? (_jsx("div", { className: "flex h-28 w-full items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-violet-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Picture', className: cn('object-contain', large ? 'mx-auto max-h-80 w-auto' : 'h-full w-full') })) }));
52
39
  }
40
+ function WordDropdown({ value, wordBank, usedWords, disabled, onChange, }) {
41
+ return (_jsxs("div", { className: "group relative min-w-[160px] sm:min-w-[210px]", children: [_jsxs("select", { value: value, onChange: (event) => onChange(event.target.value), disabled: disabled, className: "h-12 w-full appearance-none rounded-2xl border border-slate-200 bg-white px-4 pr-11 text-sm font-semibold text-slate-700 shadow-sm outline-none transition-all hover:border-violet-300 focus:border-violet-500 focus:ring-4 focus:ring-violet-100/50 disabled:cursor-not-allowed disabled:opacity-60", "aria-label": "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p", children: [_jsx("option", { value: "", children: "-- Ch\u1ECDn t\u1EEB --" }), wordBank.map((word, index) => {
42
+ const isUsedByOther = usedWords.has(normalizeWord(word)) && normalizeWord(word) !== normalizeWord(value);
43
+ return (_jsx("option", { value: word, disabled: isUsedByOther, children: word }, `${word}-${index}`));
44
+ })] }), _jsx("div", { className: "pointer-events-none absolute right-2.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-slate-100 text-slate-500 group-focus-within:bg-violet-100 group-focus-within:text-violet-600", children: _jsx(ChevronDown, { className: "h-3.5 w-3.5", strokeWidth: 2.5 }) })] }));
45
+ }
53
46
  export function MatchWordToPictureGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
54
47
  const items = questionData.items || [];
55
48
  const wordBank = questionData.wordBank || [];
49
+ const coverUrls = toMatchWordToPictureImageUrls(questionData.imageUrl);
56
50
  const handleSelect = (itemIndex, value) => {
57
51
  const item = items[itemIndex];
58
52
  if (isReviewMode || item?.isExample || !onAnswerChange) {
@@ -69,11 +63,11 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
69
63
  usedWords.add(normalizeWord(value));
70
64
  }
71
65
  });
72
- return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, toMatchWordToPictureImageUrls(questionData.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap justify-center gap-3 rounded-lg border border-slate-100 bg-slate-50 p-3", children: toMatchWordToPictureImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh bài (${imageIndex + 1})`, large: true }, `mwtp-cover-${imageIndex}`))) })) : null, 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 } })] })) : null, wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
66
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.title ? _jsx(KidPrompt, { children: questionData.title }) : null, _jsx(KidInstruction, { children: questionData.instruction }), questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, coverUrls.length > 0 ? (_jsx("div", { className: "flex flex-wrap justify-center gap-3 rounded-lg border border-slate-100 bg-slate-50 p-3", children: coverUrls.map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh bài (${imageIndex + 1})`, large: true }, `mwtp-cover-${imageIndex}`))) })) : null, 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 } })] })) : null, wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-blue-100 bg-blue-50/60 p-3", children: [_jsx("p", { className: "mb-2 text-xs font-semibold uppercase tracking-wider text-blue-700", children: "Word bank" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
73
67
  const isUsed = usedWords.has(normalizeWord(word));
74
68
  return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
75
- ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
76
- : 'border-violet-200 bg-white text-slate-700'), children: word }, `${word}-${index}`));
69
+ ? 'border-blue-500 bg-blue-50 text-blue-800 ring-2 ring-blue-100'
70
+ : 'border-gray-200 bg-white text-slate-700'), children: word }, `${word}-${index}`));
77
71
  }) })] })), items.map((item, itemIndex) => {
78
72
  const selectedWord = item.isExample
79
73
  ? item.correctAnswer
@@ -81,14 +75,9 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
81
75
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
82
76
  const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
83
77
  Boolean(item.correctAnswer);
84
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col space-y-3 p-3 sm:flex-row sm:items-center sm:space-y-0 sm:gap-3", children: [toMatchWordToPictureImageUrls(item.imageUrl).length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: toMatchWordToPictureImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}${imageIndex > 0 ? ` (${imageIndex + 1})` : ''}` }, `${item.questionNumber}-${imageIndex}`))) })) : null, _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsxs("p", { className: "text-sm font-semibold text-gray-800", children: [item.questionNumber || itemIndex + 1, ". Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p"] }), _jsxs("div", { className: "flex items-center gap-3", children: [item.isExample ? (_jsx("span", { className: "text-sm italic text-gray-800 underline decoration-gray-400", children: item.correctAnswer })) : (_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
85
- ? 'border-green-400 bg-green-50 text-green-700'
86
- : isReviewMode && selectedWord && !isCorrect
87
- ? 'border-red-400 bg-red-50 text-red-700'
88
- : 'border-violet-300 focus:border-violet-500 focus:ring-violet-200', !canSelect && 'cursor-default opacity-80'), children: [_jsx("option", { value: "", children: "-- Ch\u1ECDn t\u1EEB --" }), wordBank.map((word, wordIndex) => {
89
- const isUsedByOther = usedWords.has(normalizeWord(word)) &&
90
- normalizeWord(word) !== normalizeWord(selectedWord);
91
- return (_jsx("option", { value: word, disabled: isUsedByOther, children: word }, `${word}-${wordIndex}`));
92
- })] })), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
78
+ const itemImages = toMatchWordToPictureImageUrls(item.imageUrl);
79
+ return (_jsx(KidQuestionCard, { title: `Câu ${item.questionNumber || itemIndex + 1}`, points: item.points || 0, isExample: item.isExample, review: isReviewMode && !item.isExample ? (isCorrect ? 'correct' : 'wrong') : null, children: _jsxs("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-start", children: [itemImages.length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: itemImages.map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}${imageIndex > 0 ? ` (${imageIndex + 1})` : ''}` }, `${item.questionNumber}-${imageIndex}`))) })) : null, _jsxs("div", { className: "min-w-0 flex-1 space-y-1.5", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p" }), item.isExample ? (_jsx("span", { className: "text-sm italic text-gray-800 underline decoration-gray-400", children: item.correctAnswer })) : (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(WordDropdown, { value: selectedWord, wordBank: wordBank, usedWords: usedWords, disabled: !canSelect, onChange: (value) => handleSelect(itemIndex, value) }), isReviewMode && (isCorrect
80
+ ? _jsx(Check, { className: "h-5 w-5 text-emerald-600" })
81
+ : _jsx(X, { className: "h-5 w-5 text-red-500" }))] })), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] }) }, `${item.questionNumber}-${itemIndex}`));
93
82
  }), 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] }))] }));
94
83
  }
@@ -32,7 +32,7 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
32
32
  const [title, setTitle] = useState(initialData?.title || '');
33
33
  const [instruction, setInstruction] = useState(initialData?.instruction || seeded.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION);
34
34
  const [passage, setPassage] = useState(initialData?.passage || seeded.passage || '');
35
- const [audioUrl] = useState(initialData?.audioUrl || seeded.audioUrl || '');
35
+ const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || seeded.audioUrl || '');
36
36
  const [imageUrl, setImageUrl] = useState(toMatchWordToPictureImageUrls(initialData?.imageUrl)[0] || '');
37
37
  const [wordBank, setWordBank] = useState(initialData?.wordBank?.length ? initialData.wordBank : seeded.wordBank);
38
38
  const [items, setItems] = useState(() => (initialData?.items?.length ? initialData.items : seeded.items));
@@ -76,12 +76,25 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
76
76
  nextErrors.push('Word bank cần ít nhất một từ.');
77
77
  if (items.length < 1)
78
78
  nextErrors.push('Cần ít nhất một câu hỏi.');
79
+ const wordSet = new Set(words.map((word) => word.trim().toLowerCase()));
80
+ const seenAnswers = new Set();
79
81
  items.forEach((item, index) => {
80
82
  if (!imageUrl.trim() && toMatchWordToPictureImageUrls(item.imageUrl).length === 0) {
81
83
  nextErrors.push(`Câu ${index + 1}: cần ảnh (hoặc ảnh chung).`);
82
84
  }
83
- if (!item.correctAnswer.trim())
85
+ const answer = item.correctAnswer.trim();
86
+ if (!answer) {
84
87
  nextErrors.push(`Câu ${index + 1}: cần chọn từ đúng.`);
88
+ return;
89
+ }
90
+ const normalized = answer.toLowerCase();
91
+ if (!wordSet.has(normalized)) {
92
+ nextErrors.push(`Câu ${index + 1}: đáp án không có trong word bank.`);
93
+ }
94
+ if (seenAnswers.has(normalized)) {
95
+ nextErrors.push(`Câu ${index + 1}: từ "${answer}" đã được dùng (mỗi từ chỉ một lần).`);
96
+ }
97
+ seenAnswers.add(normalized);
85
98
  });
86
99
  setErrors(nextErrors);
87
100
  return nextErrors.length === 0;
@@ -102,7 +115,7 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
102
115
  if (isClientMode) {
103
116
  return (_jsxs("div", { className: "space-y-4", children: [_jsx(CompactPreviewBanner, { onBack: () => setIsClientMode(false) }), _jsx(MatchWordToPictureGroupClient, { questionData: currentPayload })] }));
104
117
  }
105
- return (_jsxs("div", { className: "space-y-3", children: [displayErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-4", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), _jsxs(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(ImageIcon, { className: "h-3.5 w-3.5" }), title: "N\u1ED1i t\u1EEB v\u1EDBi tranh (Nh\u00F3m)", preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwtp-group-title", className: "text-sm font-semibold text-gray-800", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwtp-group-title", className: "h-10 border-gray-200 bg-white", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "h-10 border-gray-200 bg-white", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsx(MatchWordToPictureGroupImageField, { id: "mwtp-group-shared-image", label: "\u1EA2nh chung (optional \u2014 1 b\u1EE9c tranh nhi\u1EC1u \u00F4 s\u1ED1)", value: imageUrl, onChange: setImageUrl }), wordBank.map((word, index) => (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: word, onChange: (e) => setWordBank((prev) => prev.map((item, i) => (i === index ? e.target.value : item))), placeholder: `Từ ${index + 1}`, className: "h-10 border-gray-200 bg-white" }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), className: "h-7 w-7", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm t\u1EEB"] })] }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-sm", children: [_jsx(CardHeader, { className: "px-3 py-2", children: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsxs(CardTitle, { className: "text-sm font-semibold text-gray-800", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm tranh"] })] }) }), _jsxs(CardContent, { className: "space-y-2.5 px-3 pb-3", children: [items.map((item, index) => (_jsxs("div", { className: "space-y-2.5 rounded-md border border-gray-200 bg-white px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { className: "flex items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
118
+ return (_jsxs("div", { className: "space-y-3", children: [displayErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-4", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), _jsxs(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(ImageIcon, { className: "h-3.5 w-3.5" }), title: "N\u1ED1i t\u1EEB v\u1EDBi tranh (Nh\u00F3m)", preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwtp-group-title", className: "text-sm font-semibold text-gray-800", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwtp-group-title", className: "h-10 border-gray-200 bg-white", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "h-10 border-gray-200 bg-white", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsxs("div", { className: "grid gap-2.5 md:grid-cols-2", children: [_jsx(MatchWordToPictureGroupImageField, { id: "mwtp-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(MatchWordToPictureGroupImageField, { id: "mwtp-group-shared-image", label: "\u1EA2nh chung (optional)", value: imageUrl, onChange: setImageUrl })] }), wordBank.map((word, index) => (_jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: word, onChange: (e) => setWordBank((prev) => prev.map((item, i) => (i === index ? e.target.value : item))), placeholder: `Từ ${index + 1}`, className: "h-10 border-gray-200 bg-white" }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), className: "h-7 w-7", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm t\u1EEB"] })] }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-sm", children: [_jsx(CardHeader, { className: "px-3 py-2", children: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsxs(CardTitle, { className: "text-sm font-semibold text-gray-800", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm tranh"] })] }) }), _jsxs(CardContent, { className: "space-y-2.5 px-3 pb-3", children: [items.map((item, index) => (_jsxs("div", { className: "space-y-2.5 rounded-md border border-gray-200 bg-white px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { className: "flex items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
106
119
  points: parsePointsValue(event.target.value, true),
107
120
  }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => setItems((prev) => prev.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, questionNumber: i + 1 }))), className: "h-7 w-7 text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] }), _jsx(MatchWordToPictureGroupImageField, { id: `mwtp-group-image-${index}`, label: "\u1EA2nh", value: toMatchWordToPictureImageUrls(item.imageUrl)[0] || '', onChange: (url) => updateItem(index, {
108
121
  imageUrl: fromMatchWordToPictureImageUrls(url),
@@ -1,6 +1,7 @@
1
- export declare function MatchWordToPictureGroupImageField({ id, label, value, onChange, }: {
1
+ export declare function MatchWordToPictureGroupImageField({ id, label, value, onChange, accept, }: {
2
2
  id: string;
3
3
  label: string;
4
4
  value: string;
5
5
  onChange: (url: string) => void;
6
+ accept?: 'image/*' | 'audio/*';
6
7
  }): import("react").JSX.Element;
@@ -6,11 +6,30 @@ import { FileUpload } from '../../../../components/ui/file-upload';
6
6
  import { ImagePreview } from '../../../../components/ui/image-preview';
7
7
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
8
8
  import { Trash2 } from 'lucide-react';
9
- export function MatchWordToPictureGroupImageField({ id, label, value, onChange, }) {
9
+ import { toMatchWordToPictureStorageKey } from '../../_shared/types/match-word-to-picture-group.type';
10
+ function isDisplayableUrl(value) {
11
+ return (value.startsWith('http://') ||
12
+ value.startsWith('https://') ||
13
+ value.startsWith('blob:') ||
14
+ value.startsWith('data:'));
15
+ }
16
+ function resolveDisplayUrl(fileUrl, previewOverride, fetchedPreviewUrl) {
17
+ if (previewOverride)
18
+ return previewOverride;
19
+ if (fetchedPreviewUrl)
20
+ return fetchedPreviewUrl;
21
+ if (isDisplayableUrl(fileUrl))
22
+ return fileUrl;
23
+ return '';
24
+ }
25
+ export function MatchWordToPictureGroupImageField({ id, label, value, onChange, accept = 'image/*', }) {
10
26
  const [previewOverride, setPreviewOverride] = useState('');
11
- const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
12
- const displayUrl = previewOverride ||
13
- fetchedPreviewUrl ||
14
- (value.startsWith('http') || value.startsWith('blob:') ? value : '');
15
- return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: "image/*", value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), value && (_jsxs("div", { className: "flex items-center gap-2", children: [displayUrl && _jsx(ImagePreview, { src: displayUrl, alt: label, className: "h-20 w-20" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => onChange(''), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) })] }))] }));
27
+ const storageKey = toMatchWordToPictureStorageKey(value);
28
+ const { previewUrl: fetchedPreviewUrl, isLoading } = usePresignedFileUrl(storageKey);
29
+ const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
30
+ const isImage = accept === 'image/*';
31
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: accept, value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: isImage ? 5 : 20, placeholder: isImage ? 'Upload ảnh hoặc paste URL' : 'Upload audio hoặc paste URL', autoUpload: true, prefix: "questions" }), value && isImage && (_jsxs("div", { className: "flex items-start gap-2", children: [isLoading && !displayUrl ? (_jsx("div", { className: "h-20 w-20 animate-pulse rounded-md bg-slate-100" })) : (_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
32
+ setPreviewOverride('');
33
+ onChange('');
34
+ }, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })), value && !isImage && (isLoading && !displayUrl ? (_jsx("div", { className: "h-8 w-48 animate-pulse rounded-md bg-slate-100" })) : (_jsx("audio", { controls: true, src: displayUrl, className: "h-8 max-w-full", children: _jsx("track", { kind: "captions" }) })))] }));
16
35
  }
@@ -104,6 +104,6 @@ export function MatchingWithLinesBoard({ leftItems, rightItems, values, correctV
104
104
  const used = values.includes(item.id);
105
105
  return (_jsxs("button", { type: "button", onClick: () => handleRightClick(item.id), disabled: locked, "aria-label": `Connect right item ${item.text || index + 1}`, className: cn('box-border flex h-auto min-h-14 w-max items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-left shadow-sm', selectedLeft !== null && !locked && 'cursor-pointer hover:border-teal-400', used && 'border-teal-200'), children: [_jsx("span", { ref: (node) => {
106
106
  rightDotRefs.current[index] = node;
107
- }, className: cn('h-4 w-4 shrink-0 rounded-full border-2 bg-white', used || selectedLeft !== null ? 'border-teal-600' : 'border-gray-400', selectedLeft !== null && 'bg-teal-100'), "aria-hidden": true }), item.imageUrl ? (_jsx("div", { className: "relative size-11 shrink-0 overflow-hidden rounded-md bg-gray-50", children: _jsx(ResolvedImage, { src: item.imageUrl, alt: item.text || `Match option ${index + 1}`, className: "h-full w-full object-contain" }) })) : (_jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: item.text || '—' }))] }, item.id || `right-${index}`));
107
+ }, className: cn('h-4 w-4 shrink-0 rounded-full border-2 bg-white', used || selectedLeft !== null ? 'border-teal-600' : 'border-gray-400', selectedLeft !== null && 'bg-teal-100'), "aria-hidden": true }), item.imageUrl ? (_jsx("div", { className: "relative size-11 shrink-0 overflow-hidden rounded-md bg-gray-50", children: _jsx(ResolvedImage, { src: item.imageUrl, alt: item.text || `Match option ${index + 1}`, className: "h-full w-full object-contain" }) })) : null, item.text?.trim() ? (_jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: item.text })) : !item.imageUrl ? (_jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: "\u2014" })) : null] }, item.id || `right-${index}`));
108
108
  }) })] })] }));
109
109
  }
@@ -1,16 +1,28 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, Lightbulb } from 'lucide-react';
3
+ import { BookOpen, Lightbulb, Volume2 } from 'lucide-react';
4
4
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
5
- import { ResolvedImage } from '../../../../components/common/ResolvedImage';
5
+ import { RichTextHtml } from '../../../../components/shared/RichTextHtml';
6
6
  import { MatchingWithLinesBoard } from './MatchingWithLinesBoard';
7
+ import { KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
8
+ import { toChooseAnswerGroupImageUrls } from '../../_shared/types/choose-the-correct-answer-group.type';
9
+ function ClientImageItem({ url, alt }) {
10
+ const { previewUrl, isLoading } = usePresignedFileUrl(url);
11
+ const src = previewUrl ||
12
+ (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
13
+ if (!src && !isLoading) {
14
+ return null;
15
+ }
16
+ return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
17
+ }
7
18
  function ClientAudio({ url }) {
8
19
  const { previewUrl, isLoading } = usePresignedFileUrl(url);
9
20
  const src = previewUrl ||
10
21
  (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
11
- if (!src && !isLoading)
22
+ if (!src && !isLoading) {
12
23
  return null;
13
- return (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: isLoading ? (_jsx("div", { className: "h-8 w-full animate-pulse rounded bg-indigo-100" })) : (_jsx("audio", { controls: true, src: src, className: "w-full", children: _jsx("track", { kind: "captions" }) })) }));
24
+ }
25
+ return (_jsxs("div", { className: "flex items-center gap-3 rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: [_jsx(Volume2, { className: "h-4 w-4 shrink-0 text-indigo-600" }), isLoading ? (_jsx("div", { className: "h-8 flex-1 animate-pulse rounded-md bg-indigo-100" })) : (_jsx("audio", { controls: true, className: "h-9 w-full", src: src, preload: "metadata", children: "Tr\u00ECnh duy\u1EC7t kh\u00F4ng h\u1ED7 tr\u1EE3 audio." }))] }));
14
26
  }
15
27
  export function MatchingWithLinesGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, onAnswersChange, }) {
16
28
  const items = questionData.items || [];
@@ -32,10 +44,10 @@ export function MatchingWithLinesGroupClient({ questionData, isReviewMode = fals
32
44
  }
33
45
  });
34
46
  };
35
- return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), questionData.passage ? (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })) : null, questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, questionData.imageUrl ? (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(ResolvedImage, { src: questionData.imageUrl, alt: "Match passage image", className: "max-h-44 w-auto rounded-md object-contain" }) })) : null, _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
36
- imageUrl: item.imageUrl,
37
- text: item.text,
38
- isExample: item.isExample,
39
- questionNumber: item.questionNumber,
40
- })), rightItems: rightItems, values: values, correctValues: isReviewMode ? correctValues : undefined, onChange: handleChange, isReviewMode: isReviewMode, disabled: isReviewMode || !canEdit }), 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] }))] }));
47
+ return (_jsxs("div", { className: "space-y-4", children: [questionData.title ? _jsx(KidPrompt, { children: questionData.title }) : null, _jsx(KidInstruction, { children: questionData.instruction }), toChooseAnswerGroupImageUrls(questionData.audioUrl).map((url, audioIndex) => (_jsx(ClientAudio, { url: url }, `group-audio-${audioIndex}`))), toChooseAnswerGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toChooseAnswerGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh nhóm (${imageIndex + 1})` }, `group-image-${imageIndex}`))) })), questionData.passage ? (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "overflow-x-auto", children: _jsx(RichTextHtml, { html: questionData.passage, className: "text-gray-800 leading-relaxed" }) })] })) : null, _jsx(KidQuestionCard, { title: "N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB", children: _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
48
+ imageUrl: item.imageUrl,
49
+ text: item.text,
50
+ isExample: item.isExample,
51
+ questionNumber: item.questionNumber,
52
+ })), rightItems: rightItems, values: values, correctValues: isReviewMode ? correctValues : undefined, onChange: handleChange, isReviewMode: isReviewMode, disabled: isReviewMode || !canEdit }) }), 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] }))] }));
41
53
  }
@@ -4,17 +4,28 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { Button } from '../../../../components/ui/button';
5
5
  import { Label } from '../../../../components/ui/label';
6
6
  import { Input } from '../../../../components/ui/input';
7
+ import { Textarea } from '../../../../components/ui/textarea';
7
8
  import { PointsInput } from '../../../../components/ui/points-input';
8
9
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
9
10
  import { Switch } from '../../../../components/ui/switch';
10
11
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
11
- import { Link2, Plus, Star, Trash2 } from 'lucide-react';
12
+ import { BookOpen, ImageIcon, Link2, Plus, Star, Trash2, Volume2 } from 'lucide-react';
13
+ import { ChooseAnswerGroupAudioUploadItem, ChooseAnswerGroupImageUploadItem, } from '../choose-the-correct-answer-group/ChooseAnswerGroupImageUpload';
14
+ import { fromChooseAnswerGroupImageUrls, toEditableChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
12
15
  import { CompactCreatorHeader, CompactExplanationToggle, CompactFieldCard, CompactPreviewBanner, } from '../../_shared/components/compact';
13
16
  import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
14
17
  import { MatchingWithLinesGroupClient } from './MatchingWithLinesGroupClient';
15
18
  import { MatchingWithLinesImageField } from './MatchingWithLinesImageField';
16
19
  import { createEmptyMatchingPair, nextRightId } from './map-matching-with-lines-group-data';
17
20
  import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
21
+ function rightOptionLabel(item) {
22
+ const text = item.text?.trim();
23
+ if (text)
24
+ return `${text} (${item.id})`;
25
+ if (item.imageUrl?.trim())
26
+ return `Ảnh (${item.id})`;
27
+ return item.id;
28
+ }
18
29
  function parsePointsValue(raw, allowZero = false) {
19
30
  if (raw === '')
20
31
  return allowZero ? 0 : 1;
@@ -66,27 +77,36 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
66
77
  const [rightItems, setRightItems] = useState(() => normalizeRightItems(initialData?.rightItems));
67
78
  const [items, setItems] = useState(() => normalizeItems(initialData?.items, normalizeRightItems(initialData?.rightItems)));
68
79
  const [passage, setPassage] = useState(initialData?.passage || '');
69
- const [audioUrl] = useState(initialData?.audioUrl || '');
70
- const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
80
+ const [imageUrls, setImageUrls] = useState(() => (toEditableChooseAnswerGroupImageUrls(initialData?.imageUrl)));
81
+ const [audioUrls, setAudioUrls] = useState(() => (toEditableChooseAnswerGroupImageUrls(initialData?.audioUrl)));
71
82
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
72
83
  const [isClientMode, setIsClientMode] = useState(false);
84
+ const [isPassageVisible, setIsPassageVisible] = useState(() => {
85
+ const html = initialData?.passage;
86
+ return Boolean(html && html !== '<p></p>' && html !== '<p><br></p>' && html.trim());
87
+ });
73
88
  const [errors, setErrors] = useState([]);
74
89
  const onChangeRef = useRef(onChange);
75
90
  const previousPayloadRef = useRef('');
76
91
  useEffect(() => {
77
92
  onChangeRef.current = onChange;
78
93
  }, [onChange]);
79
- const buildPayload = (nextTitle = title, nextInstruction = instruction, nextPassage = passage, nextRightItems = rightItems, nextItems = items, nextImageUrl = imageUrl, nextExplanation = explanation) => ({
80
- title: nextTitle,
81
- instruction: nextInstruction,
82
- ...(nextPassage.trim() ? { passage: nextPassage } : {}),
83
- ...(audioUrl ? { audioUrl } : {}),
84
- ...(nextImageUrl.trim() ? { imageUrl: nextImageUrl } : {}),
85
- rightItems: nextRightItems,
86
- items: nextItems,
87
- explanation: nextExplanation,
88
- points: sumPoints(nextItems),
89
- });
94
+ const buildPayload = (nextTitle = title, nextInstruction = instruction, nextPassage = passage, nextRightItems = rightItems, nextItems = items, nextAudioUrls = audioUrls, nextImageUrls = imageUrls, nextExplanation = explanation) => {
95
+ const nextAudioUrl = fromChooseAnswerGroupImageUrls(nextAudioUrls);
96
+ const nextImageUrl = fromChooseAnswerGroupImageUrls(nextImageUrls);
97
+ const isContentEmpty = (html) => !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
98
+ return {
99
+ title: nextTitle,
100
+ instruction: nextInstruction,
101
+ ...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
102
+ ...(nextAudioUrl !== undefined ? { audioUrl: nextAudioUrl } : {}),
103
+ ...(nextImageUrl !== undefined ? { imageUrl: nextImageUrl } : {}),
104
+ rightItems: nextRightItems,
105
+ items: nextItems,
106
+ explanation: nextExplanation,
107
+ points: sumPoints(nextItems),
108
+ };
109
+ };
90
110
  const debouncedOnChange = useDebouncedCallback((payload) => {
91
111
  onChangeRef.current?.(payload);
92
112
  }, 300);
@@ -98,7 +118,7 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
98
118
  debouncedOnChange(payload);
99
119
  onUnsavedChangesChange?.(true);
100
120
  }
101
- }, [title, instruction, passage, audioUrl, imageUrl, rightItems, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
121
+ }, [title, instruction, passage, audioUrls, imageUrls, rightItems, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
102
122
  const validate = () => {
103
123
  const nextErrors = [];
104
124
  if (!instruction.trim()) {
@@ -144,7 +164,7 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
144
164
  getFormData: () => buildPayload(),
145
165
  };
146
166
  }
147
- }, [validationRef, title, instruction, passage, audioUrl, imageUrl, rightItems, items, explanation]);
167
+ }, [validationRef, title, instruction, passage, audioUrls, imageUrls, rightItems, items, explanation]);
148
168
  const updateItem = (index, patch) => {
149
169
  setItems((prev) => {
150
170
  const next = [...prev];
@@ -189,7 +209,29 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
189
209
  if (isClientMode) {
190
210
  return (_jsxs("div", { className: "space-y-4", children: [_jsx(CompactPreviewBanner, { onBack: () => setIsClientMode(false) }), _jsx(MatchingWithLinesGroupClient, { questionData: currentPayload, userAnswers: currentPayload.items.map((item) => item.correctAnswer) })] }));
191
211
  }
192
- return (_jsxs("div", { className: "space-y-3", children: [displayErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-4", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) }) })), _jsxs(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(Link2, { className: "h-3.5 w-3.5" }), title: "N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB (Nh\u00F3m)", preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwl-title", className: "text-sm font-semibold text-gray-800", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwl-title", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY", className: "h-10 border-gray-200 bg-white" })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwl-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "mwl-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION, className: "h-10 border-gray-200 bg-white" })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { className: "text-sm font-semibold text-gray-800", children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" })] }), _jsx(MatchingWithLinesImageField, { id: "mwl-group-image", label: "\u1EA2nh chung (optional)", value: imageUrl, onChange: setImageUrl })] }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-sm", children: [_jsx(CardHeader, { className: "px-3 py-2", children: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsxs(CardTitle, { className: "text-sm font-semibold text-gray-800", children: ["C\u1EB7p n\u1ED1i (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addPair, className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm c\u1EB7p"] })] }) }), _jsxs(CardContent, { className: "space-y-2.5 px-3 pb-3", children: [items.map((item, index) => (_jsxs("div", { className: "space-y-2.5 rounded-md border border-gray-200 bg-white px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u1EB7p ", index + 1] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `mwl-example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `mwl-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
212
+ return (_jsxs("div", { className: "space-y-3", children: [displayErrors.length > 0 && (_jsx("div", { className: "rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-4", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) }) })), _jsxs(CompactFieldCard, { header: _jsx(CompactCreatorHeader, { icon: _jsx(Link2, { className: "h-3.5 w-3.5" }), title: "N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB (Nh\u00F3m)", preview: { onClick: () => setIsClientMode(true) } }), children: [_jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwl-title", className: "text-sm font-semibold text-gray-800", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwl-title", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY", className: "h-10 border-gray-200 bg-white" })] }), _jsxs("div", { className: "space-y-1.5", children: [_jsx(Label, { htmlFor: "mwl-instruction", className: "text-sm font-semibold text-gray-800", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsxs("div", { className: "relative", children: [_jsx(Textarea, { id: "mwl-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION, rows: 2, className: "min-h-[56px] border-gray-200 pb-11" }), _jsxs("div", { className: "absolute bottom-1.5 right-1.5 z-10 flex items-center gap-1 rounded-md border border-indigo-100 bg-white/95 p-0.5 shadow-sm", children: [_jsx("button", { type: "button", title: "Th\u00EAm h\u00ECnh \u1EA3nh", onClick: () => {
213
+ const hasEmpty = imageUrls.some((url) => !url.trim());
214
+ if (imageUrls.length === 0 || !hasEmpty) {
215
+ setImageUrls([...imageUrls, '']);
216
+ }
217
+ }, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${imageUrls.length > 0
218
+ ? 'bg-indigo-600 text-white shadow-sm'
219
+ : 'bg-indigo-50 text-indigo-600 hover:bg-indigo-100'}`, children: _jsx(ImageIcon, { className: "h-4 w-4" }) }), _jsx("button", { type: "button", title: "Th\u00EAm audio", onClick: () => {
220
+ const hasEmpty = audioUrls.some((url) => !url.trim());
221
+ if (audioUrls.length === 0 || !hasEmpty) {
222
+ setAudioUrls([...audioUrls, '']);
223
+ }
224
+ }, className: `inline-flex h-8 w-8 items-center justify-center rounded-md transition-colors ${audioUrls.length > 0
225
+ ? 'bg-violet-600 text-white shadow-sm'
226
+ : 'bg-violet-50 text-violet-600 hover:bg-violet-100'}`, children: _jsx(Volume2, { className: "h-4 w-4" }) })] })] })] }), imageUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [imageUrls.map((url, imageIndex) => (_jsx(ChooseAnswerGroupImageUploadItem, { index: imageIndex, itemIndex: -1, imageUrl: url, onChange: (nextUrl) => {
227
+ setImageUrls((current) => current.map((item, idx) => (idx === imageIndex ? nextUrl : item)));
228
+ }, onRemove: () => {
229
+ setImageUrls((current) => current.filter((_, idx) => idx !== imageIndex));
230
+ } }, `mwl-group-image-${imageIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setImageUrls((current) => (current.length ? [...current, ''] : [''])), className: "h-8 gap-1.5 border-dashed border-indigo-300 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm \u1EA3nh"] })] })), audioUrls.length > 0 && (_jsxs("div", { className: "space-y-2 rounded-md border border-gray-200 bg-slate-50/80 p-2", children: [audioUrls.map((url, audioIndex) => (_jsx(ChooseAnswerGroupAudioUploadItem, { index: audioIndex, itemIndex: -1, audioUrl: url, onChange: (nextUrl) => {
231
+ setAudioUrls((current) => current.map((item, idx) => (idx === audioIndex ? nextUrl : item)));
232
+ }, onRemove: () => {
233
+ setAudioUrls((current) => current.filter((_, idx) => idx !== audioIndex));
234
+ } }, `mwl-group-audio-${audioIndex}`))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setAudioUrls((current) => (current.length ? [...current, ''] : [''])), className: "h-8 gap-1.5 border-dashed border-violet-300 text-violet-600 hover:bg-violet-50 hover:text-violet-700", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm audio"] })] })), !isPassageVisible ? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsPassageVisible(true), className: "h-8 gap-1.5 border-gray-200 px-2.5 text-xs text-gray-600 hover:bg-gray-50", children: [_jsx(BookOpen, { className: "h-3.5 w-3.5" }), "Th\u00EAm n\u1ED9i dung b\u00E0i \u0111\u1ECDc"] })) : (_jsxs("div", { className: "relative space-y-1.5 pr-10", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsPassageVisible(false), className: "absolute right-0 top-0 z-10 h-7 px-2 text-xs text-gray-500 hover:text-gray-700", children: "\u1EA8n" }), _jsxs(Label, { className: "text-sm font-semibold text-gray-800", children: ["N\u1ED9i dung b\u00E0i \u0111\u1ECDc ", _jsx("span", { className: "text-xs font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(RichTextEditor, { value: passage, onChange: setPassage, variant: "compact", minHeightClassName: "min-h-[72px]" })] }))] }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-sm", children: [_jsx(CardHeader, { className: "px-3 py-2", children: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsxs(CardTitle, { className: "text-sm font-semibold text-gray-800", children: ["C\u1EB7p n\u1ED1i (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addPair, className: "h-8 gap-1.5 px-2.5 text-xs", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm c\u1EB7p"] })] }) }), _jsxs(CardContent, { className: "space-y-2.5 px-3 pb-3", children: [items.map((item, index) => (_jsxs("div", { className: "space-y-2.5 rounded-md border border-gray-200 bg-white px-3 py-2.5", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u1EB7p ", index + 1] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `mwl-example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `mwl-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
193
235
  points: parsePointsValue(event.target.value, true),
194
- }) }) })), items.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removePair(index), className: "h-7 w-7 text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] }), _jsxs("div", { className: "grid gap-2.5 md:grid-cols-2", children: [_jsxs("div", { className: "space-y-2.5", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-image-${index}`, label: "\u1EA2nh c\u1ED9t tr\u00E1i", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-left-text-${index}`, children: "Ch\u1EEF c\u1ED9t tr\u00E1i (text-to-text)" }), _jsx(Input, { id: `mwl-left-text-${index}`, value: item.text || '', onChange: (e) => updateItem(index, { text: e.target.value }), placeholder: "ball", className: "mt-1 bg-white" })] })] }), _jsxs("div", { className: "space-y-2.5", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-right-image-${index}`, label: "\u1EA2nh c\u1ED9t ph\u1EA3i (image-to-image)", value: rightItems[index]?.imageUrl || '', onChange: (url) => updateRightItem(index, { imageUrl: url || undefined }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-right-text-${index}`, children: "T\u1EEB c\u1ED9t ph\u1EA3i" }), _jsx(Input, { id: `mwl-right-text-${index}`, value: rightItems[index]?.text || '', onChange: (e) => updateRightItem(index, { text: e.target.value }), placeholder: "paintbrush", className: "mt-1 bg-white" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-answer-${index}`, children: "\u0110\u00E1p \u00E1n \u0111\u00FAng (id t\u1EEB)" }), _jsxs("select", { id: `mwl-answer-${index}`, value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), className: "mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm", children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), rightItems.map((right) => (_jsxs("option", { value: right.id, children: [right.text || right.id, " (", right.id, ")"] }, right.id)))] })] })] })] })] }, `${rightItems[index]?.id || index}`))), _jsx(CompactExplanationToggle, { value: explanation, onValueChange: setExplanation, defaultVisible: Boolean(initialData?.explanation) })] })] })] }));
236
+ }) }) })), items.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removePair(index), className: "h-7 w-7 text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) }))] })] }), _jsxs("div", { className: "grid gap-2.5 md:grid-cols-2", children: [_jsxs("div", { className: "space-y-2.5", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-image-${index}`, label: "\u1EA2nh c\u1ED9t tr\u00E1i", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-left-text-${index}`, children: "Ch\u1EEF c\u1ED9t tr\u00E1i (text-to-text)" }), _jsx(Input, { id: `mwl-left-text-${index}`, value: item.text || '', onChange: (e) => updateItem(index, { text: e.target.value }), placeholder: "ball", className: "mt-1 bg-white" })] })] }), _jsxs("div", { className: "space-y-2.5", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-right-image-${index}`, label: "\u1EA2nh c\u1ED9t ph\u1EA3i (image-to-image)", value: rightItems[index]?.imageUrl || '', onChange: (url) => updateRightItem(index, { imageUrl: url || undefined }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-right-text-${index}`, children: "T\u1EEB c\u1ED9t ph\u1EA3i" }), _jsx(Input, { id: `mwl-right-text-${index}`, value: rightItems[index]?.text || '', onChange: (e) => updateRightItem(index, { text: e.target.value }), placeholder: "paintbrush", className: "mt-1 bg-white" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-answer-${index}`, children: "\u0110\u00E1p \u00E1n \u0111\u00FAng (id t\u1EEB)" }), _jsxs("select", { id: `mwl-answer-${index}`, value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), className: "mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm", children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), rightItems.map((right) => (_jsx("option", { value: right.id, children: rightOptionLabel(right) }, right.id)))] })] })] })] })] }, `${rightItems[index]?.id || index}`))), _jsx(CompactExplanationToggle, { value: explanation, onValueChange: setExplanation, defaultVisible: Boolean(initialData?.explanation) })] })] })] }));
195
237
  }
@@ -1,6 +1,7 @@
1
- export declare function MatchingWithLinesImageField({ id, label, value, onChange, }: {
1
+ export declare function MatchingWithLinesImageField({ id, label, value, onChange, accept, }: {
2
2
  id: string;
3
3
  label: string;
4
4
  value: string;
5
5
  onChange: (url: string) => void;
6
+ accept?: 'image/*' | 'audio/*';
6
7
  }): import("react").JSX.Element;
@@ -6,21 +6,40 @@ import { FileUpload } from '../../../../components/ui/file-upload';
6
6
  import { ImagePreview } from '../../../../components/ui/image-preview';
7
7
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
8
8
  import { Trash2 } from 'lucide-react';
9
+ function isDisplayableUrl(value) {
10
+ return (value.startsWith('http://') ||
11
+ value.startsWith('https://') ||
12
+ value.startsWith('blob:') ||
13
+ value.startsWith('data:'));
14
+ }
15
+ function toStorageKey(value) {
16
+ const trimmed = value.trim();
17
+ if (!trimmed)
18
+ return '';
19
+ if (isDisplayableUrl(trimmed))
20
+ return trimmed;
21
+ if (trimmed.startsWith('/images/') || trimmed.startsWith('/_next/')) {
22
+ return trimmed;
23
+ }
24
+ return trimmed.replace(/^\/+/, '');
25
+ }
9
26
  function resolveDisplayUrl(fileUrl, previewOverride, fetchedPreviewUrl) {
10
27
  if (previewOverride)
11
28
  return previewOverride;
12
29
  if (fetchedPreviewUrl)
13
30
  return fetchedPreviewUrl;
14
- if (fileUrl.startsWith('http') || fileUrl.startsWith('blob:'))
31
+ if (isDisplayableUrl(fileUrl))
15
32
  return fileUrl;
16
33
  return '';
17
34
  }
18
- export function MatchingWithLinesImageField({ id, label, value, onChange, }) {
35
+ export function MatchingWithLinesImageField({ id, label, value, onChange, accept = 'image/*', }) {
19
36
  const [previewOverride, setPreviewOverride] = useState('');
20
- const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
37
+ const storageKey = toStorageKey(value);
38
+ const { previewUrl: fetchedPreviewUrl, isLoading } = usePresignedFileUrl(storageKey);
21
39
  const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
22
- return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: "image/*", value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), value && (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
40
+ const isImage = accept === 'image/*';
41
+ return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: accept, value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: isImage ? 5 : 20, placeholder: isImage ? 'Upload ảnh hoặc paste URL' : 'Upload audio hoặc paste URL', autoUpload: true, prefix: "questions" }), value && isImage && (_jsxs("div", { className: "flex items-start gap-2", children: [isLoading && !displayUrl ? (_jsx("div", { className: "h-20 w-20 animate-pulse rounded-md bg-slate-100" })) : (_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" })), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
23
42
  setPreviewOverride('');
24
43
  onChange('');
25
- }, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }))] }));
44
+ }, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })), value && !isImage && (isLoading && !displayUrl ? (_jsx("div", { className: "h-8 w-48 animate-pulse rounded-md bg-slate-100" })) : (_jsx("audio", { controls: true, src: displayUrl, className: "h-8 max-w-full", children: _jsx("track", { kind: "captions" }) })))] }));
26
45
  }
@@ -1,4 +1,8 @@
1
1
  import { MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/matching-with-lines-group.type';
2
+ import { fromChooseAnswerGroupImageUrls, toChooseAnswerGroupImageUrls, } from '../../_shared/types/choose-the-correct-answer-group.type';
3
+ function readSharedMedia(value) {
4
+ return fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(value));
5
+ }
2
6
  function isRecord(value) {
3
7
  return typeof value === 'object' && value !== null && !Array.isArray(value);
4
8
  }
@@ -72,9 +76,9 @@ export function mapQuestionToMatchingWithLinesGroupData(question) {
72
76
  const meta = asRecord(content.meta ?? answer.meta);
73
77
  if (Array.isArray(content.items)) {
74
78
  const items = content.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
75
- const audioUrl = asString(meta.audioUrl).trim() || asString(answer.audioUrl).trim();
79
+ const audioUrl = readSharedMedia(meta.audioUrl) ?? readSharedMedia(answer.audioUrl);
76
80
  const passage = asString(meta.passage).trim() || asString(answer.passage).trim();
77
- const imageUrl = asString(meta.imageUrl).trim() || asString(answer.imageUrl).trim();
81
+ const imageUrl = readSharedMedia(meta.imageUrl) ?? readSharedMedia(answer.imageUrl);
78
82
  return {
79
83
  title: asString(meta.title) || asString(answer.title),
80
84
  instruction: asString(meta.instruction) ||
@@ -91,9 +95,9 @@ export function mapQuestionToMatchingWithLinesGroupData(question) {
91
95
  }
92
96
  if (Array.isArray(answer.items)) {
93
97
  const items = answer.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
94
- const audioUrl = asString(answer.audioUrl).trim();
98
+ const audioUrl = readSharedMedia(answer.audioUrl);
95
99
  const passage = asString(answer.passage).trim();
96
- const imageUrl = asString(answer.imageUrl).trim();
100
+ const imageUrl = readSharedMedia(answer.imageUrl);
97
101
  return {
98
102
  title: asString(answer.title),
99
103
  instruction: asString(answer.instruction) || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,