@tinyweb_dev/oe-exam-sdk 1.0.0 → 1.0.1

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 (70) hide show
  1. package/dist/api/exam-questions.d.ts +1 -1
  2. package/dist/api/exam-questions.js +39 -1
  3. package/dist/api/exam-questions.types.d.ts +28 -0
  4. package/dist/api/exam-taking.types.d.ts +1 -0
  5. package/dist/components/exams/ExamCreator.d.ts +7 -1
  6. package/dist/components/exams/ExamCreator.js +57 -21
  7. package/dist/components/exams/ExamPreviewDialog.js +4 -2
  8. package/dist/components/exams/ExamQuestionsPageContainer.d.ts +5 -0
  9. package/dist/components/exams/ExamQuestionsPageContainer.js +2 -2
  10. package/dist/components/exams/api.d.ts +1 -1
  11. package/dist/components/exams/index.d.ts +1 -1
  12. package/dist/components/exams/take/components/QuestionRenderer.d.ts +6 -1
  13. package/dist/components/exams/take/components/QuestionRenderer.js +4 -4
  14. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
  15. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
  16. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.d.ts +7 -1
  17. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +18 -9
  18. package/dist/components/exams/take/exam-taking.utils.d.ts +1 -0
  19. package/dist/components/exams/take/exam-taking.utils.js +1 -0
  20. package/dist/components/exams/take/index.d.ts +6 -0
  21. package/dist/components/exams/take/index.js +5 -0
  22. package/dist/components/exams/take/utils/question-transformers.d.ts +9 -1
  23. package/dist/components/exams/take/utils/question-transformers.js +68 -4
  24. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
  25. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
  26. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
  27. package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
  28. package/dist/components/questions/_shared/types/question-group.type.js +4 -1
  29. package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
  30. package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
  31. package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
  32. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
  33. package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
  34. package/dist/components/questions/question-bank/QuestionBankPickerDialog.d.ts +45 -0
  35. package/dist/components/questions/question-bank/QuestionBankPickerDialog.js +209 -0
  36. package/dist/components/questions/question-bank/QuestionBankPickerRow.d.ts +9 -0
  37. package/dist/components/questions/question-bank/QuestionBankPickerRow.js +20 -0
  38. package/dist/components/questions/question-bank/index.d.ts +1 -0
  39. package/dist/components/questions/question-bank/index.js +1 -0
  40. package/dist/components/questions/question-bank/question-bank-picker.utils.d.ts +55 -0
  41. package/dist/components/questions/question-bank/question-bank-picker.utils.js +408 -0
  42. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
  43. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
  44. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
  45. package/dist/components/questions/types/fill-in-blank/register.js +5 -0
  46. package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
  47. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
  48. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
  49. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
  50. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
  51. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
  52. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
  53. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +8 -1
  54. package/dist/components/questions/types/word-fill-structured-form/transform.js +4 -0
  55. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
  56. package/dist/components/questions/viewer/QuestionViewer.js +12 -1
  57. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
  58. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
  59. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  60. package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
  61. package/dist/index.d.ts +4 -3
  62. package/dist/index.js +2 -1
  63. package/dist/shared/constants/ApiConstant.d.ts +1 -0
  64. package/dist/shared/constants/ApiConstant.js +1 -0
  65. package/dist/shared/lib/rich-text.d.ts +4 -0
  66. package/dist/shared/lib/rich-text.js +25 -0
  67. package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
  68. package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
  69. package/dist/shared/lib/utils/question-transform.js +1 -1
  70. package/package.json +1 -1
@@ -0,0 +1,408 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useMemo } from 'react';
4
+ import { BookOpen, FileText, Grid3X3, Image as ImageIcon, Volume2, } from 'lucide-react';
5
+ import { Label } from '../../../components/ui/label';
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '../../../components/ui/select';
7
+ export const QUESTION_BANK_FILTER_ALL_VALUE = '__all__';
8
+ /** Keep selected id visible even before cascade options finish loading. */
9
+ export function mergeFilterOptions(options, selectedId, selectedLabel) {
10
+ const id = selectedId.trim();
11
+ if (!id)
12
+ return options;
13
+ if (options.some((opt) => opt.id === id))
14
+ return options;
15
+ return [{ id, name: selectedLabel?.trim() || id }, ...options];
16
+ }
17
+ export function FilterSelect({ label, value, options, onChange, disabled, selectedLabel, }) {
18
+ const mergedOptions = useMemo(() => mergeFilterOptions(options, value, selectedLabel), [options, value, selectedLabel]);
19
+ return (_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { className: "text-[11px] font-semibold text-gray-500", children: label }), _jsxs(Select, { value: value || QUESTION_BANK_FILTER_ALL_VALUE, onValueChange: (v) => onChange(v === QUESTION_BANK_FILTER_ALL_VALUE ? '' : v), disabled: disabled, children: [_jsx(SelectTrigger, { className: "h-9 w-full bg-white", children: _jsx(SelectValue, { placeholder: `Tất cả ${label.toLowerCase()}` }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: QUESTION_BANK_FILTER_ALL_VALUE, children: "T\u1EA5t c\u1EA3" }), mergedOptions.map((opt) => (_jsx(SelectItem, { value: opt.id, children: opt.name }, opt.id)))] })] })] }));
20
+ }
21
+ /** Map plain string list (e.g. questionTypes) → FilterSelect options. */
22
+ export function toStringOptions(values) {
23
+ return values.map((value) => ({ id: value, name: questionTypeLabel(value) }));
24
+ }
25
+ /** Vietnamese labels for the most common question types in the bank. */
26
+ const QUESTION_TYPE_LABELS = {
27
+ CHOOSE_THE_CORRECT_ANSWER: 'Chọn đáp án đúng',
28
+ CHOOSE_THE_CORRECT_ANSWER_GROUP: 'Chọn đáp án đúng (nhóm)',
29
+ FILL_IN_BLANK: 'Điền vào chỗ trống',
30
+ FILL_IN_BLANK_GROUP: 'Điền chỗ trống (nhóm)',
31
+ WORD_FILL_STRUCTURED_FORM: 'Điền từ / cấu trúc',
32
+ READ_PASSAGE_AND_ANSWER_QUESTIONS: 'Đọc hiểu',
33
+ READ_DISPLAYED_CONTENT: 'Đọc nội dung hiển thị',
34
+ ANSWER_THE_QUESTION: 'Trả lời câu hỏi',
35
+ MATCH_BY_WRITING_ANSWER: 'Nối bằng cách viết đáp án',
36
+ SPEAKING_DESCRIBE_IMAGE: 'Nói: mô tả tranh',
37
+ SPEAKING_CONVERSATION: 'Nói: hội thoại',
38
+ GN_SPEAKING_INTERVIEW: 'Nói: phỏng vấn',
39
+ LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER: 'Nhìn tranh điền chỗ trống',
40
+ LOOK_PICTURE_CHOOSE_CORRECT_ANSWER: 'Nhìn tranh chọn đáp án',
41
+ LOOK_PICTURE_FILL_WORD_HINT: 'Nhìn tranh điền từ gợi ý',
42
+ LABEL_THE_PICTURE: 'Gắn nhãn hình ảnh',
43
+ MATCH_WORD_TO_PICTURE: 'Nối từ với tranh',
44
+ MATCH_WORD_TO_PICTURE_GROUP: 'Nối từ với tranh (nhóm)',
45
+ MATCHING_WITH_LINES_GROUP: 'Nối bằng đường kẻ (nhóm)',
46
+ MATCH_COLUMNS_TO_MAKE_SENTENCES: 'Ghép cột thành câu',
47
+ WORD_ORDERING_GROUP: 'Sắp xếp từ (nhóm)',
48
+ WORD_ORDER_AND_MATCH_GROUP: 'Sắp xếp từ & nối (nhóm)',
49
+ SORT_WORDS_INTO_CATEGORIES: 'Sắp xếp từ vào nhóm',
50
+ CHOOSE_THEN_ANSWER_GROUP: 'Chọn rồi trả lời (nhóm)',
51
+ ANSWER_THE_QUESTION_GROUP: 'Trả lời câu hỏi (nhóm)',
52
+ TRUE_FALSE_GROUP: 'Đúng / Sai (nhóm)',
53
+ TRUE_FALSE_CORRECT_GROUP: 'Đúng / Sai & sửa (nhóm)',
54
+ CROSS_OUT_WORD_GROUP: 'Gạch từ thừa (nhóm)',
55
+ FILL_MISSING_WORDS_IN_GRID: 'Điền chữ trong bảng',
56
+ CROSSWORD_PUZZLE: 'Ô chữ',
57
+ FIND_WORDS_IN_MATRIX: 'Tìm từ trong bảng',
58
+ WRITE_A_SHORT_LETTER: 'Viết thư ngắn',
59
+ WRITE_SHORT_PARAGRAPH: 'Viết đoạn văn ngắn',
60
+ WRITE_CORRECT_VERB_FORM: 'Viết dạng động từ',
61
+ SIMPLE_ANSWER: 'Trả lời ngắn',
62
+ SPONTANEOUS_QA_GROUP: 'Hỏi đáp tự do (nhóm)',
63
+ };
64
+ export function questionTypeLabel(type) {
65
+ return QUESTION_TYPE_LABELS[type] ?? type.replace(/_/g, ' ').toLowerCase();
66
+ }
67
+ var QuestionTypeVisualKind;
68
+ (function (QuestionTypeVisualKind) {
69
+ QuestionTypeVisualKind["IMAGE"] = "image";
70
+ QuestionTypeVisualKind["PASSAGE"] = "passage";
71
+ QuestionTypeVisualKind["GRID"] = "grid";
72
+ QuestionTypeVisualKind["TEXT"] = "text";
73
+ })(QuestionTypeVisualKind || (QuestionTypeVisualKind = {}));
74
+ const TYPE_VISUAL_KIND = {
75
+ LOOK_PICTURE_FILL_BLANK_CHOOSE_ANSWER: QuestionTypeVisualKind.IMAGE,
76
+ LOOK_PICTURE_CHOOSE_CORRECT_ANSWER: QuestionTypeVisualKind.IMAGE,
77
+ LOOK_PICTURE_FILL_WORD_HINT: QuestionTypeVisualKind.IMAGE,
78
+ LABEL_THE_PICTURE: QuestionTypeVisualKind.IMAGE,
79
+ MATCH_WORD_TO_PICTURE: QuestionTypeVisualKind.IMAGE,
80
+ MATCH_WORD_TO_PICTURE_GROUP: QuestionTypeVisualKind.IMAGE,
81
+ MATCHING_WITH_LINES_GROUP: QuestionTypeVisualKind.IMAGE,
82
+ READ_PASSAGE_AND_ANSWER_QUESTIONS: QuestionTypeVisualKind.PASSAGE,
83
+ READ_PASSAGE_AND_COMPLETE_STORY: QuestionTypeVisualKind.PASSAGE,
84
+ FILL_MISSING_WORDS_IN_GRID: QuestionTypeVisualKind.GRID,
85
+ CROSSWORD_PUZZLE: QuestionTypeVisualKind.GRID,
86
+ FIND_WORDS_IN_MATRIX: QuestionTypeVisualKind.GRID,
87
+ };
88
+ /** Icon + tint per question type family — quick visual scanning in lists. */
89
+ export function QuestionTypeIcon({ type }) {
90
+ const kind = TYPE_VISUAL_KIND[type] ?? QuestionTypeVisualKind.TEXT;
91
+ switch (kind) {
92
+ case QuestionTypeVisualKind.IMAGE:
93
+ return _jsx(ImageIcon, { className: "h-4 w-4 text-blue-500" });
94
+ case QuestionTypeVisualKind.PASSAGE:
95
+ return _jsx(BookOpen, { className: "h-4 w-4 text-green-600" });
96
+ case QuestionTypeVisualKind.GRID:
97
+ return _jsx(Grid3X3, { className: "h-4 w-4 text-purple-500" });
98
+ default:
99
+ return _jsx(FileText, { className: "h-4 w-4 text-gray-400" });
100
+ }
101
+ }
102
+ export var QuestionDifficulty;
103
+ (function (QuestionDifficulty) {
104
+ QuestionDifficulty["EASY"] = "EASY";
105
+ QuestionDifficulty["MEDIUM"] = "MEDIUM";
106
+ QuestionDifficulty["HARD"] = "HARD";
107
+ })(QuestionDifficulty || (QuestionDifficulty = {}));
108
+ const DIFFICULTY_BADGE_CLASS = {
109
+ [QuestionDifficulty.EASY]: 'border-green-200 bg-green-50 text-green-700',
110
+ [QuestionDifficulty.MEDIUM]: 'border-amber-200 bg-amber-50 text-amber-700',
111
+ [QuestionDifficulty.HARD]: 'border-red-200 bg-red-50 text-red-700',
112
+ };
113
+ const DIFFICULTY_LABEL = {
114
+ [QuestionDifficulty.EASY]: 'Dễ',
115
+ [QuestionDifficulty.MEDIUM]: 'TB',
116
+ [QuestionDifficulty.HARD]: 'Khó',
117
+ };
118
+ function parseDifficulty(raw) {
119
+ const upper = raw?.trim().toUpperCase();
120
+ if (upper === QuestionDifficulty.EASY)
121
+ return QuestionDifficulty.EASY;
122
+ if (upper === QuestionDifficulty.MEDIUM)
123
+ return QuestionDifficulty.MEDIUM;
124
+ if (upper === QuestionDifficulty.HARD)
125
+ return QuestionDifficulty.HARD;
126
+ return null;
127
+ }
128
+ export function difficultyBadge(raw) {
129
+ const difficulty = parseDifficulty(raw);
130
+ if (!difficulty)
131
+ return null;
132
+ return {
133
+ label: DIFFICULTY_LABEL[difficulty],
134
+ className: DIFFICULTY_BADGE_CLASS[difficulty],
135
+ };
136
+ }
137
+ function firstStringOf(record, keys) {
138
+ for (const key of keys) {
139
+ const value = record[key];
140
+ if (typeof value === 'string' && value.trim())
141
+ return value.trim();
142
+ }
143
+ return null;
144
+ }
145
+ function asRecord(value) {
146
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
147
+ return value;
148
+ }
149
+ return null;
150
+ }
151
+ /** Strip HTML tags / entities so list rows show readable text, not markup. */
152
+ export function stripHtmlToText(raw) {
153
+ let text = raw
154
+ .replace(/<\s*br\s*\/?>/gi, ' ')
155
+ .replace(/<\/\s*(p|div|tr|li|h[1-6]|table)\s*>/gi, ' ')
156
+ .replace(/<[^>]+>/g, ' ')
157
+ .replace(/\{(\d+)\}/g, '___')
158
+ .replace(/&nbsp;/gi, ' ')
159
+ .replace(/&/gi, '&')
160
+ .replace(/</gi, '<')
161
+ .replace(/>/gi, '>')
162
+ .replace(/"/gi, '"')
163
+ .replace(/'/g, "'")
164
+ .replace(/'/gi, "'");
165
+ text = text.replace(/\s+/g, ' ').trim();
166
+ return text;
167
+ }
168
+ const PREVIEW_TEXT_KEYS = [
169
+ 'question',
170
+ 'questionText',
171
+ 'sentence',
172
+ 'text',
173
+ 'passage',
174
+ 'prompt',
175
+ 'instruction',
176
+ 'mainTitle',
177
+ 'title',
178
+ // Pearson WORD_FILL_STRUCTURED_FORM nests HTML under `content`.
179
+ 'content',
180
+ ];
181
+ const PREVIEW_MAX_LEN = 220;
182
+ function clampPreview(text, max = PREVIEW_MAX_LEN) {
183
+ if (text.length <= max)
184
+ return text;
185
+ return `${text.slice(0, max - 1).trimEnd()}…`;
186
+ }
187
+ function optionPreviewLabel(option) {
188
+ if (typeof option === 'string' && option.trim())
189
+ return option.trim();
190
+ const record = asRecord(option);
191
+ if (!record)
192
+ return null;
193
+ return firstStringOf(record, ['text', 'label', 'value']);
194
+ }
195
+ /** Build "A / B / C" snippet from options array when question stem is empty. */
196
+ function optionsPreview(options) {
197
+ if (!Array.isArray(options) || options.length === 0)
198
+ return null;
199
+ const labels = options
200
+ .map(optionPreviewLabel)
201
+ .filter((v) => Boolean(v))
202
+ .slice(0, 4);
203
+ if (labels.length === 0)
204
+ return null;
205
+ return labels.join(' · ');
206
+ }
207
+ function extractFromRecord(record) {
208
+ // Prefer human-readable fields; strip HTML when present.
209
+ for (const key of PREVIEW_TEXT_KEYS) {
210
+ const value = record[key];
211
+ if (typeof value !== 'string' || !value.trim())
212
+ continue;
213
+ const cleaned = stripHtmlToText(value);
214
+ if (cleaned)
215
+ return cleaned;
216
+ }
217
+ // Options-only MCQ (empty question stem).
218
+ const fromOptions = optionsPreview(record.options);
219
+ if (fromOptions)
220
+ return fromOptions;
221
+ // Word bank / labels.
222
+ if (Array.isArray(record.wordBank)) {
223
+ const words = record.wordBank
224
+ .map((w) => {
225
+ if (typeof w === 'string')
226
+ return w;
227
+ const r = asRecord(w);
228
+ return r ? firstStringOf(r, ['text', 'label', 'word']) : null;
229
+ })
230
+ .filter((v) => Boolean(v))
231
+ .slice(0, 5);
232
+ if (words.length > 0)
233
+ return `Từ: ${words.join(', ')}`;
234
+ }
235
+ // Crossword clues.
236
+ const clues = asRecord(record.clues);
237
+ if (clues) {
238
+ const across = Array.isArray(clues.across) ? clues.across : [];
239
+ const firstClue = asRecord(across[0]);
240
+ const clueText = firstClue ? firstStringOf(firstClue, ['clue']) : null;
241
+ if (clueText)
242
+ return clueText;
243
+ }
244
+ // Nested group items / subQuestions.
245
+ const nested = record.items ?? record.subQuestions;
246
+ if (Array.isArray(nested) && nested.length > 0) {
247
+ for (const raw of nested) {
248
+ const item = asRecord(raw);
249
+ if (!item)
250
+ continue;
251
+ const nestedContent = asRecord(item.content) ?? item;
252
+ const sub = extractFromRecord(nestedContent);
253
+ if (sub)
254
+ return sub;
255
+ }
256
+ }
257
+ // meta.instruction (group wrappers).
258
+ const meta = asRecord(record.meta);
259
+ if (meta) {
260
+ const instruction = firstStringOf(meta, ['instruction', 'title']);
261
+ if (instruction)
262
+ return stripHtmlToText(instruction);
263
+ }
264
+ return null;
265
+ }
266
+ /**
267
+ * Human-readable preview for bank list rows.
268
+ * Handles Pearson HTML (`content.content`), group items, options-only MCQ.
269
+ */
270
+ export function previewText(item) {
271
+ const content = item.content;
272
+ if (typeof content === 'string' && content.trim()) {
273
+ const cleaned = stripHtmlToText(content);
274
+ if (cleaned)
275
+ return clampPreview(cleaned);
276
+ }
277
+ const record = asRecord(content);
278
+ if (record) {
279
+ const extracted = extractFromRecord(record);
280
+ if (extracted)
281
+ return clampPreview(extracted);
282
+ }
283
+ // Title often embeds exercise name; better than a bare dash.
284
+ const title = item.title?.trim();
285
+ if (title)
286
+ return clampPreview(stripHtmlToText(title), 120);
287
+ return item.explanation?.trim() || '—';
288
+ }
289
+ /** Prefer content preview over XML-ish titles for the primary row heading. */
290
+ export function primaryDisplayText(item) {
291
+ const preview = previewText(item);
292
+ const title = item.title?.trim() ?? '';
293
+ // Title looks like a source file path → show content instead.
294
+ if (/\.xml\b/i.test(title) || /exercise-\d+/i.test(title)) {
295
+ return preview !== '—' ? preview : title;
296
+ }
297
+ if (title)
298
+ return title;
299
+ return preview;
300
+ }
301
+ function readMetaString(meta, keys) {
302
+ if (!meta)
303
+ return null;
304
+ for (const key of keys) {
305
+ const value = meta[key];
306
+ if (typeof value === 'string' && value.trim())
307
+ return value.trim();
308
+ if (typeof value === 'number' && Number.isFinite(value))
309
+ return String(value);
310
+ }
311
+ return null;
312
+ }
313
+ /** Pretty exercise label: "01-exercise-1.xml" → "Ex 1". */
314
+ function formatExerciseLabel(assetName) {
315
+ if (!assetName)
316
+ return null;
317
+ const match = assetName.match(/exercise[-_]?(\d+)/i) ??
318
+ assetName.match(/^0*(\d+)[-_]/);
319
+ if (match?.[1])
320
+ return `Ex ${Number(match[1])}`;
321
+ // Strip .xml extension as fallback.
322
+ return assetName.replace(/\.xml$/i, '');
323
+ }
324
+ /**
325
+ * Compact provenance for row footer (right side):
326
+ * "Unit 0 · 0.3 Favourites · Ex 1"
327
+ */
328
+ export function provenanceFooterText(item) {
329
+ const meta = asRecord(item.meta);
330
+ const unit = readMetaString(meta, ['unitName', 'unitTitle']) ??
331
+ readMetaString(asRecord(meta?.unit), ['name', 'title']);
332
+ const lesson = readMetaString(meta, ['lessonName', 'lessonTitle']) ??
333
+ readMetaString(asRecord(meta?.lesson), ['name', 'title']);
334
+ const asset = readMetaString(meta, ['assetName', 'assetTitle']) ??
335
+ readMetaString(asRecord(meta?.asset), ['name', 'title']);
336
+ const exercise = formatExerciseLabel(asset);
337
+ const parts = [];
338
+ if (unit) {
339
+ // "0" / bare number → "Unit 0"; already labeled names stay as-is.
340
+ parts.push(/^\d+([.]\d+)?$/.test(unit) ? `Unit ${unit}` : unit);
341
+ }
342
+ if (lesson)
343
+ parts.push(lesson);
344
+ if (exercise)
345
+ parts.push(exercise);
346
+ if (parts.length === 0)
347
+ return null;
348
+ return parts.join(' · ');
349
+ }
350
+ /** Sub-item count for group types — shown as “n câu con”. */
351
+ export function subItemCount(item) {
352
+ const record = asRecord(item.content);
353
+ if (!record)
354
+ return null;
355
+ const items = record.items ?? record.subQuestions;
356
+ if (Array.isArray(items) && items.length > 1)
357
+ return items.length;
358
+ // WORD_FILL blanks counted via {n} placeholders in HTML content.
359
+ const html = typeof record.content === 'string' ? record.content : '';
360
+ if (html) {
361
+ const matches = html.match(/\{\d+\}/g);
362
+ if (matches && matches.length > 1)
363
+ return matches.length;
364
+ }
365
+ return null;
366
+ }
367
+ /** Detect media attachments to surface image/audio hints on list rows. */
368
+ export function contentMediaFlags(item) {
369
+ const record = asRecord(item.content);
370
+ if (!record)
371
+ return { hasImage: false, hasAudio: false };
372
+ const topImage = Boolean(firstStringOf(record, ['imageUrl', 'backgroundImageUrl']));
373
+ const topAudio = Boolean(firstStringOf(record, ['audioUrl']));
374
+ const meta = asRecord(record.meta);
375
+ const metaImage = Boolean(meta && firstStringOf(meta, ['imageUrl', 'backgroundImageUrl']));
376
+ const metaAudio = Boolean(meta && firstStringOf(meta, ['audioUrl']));
377
+ // Options may be image-based.
378
+ let optionImage = false;
379
+ if (Array.isArray(record.options)) {
380
+ optionImage = record.options.some((opt) => {
381
+ const r = asRecord(opt);
382
+ return Boolean(r && firstStringOf(r, ['imageUrl']));
383
+ });
384
+ }
385
+ // Group first item media.
386
+ let nestedImage = false;
387
+ let nestedAudio = false;
388
+ const nested = record.items ?? record.subQuestions;
389
+ if (Array.isArray(nested) && nested.length > 0) {
390
+ const first = asRecord(nested[0]);
391
+ const nestedContent = first ? (asRecord(first.content) ?? first) : null;
392
+ if (nestedContent) {
393
+ nestedImage = Boolean(firstStringOf(nestedContent, ['imageUrl', 'backgroundImageUrl']));
394
+ nestedAudio = Boolean(firstStringOf(nestedContent, ['audioUrl']));
395
+ }
396
+ }
397
+ return {
398
+ hasImage: topImage || metaImage || optionImage || nestedImage,
399
+ hasAudio: topAudio || metaAudio || nestedAudio,
400
+ };
401
+ }
402
+ /** Small inline icons for image/audio presence. */
403
+ export function MediaHints({ item }) {
404
+ const { hasImage, hasAudio } = contentMediaFlags(item);
405
+ if (!hasImage && !hasAudio)
406
+ return null;
407
+ return (_jsxs("span", { className: "inline-flex items-center gap-1 text-gray-400", children: [hasImage ? _jsx(ImageIcon, { className: "h-3.5 w-3.5" }) : null, hasAudio ? _jsx(Volume2, { className: "h-3.5 w-3.5" }) : null] }));
408
+ }
@@ -12,10 +12,10 @@ import { RadioGroup } from '../../../../components/ui/radio-group';
12
12
  import { Switch } from '../../../../components/ui/switch';
13
13
  import { usePresignedFileUrl, useDebouncedCallback } from '../../../../shared/lib/hooks';
14
14
  import { useReactHookForm } from '../../../../shared/lib/hooks/useReactHookForm';
15
- import { Pencil, Eye, Plus, Trash2, HelpCircle, CheckCircle2, Lightbulb, MessageSquare, ImageIcon, Type, Users, BookOpen, Star, ListChecks } from 'lucide-react';
15
+ import { Pencil, Eye, Plus, Trash2, HelpCircle, CheckCircle2, Lightbulb, MessageSquare, ImageIcon, Type, Users, BookOpen, Star, ListChecks, Pilcrow } from 'lucide-react';
16
16
  import { FileUpload } from '../../../../components/ui/file-upload';
17
17
  import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
18
- import { isRichTextEmpty } from '../../../../shared/lib/rich-text';
18
+ import { hasRichTextFormatting, htmlToPlainEditorText, isRichTextEmpty } from '../../../../shared/lib/rich-text';
19
19
  import { ChooseTheCorrectAnswerClient } from './ChooseTheCorrectAnswerClient';
20
20
  import { normalizeChooseAnswerCorrectAnswer, normalizeChooseAnswerOptions, pickRawChooseAnswer, } from './normalize';
21
21
  import { chooseCorrectAnswerFormSchema, } from '../../../../shared/lib/validation/choose-correct-answer.validation';
@@ -24,6 +24,24 @@ import { ArticlesGroupCard } from '../../groups/ArticlesGroupCard';
24
24
  import { DocumentGroupCard } from '../../groups/DocumentGroupCard';
25
25
  import { useBasicQuestionGroup } from '../../_shared/hooks/useBasicQuestionGroup';
26
26
  import { z } from 'zod';
27
+ function shouldStartInTipTapMode(apiData) {
28
+ const question = apiData?.content?.question || apiData?.question || '';
29
+ if (hasRichTextFormatting(question))
30
+ return true;
31
+ const rawOptions = apiData?.content?.options || apiData?.options;
32
+ if (!Array.isArray(rawOptions))
33
+ return false;
34
+ return rawOptions.some((option) => hasRichTextFormatting(typeof option === 'string' ? option : option?.text));
35
+ }
36
+ function ChooseAnswerTextInput({ value, onChange, isTipTapMode, placeholder, compact = false, }) {
37
+ if (isTipTapMode) {
38
+ return (_jsx(RichTextEditor, { value: value, onChange: onChange, variant: "compact", minHeightClassName: compact ? 'min-h-[72px]' : 'min-h-[100px]' }));
39
+ }
40
+ if (compact) {
41
+ return (_jsx(Input, { value: htmlToPlainEditorText(value), onChange: (event) => onChange(event.target.value), placeholder: placeholder, className: "border-gray-200 bg-white" }));
42
+ }
43
+ return (_jsx(Textarea, { value: htmlToPlainEditorText(value), onChange: (event) => onChange(event.target.value), placeholder: placeholder, rows: 3, className: "min-h-[72px] border-gray-200 bg-white" }));
44
+ }
27
45
  // Helper component to resolve presigned URL for option image preview (same pattern as Client's OptionImage)
28
46
  function OptionImagePreview({ src, alt }) {
29
47
  const { previewUrl, isLoading } = usePresignedFileUrl(src);
@@ -115,6 +133,9 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
115
133
  const [isClientMode, setIsClientMode] = useState(false);
116
134
  const [imagePreviewUrl, setImagePreviewUrl] = useState('');
117
135
  const [optionPreviewUrls, setOptionPreviewUrls] = useState({});
136
+ const [isImageInputVisible, setIsImageInputVisible] = useState(() => Boolean(apiData?.content?.imageUrl || apiData?.imageUrl));
137
+ const [isTipTapMode, setIsTipTapMode] = useState(() => shouldStartInTipTapMode(apiData));
138
+ const [isExplanationVisible, setIsExplanationVisible] = useState(() => Boolean(apiData?.explanation));
118
139
  // ============ GROUP (shared title / content / image) ============
119
140
  const { isGrouped, currentGroupNumber, isFirstInGroup, groupTitle, groupSubtitle, groupContent, imageUrl: groupImageUrl, articles, setGroupTitle, setGroupSubtitle, setGroupContent, setImageUrl: setGroupImageUrl, setArticles, } = useBasicQuestionGroup({
120
141
  partId,
@@ -426,9 +447,12 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
426
447
  form.setValue('correctAnswer', enabled ? normalized : (normalized[0] ?? -1), { shouldValidate: true });
427
448
  }, className: `inline-flex items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${watchedMultipleAnswers
428
449
  ? 'border-violet-300 bg-violet-50 text-violet-700'
429
- : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'} ${Boolean(multipleAnswers) ? 'cursor-not-allowed opacity-50' : ''}`, children: [_jsx(ListChecks, { className: "h-4 w-4" }), "Nhi\u1EC1u \u0111\u00E1p \u00E1n"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(RichTextEditor, { value: watchedQuestion, onChange: (html) => form.setValue('question', html, { shouldValidate: true }), variant: "compact", minHeightClassName: "min-h-[100px]" }), form.hasError('question') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('question') }))] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-indigo-500" }), "H\u00ECnh \u1EA3nh ", _jsx("span", { className: "text-sm font-normal text-gray-500" })] }), _jsx(FileUpload, { id: "question-image", label: "", accept: "image/*", value: watchedImageUrl || '', onChange: (url) => {
450
+ : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'} ${Boolean(multipleAnswers) ? 'cursor-not-allowed opacity-50' : ''}`, children: [_jsx(ListChecks, { className: "h-4 w-4" }), "Nhi\u1EC1u \u0111\u00E1p \u00E1n"] }), _jsxs("button", { type: "button", style: { paddingBlock: 'calc(var(--spacing) * 1)' }, onClick: () => setIsTipTapMode((enabled) => !enabled), title: isTipTapMode ? 'Chuyển về ô nhập thường' : 'Bật định dạng chữ (in đậm, danh sách, bảng)', className: `inline-flex items-center gap-2 rounded-md border px-3.5 text-sm font-medium transition-colors select-none ${isTipTapMode
451
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-700'
452
+ : 'border-gray-200 bg-white text-gray-400 hover:text-gray-600'}`, children: [_jsx(Pilcrow, { className: "h-4 w-4" }), "\u0110\u1ECBnh d\u1EA1ng"] })] })] }), viewMode === 'CONVERSATION' && (_jsxs("div", { className: "space-y-3", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Users, { className: "h-4 w-4 text-indigo-500" }), "H\u1ED9i tho\u1EA1i ", _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn - d\u00F9ng cho c\u00E2u h\u1ECFi d\u1EA1ng h\u1ED9i tho\u1EA1i)" })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "askerName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "askerName", ...form.register('askerName'), placeholder: "VD: John, Mary, Teacher...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('askerName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('askerName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('askerName') }))] }), _jsxs("div", { className: "space-y-1.5", children: [_jsxs(Label, { htmlFor: "answererName", className: "flex items-center gap-1 text-sm font-medium text-gray-600", children: ["T\u00EAn ng\u01B0\u1EDDi tr\u1EA3 l\u1EDDi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Input, { id: "answererName", ...form.register('answererName'), placeholder: "VD: Tom, Anna, Student...", className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${form.hasError('answererName') ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), form.hasError('answererName') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('answererName') }))] })] })] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('question')) && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { htmlFor: "question", className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(MessageSquare, { className: "h-4 w-4 text-indigo-500" }), _jsx("span", { className: "flex items-center gap-1", children: "C\u00E2u h\u1ECFi" }), _jsx("span", { className: "text-sm font-normal text-gray-500", children: "(tu\u1EF3 ch\u1ECDn)" })] }), _jsx(ChooseAnswerTextInput, { value: watchedQuestion, onChange: (html) => form.setValue('question', html, { shouldValidate: true }), isTipTapMode: isTipTapMode, placeholder: "Nh\u1EADp c\u00E2u h\u1ECFi..." }), form.hasError('question') && (_jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('question') }))] })), ((!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && !isImageInputVisible
453
+ || !isExplanationVisible) && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [(!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && !isImageInputVisible && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsImageInputVisible(true), className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(ImageIcon, { className: "h-4 w-4" }), watchedImageUrl ? 'Hiện hình ảnh' : 'Thêm hình ảnh'] })), !isExplanationVisible && (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsExplanationVisible(true), className: "gap-2 border-amber-200 text-amber-700 hover:bg-amber-50 hover:text-amber-800", children: [_jsx(BookOpen, { className: "h-4 w-4" }), watchedExplanation ? 'Hiện giải thích' : 'Thêm giải thích'] }))] })), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('imageUrl') || questionConfig.includes('imageUrl ')) && isImageInputVisible && (_jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(ImageIcon, { className: "h-4 w-4 text-indigo-500" }), "H\u00ECnh \u1EA3nh"] }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsImageInputVisible(false), className: "h-8 text-gray-500 hover:text-gray-700", children: "\u1EA8n" })] }), _jsx(FileUpload, { id: "question-image", label: "", accept: "image/*", value: watchedImageUrl || '', onChange: (url) => {
430
454
  form.setValue('imageUrl', url, { shouldValidate: true });
431
- }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), form.hasError('imageUrl') && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: form.getFieldError('imageUrl') })), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, index) => (_jsx("li", { children: error }, index))) })] })] })), form.formState.errors.options?.message && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.formState.errors.options.message }) }))] })] }), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('options')) && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-indigo-100", children: _jsx(Lightbulb, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["C\u00E1c l\u1EF1a ch\u1ECDn ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", watchedOptions.length, " \u0111\u00E1p \u00E1n)"] })] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: watchedOptionType === 'image', disabled: true, className: watchedOptionType === 'image' ? '!bg-indigo-600 !opacity-100' : '!bg-indigo-600 !opacity-100', onCheckedChange: (checked) => {
455
+ }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), form.hasError('imageUrl') && (_jsx("p", { className: "mt-1 text-sm text-red-600", children: form.getFieldError('imageUrl') })), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), isExplanationVisible && (_jsxs("div", { className: "relative", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsExplanationVisible(false), className: "absolute right-3 top-3 z-10 h-8 text-gray-500 hover:text-gray-700", children: "\u1EA8n" }), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", 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-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "mt-0.5 text-xs text-gray-500", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { ...form.register('explanation'), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] resize-none border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200" }) })] })] })), externalErrors && externalErrors.length > 0 && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-red-200 bg-gradient-to-r from-red-50 to-rose-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-red-100", children: _jsx("span", { className: "text-lg", children: "\u274C" }) }), _jsxs("div", { className: "text-sm", children: [_jsx("p", { className: "font-semibold text-red-800", children: "L\u1ED7i:" }), _jsx("ul", { className: "mt-1 list-inside list-disc text-red-700", children: externalErrors.map((error, index) => (_jsx("li", { children: error }, index))) })] })] })), form.formState.errors.options?.message && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.formState.errors.options.message }) }))] })] }), (!questionConfig || questionConfig.length === 0 || questionConfig.includes('options')) && (_jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-indigo-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex h-6 w-6 items-center justify-center rounded-md bg-indigo-100", children: _jsx(Lightbulb, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(CardTitle, { className: "text-base font-semibold text-gray-800", children: ["C\u00E1c l\u1EF1a ch\u1ECDn ", _jsxs("span", { className: "text-xs font-normal text-gray-500", children: ["(", watchedOptions.length, " \u0111\u00E1p \u00E1n)"] })] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5", children: [_jsx(Type, { className: `h-3.5 w-3.5 ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}` }), _jsx("span", { className: `text-xs font-medium ${watchedOptionType === 'text' ? 'text-indigo-600' : 'text-gray-400'}`, children: "Text" }), _jsx(Switch, { checked: watchedOptionType === 'image', disabled: true, className: watchedOptionType === 'image' ? '!bg-indigo-600 !opacity-100' : '!bg-indigo-600 !opacity-100', onCheckedChange: (checked) => {
432
456
  const newType = checked ? 'image' : 'text';
433
457
  const currentOptions = form.getValues('options');
434
458
  const currentCorrectAnswer = form.getValues('correctAnswer');
@@ -492,11 +516,11 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
492
516
  setOptionPreviewUrls((prev) => ({ ...prev, [index]: presignedUrl }));
493
517
  }, maxSize: 5, placeholder: `Upload ảnh đáp án ${optionLetter}...`, autoUpload: true, prefix: "questions/options" }), (optionPreviewUrls[index] || option) && (_jsx(OptionImagePreview, { src: optionPreviewUrls[index] || option, alt: `Đáp án ${optionLetter}` }))] })) : (
494
518
  /* Text Input Mode */
495
- _jsx(RichTextEditor, { value: option, onChange: (html) => {
519
+ _jsx(ChooseAnswerTextInput, { value: option, onChange: (html) => {
496
520
  const newOptions = [...watchedOptions];
497
521
  newOptions[index] = html;
498
522
  form.setValue('options', newOptions, { shouldValidate: true });
499
- }, variant: "compact", minHeightClassName: "min-h-[72px]" })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), watchedMultipleAnswers ? 'Đáp án đúng đã chọn' : 'Đáp án đúng'] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
523
+ }, isTipTapMode: isTipTapMode, compact: true, placeholder: `Nhập đáp án ${optionLetter}...` })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), watchedMultipleAnswers ? 'Đáp án đúng đã chọn' : 'Đáp án đúng'] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
500
524
  if (watchedMultipleAnswers) {
501
525
  const currentAnswers = Array.isArray(form.getValues('correctAnswer'))
502
526
  ? form.getValues('correctAnswer')
@@ -551,11 +575,11 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
551
575
  setOptionPreviewUrls((prev) => ({ ...prev, [index]: presignedUrl }));
552
576
  }, maxSize: 5, placeholder: `Upload ảnh đáp án ${optionLetter}...`, autoUpload: true, prefix: "questions/options" }), (optionPreviewUrls[index] || option) && (_jsx(OptionImagePreview, { src: optionPreviewUrls[index] || option, alt: `Đáp án ${optionLetter}` }))] })) : (
553
577
  /* Text Input Mode */
554
- _jsx(RichTextEditor, { value: option, onChange: (html) => {
578
+ _jsx(ChooseAnswerTextInput, { value: option, onChange: (html) => {
555
579
  const newOptions = [...watchedOptions];
556
580
  newOptions[index] = html;
557
581
  form.setValue('options', newOptions, { shouldValidate: true });
558
- }, variant: "compact", minHeightClassName: "min-h-[72px]" })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), "\u0110\u00E1p \u00E1n \u0111\u00FAng"] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
582
+ }, isTipTapMode: isTipTapMode, compact: true, placeholder: `Nhập đáp án ${optionLetter}...` })), optionError && (_jsx("p", { className: "text-sm text-red-600", children: optionError.message })), isCorrect && (_jsxs("span", { className: "inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), "\u0110\u00E1p \u00E1n \u0111\u00FAng"] }))] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
559
583
  form.setValue('correctAnswer', index, { shouldValidate: true });
560
584
  }, className: isCorrect ? 'text-green-600 hover:text-green-700' : 'text-gray-400 hover:text-green-600', title: "Ch\u1ECDn l\u00E0m \u0111\u00E1p \u00E1n \u0111\u00FAng", children: _jsx(CheckCircle2, { className: "h-4 w-4" }) }), watchedOptions.length > 2 && (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => {
561
585
  const currentOptions = form.getValues('options');
@@ -573,7 +597,7 @@ function ChooseTheCorrectAnswerCreatorContent({ initialData, onSave, onCancel, o
573
597
  }) })), ((Array.isArray(watchedCorrectAnswer) && watchedCorrectAnswer.length === 0)
574
598
  || (!Array.isArray(watchedCorrectAnswer) && watchedCorrectAnswer === -1)) && (_jsxs("div", { className: "flex items-start gap-3 rounded-xl border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 p-4", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-amber-100", children: _jsx("span", { className: "text-lg", children: "\u26A0\uFE0F" }) }), _jsxs("div", { className: "text-sm text-amber-800", children: [_jsx("p", { className: "font-semibold", children: "Ch\u01B0a ch\u1ECDn \u0111\u00E1p \u00E1n \u0111\u00FAng" }), _jsx("p", { className: "mt-0.5 text-amber-700", children: watchedMultipleAnswers
575
599
  ? 'Click vào ô chữ cái hoặc icon ✓ để chọn một hoặc nhiều đáp án đúng'
576
- : 'Click vào ô chữ cái hoặc icon ✓ để chọn đáp án đúng' })] })] })), form.hasError('correctAnswer') && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('correctAnswer') }) }))] })] })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-amber-100/50", children: [_jsx(CardHeader, { className: "px-4 py-3", 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-amber-500 to-orange-500 shadow-sm", children: _jsx(BookOpen, { className: "h-3.5 w-3.5 text-white" }) }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(CardTitle, { className: "text-base font-semibold text-gray-800", children: "Gi\u1EA3i th\u00EDch" }), _jsx("span", { className: "inline-flex items-center rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600", children: "Kh\u00F4ng b\u1EAFt bu\u1ED9c" })] }), _jsx("p", { className: "text-xs text-gray-500 mt-0.5", children: "Gi\u1EA3i th\u00EDch cho \u0111\u00E1p \u00E1n \u0111\u00FAng \u2014 hi\u1EC3n th\u1ECB sau khi h\u1ECDc sinh ho\u00E0n th\u00E0nh c\u00E2u h\u1ECFi" })] })] }) }), _jsx(CardContent, { className: "px-4 pb-4", children: _jsx(Textarea, { ...form.register('explanation'), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3, className: "min-h-[80px] border-gray-200 bg-white transition-all focus:border-amber-300 focus:ring-amber-200 resize-none" }) })] })] }));
600
+ : 'Click vào ô chữ cái hoặc icon ✓ để chọn đáp án đúng' })] })] })), form.hasError('correctAnswer') && (_jsx("div", { className: "rounded-xl border border-red-200 bg-red-50 p-4", children: _jsx("p", { className: "text-sm text-red-600", children: form.getFieldError('correctAnswer') }) }))] })] }))] }));
577
601
  }
578
602
  export function ChooseTheCorrectAnswerCreator(props) {
579
603
  return _jsx(ChooseTheCorrectAnswerCreatorContent, { ...props });
@@ -3,6 +3,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useState, useEffect, useMemo } from 'react';
4
4
  import { Input } from '../../../../components/ui/input';
5
5
  import { Check, X, PenLine, AlertTriangle, ClipboardList, Lightbulb, BookOpen, Hash, Volume2, } from 'lucide-react';
6
+ import { ImageLabelWordBank } from '../../groups/ImageLabelWordBank';
7
+ import { hasImageLabels } from '../../_shared/types/question-group.type';
6
8
  import { extractPlaceholderIndices, formatBlankAnswersDisplay, getPrimaryBlankAnswer, isBlankInputCorrect, normalizeBlankAnswers, normalizeFillInBlankAnswers, } from './answer-utils';
7
9
  import { parseFillBlankSegments } from '../../../../shared/lib/utils/fill-in-blank';
8
10
  import { cn } from '../../../../shared/lib/utils';
@@ -31,7 +33,9 @@ export function FillInBlankClient({ questionData, questionConfig, onSubmit, isRe
31
33
  const groupContent = questionData.groupContent || '';
32
34
  const groupImageUrl = questionData.groupImageUrl || '';
33
35
  const groupNumber = questionData.groupNumber;
34
- const hasGroupData = isGroup && (groupTitle || groupContent || groupImageUrl);
36
+ const imageLabels = questionData.imageLabels;
37
+ const hasImageLabelBank = hasImageLabels(imageLabels);
38
+ const hasGroupData = isGroup && (groupTitle || groupContent || groupImageUrl || hasImageLabelBank);
35
39
  // Custom hooks for presigned URL (question-level image, not group image)
36
40
  const { previewUrl: imagePreviewUrl, isLoading: isImageLoading } = usePresignedFileUrl(
37
41
  // Only use question-level imageUrl when NOT grouped (group image is handled separately)
@@ -128,7 +132,7 @@ export function FillInBlankClient({ questionData, questionConfig, onSubmit, isRe
128
132
  const renderGroupBlock = () => {
129
133
  if (!hasGroupData)
130
134
  return null;
131
- return (_jsxs("div", { className: "relative overflow-hidden rounded-2xl border border-indigo-100 bg-white shadow-sm ring-1 ring-black/5 transition-all", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-indigo-50/80 via-white to-blue-50/30 opacity-70 pointer-events-none" }), _jsx("div", { className: "absolute left-0 top-0 bottom-0 w-1.5 bg-gradient-to-b from-indigo-500 to-blue-600 rounded-l-2xl" }), _jsxs("div", { className: "relative p-6 sm:p-7", children: [_jsxs("div", { className: "flex items-start gap-4 mb-5 border-b border-indigo-50/60 pb-4", children: [_jsx("div", { className: "flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-100 to-blue-100 shadow-inner ring-1 ring-indigo-200/50", children: _jsx(BookOpen, { className: "h-5 w-5 text-indigo-600" }) }), _jsxs("div", { className: "flex-1 pt-1", children: [_jsxs("div", { className: "flex items-center gap-2 mb-1", children: [groupNumber && (_jsxs("span", { className: "inline-flex items-center rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-700/10", children: ["Nh\u00F3m ", groupNumber] })), _jsx("span", { className: "inline-flex items-center rounded-md bg-orange-50 px-2 py-1 text-xs font-semibold text-orange-700 ring-1 ring-inset ring-orange-700/10", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" })] }), groupTitle && (_jsx("h4", { className: "text-xl font-bold text-slate-800 leading-tight tracking-tight", children: groupTitle }))] })] }), _jsxs("div", { className: "space-y-6 text-slate-700", children: [groupContent && (_jsx("div", { className: "prose prose-slate prose-p:leading-relaxed prose-p:mb-3 max-w-none text-[15px]", children: groupContent.split('\n').map((line, i) => (_jsx("p", { className: "last:mb-0", children: line }, i))) })), groupImageUrl && (_jsxs("div", { className: "mt-5 relative overflow-hidden rounded-xl border border-slate-200/60 bg-slate-50 shadow-sm transition-all hover:shadow-md", children: [_jsx("div", { className: "flex items-center justify-between border-b border-slate-200/60 bg-slate-100/50 px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("span", { className: "ml-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: "T\u00E0i li\u1EC7u \u0111\u00EDnh k\u00E8m" })] }) }), _jsx("div", { className: "flex items-center justify-center p-4 min-h-[160px]", children: _jsx(GroupImage, { src: groupImageUrl, alt: groupTitle || 'Hình ảnh nhóm' }) })] }))] })] })] }));
135
+ return (_jsxs("div", { className: "relative overflow-hidden rounded-2xl border border-indigo-100 bg-white shadow-sm ring-1 ring-black/5 transition-all", children: [_jsx("div", { className: "absolute inset-0 bg-gradient-to-br from-indigo-50/80 via-white to-blue-50/30 opacity-70 pointer-events-none" }), _jsx("div", { className: "absolute left-0 top-0 bottom-0 w-1.5 bg-gradient-to-b from-indigo-500 to-blue-600 rounded-l-2xl" }), _jsxs("div", { className: "relative p-6 sm:p-7", children: [_jsxs("div", { className: "flex items-start gap-4 mb-5 border-b border-indigo-50/60 pb-4", children: [_jsx("div", { className: "flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-100 to-blue-100 shadow-inner ring-1 ring-indigo-200/50", children: _jsx(BookOpen, { className: "h-5 w-5 text-indigo-600" }) }), _jsxs("div", { className: "flex-1 pt-1", children: [_jsxs("div", { className: "flex items-center gap-2 mb-1", children: [groupNumber && (_jsxs("span", { className: "inline-flex items-center rounded-md bg-indigo-50 px-2 py-1 text-xs font-semibold text-indigo-700 ring-1 ring-inset ring-indigo-700/10", children: ["Nh\u00F3m ", groupNumber] })), _jsx("span", { className: "inline-flex items-center rounded-md bg-orange-50 px-2 py-1 text-xs font-semibold text-orange-700 ring-1 ring-inset ring-orange-700/10", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" })] }), groupTitle && (_jsx("h4", { className: "text-xl font-bold text-slate-800 leading-tight tracking-tight", children: groupTitle }))] })] }), _jsxs("div", { className: "space-y-6 text-slate-700", children: [hasImageLabelBank && (_jsx(ImageLabelWordBank, { items: imageLabels })), groupContent && (_jsx("div", { className: "prose prose-slate prose-p:leading-relaxed prose-p:mb-3 max-w-none text-[15px]", children: groupContent.split('\n').map((line, i) => (_jsx("p", { className: "last:mb-0", children: line }, i))) })), groupImageUrl && (_jsxs("div", { className: "mt-5 relative overflow-hidden rounded-xl border border-slate-200/60 bg-slate-50 shadow-sm transition-all hover:shadow-md", children: [_jsx("div", { className: "flex items-center justify-between border-b border-slate-200/60 bg-slate-100/50 px-4 py-2", children: _jsxs("div", { className: "flex items-center gap-1.5", children: [_jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("div", { className: "h-2.5 w-2.5 rounded-full bg-slate-300" }), _jsx("span", { className: "ml-2 text-[11px] font-semibold uppercase tracking-wider text-slate-500", children: "T\u00E0i li\u1EC7u \u0111\u00EDnh k\u00E8m" })] }) }), _jsx("div", { className: "flex items-center justify-center p-4 min-h-[160px]", children: _jsx(GroupImage, { src: groupImageUrl, alt: groupTitle || 'Hình ảnh nhóm' }) })] }))] })] })] }));
132
136
  };
133
137
  /** Render inline blanks within question text (new format) */
134
138
  const renderQuestionWithBlanks = () => {