@tinyweb_dev/oe-exam-sdk 1.0.10 → 1.0.12

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 (33) hide show
  1. package/dist/components/exams/take/ExamTakingPageContainer.js +6 -0
  2. package/dist/components/exams/take/components/QuestionRenderer.js +6 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureGroupRenderer.d.ts +14 -0
  4. package/dist/components/exams/take/components/question-renderers/MoversMatchWordToPictureGroupRenderer.js +36 -0
  5. package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +4 -0
  6. package/dist/components/exams/take/components/question-renderers/index.d.ts +1 -0
  7. package/dist/components/exams/take/components/question-renderers/index.js +1 -0
  8. package/dist/components/exams/take/utils/answer-transformers.js +1 -0
  9. package/dist/components/exams/take/utils/question-transformers.d.ts +27 -3
  10. package/dist/components/exams/take/utils/question-transformers.js +45 -2
  11. package/dist/components/questions/_shared/components/compact/KidQuestionCard.d.ts +5 -2
  12. package/dist/components/questions/_shared/components/compact/KidQuestionCard.js +2 -2
  13. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +1 -0
  14. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.js +14 -0
  15. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +2 -2
  16. package/dist/components/questions/components/QuestionSearchDropdown.js +1 -0
  17. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +22 -33
  18. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +16 -3
  19. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.d.ts +2 -1
  20. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupImageField.js +25 -6
  21. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +1 -1
  22. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +16 -5
  23. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +60 -18
  24. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +2 -1
  25. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +24 -5
  26. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +8 -4
  27. package/dist/components/questions/types/matching-with-lines-group/transform.js +5 -4
  28. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +19 -15
  29. package/dist/components/results/renderers/review-question-body-movers.js +6 -2
  30. package/dist/components/results/review-data.js +12 -0
  31. package/dist/shared/lib/utils/question-reverse-transform.js +17 -2
  32. package/dist/shared/types/questions/matching-with-lines-group.d.ts +2 -2
  33. package/package.json +1 -1
@@ -632,6 +632,12 @@ function ExamTakingPageContainerInner({ roomId, api, onNavigate, resultPath = (a
632
632
  map.set(`${question.id}-item-${index}`, question.id);
633
633
  });
634
634
  }
635
+ if (part.questionType === 'MATCH_WORD_TO_PICTURE_GROUP' &&
636
+ Array.isArray(content?.items)) {
637
+ content.items.forEach((_, index) => {
638
+ map.set(`${question.id}-item-${index}`, question.id);
639
+ });
640
+ }
635
641
  if (part.questionType === 'CHOOSE_THEN_ANSWER_GROUP' &&
636
642
  Array.isArray(content?.items)) {
637
643
  content.items.forEach((_, index) => {
@@ -8,9 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * 2. Transforms data to renderer-specific props
9
9
  * 3. Renders the correct component for each question type
10
10
  */
11
- import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordOrderingGroupRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrosswordPuzzleRenderer, FindWordsInMatrixRenderer, SortWordsIntoCategoriesRenderer, ChooseThenAnswerGroupRenderer, } from './question-renderers';
11
+ import { MoversListenAndWriteRenderer, MoversChooseBestAnswerRenderer, MoversChooseAdjectiveRenderer, MoversWritingRenderer, MoversColoringRenderer, MoversReadingPassageRenderer, MoversLabelThePictureRenderer, MoversGridFillRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversCrossOutWordGroupRenderer, MoversAnswerTheQuestionRenderer, MoversWriteSentencesRenderer, MoversWordOrderingRenderer, MoversWordOrderingGroupRenderer, MoversWordFillParagraphRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversMatchWordToPictureGroupRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderAndMatchGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrosswordPuzzleRenderer, FindWordsInMatrixRenderer, SortWordsIntoCategoriesRenderer, ChooseThenAnswerGroupRenderer, } from './question-renderers';
12
12
  import { SpeakingQuestionRenderer } from './speaking/renderers';
13
- import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformReadingPassage, transformLabelPicture, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformWordOrdering, transformWordOrderingGroupTake, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformMatchingWithLinesGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, transformSortWordsIntoCategories, transformChooseThenAnswerGroup, } from '../utils/question-transformers';
13
+ import { transformFillInBlank, transformWriteCorrectVerbForm, transformChooseCorrectAnswer, transformChooseTheCorrectAnswerGroup, transformChooseCorrectAdjective, transformWriteShortLetter, transformColorObjects, transformReadingPassage, transformLabelPicture, transformGridFill, transformAnswerTheQuestion, transformAnswerTheQuestionGroup, transformTrueFalse, transformTrueFalseGroup, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformWordOrdering, transformWordOrderingGroupTake, transformWordFillParagraph, transformMatchByWritingAnswer, transformMatchWordToPicture, transformMatchWordToPictureGroup, transformWriteSentences, transformWordFillStructuredForm, transformWordOrderAndMatchGroup, transformMatchingWithLinesGroup, transformCrosswordPuzzle, transformFindWordsInMatrix, transformSortWordsIntoCategories, transformChooseThenAnswerGroup, } from '../utils/question-transformers';
14
14
  import { transformCrossOutWordGroup } from '../utils/cross-out-word-group.transformer';
15
15
  import { normalizeColoringAssignments } from '../utils/coloring-assignments';
16
16
  export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode = false, onExit, onPartComplete, onPartBack, speakingTheme, initialQuestionIndex, skipTeacherVideo, onTeacherIntroPlayed, currentQuestionIndex, compactLayout = false, }) {
@@ -200,6 +200,10 @@ export function QuestionRenderer({ part, answers, onAnswerChange, isReviewMode =
200
200
  const data = transformMatchByWritingAnswer(questions);
201
201
  return (_jsx(MoversMatchByWritingAnswerRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, audioUrl: part.audioUrl, options: data.options, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, viewMode: data.viewMode, groupImageUrl: data.groupImageUrl }));
202
202
  }
203
+ case 'MATCH_WORD_TO_PICTURE_GROUP': {
204
+ const data = transformMatchWordToPictureGroup(questions);
205
+ return (_jsx(MoversMatchWordToPictureGroupRenderer, { partNumber: partNo, partName: part.name, questionCount: data.questions.filter((q) => !q.isExample).length, instruction: part.instructions || data.instruction, audioUrl: part.audioUrl, data: data, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
206
+ }
203
207
  case 'MATCH_WORD_TO_PICTURE': {
204
208
  const data = transformMatchWordToPicture(questions);
205
209
  return (_jsx(MoversMatchWordToPictureRenderer, { partNumber: partNo, partName: part.name, questionCount: questionCount, instruction: part.instructions || data.instruction, wordBank: data.wordBank, questions: getRenderQuestions(data.questions), answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
@@ -0,0 +1,14 @@
1
+ import type { MatchWordToPictureGroupPartData } from '../../utils/question-transformers';
2
+ interface MoversMatchWordToPictureGroupRendererProps {
3
+ partNumber: number;
4
+ partName?: string;
5
+ questionCount: number;
6
+ instruction: string;
7
+ audioUrl?: string;
8
+ data: MatchWordToPictureGroupPartData;
9
+ answers: Record<string, string | undefined>;
10
+ onAnswerChange: (questionId: string, value: string) => void;
11
+ isReviewMode?: boolean;
12
+ }
13
+ export declare function MoversMatchWordToPictureGroupRenderer({ partNumber, partName, questionCount, instruction, audioUrl, data, answers, onAnswerChange, isReviewMode, }: MoversMatchWordToPictureGroupRendererProps): import("react").JSX.Element;
14
+ export {};
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
4
+ import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
5
+ import { MatchWordToPictureGroupClient } from '../../../../questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient';
6
+ export function MoversMatchWordToPictureGroupRenderer({ partNumber, partName, questionCount, instruction, audioUrl, data, answers, onAnswerChange, isReviewMode = false, }) {
7
+ const userAnswers = data.questions.map((question) => {
8
+ if (question.isExample)
9
+ return question.correctAnswer;
10
+ return typeof answers[question.id] === 'string' ? answers[question.id] || '' : '';
11
+ });
12
+ const resolvedAudio = data.audioUrl || audioUrl || '';
13
+ return (_jsxs("div", { className: "w-full space-y-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, partName: partName, questionCount: questionCount }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction }), _jsx("div", { className: "mt-4", children: _jsx(MatchWordToPictureGroupClient, { questionData: {
14
+ title: data.title,
15
+ instruction: data.instruction,
16
+ ...(data.passage ? { passage: data.passage } : {}),
17
+ ...(resolvedAudio ? { audioUrl: resolvedAudio } : {}),
18
+ ...(data.imageUrl ? { imageUrl: data.imageUrl } : {}),
19
+ wordBank: data.wordBank,
20
+ items: data.questions.map((question) => ({
21
+ imageUrl: question.imageUrl,
22
+ correctAnswer: question.correctAnswer,
23
+ isExample: question.isExample,
24
+ points: question.points,
25
+ questionNumber: question.questionNumber,
26
+ })),
27
+ explanation: data.explanation,
28
+ }, isReviewMode: isReviewMode, userAnswers: userAnswers, onAnswerChange: (next) => {
29
+ next.forEach((value, index) => {
30
+ const target = data.questions[index];
31
+ if (target && !target.isExample) {
32
+ onAnswerChange(target.id, value);
33
+ }
34
+ });
35
+ } }) })] }));
36
+ }
@@ -13,9 +13,13 @@ export function MoversMatchingWithLinesGroupRenderer({ partNumber, partName, que
13
13
  return (_jsxs("div", { className: "w-full space-y-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, partName: partName, questionCount: questionCount }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction }), _jsx("div", { className: "mt-4", children: _jsx(MatchingWithLinesGroupClient, { questionData: {
14
14
  title: data.title,
15
15
  instruction: data.instruction,
16
+ ...(data.passage ? { passage: data.passage } : {}),
17
+ ...(data.audioUrl ? { audioUrl: data.audioUrl } : {}),
18
+ ...(data.imageUrl ? { imageUrl: data.imageUrl } : {}),
16
19
  rightItems: data.rightItems,
17
20
  items: data.questions.map((question) => ({
18
21
  imageUrl: question.imageUrl,
22
+ text: question.text,
19
23
  correctAnswer: question.correctRightId,
20
24
  isExample: question.isExample,
21
25
  points: question.points,
@@ -17,6 +17,7 @@ export { MoversWordOrderingGroupRenderer } from './MoversWordOrderingGroupRender
17
17
  export { MoversWordFillParagraphRenderer } from './MoversWordFillParagraphRenderer';
18
18
  export { MoversMatchByWritingAnswerRenderer } from './MoversMatchByWritingAnswerRenderer';
19
19
  export { MoversMatchWordToPictureRenderer } from './MoversMatchWordToPictureRenderer';
20
+ export { MoversMatchWordToPictureGroupRenderer } from './MoversMatchWordToPictureGroupRenderer';
20
21
  export { MoversWordFillStructuredFormRenderer } from './MoversWordFillStructuredFormRenderer';
21
22
  export { MoversWordOrderAndMatchGroupRenderer } from './MoversWordOrderAndMatchGroupRenderer';
22
23
  export { MoversMatchingWithLinesGroupRenderer } from './MoversMatchingWithLinesGroupRenderer';
@@ -21,6 +21,7 @@ export { MoversWordOrderingGroupRenderer } from './MoversWordOrderingGroupRender
21
21
  export { MoversWordFillParagraphRenderer } from './MoversWordFillParagraphRenderer';
22
22
  export { MoversMatchByWritingAnswerRenderer } from './MoversMatchByWritingAnswerRenderer';
23
23
  export { MoversMatchWordToPictureRenderer } from './MoversMatchWordToPictureRenderer';
24
+ export { MoversMatchWordToPictureGroupRenderer } from './MoversMatchWordToPictureGroupRenderer';
24
25
  export { MoversWordFillStructuredFormRenderer } from './MoversWordFillStructuredFormRenderer';
25
26
  export { MoversWordOrderAndMatchGroupRenderer } from './MoversWordOrderAndMatchGroupRenderer';
26
27
  export { MoversMatchingWithLinesGroupRenderer } from './MoversMatchingWithLinesGroupRenderer';
@@ -90,6 +90,7 @@ function findParentQuestion(subQuestionId, parts) {
90
90
  part.questionType === 'TRUE_FALSE_CORRECT_GROUP' ||
91
91
  part.questionType === 'FILL_IN_BLANK_GROUP' ||
92
92
  part.questionType === 'MATCHING_WITH_LINES_GROUP' ||
93
+ part.questionType === 'MATCH_WORD_TO_PICTURE_GROUP' ||
93
94
  part.questionType === 'WORD_ORDER_AND_MATCH_GROUP' ||
94
95
  part.questionType === 'SPONTANEOUS_QA_GROUP' ||
95
96
  part.questionType === 'ANSWER_THE_QUESTION_GROUP' ||
@@ -5,7 +5,7 @@
5
5
  * Each transformer handles specific question type data structure.
6
6
  */
7
7
  import type { FillBlankQuestion, ChooseBestAnswerQuestion, ReadingPassageQuestion, LabelThePictureLabel, ColoringHintItem, ColoringRegion } from '../types';
8
- export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'TRUE_FALSE_CORRECT_GROUP' | 'FILL_IN_BLANK_GROUP' | 'MATCHING_WITH_LINES_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_ORDERING_GROUP' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'MATCH_WORD_TO_PICTURE' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'CROSSWORD_PUZZLE' | 'FIND_WORDS_IN_MATRIX' | 'SORT_WORDS_INTO_CATEGORIES' | 'CHOOSE_THEN_ANSWER_GROUP' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'SPEAKING_CUE_CARD';
8
+ export type QuestionType = 'FILL_IN_BLANK' | 'WRITE_CORRECT_VERB_FORM' | 'CHOOSE_THE_CORRECT_ANSWER' | 'CHOOSE_THE_CORRECT_ANSWER_GROUP' | 'CHOOSE_CORRECT_ADJECTIVE' | 'WRITE_A_SHORT_LETTER' | 'READ_AND_COLOR_OBJECTS' | 'READ_PASSAGE_AND_ANSWER_QUESTIONS' | 'LABEL_THE_PICTURE' | 'FILL_MISSING_WORDS_IN_GRID' | 'IMAGE_OBJECT_MATCHING' | 'ANSWER_THE_QUESTION' | 'ANSWER_THE_QUESTION_GROUP' | 'TRUE_FALSE' | 'TRUE_FALSE_GROUP' | 'TRUE_FALSE_CORRECT_GROUP' | 'FILL_IN_BLANK_GROUP' | 'MATCHING_WITH_LINES_GROUP' | 'CROSS_OUT_WORD_GROUP' | 'WORD_ORDERING' | 'WORD_ORDERING_GROUP' | 'WORD_FILL_PARAGRAPH' | 'MATCH_BY_WRITING_ANSWER' | 'MATCH_WORD_TO_PICTURE' | 'MATCH_WORD_TO_PICTURE_GROUP' | 'WRITE_SENTENCES' | 'WORD_FILL_STRUCTURED_FORM' | 'CROSSWORD_PUZZLE' | 'FIND_WORDS_IN_MATRIX' | 'SORT_WORDS_INTO_CATEGORIES' | 'CHOOSE_THEN_ANSWER_GROUP' | 'WORD_ORDER_AND_MATCH_GROUP' | 'LISTEN_AND_CHOOSE_OBJECTS_IN_SCENE' | 'LISTEN_AND_CHOOSE_FROM_ANSWER_GROUP' | 'LISTEN_AND_DRAG_OBJECTS_INTO_SCENE' | 'LISTEN_AND_SPEAK_ANSWER' | 'LISTEN_AND_SPEAK_COMPARE_IMAGES' | 'LISTEN_AND_SPEAK_WITH_STORY_IMAGES' | 'LISTEN_AND_SPEAK_IMAGE_GROUP' | 'LISTEN_AND_SPEAK_ODD_ONE_OUT' | 'LISTEN_AND_SPEAK_QUESTION_LIST' | 'LISTEN_AND_SPEAK_INFO_EXCHANGE' | 'READ_DISPLAYED_CONTENT' | 'SPEAKING_DESCRIBE_IMAGE' | 'SPONTANEOUS_QA' | 'SPONTANEOUS_QA_GROUP' | 'SPEAKING_CONVERSATION' | 'SPEAKING_CUE_CARD';
9
9
  export interface ApiQuestion {
10
10
  id: string;
11
11
  content: any;
@@ -372,6 +372,25 @@ export declare function transformMatchWordToPicture(questions: ApiQuestion[]): {
372
372
  wordBank: string[];
373
373
  instruction: string;
374
374
  };
375
+ export interface MatchWordToPictureGroupQuestionItem {
376
+ id: string;
377
+ questionNumber: number;
378
+ imageUrl?: string | string[];
379
+ correctAnswer: string;
380
+ isExample: boolean;
381
+ points: number;
382
+ }
383
+ export interface MatchWordToPictureGroupPartData {
384
+ questions: MatchWordToPictureGroupQuestionItem[];
385
+ wordBank: string[];
386
+ title?: string;
387
+ instruction: string;
388
+ passage?: string;
389
+ audioUrl?: string;
390
+ imageUrl?: string | string[];
391
+ explanation?: string;
392
+ }
393
+ export declare function transformMatchWordToPictureGroup(questions: ApiQuestion[]): MatchWordToPictureGroupPartData;
375
394
  export declare function transformWriteSentences(questions: ApiQuestion[]): {
376
395
  questions: Array<{
377
396
  id: string;
@@ -574,7 +593,8 @@ export declare function transformChooseThenAnswerGroup(questions: ApiQuestion[])
574
593
  export interface MatchingWithLinesGroupQuestionItem {
575
594
  id: string;
576
595
  questionNumber: number;
577
- imageUrl: string;
596
+ imageUrl?: string;
597
+ text?: string;
578
598
  correctRightId: string;
579
599
  isExample: boolean;
580
600
  points: number;
@@ -583,10 +603,14 @@ export interface MatchingWithLinesGroupPartData {
583
603
  questions: MatchingWithLinesGroupQuestionItem[];
584
604
  rightItems: Array<{
585
605
  id: string;
586
- text: string;
606
+ text?: string;
607
+ imageUrl?: string;
587
608
  }>;
588
609
  title?: string;
589
610
  instruction: string;
611
+ passage?: string;
612
+ audioUrl?: string | string[];
613
+ imageUrl?: string | string[];
590
614
  explanation?: string;
591
615
  }
592
616
  export declare function transformMatchingWithLinesGroup(questions: ApiQuestion[]): MatchingWithLinesGroupPartData;
@@ -1045,6 +1045,39 @@ export function transformMatchWordToPicture(questions) {
1045
1045
  instruction,
1046
1046
  };
1047
1047
  }
1048
+ export function transformMatchWordToPictureGroup(questions) {
1049
+ const parent = questions[0];
1050
+ const content = (parent?.content || {});
1051
+ const items = Array.isArray(content.items) ? content.items : [];
1052
+ const parentCorrect = Array.isArray(parent?.correct_answer?.answer)
1053
+ ? parent.correct_answer.answer
1054
+ : [];
1055
+ const title = typeof content.meta?.title === 'string' ? content.meta.title.trim() : '';
1056
+ const passage = typeof content.meta?.passage === 'string' ? content.meta.passage.trim() : '';
1057
+ const audioUrl = typeof content.meta?.audioUrl === 'string' ? content.meta.audioUrl.trim() : '';
1058
+ return {
1059
+ questions: items.map((item, index) => {
1060
+ const isExample = item.isExample === true;
1061
+ const fromItem = typeof item.correctAnswer?.answer === 'string' ? item.correctAnswer.answer : '';
1062
+ const fromParent = typeof parentCorrect[index] === 'string' ? parentCorrect[index] : '';
1063
+ return {
1064
+ id: `${parent?.id || 'q'}-item-${index}`,
1065
+ questionNumber: item.questionNumber || index + 1,
1066
+ imageUrl: item.content?.imageUrl,
1067
+ correctAnswer: fromItem || fromParent,
1068
+ isExample,
1069
+ points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
1070
+ };
1071
+ }),
1072
+ wordBank: Array.isArray(content.meta?.wordBank) ? content.meta.wordBank : [],
1073
+ ...(title ? { title } : {}),
1074
+ instruction: content.meta?.instruction || 'Look and complete.',
1075
+ ...(passage ? { passage } : {}),
1076
+ ...(audioUrl ? { audioUrl } : {}),
1077
+ ...(content.meta?.imageUrl ? { imageUrl: content.meta.imageUrl } : {}),
1078
+ explanation: parent?.explanation,
1079
+ };
1080
+ }
1048
1081
  export function transformWriteSentences(questions) {
1049
1082
  const firstQuestion = questions[0];
1050
1083
  const content = firstQuestion?.content;
@@ -1388,18 +1421,25 @@ export function transformMatchingWithLinesGroup(questions) {
1388
1421
  const rightItems = Array.isArray(content.rightItems)
1389
1422
  ? content.rightItems.map((item, index) => ({
1390
1423
  id: typeof item?.id === 'string' ? item.id : `r${index + 1}`,
1391
- text: typeof item?.text === 'string' ? item.text : '',
1424
+ ...(typeof item?.text === 'string' && item.text.trim() ? { text: item.text } : {}),
1425
+ ...(typeof item?.imageUrl === 'string' && item.imageUrl.trim() ? { imageUrl: item.imageUrl } : {}),
1392
1426
  }))
1393
1427
  : [];
1428
+ const passage = content.meta?.passage?.trim() || '';
1429
+ const audioUrl = content.meta?.audioUrl;
1430
+ const imageUrl = content.meta?.imageUrl;
1394
1431
  return {
1395
1432
  questions: items.map((item, index) => {
1396
1433
  const isExample = item.isExample === true;
1397
1434
  const fromItem = typeof item.correctAnswer?.answer === 'string' ? item.correctAnswer.answer : '';
1398
1435
  const fromParent = typeof parentCorrect[index] === 'string' ? parentCorrect[index] : '';
1436
+ const itemImage = item.content?.imageUrl?.trim() || '';
1437
+ const itemText = item.content?.text?.trim() || '';
1399
1438
  return {
1400
1439
  id: `${parent?.id || 'q'}-item-${index}`,
1401
1440
  questionNumber: item.questionNumber || index + 1,
1402
- imageUrl: item.content?.imageUrl || '',
1441
+ ...(itemImage ? { imageUrl: itemImage } : {}),
1442
+ ...(itemText ? { text: itemText } : {}),
1403
1443
  correctRightId: fromItem || fromParent,
1404
1444
  isExample,
1405
1445
  points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -1408,6 +1448,9 @@ export function transformMatchingWithLinesGroup(questions) {
1408
1448
  rightItems,
1409
1449
  title: content.meta?.title || '',
1410
1450
  instruction: content.meta?.instruction || 'Look and match.',
1451
+ ...(passage ? { passage } : {}),
1452
+ ...(audioUrl ? { audioUrl } : {}),
1453
+ ...(imageUrl ? { imageUrl } : {}),
1411
1454
  explanation: parent?.explanation,
1412
1455
  };
1413
1456
  }
@@ -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;
@@ -2,6 +2,6 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Check, CircleHelp, Star, X } from 'lucide-react';
4
4
  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 })] }));
5
+ export function KidQuestionCard({ icon, hideIcon = false, title, titleClassName, points, isExample = false, review, headerExtra, children, className, contentClassName, }) {
6
+ return (_jsxs("div", { className: cn('overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm', className), children: [_jsxs("div", { className: "flex items-center justify-between gap-2 border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-3 py-2", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [!hideIcon && (_jsx("div", { className: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-indigo-600 text-white", children: icon ?? _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) })), _jsx("span", { className: cn('text-sm font-medium text-gray-700', titleClassName ?? 'truncate'), children: title }), isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "C\u00E2u v\u00ED d\u1EE5"] }))] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [headerExtra, typeof points === 'number' && (_jsxs("span", { className: "text-xs text-gray-500", children: [points, " \u0111i\u1EC3m"] })), review === 'correct' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-semibold text-green-700", children: [_jsx(Check, { className: "h-3 w-3" }), "\u0110\u00FAng"] })), review === 'wrong' && (_jsxs("div", { className: "flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700", children: [_jsx(X, { className: "h-3 w-3" }), "Sai"] }))] })] }), _jsx("div", { className: cn('space-y-3 p-3', contentClassName), children: children })] }));
7
7
  }
@@ -3,6 +3,7 @@ export declare const MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION = "Look and
3
3
  export declare const MATCH_WORD_TO_PICTURE_ITEM_TYPE: "MATCH_WORD_TO_PICTURE";
4
4
  export declare function toMatchWordToPictureImageUrls(value?: unknown): string[];
5
5
  export declare function fromMatchWordToPictureImageUrls(value?: unknown): string | string[] | undefined;
6
+ export declare function toMatchWordToPictureStorageKey(value: string): string;
6
7
  export interface MatchWordToPictureGroupItemData {
7
8
  imageUrl?: string | string[];
8
9
  correctAnswer: string;
@@ -19,3 +19,17 @@ export function fromMatchWordToPictureImageUrls(value) {
19
19
  }
20
20
  return urls.length === 1 ? urls[0] : urls;
21
21
  }
22
+ export function toMatchWordToPictureStorageKey(value) {
23
+ const trimmed = value.trim();
24
+ if (!trimmed)
25
+ return '';
26
+ if (trimmed.startsWith('http://')
27
+ || trimmed.startsWith('https://')
28
+ || trimmed.startsWith('blob:')
29
+ || trimmed.startsWith('data:')
30
+ || trimmed.startsWith('/images/')
31
+ || trimmed.startsWith('/_next/')) {
32
+ return trimmed;
33
+ }
34
+ return trimmed.replace(/^\/+/, '');
35
+ }
@@ -18,9 +18,9 @@ export interface MatchingWithLinesGroupData {
18
18
  instruction: string;
19
19
  /** Shared reading passage (HTML) above the matching board. */
20
20
  passage?: string;
21
- audioUrl?: string;
21
+ audioUrl?: string | string[];
22
22
  /** Shared cover / portrait image (not a left/right match item). */
23
- imageUrl?: string;
23
+ imageUrl?: string | string[];
24
24
  rightItems: MatchingWithLinesRightItem[];
25
25
  items: MatchingWithLinesGroupItemData[];
26
26
  explanation?: string;
@@ -26,6 +26,7 @@ const IMAGE_BASED_TYPES = [
26
26
  'IMAGE_OBJECT_MATCHING',
27
27
  'FILL_IN_BLANK',
28
28
  'MATCH_WORD_TO_PICTURE',
29
+ 'MATCH_WORD_TO_PICTURE_GROUP',
29
30
  'MATCHING_WITH_LINES_GROUP',
30
31
  ];
31
32
  // Passage-based question types
@@ -1,48 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { BookOpen, Lightbulb, Volume2 } 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';
7
- import { KidChoiceRow, KidInstruction, KidPrompt, KidQuestionCard, optionLetter, } from '../../_shared/components/compact';
6
+ import { toMatchWordToPictureImageUrls, toMatchWordToPictureStorageKey, } from '../../_shared/types/match-word-to-picture-group.type';
7
+ import { KidInstruction, KidPrompt, KidQuestionCard, } from '../../_shared/components/compact';
8
8
  function normalizeWord(value) {
9
9
  return value.trim().toLowerCase();
10
10
  }
11
- function isPlayableAudioUrl(value) {
11
+ function isPlayableUrl(value) {
12
12
  return (value.startsWith('http://')
13
13
  || value.startsWith('https://')
14
14
  || value.startsWith('blob:')
15
15
  || value.startsWith('data:'));
16
16
  }
17
- function toPresignKey(value) {
18
- const trimmed = value.trim();
19
- if (isPlayableAudioUrl(trimmed)) {
20
- return trimmed;
21
- }
22
- return trimmed.replace(/^\/+/, '');
23
- }
24
17
  function ClientAudio({ url }) {
25
- const fileKey = toPresignKey(url);
18
+ const fileKey = toMatchWordToPictureStorageKey(url);
26
19
  const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
27
- const src = previewUrl || (isPlayableAudioUrl(fileKey) ? fileKey : '');
20
+ const src = previewUrl || (isPlayableUrl(fileKey) ? fileKey : '');
28
21
  if (isLoading) {
29
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" })] }));
30
23
  }
31
- if (!src) {
24
+ if (!src)
32
25
  return null;
33
- }
34
- 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" })] }));
35
- }
36
- function isDisplayableImageUrl(value) {
37
- return (value.startsWith('http://')
38
- || value.startsWith('https://')
39
- || value.startsWith('blob:')
40
- || 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" }) })] }));
41
27
  }
42
28
  function ClientImageItem({ url, alt, large = false, }) {
43
- const fileKey = toPresignKey(url);
29
+ const fileKey = toMatchWordToPictureStorageKey(url);
44
30
  const { previewUrl, isLoading } = usePresignedFileUrl(fileKey);
45
- const src = previewUrl || (isDisplayableImageUrl(fileKey) ? fileKey : '');
31
+ const src = previewUrl || (isPlayableUrl(fileKey) ? fileKey : '');
46
32
  if (!url) {
47
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" }));
48
34
  }
@@ -51,9 +37,16 @@ function ClientImageItem({ url, alt, large = false, }) {
51
37
  }
52
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') })) }));
53
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
+ }
54
46
  export function MatchWordToPictureGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
55
47
  const items = questionData.items || [];
56
48
  const wordBank = questionData.wordBank || [];
49
+ const coverUrls = toMatchWordToPictureImageUrls(questionData.imageUrl);
57
50
  const handleSelect = (itemIndex, value) => {
58
51
  const item = items[itemIndex];
59
52
  if (isReviewMode || item?.isExample || !onAnswerChange) {
@@ -70,7 +63,7 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
70
63
  usedWords.add(normalizeWord(value));
71
64
  }
72
65
  });
73
- 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, 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-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) => {
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) => {
74
67
  const isUsed = usedWords.has(normalizeWord(word));
75
68
  return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
76
69
  ? 'border-blue-500 bg-blue-50 text-blue-800 ring-2 ring-blue-100'
@@ -82,13 +75,9 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
82
75
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
83
76
  const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
84
77
  Boolean(item.correctAnswer);
85
- 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: [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-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 })) : (wordBank.map((word, wordIndex) => {
86
- const isSelected = normalizeWord(word) === normalizeWord(selectedWord);
87
- const isUsedByOther = usedWords.has(normalizeWord(word)) && !isSelected;
88
- const isWordCorrect = normalizeWord(word) === normalizeWord(item.correctAnswer);
89
- return (_jsx(KidChoiceRow, { letter: optionLetter(wordIndex), selected: isSelected, isReviewMode: isReviewMode, isCorrect: isReviewMode && isWordCorrect, disabled: !canSelect || isUsedByOther, onClick: canSelect && !isUsedByOther
90
- ? () => handleSelect(itemIndex, isSelected ? '' : word)
91
- : undefined, children: word }, `${word}-${wordIndex}`));
92
- })), 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,17 +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
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
+ }
8
18
  function ClientAudio({ url }) {
9
19
  const { previewUrl, isLoading } = usePresignedFileUrl(url);
10
20
  const src = previewUrl ||
11
21
  (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
12
- if (!src && !isLoading)
22
+ if (!src && !isLoading) {
13
23
  return null;
14
- 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." }))] }));
15
26
  }
16
27
  export function MatchingWithLinesGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, onAnswersChange, }) {
17
28
  const items = questionData.items || [];
@@ -33,7 +44,7 @@ export function MatchingWithLinesGroupClient({ questionData, isReviewMode = fals
33
44
  }
34
45
  });
35
46
  };
36
- return (_jsxs("div", { className: "space-y-4", children: [questionData.title ? _jsx(KidPrompt, { children: questionData.title }) : null, _jsx(KidInstruction, { 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(KidQuestionCard, { title: "N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB", children: _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
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) => ({
37
48
  imageUrl: item.imageUrl,
38
49
  text: item.text,
39
50
  isExample: item.isExample,
@@ -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,
@@ -1,4 +1,5 @@
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';
2
3
  function sumGradablePoints(items) {
3
4
  return items.reduce((total, item) => {
4
5
  if (item.isExample)
@@ -30,16 +31,16 @@ export const transformMatchingWithLinesGroup = (question) => {
30
31
  });
31
32
  const title = typeof answerData?.title === 'string' ? answerData.title.trim() : '';
32
33
  const passage = typeof answerData?.passage === 'string' ? answerData.passage.trim() : '';
33
- const audioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
34
- const imageUrl = typeof answerData?.imageUrl === 'string' ? answerData.imageUrl.trim() : '';
34
+ const audioUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answerData?.audioUrl));
35
+ const imageUrl = fromChooseAnswerGroupImageUrls(toChooseAnswerGroupImageUrls(answerData?.imageUrl));
35
36
  return {
36
37
  apiContent: {
37
38
  meta: {
38
39
  instruction: answerData?.instruction || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
39
40
  ...(title ? { title } : {}),
40
41
  ...(passage ? { passage } : {}),
41
- ...(audioUrl ? { audioUrl } : {}),
42
- ...(imageUrl ? { imageUrl } : {}),
42
+ ...(audioUrl !== undefined ? { audioUrl } : {}),
43
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
43
44
  },
44
45
  rightItems: rightItems.map((item) => {
45
46
  const imageUrl = item.imageUrl?.trim() || '';
@@ -1,10 +1,10 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import React, { useState, useEffect, useMemo, useCallback } from 'react';
4
- import { Check, X, FileText, Library } from 'lucide-react';
4
+ import { Check, X, Library } from 'lucide-react';
5
5
  import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  import { cn } from '../../../../shared/lib/utils';
7
- import { KidInstruction, KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
7
+ import { KidPrompt, KidQuestionCard } from '../../_shared/components/compact';
8
8
  import { detectWfsfBankMode, isOptionsWordEntry, normalizeSharedWordBank, } from './wfsf-word-bank.utils';
9
9
  import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, primaryWfsfAnswers, } from '../../../../shared/lib/utils/wfsf-blank-match';
10
10
  function normalizeWfsfBlankStyle(raw) {
@@ -138,9 +138,13 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
138
138
  // Header = page rubric. Pearson preview maps draft.instruction → explanation.
139
139
  const rawExplanation = String(explanation || questionData.explanation || '').trim();
140
140
  const headerInstruction = String(instruction || dataInstruction || rawExplanation || '').trim();
141
- const headerLabel = headerInstruction || DEFAULT_WFSF_HEADER;
141
+ // Header reads "Bài x: rubric" — the question type name is never shown.
142
+ const numberLabel = typeof questionNumber === 'number' && questionNumber > 0
143
+ ? `Bài ${questionNumber}`
144
+ : '';
145
+ const headerTitle = numberLabel ? (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "font-bold", children: [numberLabel, headerInstruction ? ':' : ''] }), headerInstruction ? ` ${headerInstruction}` : ''] })) : (headerInstruction || DEFAULT_WFSF_HEADER);
142
146
  // Do not re-show the same rubric under "Giải thích".
143
- const displayExplanation = rawExplanation && rawExplanation !== headerLabel ? rawExplanation : '';
147
+ const displayExplanation = rawExplanation && rawExplanation !== headerInstruction ? rawExplanation : '';
144
148
  const handleInputChange = useCallback((key, value) => {
145
149
  if (isReviewMode)
146
150
  return;
@@ -178,8 +182,8 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
178
182
  : undefined, size: isLetterBlank ? undefined : isPhraseBlank ? 28 : 10, className: cn('inline-block bg-slate-50/40 text-center text-sm font-semibold outline-none transition-all align-baseline hover:bg-slate-100/60 focus:bg-teal-50/60 focus:ring-0', isLetterBlank
179
183
  ? 'h-7 w-7 rounded-md border px-0 uppercase'
180
184
  : isPhraseBlank
181
- ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-md border px-3 py-1.5'
182
- : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-md border px-3 py-1.5', isReviewMode
185
+ ? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-none border-0 border-b-2 bg-transparent px-1 py-0.5'
186
+ : 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-none border-0 border-b-2 bg-transparent px-1 py-0.5', isReviewMode
183
187
  ? isCorrectFill
184
188
  ? 'border-green-400 bg-green-50 text-green-800'
185
189
  : isWrongFill
@@ -278,12 +282,12 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
278
282
  .map((node, index) => renderNode(node, `root-${index}`))
279
283
  .filter((node) => node !== null);
280
284
  }, [content, renderBlank]);
281
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(KidInstruction, { children: headerInstruction }), _jsxs(KidQuestionCard, { icon: _jsx(FileText, { className: "h-3.5 w-3.5" }), title: headerInstruction ? DEFAULT_WFSF_HEADER : headerLabel, headerExtra: typeof questionNumber === 'number' && questionNumber > 0 ? (_jsxs("span", { className: "text-[11px] font-semibold uppercase tracking-wider text-teal-700", children: ["B\u00E0i ", questionNumber] })) : undefined, review: isReviewMode && allFilled ? (isAllCorrect ? 'correct' : 'wrong') : null, children: [title && (_jsx(KidPrompt, { children: _jsx("h2", { className: "text-sm font-semibold text-gray-800", children: title }) })), allAudios.length > 0 && (_jsx("div", { className: "flex flex-col items-center gap-2", children: allAudios.map((audio, idx) => (_jsx(ClientAudioItem, { url: audio }, idx))) })), allImages.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: allImages.map((img, idx) => (_jsx(ClientImageItem, { url: img, alt: title ? `${title} (${idx + 1})` : undefined }, idx))) })), bankMode === 'WITH_WORD_BANK' && sharedWords.length > 0 && (_jsxs("div", { className: "rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50 p-3", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2", children: [_jsx(Library, { className: "h-4 w-4 text-purple-600" }), _jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-purple-700", children: "Word Bank" })] }), _jsx("div", { className: "flex flex-wrap gap-2", children: sharedWords.map((item, index) => {
282
- const used = usedSharedWords.has(item.word.trim().toLowerCase());
283
- return (_jsxs("span", { className: cn('rounded-full border px-3 py-1 text-sm font-medium', item.isExample
284
- ? 'border-amber-300 bg-amber-100 text-amber-800'
285
- : used
286
- ? 'border-purple-200 bg-white/60 text-purple-400 line-through'
287
- : 'border-purple-300 bg-white text-purple-800'), children: [item.word, item.isExample ? ' (ex)' : ''] }, `${item.word}-${index}`));
288
- }) })] })), _jsx("div", { className: "structured-form-content space-y-2 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5 text-base leading-loose text-gray-800", children: renderedContent }), isReviewMode && !isAllCorrect && (_jsxs("div", { className: "rounded-lg border border-green-200 bg-green-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-green-600", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: expectedBlankKeys.map((key) => (_jsxs("span", { className: "rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-sm font-semibold text-green-800", children: [`{${key}}`, ": ", formatWfsfCorrectLabel(answers[key])] }, key))) })] })), isReviewMode && displayExplanation && (_jsxs("div", { className: "rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-blue-600", children: "Gi\u1EA3i th\u00EDch" }), _jsx("p", { className: "mt-1 text-sm text-blue-800", children: displayExplanation })] }))] })] }));
285
+ return (_jsx("div", { className: "space-y-4", children: _jsxs(KidQuestionCard, { hideIcon: true, title: headerTitle, titleClassName: "whitespace-normal break-words text-base font-normal text-gray-800", review: isReviewMode && allFilled ? (isAllCorrect ? 'correct' : 'wrong') : null, children: [title && (_jsx(KidPrompt, { children: _jsx("h2", { className: "text-sm font-semibold text-gray-800", children: title }) })), allAudios.length > 0 && (_jsx("div", { className: "flex flex-col items-center gap-2", children: allAudios.map((audio, idx) => (_jsx(ClientAudioItem, { url: audio }, idx))) })), allImages.length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: allImages.map((img, idx) => (_jsx(ClientImageItem, { url: img, alt: title ? `${title} (${idx + 1})` : undefined }, idx))) })), bankMode === 'WITH_WORD_BANK' && sharedWords.length > 0 && (_jsxs("div", { className: "rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-pink-50 p-3", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2", children: [_jsx(Library, { className: "h-4 w-4 text-purple-600" }), _jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-purple-700", children: "Word Bank" })] }), _jsx("div", { className: "flex flex-wrap gap-2", children: sharedWords.map((item, index) => {
286
+ const used = usedSharedWords.has(item.word.trim().toLowerCase());
287
+ return (_jsxs("span", { className: cn('rounded-full border px-3 py-1 text-sm font-medium', item.isExample
288
+ ? 'border-amber-300 bg-amber-100 text-amber-800'
289
+ : used
290
+ ? 'border-purple-200 bg-white/60 text-purple-400 line-through'
291
+ : 'border-purple-300 bg-white text-purple-800'), children: [item.word, item.isExample ? ' (ex)' : ''] }, `${item.word}-${index}`));
292
+ }) })] })), _jsx("div", { className: "structured-form-content space-y-2 rounded-lg border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-5 text-base leading-loose text-gray-800", children: renderedContent }), isReviewMode && !isAllCorrect && (_jsxs("div", { className: "rounded-lg border border-green-200 bg-green-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-green-600", children: "\u0110\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("div", { className: "mt-2 flex flex-wrap gap-2", children: expectedBlankKeys.map((key) => (_jsxs("span", { className: "rounded-lg border border-green-300 bg-green-100 px-3 py-1.5 text-sm font-semibold text-green-800", children: [`{${key}}`, ": ", formatWfsfCorrectLabel(answers[key])] }, key))) })] })), isReviewMode && displayExplanation && (_jsxs("div", { className: "rounded-lg border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-3", children: [_jsx("span", { className: "text-xs font-medium uppercase tracking-wider text-blue-600", children: "Gi\u1EA3i th\u00EDch" }), _jsx("p", { className: "mt-1 text-sm text-blue-800", children: displayExplanation })] }))] }) }));
289
293
  }
@@ -4,8 +4,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
4
4
  * Movers fallback review cases (isReviewMode=true).
5
5
  * Source: oe-exam-fe ReviewQuestionRenderer.tsx
6
6
  */
7
- import { MoversAnswerTheQuestionRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWordOrderingGroupRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, ChooseThenAnswerGroupRenderer, SortWordsIntoCategoriesRenderer, } from '../../../components/exams/take/components/question-renderers';
8
- import { transformAnswerTheQuestion, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformMatchingWithLinesGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWordOrderingGroupTake, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, transformChooseThenAnswerGroup, transformSortWordsIntoCategories, } from '../../../components/exams/take/utils/question-transformers';
7
+ import { MoversAnswerTheQuestionRenderer, MoversGridFillRenderer, MoversLabelThePictureRenderer, MoversMatchByWritingAnswerRenderer, MoversMatchWordToPictureRenderer, MoversMatchWordToPictureGroupRenderer, MoversReadingPassageRenderer, MoversTrueFalseRenderer, MoversTrueFalseCorrectGroupRenderer, MoversFillInBlankGroupRenderer, MoversMatchingWithLinesGroupRenderer, MoversCrossOutWordGroupRenderer, MoversWordFillParagraphRenderer, MoversWordFillStructuredFormRenderer, MoversWordOrderingRenderer, MoversWordOrderingGroupRenderer, MoversWriteSentencesRenderer, MoversWritingRenderer, MoversCrosswordPuzzleRenderer, ChooseThenAnswerGroupRenderer, SortWordsIntoCategoriesRenderer, } from '../../../components/exams/take/components/question-renderers';
8
+ import { transformAnswerTheQuestion, transformGridFill, transformLabelPicture, transformMatchByWritingAnswer, transformMatchWordToPicture, transformMatchWordToPictureGroup, transformReadingPassage, transformTrueFalse, transformTrueFalseCorrectGroup, transformFillInBlankGroup, transformMatchingWithLinesGroup, transformWordFillParagraph, transformWordFillStructuredForm, transformWordOrdering, transformWordOrderingGroupTake, transformWriteSentences, transformWriteShortLetter, transformCrosswordPuzzle, transformChooseThenAnswerGroup, transformSortWordsIntoCategories, } from '../../../components/exams/take/utils/question-transformers';
9
9
  import { normalizeCrossOutWordGroupAnswer, transformCrossOutWordGroup, } from '../../../components/exams/take/utils/cross-out-word-group.transformer';
10
10
  function asApiQuestions(questions) {
11
11
  return questions;
@@ -183,6 +183,10 @@ export function renderMoversReviewBody(ctx) {
183
183
  const data = transformMatchByWritingAnswer(apiQuestions);
184
184
  return (_jsx(MoversMatchByWritingAnswerRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, audioUrl: part.audioUrl, options: data.options, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, viewMode: data.viewMode, groupImageUrl: data.groupImageUrl, correctAnswerTextMap: correctAnswerTextMap, isCorrectMap: isCorrectMap }));
185
185
  }
186
+ case 'MATCH_WORD_TO_PICTURE_GROUP': {
187
+ const data = transformMatchWordToPictureGroup(apiQuestions);
188
+ return (_jsx(MoversMatchWordToPictureGroupRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: data.questions.filter((q) => !q.isExample).length, audioUrl: part.audioUrl, data: data, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true }));
189
+ }
186
190
  case 'MATCH_WORD_TO_PICTURE': {
187
191
  const data = transformMatchWordToPicture(apiQuestions);
188
192
  return (_jsx(MoversMatchWordToPictureRenderer, { partNumber: partNo, partName: part.name, instruction: part.instructions || data.instruction, questionCount: questionCount, wordBank: data.wordBank, questions: data.questions, answers: answers, onAnswerChange: handleAnswerChange, isReviewMode: true, correctAnswerTextMap: correctAnswerTextMap, isCorrectMap: isCorrectMap }));
@@ -11,6 +11,7 @@ const FLATTENED_ANSWER_TYPES = new Set([
11
11
  'CHOOSE_THEN_ANSWER_GROUP',
12
12
  'FILL_IN_BLANK_GROUP',
13
13
  'MATCHING_WITH_LINES_GROUP',
14
+ 'MATCH_WORD_TO_PICTURE_GROUP',
14
15
  ]);
15
16
  export function transformResultAnswersForReview(answers, templateParts = []) {
16
17
  const groups = groupAnswers(answers);
@@ -282,6 +283,17 @@ function transformStudentAnswer(questionId, questionType, value) {
282
283
  typeof item === 'string' ? item : '',
283
284
  ]));
284
285
  }
286
+ if (questionType === 'MATCH_WORD_TO_PICTURE_GROUP') {
287
+ const answer = Array.isArray(record.answer)
288
+ ? record.answer
289
+ : Array.isArray(value)
290
+ ? value
291
+ : [];
292
+ return Object.fromEntries(answer.map((item, index) => [
293
+ `${questionId}-item-${index}`,
294
+ typeof item === 'string' ? item : '',
295
+ ]));
296
+ }
285
297
  if (questionType === 'CHOOSE_THE_CORRECT_ANSWER' || questionType === 'CHOOSE_CORRECT_ADJECTIVE') {
286
298
  const rawAns = record.answer ?? record.optionId ?? record.optionIds;
287
299
  const ids = getOptionIds(questionId, rawAns);
@@ -511,14 +511,29 @@ const reverseMatchingWithLinesGroup = (apiQuestion) => {
511
511
  questionNumber: asNumber(item.questionNumber, index + 1),
512
512
  };
513
513
  });
514
+ const readMatchingMedia = (value) => {
515
+ if (Array.isArray(value)) {
516
+ const cleaned = value
517
+ .filter((item) => typeof item === 'string')
518
+ .map((item) => item.trim())
519
+ .filter((item) => item !== '');
520
+ if (cleaned.length === 0)
521
+ return undefined;
522
+ return cleaned.length === 1 ? cleaned[0] : cleaned;
523
+ }
524
+ const single = asString(value).trim();
525
+ return single || undefined;
526
+ };
527
+ const audioUrl = readMatchingMedia(meta.audioUrl);
528
+ const imageUrl = readMatchingMedia(meta.imageUrl);
514
529
  return {
515
530
  content: '',
516
531
  answer: {
517
532
  title: asString(meta.title),
518
533
  instruction: asString(meta.instruction, 'Look and match.'),
519
534
  ...(asString(meta.passage).trim() ? { passage: asString(meta.passage).trim() } : {}),
520
- ...(asString(meta.audioUrl).trim() ? { audioUrl: asString(meta.audioUrl).trim() } : {}),
521
- ...(asString(meta.imageUrl).trim() ? { imageUrl: asString(meta.imageUrl).trim() } : {}),
535
+ ...(audioUrl !== undefined ? { audioUrl } : {}),
536
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
522
537
  rightItems,
523
538
  items,
524
539
  explanation: asString(apiQuestion.explanation),
@@ -20,9 +20,9 @@ export interface MatchingWithLinesGroupContent {
20
20
  meta?: {
21
21
  instruction?: string;
22
22
  title?: string;
23
- audioUrl?: string;
23
+ audioUrl?: string | string[];
24
24
  passage?: string;
25
- imageUrl?: string;
25
+ imageUrl?: string | string[];
26
26
  };
27
27
  rightItems: MatchingWithLinesRightItem[];
28
28
  items: MatchingWithLinesGroupItem[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",