@tinyweb_dev/oe-exam-sdk 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.js +1 -0
  4. package/dist/components/exams/take/utils/question-transformers.d.ts +2 -1
  5. package/dist/components/exams/take/utils/question-transformers.js +2 -0
  6. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +3 -0
  7. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +12 -1
  8. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
  9. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +4 -1
  10. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
  11. package/dist/components/questions/_shared/types/word-ordering-group.type.d.ts +1 -0
  12. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +12 -1
  13. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +6 -2
  14. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +5 -0
  15. package/dist/components/questions/types/answer-the-question-group/transform.js +3 -0
  16. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
  17. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
  18. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
  19. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
  20. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
  21. package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
  22. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +6 -6
  23. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -7
  24. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +4 -0
  25. package/dist/components/questions/types/match-word-to-picture-group/transform.js +9 -1
  26. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
  27. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
  28. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
  29. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
  30. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
  31. package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
  32. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
  33. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +1 -0
  34. package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.js +3 -2
  35. package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.js +2 -0
  36. package/dist/components/questions/types/word-ordering-group/transform.js +4 -1
  37. package/dist/shared/lib/utils/question-reverse-transform.js +31 -5
  38. package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
  39. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +3 -1
  40. package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
  41. package/dist/shared/types/questions/word-ordering-group.d.ts +1 -0
  42. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import type { FillInBlankWordBankEntry } from '../../_shared/types/fill-in-blank-group.type';
1
2
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
3
  export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
3
4
  export type FillBlankStemPart = {
@@ -15,6 +16,28 @@ export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: nu
15
16
  export declare function joinSlashAnswers(alts: string[] | undefined): string;
16
17
  export declare function parseSlashAnswers(text: string): string[];
17
18
  export declare function splitStem(question: string): FillBlankStemPart[];
19
+ export type StemTextDecoration = {
20
+ type: 'text';
21
+ value: string;
22
+ } | {
23
+ type: 'tick';
24
+ value: string;
25
+ } | {
26
+ type: 'cross';
27
+ value: string;
28
+ } | {
29
+ type: 'br';
30
+ };
31
+ /** Split a stem text fragment into ticks, crosses, line breaks, and plain text. */
32
+ export declare function splitStemTextDecorations(text: string): StemTextDecoration[];
18
33
  export declare function normalizeStudentText(value: string): string;
19
34
  export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
35
+ export declare function parseWordBankEntry(raw: unknown): FillInBlankWordBankEntry;
36
+ export declare function normalizeWordBank(raw: unknown, requiredLength?: number): FillInBlankWordBankEntry[] | undefined;
37
+ export declare function resolveBlankOptions(blankIndex: number, itemWordBank?: FillInBlankWordBankEntry[], groupWordBank?: FillInBlankWordBankEntry[]): string[] | undefined;
38
+ export declare function hasAnyBlankOptions(items: Array<{
39
+ wordBank?: FillInBlankWordBankEntry[];
40
+ }>, groupWordBank?: FillInBlankWordBankEntry[]): boolean;
41
+ export declare function toFillInBlankImageUrls(value: unknown): string[];
42
+ export declare function fromFillInBlankImageUrls(urls: string[]): string | string[] | undefined;
20
43
  export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];
@@ -75,6 +75,42 @@ export function splitStem(question) {
75
75
  }
76
76
  return parts;
77
77
  }
78
+ const STEM_TICK_MARKS = new Set(['✓', '✔', '✅']);
79
+ const STEM_CROSS_MARKS = new Set(['✗', '✘', '❌', '×']);
80
+ const STEM_DECORATION_RE = /[✓✔✅✗✘❌×]|\n/g;
81
+ /** Split a stem text fragment into ticks, crosses, line breaks, and plain text. */
82
+ export function splitStemTextDecorations(text) {
83
+ if (!text)
84
+ return [];
85
+ const normalized = text.replace(/\r\n/g, '\n');
86
+ const parts = [];
87
+ let lastIndex = 0;
88
+ let match;
89
+ const regex = new RegExp(STEM_DECORATION_RE.source, 'g');
90
+ while ((match = regex.exec(normalized)) !== null) {
91
+ if (match.index > lastIndex) {
92
+ parts.push({ type: 'text', value: normalized.slice(lastIndex, match.index) });
93
+ }
94
+ const token = match[0];
95
+ if (token === '\n') {
96
+ parts.push({ type: 'br' });
97
+ }
98
+ else if (STEM_TICK_MARKS.has(token)) {
99
+ parts.push({ type: 'tick', value: token });
100
+ }
101
+ else if (STEM_CROSS_MARKS.has(token)) {
102
+ parts.push({ type: 'cross', value: token });
103
+ }
104
+ else {
105
+ parts.push({ type: 'text', value: token });
106
+ }
107
+ lastIndex = match.index + token.length;
108
+ }
109
+ if (lastIndex < normalized.length) {
110
+ parts.push({ type: 'text', value: normalized.slice(lastIndex) });
111
+ }
112
+ return parts;
113
+ }
78
114
  export function normalizeStudentText(value) {
79
115
  return value.toString().trim().toLowerCase().replace(/\s+/g, ' ');
80
116
  }
@@ -84,6 +120,59 @@ export function isBlankMatch(studentValue, alts) {
84
120
  return false;
85
121
  return alts.some((alt) => normalizeStudentText(alt) === user);
86
122
  }
123
+ export function parseWordBankEntry(raw) {
124
+ if (raw == null)
125
+ return null;
126
+ const options = Array.isArray(raw)
127
+ ? raw.map((item) => String(item ?? '').trim()).filter(Boolean)
128
+ : typeof raw === 'object' && Array.isArray(raw.options)
129
+ ? raw.options
130
+ .map((item) => String(item ?? '').trim())
131
+ .filter(Boolean)
132
+ : [];
133
+ return options.length >= 2 ? { options } : null;
134
+ }
135
+ export function normalizeWordBank(raw, requiredLength) {
136
+ if (!Array.isArray(raw))
137
+ return undefined;
138
+ let result = raw.map((entry) => parseWordBankEntry(entry));
139
+ if (typeof requiredLength === 'number' && requiredLength > 0) {
140
+ result = result.slice(0, requiredLength);
141
+ while (result.length < requiredLength)
142
+ result.push(null);
143
+ }
144
+ return result.some((entry) => entry && entry.options.length >= 2) ? result : undefined;
145
+ }
146
+ export function resolveBlankOptions(blankIndex, itemWordBank, groupWordBank) {
147
+ const entry = itemWordBank?.[blankIndex] ?? groupWordBank?.[blankIndex];
148
+ if (entry && entry.options.length >= 2)
149
+ return entry.options;
150
+ return undefined;
151
+ }
152
+ export function hasAnyBlankOptions(items, groupWordBank) {
153
+ if (normalizeWordBank(groupWordBank))
154
+ return true;
155
+ return items.some((item) => Boolean(normalizeWordBank(item.wordBank)));
156
+ }
157
+ export function toFillInBlankImageUrls(value) {
158
+ if (Array.isArray(value)) {
159
+ return value
160
+ .filter((url) => typeof url === 'string')
161
+ .map((url) => url.trim())
162
+ .filter((url) => url.length > 0);
163
+ }
164
+ if (typeof value === 'string' && value.trim()) {
165
+ return [value.trim()];
166
+ }
167
+ return [];
168
+ }
169
+ export function fromFillInBlankImageUrls(urls) {
170
+ if (urls.length === 0)
171
+ return undefined;
172
+ if (urls.length === 1)
173
+ return urls[0];
174
+ return urls;
175
+ }
87
176
  export function padStudentAnswers(raw, blankCount) {
88
177
  const values = Array.isArray(raw?.answers)
89
178
  ? raw.answers.map((item) => String(item ?? ''))
@@ -1,5 +1,5 @@
1
1
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
- import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
2
+ import { blankSlotCount, extractPlaceholderIndices, hasAnyBlankOptions, fromFillInBlankImageUrls, normalizeAnswersMatrix, normalizeWordBank, toFillInBlankImageUrls, } from './blank-utils';
3
3
  function isRecord(value) {
4
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
5
5
  }
@@ -39,16 +39,18 @@ export function mapFillInBlankGroupItem(item, index, fallbackAnswer) {
39
39
  return [];
40
40
  return alts.length > 0 ? alts : [''];
41
41
  });
42
+ const wordBank = normalizeWordBank(content.wordBank ?? record.wordBank, blankSlotCount(question));
42
43
  return {
43
44
  question,
44
45
  answers,
45
46
  caseSensitive: correctAnswer.caseSensitive === true
46
47
  || record.caseSensitive === true
47
48
  || fallback.caseSensitive === true,
48
- imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
49
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(content.imageUrl ?? record.imageUrl)),
49
50
  audioUrl: asString(content.audioUrl) || asString(record.audioUrl),
50
51
  showHints: content.showHints === true,
51
52
  hints: asStringArray(content.hints),
53
+ ...(wordBank ? { wordBank } : {}),
52
54
  isExample,
53
55
  points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
54
56
  questionNumber: typeof record.questionNumber === 'number' ? record.questionNumber : index + 1,
@@ -66,33 +68,49 @@ export function mapQuestionToFillInBlankGroupData(question) {
66
68
  const meta = asRecord(content.meta);
67
69
  if (Array.isArray(content.items)) {
68
70
  const items = content.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index]));
71
+ const groupWordBank = normalizeWordBank(meta.wordBank ?? content.wordBank);
72
+ const viewMode = meta.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
73
+ ? 'WITH_OPTIONS'
74
+ : undefined;
69
75
  return {
70
76
  instruction: asString(meta.instruction) || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
77
+ passage: asString(meta.passage),
71
78
  audioUrl: asString(meta.audioUrl),
72
- imageUrl: asString(meta.imageUrl),
79
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(meta.imageUrl)),
73
80
  showHints: meta.showHints === true,
74
81
  hints: asStringArray(meta.hints),
82
+ ...(viewMode ? { viewMode } : {}),
83
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
75
84
  items,
76
85
  explanation,
77
86
  points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
78
87
  };
79
88
  }
80
89
  if (Array.isArray(answer.items)) {
90
+ const items = answer.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index]));
91
+ const groupWordBank = normalizeWordBank(answer.wordBank);
92
+ const viewMode = answer.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
93
+ ? 'WITH_OPTIONS'
94
+ : undefined;
81
95
  return {
82
96
  instruction: asString(answer.instruction, FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION),
97
+ passage: asString(answer.passage),
83
98
  audioUrl: asString(answer.audioUrl),
84
- imageUrl: asString(answer.imageUrl),
99
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(answer.imageUrl)),
85
100
  showHints: answer.showHints === true,
86
101
  hints: asStringArray(answer.hints),
87
- items: answer.items.map((item, index) => mapFillInBlankGroupItem(item, index, fallbackAnswers[index])),
102
+ ...(viewMode ? { viewMode } : {}),
103
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
104
+ items,
88
105
  explanation,
89
106
  points: typeof answer.points === 'number' ? answer.points : undefined,
90
107
  };
91
108
  }
92
109
  return {
93
110
  instruction: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
111
+ passage: '',
94
112
  audioUrl: '',
95
- imageUrl: '',
113
+ imageUrl: undefined,
96
114
  showHints: false,
97
115
  hints: [],
98
116
  items: [createEmptyFillInBlankGroupItem(1)],
@@ -1,5 +1,5 @@
1
1
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, FILL_IN_BLANK_ITEM_TYPE, } from '../../_shared/types/fill-in-blank-group.type';
2
- import { blankSlotCount, extractPlaceholderIndices, normalizeAnswersMatrix, } from './blank-utils';
2
+ import { blankSlotCount, extractPlaceholderIndices, hasAnyBlankOptions, fromFillInBlankImageUrls, normalizeAnswersMatrix, normalizeWordBank, toFillInBlankImageUrls, } from './blank-utils';
3
3
  function sumGradablePoints(items) {
4
4
  return items.reduce((total, item) => {
5
5
  if (item.isExample)
@@ -20,6 +20,9 @@ function toItemAnswer(item) {
20
20
  caseSensitive: item.caseSensitive === true,
21
21
  };
22
22
  }
23
+ function isContentEmpty(html) {
24
+ return !html || html === '<p></p>' || html === '<p><br></p>' || html.trim() === '';
25
+ }
23
26
  /**
24
27
  * wrapOnSave nests creator fields under `answer`. getFormData() (Quiz Playground
25
28
  * Update save) returns the same fields at the root. Prefer nested items, then root.
@@ -50,15 +53,18 @@ export const transformFillInBlankGroup = (question) => {
50
53
  const hints = Array.isArray(item.hints)
51
54
  ? item.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
52
55
  : [];
56
+ const wordBank = normalizeWordBank(item.wordBank, blankSlotCount(item.question || ''));
57
+ const imageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(item.imageUrl));
53
58
  return {
54
59
  questionType: FILL_IN_BLANK_ITEM_TYPE,
55
60
  ...(item.isExample ? { isExample: true } : {}),
56
61
  content: {
57
62
  question: item.question || '',
58
- ...(item.imageUrl ? { imageUrl: item.imageUrl } : {}),
63
+ ...(imageUrl !== undefined ? { imageUrl } : {}),
59
64
  ...(item.audioUrl ? { audioUrl: item.audioUrl } : {}),
60
65
  ...(item.showHints ? { showHints: true } : {}),
61
66
  ...(hints.length > 0 ? { hints } : {}),
67
+ ...(wordBank ? { wordBank } : {}),
62
68
  },
63
69
  correctAnswer,
64
70
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -68,14 +74,23 @@ export const transformFillInBlankGroup = (question) => {
68
74
  const groupHints = Array.isArray(answerData?.hints)
69
75
  ? answerData.hints.map((hint) => String(hint ?? '').trim()).filter(Boolean)
70
76
  : [];
77
+ const passage = answerData?.passage?.trim();
78
+ const groupWordBank = normalizeWordBank(answerData?.wordBank);
79
+ const viewMode = answerData?.viewMode === 'WITH_OPTIONS' || hasAnyBlankOptions(items, groupWordBank)
80
+ ? 'WITH_OPTIONS'
81
+ : undefined;
82
+ const groupImageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(answerData?.imageUrl));
71
83
  return {
72
84
  apiContent: {
73
85
  meta: {
74
86
  instruction: answerData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION,
87
+ ...(!isContentEmpty(passage) ? { passage } : {}),
75
88
  ...(answerData?.audioUrl ? { audioUrl: answerData.audioUrl } : {}),
76
- ...(answerData?.imageUrl ? { imageUrl: answerData.imageUrl } : {}),
89
+ ...(groupImageUrl !== undefined ? { imageUrl: groupImageUrl } : {}),
77
90
  ...(answerData?.showHints ? { showHints: true } : {}),
78
91
  ...(groupHints.length > 0 ? { hints: groupHints } : {}),
92
+ ...(viewMode ? { viewMode } : {}),
93
+ ...(groupWordBank ? { wordBank: groupWordBank } : {}),
79
94
  },
80
95
  items: apiItems,
81
96
  },
@@ -6,7 +6,7 @@ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
6
6
  function normalizeWord(value) {
7
7
  return value.trim().toLowerCase();
8
8
  }
9
- function ClientImageItem({ url, alt }) {
9
+ function ClientImageItem({ url, alt, large = false, }) {
10
10
  const { previewUrl, isLoading } = usePresignedFileUrl(url);
11
11
  const src = previewUrl ||
12
12
  (typeof url === 'string' &&
@@ -19,7 +19,7 @@ function ClientImageItem({ url, alt }) {
19
19
  if (!src && !isLoading) {
20
20
  return (_jsx("div", { className: "flex h-28 w-28 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-xs text-gray-400", children: "Kh\u00F4ng t\u1EA3i \u0111\u01B0\u1EE3c \u1EA3nh" }));
21
21
  }
22
- return (_jsx("div", { className: "relative h-28 w-28 flex-shrink-0 overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-full 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: "h-full w-full object-contain" })) }));
22
+ 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') })) }));
23
23
  }
24
24
  export function MatchWordToPictureGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
25
25
  const items = questionData.items || [];
@@ -40,19 +40,19 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
40
40
  usedWords.add(normalizeWord(value));
41
41
  }
42
42
  });
43
- return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
43
+ return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), wordBank.length > 0 && (_jsxs("div", { className: "rounded-xl border border-violet-200 bg-gradient-to-r from-violet-50 to-indigo-50 p-4", children: [_jsx("p", { className: "mb-3 text-sm font-semibold text-violet-700", children: "Word bank:" }), _jsx("div", { className: "flex flex-wrap gap-2", children: wordBank.map((word, index) => {
44
44
  const isUsed = usedWords.has(normalizeWord(word));
45
45
  return (_jsx("span", { className: cn('rounded-full border px-3 py-1 text-sm font-semibold', isUsed
46
46
  ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
47
47
  : 'border-violet-200 bg-white text-slate-700'), children: word }, `${word}-${index}`));
48
- }) })] })), items.map((item, itemIndex) => {
48
+ }) })] })), questionData.imageUrl ? (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(ClientImageItem, { url: questionData.imageUrl, alt: "Look and match", large: true }) })) : null, items.map((item, itemIndex) => {
49
49
  const selectedWord = item.isExample
50
50
  ? item.correctAnswer
51
51
  : (userAnswers[itemIndex] ?? '');
52
52
  const canSelect = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
53
53
  const isCorrect = normalizeWord(selectedWord) === normalizeWord(item.correctAnswer) &&
54
54
  Boolean(item.correctAnswer);
55
- return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [_jsx(ClientImageItem, { url: item.imageUrl, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}` }), _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsx("p", { className: "text-sm font-semibold text-gray-800", children: "Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p" }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
55
+ return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gradient-to-r from-gray-50 to-white px-4 py-2.5", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-7 w-7 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500 to-indigo-600 text-white", children: _jsx(CircleHelp, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u00E2u ", item.questionNumber || itemIndex + 1] }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex flex-col gap-4 p-4 sm:flex-row sm:items-center", children: [item.imageUrl && !questionData.imageUrl ? (_jsx(ClientImageItem, { url: item.imageUrl, alt: `Hình ảnh câu ${item.questionNumber || itemIndex + 1}` })) : null, _jsxs("div", { className: "min-w-0 flex-1 space-y-2", children: [_jsxs("p", { className: "text-sm font-semibold text-gray-800", children: [item.questionNumber || itemIndex + 1, ". Ch\u1ECDn t\u1EEB ph\u00F9 h\u1EE3p"] }), _jsxs("div", { className: "flex items-center gap-3", children: [item.isExample ? (_jsx("span", { className: "text-sm italic text-gray-800 underline decoration-gray-400", children: item.correctAnswer })) : (_jsxs("select", { value: selectedWord, disabled: !canSelect, onChange: (event) => handleSelect(itemIndex, event.target.value), className: cn('h-10 max-w-xs rounded-md border-2 bg-white px-3 text-sm font-medium', isReviewMode && isCorrect
56
56
  ? 'border-green-400 bg-green-50 text-green-700'
57
57
  : isReviewMode && selectedWord && !isCorrect
58
58
  ? 'border-red-400 bg-red-50 text-red-700'
@@ -60,6 +60,6 @@ export function MatchWordToPictureGroupClient({ questionData, isReviewMode = fal
60
60
  const isUsedByOther = usedWords.has(normalizeWord(word)) &&
61
61
  normalizeWord(word) !== normalizeWord(selectedWord);
62
62
  return (_jsx("option", { value: word, disabled: isUsedByOther, children: word }, `${word}-${wordIndex}`));
63
- })] }), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
63
+ })] })), isReviewMode && !item.isExample && (_jsx("span", { className: "flex items-center gap-1", children: isCorrect ? (_jsx(Check, { className: "h-5 w-5 text-green-500" })) : (_jsx(X, { className: "h-5 w-5 text-red-500" })) }))] }), isReviewMode && !item.isExample && !isCorrect && (_jsxs("p", { className: "text-sm text-green-700", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", _jsx("strong", { children: item.correctAnswer || '—' })] }))] })] })] }, `${item.questionNumber}-${itemIndex}`));
64
64
  }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
65
65
  }
@@ -28,7 +28,9 @@ function sumPoints(items) {
28
28
  }
29
29
  export function MatchWordToPictureGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
30
30
  const seeded = mapQuestionToMatchWordToPictureGroupData(initialData ? { answer: initialData } : null);
31
+ const [title, setTitle] = useState(initialData?.title || '');
31
32
  const [instruction, setInstruction] = useState(initialData?.instruction || seeded.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION);
33
+ const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
32
34
  const [wordBank, setWordBank] = useState(initialData?.wordBank?.length ? initialData.wordBank : seeded.wordBank);
33
35
  const [items, setItems] = useState(() => (initialData?.items?.length ? initialData.items : seeded.items));
34
36
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
@@ -39,8 +41,10 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
39
41
  useEffect(() => {
40
42
  onChangeRef.current = onChange;
41
43
  }, [onChange]);
42
- const buildPayload = (nextInstruction = instruction, nextWordBank = wordBank, nextItems = items, nextExplanation = explanation) => ({
44
+ const buildPayload = (nextTitle = title, nextInstruction = instruction, nextImageUrl = imageUrl, nextWordBank = wordBank, nextItems = items, nextExplanation = explanation) => ({
45
+ ...(nextTitle.trim() ? { title: nextTitle } : {}),
43
46
  instruction: nextInstruction,
47
+ ...(nextImageUrl.trim() ? { imageUrl: nextImageUrl } : {}),
44
48
  wordBank: nextWordBank,
45
49
  items: nextItems,
46
50
  explanation: nextExplanation,
@@ -57,7 +61,7 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
57
61
  debouncedOnChange(payload);
58
62
  onUnsavedChangesChange?.(true);
59
63
  }
60
- }, [instruction, wordBank, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
64
+ }, [title, instruction, imageUrl, wordBank, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
61
65
  const validate = () => {
62
66
  const nextErrors = [];
63
67
  if (!instruction.trim())
@@ -68,8 +72,9 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
68
72
  if (items.length < 1)
69
73
  nextErrors.push('Cần ít nhất một câu hỏi.');
70
74
  items.forEach((item, index) => {
71
- if (!item.imageUrl.trim())
72
- nextErrors.push(`Câu ${index + 1}: cần ảnh.`);
75
+ if (!imageUrl.trim() && !item.imageUrl?.trim()) {
76
+ nextErrors.push(`Câu ${index + 1}: cần ảnh (hoặc ảnh chung).`);
77
+ }
73
78
  if (!item.correctAnswer.trim())
74
79
  nextErrors.push(`Câu ${index + 1}: cần chọn từ đúng.`);
75
80
  });
@@ -83,13 +88,16 @@ export function MatchWordToPictureGroupCreator({ initialData, onChange, external
83
88
  getFormData: () => buildPayload(),
84
89
  };
85
90
  }
86
- }, [validationRef, instruction, wordBank, items, explanation]);
91
+ }, [validationRef, title, instruction, imageUrl, wordBank, items, explanation]);
87
92
  const updateItem = (index, patch) => {
88
93
  setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
89
94
  };
90
95
  const currentPayload = buildPayload();
91
96
  const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
92
- return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i t\u1EEB v\u1EDBi tranh (nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Word bank d\u00F9ng chung; m\u1ED7i d\u00F2ng m\u1ED9t tranh." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-5", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchWordToPictureGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Word bank" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "mt-1", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), 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}` }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm t\u1EEB"] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm tranh"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-3", 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, {
97
+ return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i t\u1EEB v\u1EDBi tranh (nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "Word bank d\u00F9ng chung. C\u00F3 th\u1EC3 1 \u1EA3nh chung + \u00F4 s\u1ED1, ho\u1EB7c m\u1ED7i d\u00F2ng m\u1ED9t tranh." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: _jsx("ul", { className: "list-disc space-y-1 pl-5", children: displayErrors.map((err, i) => _jsx("li", { children: err }, i)) }) })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchWordToPictureGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Word bank" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwtp-group-title", className: "mt-1", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwtp-group-instruction", children: "H\u01B0\u1EDBng d\u1EABn" }), _jsx(Input, { id: "mwtp-group-instruction", className: "mt-1", value: instruction, onChange: (e) => setInstruction(e.target.value) })] }), _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}` }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setWordBank((prev) => prev.filter((_, i) => i !== index)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setWordBank((prev) => [...prev, '']), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm t\u1EEB"] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Tranh (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setItems((prev) => [...prev, createEmptyMatchWordToPictureGroupItem(prev.length + 1)]), children: [_jsx(Plus, { className: "mr-1 h-4 w-4" }), "Th\u00EAm tranh"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("span", { className: "text-sm font-medium", children: ["C\u00E2u ", item.questionNumber || index + 1] }), _jsxs("div", { className: "flex items-center gap-3", 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, {
93
98
  points: parsePointsValue(event.target.value, true),
94
- }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setItems((prev) => prev.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, questionNumber: i + 1 }))), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] })] }), _jsx(MatchWordToPictureGroupImageField, { id: `mwtp-group-image-${index}`, label: "\u1EA2nh", value: item.imageUrl, onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("select", { className: "rounded-md border px-3 py-2 text-sm", value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), wordBank.filter((word) => word.trim()).map((word) => (_jsx("option", { value: word, children: word }, word)))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { value: explanation, onChange: (event) => setExplanation(event.target.value), rows: 3 }) })] })] }))] }));
99
+ }) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setItems((prev) => prev.filter((_, i) => i !== index).map((entry, i) => ({ ...entry, questionNumber: i + 1 }))), children: _jsx(Trash2, { className: "h-4 w-4 text-red-500" }) }))] })] }), _jsx(MatchWordToPictureGroupImageField, { id: `mwtp-group-image-${index}`, label: "\u1EA2nh", value: item.imageUrl, onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("select", { className: "rounded-md border px-3 py-2 text-sm", value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), Array.from(new Set([
100
+ ...wordBank.map((word) => word.trim()).filter(Boolean),
101
+ item.correctAnswer.trim(),
102
+ ].filter(Boolean))).map((word) => (_jsx("option", { value: word, children: word }, word)))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { value: explanation, onChange: (event) => setExplanation(event.target.value), rows: 3 }) })] })] }))] }));
95
103
  }
@@ -57,10 +57,14 @@ export function mapQuestionToMatchWordToPictureGroupData(question) {
57
57
  : asStringList(answer.wordBank).length
58
58
  ? asStringList(answer.wordBank)
59
59
  : Array.from(new Set(items.map((item) => item.correctAnswer).filter(Boolean)));
60
+ const title = asString(meta.title) || asString(answer.title);
61
+ const imageUrl = asString(meta.imageUrl).trim() || asString(answer.imageUrl).trim();
60
62
  return {
63
+ ...(title ? { title } : {}),
61
64
  instruction: asString(meta.instruction) ||
62
65
  asString(answer.instruction) ||
63
66
  MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
67
+ ...(imageUrl ? { imageUrl } : {}),
64
68
  wordBank,
65
69
  items,
66
70
  explanation,
@@ -15,7 +15,9 @@ export const transformMatchWordToPictureGroup = (question) => {
15
15
  const apiItems = items.map((item, index) => ({
16
16
  questionType: MATCH_WORD_TO_PICTURE_ITEM_TYPE,
17
17
  ...(item.isExample ? { isExample: true } : {}),
18
- content: { imageUrl: item.imageUrl || '' },
18
+ content: {
19
+ ...(item.imageUrl?.trim() ? { imageUrl: item.imageUrl.trim() } : {}),
20
+ },
19
21
  correctAnswer: { answer: item.correctAnswer || '' },
20
22
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
21
23
  questionNumber: item.questionNumber || index + 1,
@@ -24,6 +26,12 @@ export const transformMatchWordToPictureGroup = (question) => {
24
26
  apiContent: {
25
27
  meta: {
26
28
  instruction: answerData?.instruction || MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION,
29
+ ...(typeof answerData?.title === 'string' && answerData.title.trim()
30
+ ? { title: answerData.title.trim() }
31
+ : {}),
32
+ ...(typeof answerData?.imageUrl === 'string' && answerData.imageUrl.trim()
33
+ ? { imageUrl: answerData.imageUrl.trim() }
34
+ : {}),
27
35
  wordBank,
28
36
  },
29
37
  items: apiItems,
@@ -1,11 +1,13 @@
1
1
  export interface MatchingWithLinesBoardLeftItem {
2
- imageUrl: string;
2
+ imageUrl?: string;
3
+ text?: string;
3
4
  isExample?: boolean;
4
5
  questionNumber?: number;
5
6
  }
6
7
  export interface MatchingWithLinesBoardRightItem {
7
8
  id: string;
8
- text: string;
9
+ text?: string;
10
+ imageUrl?: string;
9
11
  }
10
12
  interface MatchingWithLinesBoardProps {
11
13
  leftItems: MatchingWithLinesBoardLeftItem[];
@@ -1,5 +1,5 @@
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 { useCallback, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { Star } from 'lucide-react';
5
5
  import { cn } from '../../../../shared/lib/utils';
@@ -94,7 +94,7 @@ export function MatchingWithLinesBoard({ leftItems, rightItems, values, correctV
94
94
  return;
95
95
  assignPair(selectedLeft, rightId);
96
96
  };
97
- return (_jsxs("div", { ref: containerRef, className: "relative", children: [_jsx("svg", { className: "pointer-events-none absolute inset-0 h-full w-full", "aria-hidden": true, children: lines.map((line, index) => (_jsx("line", { x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: line.color, strokeWidth: 3, strokeLinecap: "round" }, `${line.x1}-${line.y1}-${index}`))) }), _jsxs("div", { className: "flex justify-between gap-6 sm:gap-10", children: [_jsx("div", { className: "flex w-max flex-col gap-2", children: leftItems.map((item, index) => (_jsxs("button", { type: "button", onClick: () => handleLeftClick(index), disabled: locked || item.isExample, "aria-label": `Connect left item ${index + 1}`, "aria-pressed": selectedLeft === index, className: cn('box-border flex h-14 w-max items-center gap-2 rounded-lg border bg-white px-1.5 text-left shadow-sm', item.isExample ? 'border-amber-200' : 'border-gray-200', selectedLeft === index && 'ring-2 ring-teal-500', !locked && !item.isExample && 'cursor-pointer hover:border-teal-400'), children: [_jsxs("div", { className: "relative size-11 shrink-0 overflow-hidden rounded-md bg-gray-50", children: [_jsx(ResolvedImage, { src: item.imageUrl, alt: `Match item ${item.questionNumber || index + 1}`, className: "h-full w-full object-contain" }), item.isExample && (_jsxs("span", { className: "absolute left-0.5 top-0.5 inline-flex items-center gap-0.5 rounded-full bg-amber-100 px-1 py-0.5 text-[10px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Ex"] }))] }), _jsx("span", { ref: (node) => {
97
+ return (_jsxs("div", { ref: containerRef, className: "relative", children: [_jsx("svg", { className: "pointer-events-none absolute inset-0 h-full w-full", "aria-hidden": true, children: lines.map((line, index) => (_jsx("line", { x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2, stroke: line.color, strokeWidth: 3, strokeLinecap: "round" }, `${line.x1}-${line.y1}-${index}`))) }), _jsxs("div", { className: "flex justify-between gap-6 sm:gap-10", children: [_jsx("div", { className: "flex w-max flex-col gap-2", children: leftItems.map((item, index) => (_jsxs("button", { type: "button", onClick: () => handleLeftClick(index), disabled: locked || item.isExample, "aria-label": `Connect left item ${item.text || index + 1}`, "aria-pressed": selectedLeft === index, className: cn('box-border flex h-auto min-h-14 w-max items-center gap-2 rounded-lg border bg-white px-1.5 py-1 text-left shadow-sm', item.isExample ? 'border-amber-200' : 'border-gray-200', selectedLeft === index && 'ring-2 ring-teal-500', !locked && !item.isExample && 'cursor-pointer hover:border-teal-400'), children: [item.imageUrl ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "relative size-11 shrink-0 overflow-hidden rounded-md bg-gray-50", children: [_jsx(ResolvedImage, { src: item.imageUrl, alt: item.text || `Match item ${item.questionNumber || index + 1}`, className: "h-full w-full object-contain" }), item.isExample && (_jsxs("span", { className: "absolute left-0.5 top-0.5 inline-flex items-center gap-0.5 rounded-full bg-amber-100 px-1 py-0.5 text-[10px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Ex"] }))] }), item.text?.trim() ? (_jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: item.text })) : null] })) : (_jsxs("span", { className: "relative whitespace-nowrap pl-1 text-sm font-medium text-gray-800", children: [item.isExample && (_jsxs("span", { className: "mr-1 inline-flex items-center gap-0.5 rounded-full bg-amber-100 px-1 py-0.5 text-[10px] font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Ex"] })), item.text || '—'] })), _jsx("span", { ref: (node) => {
98
98
  leftDotRefs.current[index] = node;
99
99
  }, className: cn('h-4 w-4 shrink-0 rounded-full border-2 bg-white', selectedLeft === index
100
100
  ? 'border-teal-600 bg-teal-500'
@@ -102,8 +102,8 @@ export function MatchingWithLinesBoard({ leftItems, rightItems, values, correctV
102
102
  ? 'border-teal-600'
103
103
  : 'border-gray-400'), "aria-hidden": true })] }, `left-${index}`))) }), _jsx("div", { className: "flex w-max flex-col gap-2", children: rightItems.map((item, index) => {
104
104
  const used = values.includes(item.id);
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-14 w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 text-left shadow-sm', selectedLeft !== null && !locked && 'cursor-pointer hover:border-teal-400', used && 'border-teal-200'), children: [_jsx("span", { ref: (node) => {
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 }), _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" }) })) : (_jsx("span", { className: "whitespace-nowrap text-sm font-medium text-gray-800", children: item.text || '—' }))] }, item.id || `right-${index}`));
108
108
  }) })] })] }));
109
109
  }
@@ -1,7 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Lightbulb } from 'lucide-react';
3
+ import { BookOpen, Lightbulb } from 'lucide-react';
4
+ import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
5
+ import { ResolvedImage } from '../../../../components/common/ResolvedImage';
4
6
  import { MatchingWithLinesBoard } from './MatchingWithLinesBoard';
7
+ function ClientAudio({ url }) {
8
+ const { previewUrl, isLoading } = usePresignedFileUrl(url);
9
+ const src = previewUrl ||
10
+ (typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
11
+ if (!src && !isLoading)
12
+ return null;
13
+ return (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50/70 px-4 py-3", children: isLoading ? (_jsx("div", { className: "h-8 w-full animate-pulse rounded bg-indigo-100" })) : (_jsx("audio", { controls: true, src: src, className: "w-full", children: _jsx("track", { kind: "captions" }) })) }));
14
+ }
5
15
  export function MatchingWithLinesGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, onAnswersChange, }) {
6
16
  const items = questionData.items || [];
7
17
  const rightItems = questionData.rightItems || [];
@@ -22,8 +32,9 @@ export function MatchingWithLinesGroupClient({ questionData, isReviewMode = fals
22
32
  }
23
33
  });
24
34
  };
25
- return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
35
+ return (_jsxs("div", { className: "space-y-4", children: [(questionData.title || questionData.instruction) && (_jsxs("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3", children: [questionData.title && (_jsx("p", { className: "text-xs font-semibold uppercase tracking-wide text-indigo-500", children: questionData.title })), questionData.instruction && (_jsx("p", { className: "text-sm font-medium text-indigo-800", children: questionData.instruction }))] })), questionData.passage ? (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })) : null, questionData.audioUrl ? _jsx(ClientAudio, { url: questionData.audioUrl }) : null, questionData.imageUrl ? (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(ResolvedImage, { src: questionData.imageUrl, alt: "Match passage image", className: "max-h-44 w-auto rounded-md object-contain" }) })) : null, _jsx(MatchingWithLinesBoard, { leftItems: items.map((item) => ({
26
36
  imageUrl: item.imageUrl,
37
+ text: item.text,
27
38
  isExample: item.isExample,
28
39
  questionNumber: item.questionNumber,
29
40
  })), rightItems: rightItems, values: values, correctValues: isReviewMode ? correctValues : undefined, onChange: handleChange, isReviewMode: isReviewMode, disabled: isReviewMode || !canEdit }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));