@tinyweb_dev/oe-exam-sdk 0.2.6 → 0.2.8

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 (30) hide show
  1. package/dist/components/exams/take/components/QuestionRenderer.js +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversChooseBestAnswerRenderer.js +2 -2
  3. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
  4. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
  5. package/dist/components/exams/take/types.d.ts +12 -5
  6. package/dist/components/exams/take/utils/answer-transformers.js +1 -1
  7. package/dist/components/exams/take/utils/question-transformers.d.ts +1 -0
  8. package/dist/components/exams/take/utils/question-transformers.js +29 -53
  9. package/dist/components/questions/_shared/types/choose-the-correct-answer-group.type.d.ts +2 -0
  10. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
  11. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupClient.js +13 -3
  12. package/dist/components/questions/types/choose-the-correct-answer-group/ChooseTheCorrectAnswerGroupCreator.js +7 -3
  13. package/dist/components/questions/types/choose-the-correct-answer-group/map-choose-the-correct-answer-group-data.js +13 -0
  14. package/dist/components/questions/types/choose-the-correct-answer-group/transform.js +5 -0
  15. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
  16. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
  17. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
  18. package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
  19. package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
  20. package/dist/components/questions/types/fill-in-blank/register.js +19 -6
  21. package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
  22. package/dist/components/results/renderers/ReviewChooseBestAnswerRenderer.js +1 -1
  23. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
  24. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
  25. package/dist/components/results/renderers/review-question-body-dedicated.js +3 -3
  26. package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
  27. package/dist/shared/lib/utils/fill-in-blank.js +96 -0
  28. package/dist/shared/lib/utils/question-reverse-transform.js +68 -3
  29. package/dist/shared/types/questions/choose-the-correct-answer-group.d.ts +2 -0
  30. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { ImagePreview } from '../../../../components/ui/image-preview';
4
- import { useState, useRef, useEffect, useMemo } from 'react';
4
+ import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
5
5
  import { Button } from '../../../../components/ui/button';
6
6
  import { Label } from '../../../../components/ui/label';
7
7
  import { Input } from '../../../../components/ui/input';
@@ -9,45 +9,14 @@ import { Textarea } from '../../../../components/ui/textarea';
9
9
  import { PointsInput } from '../../../../components/ui/points-input';
10
10
  import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
11
11
  import { Switch } from '../../../../components/ui/switch';
12
- import { Pencil, Eye, Lightbulb, Type, MessageSquare, ToggleLeft, Plus, Trash2, ListChecks, ImageIcon, BookOpen } from 'lucide-react';
12
+ import { Pencil, Eye, Lightbulb, Type, MessageSquare, Plus, Trash2, ListChecks, ImageIcon, Volume2, BookOpen } from 'lucide-react';
13
13
  import { FillInBlankClient } from './FillInBlankClient';
14
+ import { blankHasAnswer, compactFillInBlankAnswers, formatBlankAnswersDisplay, extractPlaceholderIndices, getMaxPlaceholderIndex, normalizeFillInBlankAnswers, } from './answer-utils';
14
15
  import { useDebouncedCallback, usePresignedFileUrl } from '../../../../shared/lib/hooks';
15
16
  import { FileUpload } from '../../../../components/ui/file-upload';
16
17
  import { BasicQuestionGroupCard } from '../../groups/BasicQuestionGroupCard';
17
18
  import { useBasicQuestionGroup } from '../../_shared/hooks/useBasicQuestionGroup';
18
- // Helper: Extract placeholder indices from question text
19
- // e.g., "He {0} 10 years old and she {1} 12." -> [0, 1]
20
- function extractPlaceholderIndices(question) {
21
- const regex = /\{(\d+)\}/g;
22
- const indices = [];
23
- let match;
24
- while ((match = regex.exec(question)) !== null) {
25
- const index = parseInt(match[1], 10);
26
- if (!indices.includes(index)) {
27
- indices.push(index);
28
- }
29
- }
30
- return indices.sort((a, b) => a - b);
31
- }
32
- // Helper: Get the maximum placeholder index
33
- function getMaxPlaceholderIndex(question) {
34
- const indices = extractPlaceholderIndices(question);
35
- return indices.length > 0 ? Math.max(...indices) : -1;
36
- }
37
- // Helper: Determine which group a question belongs to based on its index in the part
38
- function getGroupNumberForIndex(questionIndexInPart, groups) {
39
- if (!groups || groups.length === 0 || questionIndexInPart === undefined)
40
- return 1;
41
- let cumulative = 0;
42
- for (const group of groups) {
43
- cumulative += group.questionCount;
44
- if (questionIndexInPart < cumulative) {
45
- return group.number;
46
- }
47
- }
48
- return groups[groups.length - 1]?.number ?? 1;
49
- }
50
- function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, externalErrors, onUnsavedChangesChange, validationRef, groups, questionIndexInPart, partId, headerExtra, }) {
19
+ function FillInBlankCreatorContent({ initialData, onChange, externalErrors, validationRef, groups, questionIndexInPart, partId, questionConfig, headerExtra, }) {
51
20
  // ============ GROUP (shared title / content / image) ============
52
21
  const { isGrouped, currentGroupNumber, groupTitle, groupSubtitle, groupContent, imageUrl: groupImageUrl, setGroupTitle, setGroupSubtitle, setGroupContent, setImageUrl: setGroupImageUrl, } = useBasicQuestionGroup({
53
22
  partId,
@@ -95,7 +64,7 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
95
64
  });
96
65
  // ============ STATE ============
97
66
  // Support NEW API format (content.question + correctAnswer.answers) and legacy formats
98
- const getInitialQuestion = () => {
67
+ const getInitialQuestion = useCallback(() => {
99
68
  // NEW API format: content.question
100
69
  if (initialData?.content?.question)
101
70
  return initialData.content.question;
@@ -106,31 +75,23 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
106
75
  if (initialData?.blanks?.[0]?.text)
107
76
  return initialData.blanks[0].text;
108
77
  return '';
109
- };
110
- const getInitialAnswers = () => {
111
- // NEW API format: correctAnswer.answers
78
+ }, [initialData]);
79
+ const getInitialAnswers = useCallback(() => {
80
+ // NEW API format: correctAnswer.answers (string[] or string[][])
112
81
  if (initialData?.correctAnswer?.answers && Array.isArray(initialData.correctAnswer.answers)) {
113
- return initialData.correctAnswer.answers;
82
+ return normalizeFillInBlankAnswers(initialData.correctAnswer.answers);
114
83
  }
115
- // Legacy format: answers at root level
84
+ // Flat format: answers at root level
116
85
  if (initialData?.answers && initialData.answers.length > 0) {
117
- return initialData.answers;
86
+ return normalizeFillInBlankAnswers(initialData.answers);
118
87
  }
119
- // Very old format: blanks[].correctAnswer
120
- if (initialData?.blanks?.[0]?.correctAnswer) {
121
- return [initialData.blanks[0].correctAnswer];
88
+ // Very old format: blanks[].correctAnswer / correctAnswers
89
+ if (initialData?.blanks?.length) {
90
+ return normalizeFillInBlankAnswers(initialData.blanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? ''));
122
91
  }
123
92
  return [];
124
- };
125
- const getInitialCaseSensitive = () => {
126
- // NEW API format: correctAnswer.caseSensitive
127
- if (initialData?.correctAnswer?.caseSensitive !== undefined) {
128
- return initialData.correctAnswer.caseSensitive;
129
- }
130
- // Legacy format: caseSensitive at root level
131
- return initialData?.caseSensitive || false;
132
- };
133
- const getInitialShowHints = () => {
93
+ }, [initialData]);
94
+ const getInitialShowHints = useCallback(() => {
134
95
  // NEW API format: content.showHints
135
96
  if (initialData?.content?.showHints !== undefined) {
136
97
  return Boolean(initialData.content.showHints);
@@ -141,8 +102,8 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
141
102
  }
142
103
  // Default: false
143
104
  return false;
144
- };
145
- const getInitialHints = () => {
105
+ }, [initialData]);
106
+ const getInitialHints = useCallback(() => {
146
107
  // NEW API format: content.hints
147
108
  if (Array.isArray(initialData?.content?.hints)) {
148
109
  return initialData.content.hints;
@@ -153,10 +114,9 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
153
114
  }
154
115
  // Default: empty array
155
116
  return [];
156
- };
117
+ }, [initialData]);
157
118
  const [question, setQuestion] = useState(getInitialQuestion());
158
119
  const [answers, setAnswers] = useState(getInitialAnswers());
159
- const [caseSensitive, setCaseSensitive] = useState(getInitialCaseSensitive());
160
120
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
161
121
  const [points, setPoints] = useState(initialData?.points || 1);
162
122
  const [showHints, setShowHints] = useState(getInitialShowHints());
@@ -165,6 +125,10 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
165
125
  const [isExample, setIsExample] = useState(initialData?.isExample || false);
166
126
  // Per-question image — stored in content.imageUrl (independent from the group card image)
167
127
  const [imageUrl, setImageUrl] = useState(() => initialData?.content?.imageUrl || initialData?.imageUrl || '');
128
+ const [audioUrl, setAudioUrl] = useState(() => initialData?.audioUrl || initialData?.content?.audioUrl || '');
129
+ // Whether per-question audio is part of this part's questionConfig.
130
+ // Template may use "audio" or "audioUrl".
131
+ const includesAudio = Boolean(questionConfig?.includes('audio') || questionConfig?.includes('audioUrl'));
168
132
  // UI State
169
133
  const [isClientMode, setIsClientMode] = useState(false);
170
134
  const [imagePreviewUrl, setImagePreviewUrl] = useState('');
@@ -181,8 +145,6 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
181
145
  const isInitializingRef = useRef(false);
182
146
  const onChangeRef = useRef(onChange);
183
147
  const previousPayloadRef = useRef('');
184
- // NOTE: Status tracking is now handled entirely by ExamCreator via localStorage (exam_question_status_{id})
185
- // The onChange callback triggers handleQuestionDraftChange in ExamCreator which updates localStorage
186
148
  // Update refs
187
149
  useEffect(() => {
188
150
  onChangeRef.current = onChange;
@@ -205,7 +167,7 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
205
167
  return newAnswers.slice(0, requiredLength);
206
168
  }
207
169
  while (newAnswers.length < requiredLength) {
208
- newAnswers.push('');
170
+ newAnswers.push(['']);
209
171
  }
210
172
  return newAnswers;
211
173
  }
@@ -213,6 +175,36 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
213
175
  });
214
176
  }
215
177
  }, [maxIndex]);
178
+ // ============ VALIDATION ============
179
+ const validateForm = useCallback(() => {
180
+ const errors = {};
181
+ if (!question.trim()) {
182
+ errors.question = 'Vui lòng nhập câu hỏi!';
183
+ }
184
+ else if (placeholderIndices.length === 0) {
185
+ errors.question = 'Câu hỏi cần có ít nhất một chỗ trống {0}!';
186
+ }
187
+ // Check if placeholder indices are sequential starting from 0
188
+ const expectedIndices = Array.from({ length: placeholderIndices.length }, (_, i) => i);
189
+ const hasGaps = !placeholderIndices.every((idx, i) => idx === expectedIndices[i]);
190
+ if (hasGaps && placeholderIndices.length > 0) {
191
+ errors.question = 'Các chỗ trống phải đánh số liên tục từ {0}!';
192
+ }
193
+ // Validate each blank has at least one non-empty correct answer
194
+ errors.answers = {};
195
+ const indicesToValidate = placeholderIndices.length > 0 ? placeholderIndices : [0];
196
+ indicesToValidate.forEach((idx) => {
197
+ if (!blankHasAnswer(answers[idx])) {
198
+ errors.answers[idx] = 'Vui lòng nhập ít nhất 1 đáp án!';
199
+ }
200
+ });
201
+ // Clean up empty answers object
202
+ if (Object.keys(errors.answers).length === 0) {
203
+ delete errors.answers;
204
+ }
205
+ setValidationErrors(errors);
206
+ return !errors.question && !errors.answers;
207
+ }, [question, placeholderIndices, answers]);
216
208
  // Expose validation function via ref
217
209
  useEffect(() => {
218
210
  if (validationRef) {
@@ -224,14 +216,14 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
224
216
  // This bypasses the debounced onChange race condition
225
217
  getFormData: () => ({
226
218
  question,
227
- answers,
228
- caseSensitive,
219
+ answers: compactFillInBlankAnswers(answers),
229
220
  explanation,
230
221
  points,
231
222
  // Keep the two image namespaces mutually exclusive so a non-grouped part never
232
223
  // emits group data (which would wrongly create a questionGroup) and vice versa.
233
224
  imageUrl: isGrouped ? '' : imageUrl,
234
225
  groupImageUrl: isGrouped ? groupImageUrl : '',
226
+ audioUrl: includesAudio ? (audioUrl || '') : '',
235
227
  showHints,
236
228
  hints: showHints ? hints : [],
237
229
  isExample,
@@ -243,7 +235,7 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
243
235
  }),
244
236
  };
245
237
  }
246
- }, [validationRef, question, answers, caseSensitive, explanation, points, isGrouped, imageUrl, groupImageUrl, showHints, hints, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups]);
238
+ }, [validationRef, question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, isExample, validateForm]);
247
239
  // ============ SYNC INITIAL DATA ============
248
240
  useEffect(() => {
249
241
  const initialDataChanged = JSON.stringify(previousInitialDataRef.current) !== JSON.stringify(initialData);
@@ -253,7 +245,6 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
253
245
  if (initialData) {
254
246
  setQuestion(getInitialQuestion());
255
247
  setAnswers(getInitialAnswers());
256
- setCaseSensitive(getInitialCaseSensitive());
257
248
  // Explanation can be at root level or in initialData
258
249
  setExplanation(initialData.explanation || '');
259
250
  // Points can be at root level
@@ -262,34 +253,35 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
262
253
  setHints(getInitialHints());
263
254
  setIsExample(initialData.isExample || false);
264
255
  setImageUrl(initialData?.content?.imageUrl || initialData.imageUrl || '');
256
+ setAudioUrl(initialData.audioUrl || initialData?.content?.audioUrl || '');
265
257
  }
266
258
  else {
267
259
  setQuestion('');
268
260
  setAnswers([]);
269
- setCaseSensitive(false);
270
261
  setExplanation('');
271
262
  setPoints(1);
272
263
  setShowHints(false);
273
264
  setHints([]);
274
265
  setIsExample(false);
275
266
  setImageUrl('');
267
+ setAudioUrl('');
276
268
  }
277
269
  setTimeout(() => {
278
270
  isInitializingRef.current = false;
279
271
  }, 150);
280
272
  }
281
- }, [initialData]);
273
+ }, [initialData, getInitialAnswers, getInitialHints, getInitialQuestion, getInitialShowHints]);
282
274
  // ============ ON CHANGE CALLBACK ============
283
275
  useEffect(() => {
284
276
  if (isInitializingRef.current) {
285
277
  const currentPayload = JSON.stringify({
286
278
  question,
287
279
  answers,
288
- caseSensitive,
289
280
  explanation,
290
281
  points,
291
282
  imageUrl,
292
283
  groupImageUrl,
284
+ audioUrl,
293
285
  showHints,
294
286
  hints,
295
287
  isExample,
@@ -304,12 +296,12 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
304
296
  }
305
297
  const payload = {
306
298
  question,
307
- answers,
308
- caseSensitive,
299
+ answers: compactFillInBlankAnswers(answers),
309
300
  explanation,
310
301
  points,
311
302
  imageUrl: isGrouped ? '' : imageUrl,
312
303
  groupImageUrl: isGrouped ? groupImageUrl : '',
304
+ audioUrl: includesAudio ? (audioUrl || '') : '',
313
305
  showHints,
314
306
  hints: showHints ? hints : [],
315
307
  isExample,
@@ -324,75 +316,26 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
324
316
  previousPayloadRef.current = payloadString;
325
317
  debouncedOnChange(payload);
326
318
  }
327
- }, [question, answers, caseSensitive, explanation, points, isGrouped, imageUrl, groupImageUrl, showHints, hints, isExample, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups]);
328
- // ============ VALIDATION ============
329
- const validateForm = () => {
330
- const errors = {};
331
- if (!question.trim()) {
332
- errors.question = 'Vui lòng nhập câu hỏi!';
333
- }
334
- else if (placeholderIndices.length === 0) {
335
- errors.question = 'Câu hỏi cần có ít nhất một chỗ trống {0}!';
336
- }
337
- // Check if placeholder indices are sequential starting from 0
338
- const expectedIndices = Array.from({ length: placeholderIndices.length }, (_, i) => i);
339
- const hasGaps = !placeholderIndices.every((idx, i) => idx === expectedIndices[i]);
340
- if (hasGaps && placeholderIndices.length > 0) {
341
- errors.question = 'Các chỗ trống phải đánh số liên tục từ {0}!';
342
- }
343
- // Validate each answer - use placeholderIndices or default [0]
344
- errors.answers = {};
345
- const indicesToValidate = placeholderIndices.length > 0 ? placeholderIndices : [0];
346
- indicesToValidate.forEach((idx) => {
347
- if (!answers[idx] || !answers[idx].trim()) {
348
- errors.answers[idx] = 'Vui lòng nhập đáp án!';
319
+ }, [question, answers, explanation, points, isGrouped, imageUrl, groupImageUrl, audioUrl, includesAudio, showHints, hints, isExample, groupTitle, groupSubtitle, groupContent, currentGroupNumber, groups, debouncedOnChange]);
320
+ // Ensure blank slot exists then update alternative at altIndex
321
+ const updateBlankAnswer = (blankIndex, altIndex, value) => {
322
+ setAnswers((prev) => {
323
+ const next = [...prev];
324
+ while (next.length <= blankIndex) {
325
+ next.push(['']);
326
+ }
327
+ const alts = [...(next[blankIndex] || [''])];
328
+ while (alts.length <= altIndex) {
329
+ alts.push('');
349
330
  }
331
+ alts[altIndex] = value;
332
+ next[blankIndex] = alts;
333
+ return next;
350
334
  });
351
- // Clean up empty answers object
352
- if (Object.keys(errors.answers).length === 0) {
353
- delete errors.answers;
354
- }
355
- setValidationErrors(errors);
356
- return !errors.question && !errors.answers;
357
- };
358
- // Handle save
359
- const handleSave = () => {
360
- if (!validateForm()) {
361
- return;
362
- }
363
- // NOTE: Save status is now managed by ExamCreator via localStorage
364
- const saveData = {
365
- question,
366
- answers,
367
- caseSensitive,
368
- explanation,
369
- points,
370
- imageUrl,
371
- showHints,
372
- hints: showHints ? hints : [],
373
- isExample,
374
- title: groupTitle,
375
- subtitle: groupSubtitle,
376
- groupContent,
377
- groupNumber: currentGroupNumber,
378
- groups,
379
- };
380
- onSave?.(saveData);
381
- };
382
- // Update answer at specific index
383
- const updateAnswer = (index, value) => {
384
- const newAnswers = [...answers];
385
- // Ensure array is long enough
386
- while (newAnswers.length <= index) {
387
- newAnswers.push('');
388
- }
389
- newAnswers[index] = value;
390
- setAnswers(newAnswers);
391
- // Clear validation error for this answer
392
- if (validationErrors.answers?.[index]) {
393
- setValidationErrors(prev => {
335
+ if (validationErrors.answers?.[blankIndex]) {
336
+ setValidationErrors((prev) => {
394
337
  const newAnswerErrors = { ...prev.answers };
395
- delete newAnswerErrors[index];
338
+ delete newAnswerErrors[blankIndex];
396
339
  return {
397
340
  ...prev,
398
341
  answers: Object.keys(newAnswerErrors).length > 0 ? newAnswerErrors : undefined,
@@ -400,11 +343,35 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
400
343
  });
401
344
  }
402
345
  };
346
+ const addBlankAlternative = (blankIndex) => {
347
+ setAnswers((prev) => {
348
+ const next = [...prev];
349
+ while (next.length <= blankIndex) {
350
+ next.push(['']);
351
+ }
352
+ next[blankIndex] = [...(next[blankIndex] || ['']), ''];
353
+ return next;
354
+ });
355
+ };
356
+ const removeBlankAlternative = (blankIndex, altIndex) => {
357
+ setAnswers((prev) => {
358
+ const next = [...prev];
359
+ const alts = [...(next[blankIndex] || [''])];
360
+ if (alts.length <= 1) {
361
+ next[blankIndex] = [''];
362
+ }
363
+ else {
364
+ alts.splice(altIndex, 1);
365
+ next[blankIndex] = alts;
366
+ }
367
+ return next;
368
+ });
369
+ };
403
370
  // Generate preview text with answers filled in
404
371
  const getPreviewText = () => {
405
372
  let preview = question;
406
373
  placeholderIndices.forEach((idx) => {
407
- const answer = answers[idx] || '_____';
374
+ const answer = formatBlankAnswersDisplay(answers[idx]) || '_____';
408
375
  preview = preview.replace(`{${idx}}`, `[${answer}]`);
409
376
  });
410
377
  return preview;
@@ -413,10 +380,10 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
413
380
  if (isClientMode) {
414
381
  const questionData = {
415
382
  question,
416
- answers,
417
- caseSensitive,
383
+ answers: compactFillInBlankAnswers(answers),
418
384
  explanation,
419
385
  imageUrl: isGrouped ? '' : (displayPreviewUrl || imageUrl),
386
+ audioUrl: includesAudio ? (audioUrl || '') : '',
420
387
  showHints,
421
388
  hints: showHints ? hints : [],
422
389
  isExample,
@@ -428,21 +395,27 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
428
395
  groupImageUrl: isGrouped ? (groupDisplayPreviewUrl || groupImageUrl) : '',
429
396
  groupNumber: isGrouped ? currentGroupNumber : undefined,
430
397
  };
431
- return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { children: _jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx(CardTitle, { children: "Fill In Blank (Preview)" }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Quay l\u1EA1i Edit Mode"] })] }) }) }), _jsx(FillInBlankClient, { questionData: questionData, autoFillCorrectAnswers: true, isReviewMode: false, explanation: explanation })] }));
398
+ return (_jsxs("div", { className: "space-y-4", children: [_jsx(Card, { children: _jsx(CardHeader, { children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx(CardTitle, { children: "Fill In Blank (Preview)" }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(false), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Quay l\u1EA1i Edit Mode"] })] }) }) }), _jsx(FillInBlankClient, { questionData: questionData, questionConfig: questionConfig, autoFillCorrectAnswers: true, isReviewMode: false, explanation: explanation })] }));
432
399
  }
433
400
  // ============ CREATOR UI ============
434
- return (_jsxs("div", { className: "space-y-4", children: [isGrouped && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, uploadIdPrefix: `fill-in-blank-${partId ?? 'group'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-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-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 shadow-sm", children: _jsx(Type, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" }), _jsx("p", { className: "text-xs text-gray-500", children: "H\u1ED7 tr\u1EE3 nhi\u1EC1u ch\u1ED7 tr\u1ED1ng trong m\u1ED9t c\u00E2u h\u1ECFi" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: !question?.trim() || placeholderIndices.length === 0, className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] })] }) }), _jsxs(CardContent, { className: "space-y-4 px-4 pb-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsx(PointsInput, { defaultValue: points, onChange: (e) => setPoints(Number(e.target.value) || 1) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-suggested-words", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-suggested-words", className: "cursor-pointer text-sm font-medium text-gray-700", children: "Hi\u1EC3n th\u1ECB t\u1EEB g\u1EE3i \u00FD" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "isExample", checked: isExample, onCheckedChange: setIsExample }), _jsx(Label, { htmlFor: "isExample", className: "cursor-pointer text-sm font-medium text-gray-700", children: "\u0110\u00E1nh d\u1EA5u l\u00E0 c\u00E2u h\u1ECFi Example" })] })] }), _jsx("div", { className: "rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-5 shadow-sm", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100", children: _jsx(Lightbulb, { className: "h-4 w-4 text-blue-600" }) }), _jsxs("div", { className: "text-sm text-blue-900", children: [_jsx("p", { className: "font-semibold text-blue-800", children: "H\u01B0\u1EDBng d\u1EABn t\u1EA1o c\u00E2u h\u1ECFi" }), _jsxs("ul", { className: "mt-2 space-y-1.5 text-blue-700", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "S\u1EED d\u1EE5ng ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{0}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{1}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{2}' }), "... \u0111\u1EC3 \u0111\u00E1nh d\u1EA5u c\u00E1c ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "H\u1EC7 th\u1ED1ng s\u1EBD t\u1EF1 \u0111\u1ED9ng t\u1EA1o \u00F4 nh\u1EADp \u0111\u00E1p \u00E1n cho m\u1ED7i ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "V\u00ED d\u1EE5: \u201CHe ", '{0}', " 10 years old and she ", '{1}', " 12.\u201D"] })] })] })] }) }), !isGrouped && (_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", children: "(T\u00F9y ch\u1ECDn)" })] }), _jsx(FileUpload, { id: "fill-in-blank-image", label: "", accept: "image/*", value: imageUrl || '', onChange: (url) => {
401
+ return (_jsxs("div", { className: "space-y-4", children: [isGrouped && (_jsx(BasicQuestionGroupCard, { groupNumber: currentGroupNumber, title: groupTitle, subtitle: groupSubtitle, content: groupContent, imageUrl: groupImageUrl, onTitleChange: setGroupTitle, onSubtitleChange: setGroupSubtitle, onContentChange: setGroupContent, onImageChange: setGroupImageUrl, uploadIdPrefix: `fill-in-blank-${partId ?? 'group'}` })), _jsxs(Card, { className: "overflow-hidden border-0 bg-white shadow-lg shadow-blue-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-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 shadow-sm", children: _jsx(Type, { className: "h-4 w-4 text-white" }) }), _jsxs("div", { children: [_jsx(CardTitle, { className: "text-base font-bold text-gray-900", children: "\u0110i\u1EC1n v\u00E0o ch\u1ED7 tr\u1ED1ng" }), _jsx("p", { className: "text-xs text-gray-500", children: "H\u1ED7 tr\u1EE3 nhi\u1EC1u ch\u1ED7 tr\u1ED1ng trong m\u1ED9t c\u00E2u h\u1ECFi" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [headerExtra, _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setIsClientMode(true), disabled: !question?.trim() || placeholderIndices.length === 0, className: "gap-2 border-indigo-200 text-indigo-600 hover:bg-indigo-50 hover:text-indigo-700", children: [_jsx(Eye, { className: "h-4 w-4" }), "Xem tr\u01B0\u1EDBc"] })] })] }) }), _jsxs(CardContent, { className: "space-y-4 px-4 pb-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-6", children: [_jsx(PointsInput, { value: points, onChange: (e) => setPoints(parseFloat(e.target.value) || 1) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-suggested-words", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-suggested-words", className: "cursor-pointer text-sm font-medium text-gray-700", children: "Hi\u1EC3n th\u1ECB t\u1EEB g\u1EE3i \u00FD" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "isExample", checked: isExample, onCheckedChange: setIsExample }), _jsx(Label, { htmlFor: "isExample", className: "cursor-pointer text-sm font-medium text-gray-700", children: "\u0110\u00E1nh d\u1EA5u l\u00E0 c\u00E2u h\u1ECFi Example" })] })] }), _jsx("div", { className: "rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-5 shadow-sm", children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("div", { className: "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100", children: _jsx(Lightbulb, { className: "h-4 w-4 text-blue-600" }) }), _jsxs("div", { className: "text-sm text-blue-900", children: [_jsx("p", { className: "font-semibold text-blue-800", children: "H\u01B0\u1EDBng d\u1EABn t\u1EA1o c\u00E2u h\u1ECFi" }), _jsxs("ul", { className: "mt-2 space-y-1.5 text-blue-700", children: [_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "S\u1EED d\u1EE5ng ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{0}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{1}' }), ", ", _jsx("code", { className: "mx-1 rounded bg-blue-100 px-1.5 py-0.5 font-mono text-blue-800", children: '{2}' }), "... \u0111\u1EC3 \u0111\u00E1nh d\u1EA5u c\u00E1c ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "H\u1EC7 th\u1ED1ng s\u1EBD t\u1EF1 \u0111\u1ED9ng t\u1EA1o \u00F4 nh\u1EADp \u0111\u00E1p \u00E1n cho m\u1ED7i ch\u1ED7 tr\u1ED1ng"] }), _jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-blue-400" }), "V\u00ED d\u1EE5: \u201CHe ", '{0}', " 10 years old and she ", '{1}', " 12.\u201D"] })] })] })] }) }), !isGrouped && (_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", children: "(T\u00F9y ch\u1ECDn)" })] }), _jsx(FileUpload, { id: "fill-in-blank-image", label: "", accept: "image/*", value: imageUrl || '', onChange: (url) => {
435
402
  setImageUrl(url);
436
- }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), _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" }), "C\u00E2u h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Textarea, { id: "question", value: question, onChange: (e) => {
403
+ }, onPresignedUrlChange: setImagePreviewUrl, maxSize: 5, placeholder: "Upload \u1EA3nh ho\u1EB7c paste URL", autoUpload: true, prefix: "questions" }), displayPreviewUrl && (_jsx(ImagePreview, { src: displayPreviewUrl, alt: "Preview", className: "mt-2 h-48 w-full max-w-md rounded-lg border border-gray-200" }))] })), includesAudio && (_jsxs("div", { className: "space-y-2", children: [_jsxs(Label, { className: "flex items-center gap-2 text-base font-semibold text-gray-800", children: [_jsx(Volume2, { className: "h-4 w-4 text-indigo-500" }), "Audio c\u00E2u h\u1ECFi"] }), _jsx(FileUpload, { id: "fill-in-blank-audio", label: "", accept: "audio/*", value: audioUrl || '', onChange: (url) => {
404
+ setAudioUrl(url);
405
+ }, maxSize: 20, placeholder: "Upload file audio", autoUpload: true, prefix: "questions", showPlayButton: true })] })), _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" }), "C\u00E2u h\u1ECFi ", _jsx("span", { className: "text-red-500", children: "*" })] }), _jsx(Textarea, { id: "question", value: question, onChange: (e) => {
437
406
  setQuestion(e.target.value);
438
407
  if (validationErrors.question) {
439
408
  setValidationErrors(prev => ({ ...prev, question: undefined }));
440
409
  }
441
- }, placeholder: "V\u00ED d\u1EE5: He {0} 10 years old and she {1} 12.", rows: 3, className: `min-h-[100px] border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${validationErrors.question ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), validationErrors.question && (_jsx("p", { className: "text-sm text-red-600", children: validationErrors.question })), placeholderIndices.length > 0 && (_jsx("div", { className: "flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2", children: _jsxs("span", { className: "text-sm text-green-700", children: ["\u2713 \u0110\u00E3 ph\u00E1t hi\u1EC7n ", _jsx("strong", { children: placeholderIndices.length }), " ch\u1ED7 tr\u1ED1ng: ", placeholderIndices.map(i => (_jsx("code", { className: "mx-0.5 rounded bg-green-100 px-1.5 py-0.5 font-mono text-green-800", children: `{${i}}` }, i)))] }) }))] }), _jsxs("div", { className: "space-y-4", 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(Type, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(Label, { className: "text-base font-semibold text-gray-800", children: ["\u0110\u00E1p \u00E1n cho c\u00E1c ch\u1ED7 tr\u1ED1ng ", _jsx("span", { className: "text-red-500", children: "*" })] })] }), _jsxs("div", { className: "grid gap-6 lg:grid-cols-2", children: [_jsx("div", { className: "space-y-3", children: (placeholderIndices.length > 0 ? placeholderIndices : [0]).map((idx) => (_jsxs("div", { className: `rounded-xl border-2 p-4 transition-all ${answers[idx]
442
- ? 'border-green-200 bg-gradient-to-r from-green-50 to-emerald-50'
443
- : 'border-gray-200 bg-white'}`, children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl font-mono font-bold transition-all ${answers[idx]
444
- ? 'bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-md'
445
- : 'bg-indigo-100 text-indigo-700'}`, children: `{${idx}}` }), _jsx(Input, { value: answers[idx] || '', onChange: (e) => updateAnswer(idx, e.target.value), placeholder: `Đáp án cho {${idx}}`, className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${validationErrors.answers?.[idx] ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` })] }), validationErrors.answers?.[idx] && (_jsx("p", { className: "mt-2 ml-13 text-sm text-red-600", children: validationErrors.answers[idx] }))] }, idx))) }), showHints && (_jsxs("div", { className: "space-y-4 rounded-xl border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-indigo-50 p-4", 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-purple-100", children: _jsx(ListChecks, { className: "h-3.5 w-3.5 text-purple-600" }) }), _jsxs(Label, { className: "text-base font-semibold text-gray-800", children: ["Danh s\u00E1ch t\u1EEB g\u1EE3i \u00FD ", _jsxs("span", { className: "text-sm font-normal text-gray-500", children: ["(", hints.length, " t\u1EEB)"] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: newHint, onChange: (e) => setNewHint(e.target.value), placeholder: "Nh\u1EADp t\u1EEB g\u1EE3i \u00FD m\u1EDBi...", className: "flex-1 border-gray-200 bg-white", onKeyDown: (e) => {
410
+ }, placeholder: "V\u00ED d\u1EE5: He {0} 10 years old and she {1} 12.", rows: 3, className: `min-h-[100px] border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${validationErrors.question ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), validationErrors.question && (_jsx("p", { className: "text-sm text-red-600", children: validationErrors.question })), placeholderIndices.length > 0 && (_jsx("div", { className: "flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2", children: _jsxs("span", { className: "text-sm text-green-700", children: ["\u2713 \u0110\u00E3 ph\u00E1t hi\u1EC7n ", _jsx("strong", { children: placeholderIndices.length }), " ch\u1ED7 tr\u1ED1ng: ", placeholderIndices.map(i => (_jsx("code", { className: "mx-0.5 rounded bg-green-100 px-1.5 py-0.5 font-mono text-green-800", children: `{${i}}` }, i)))] }) }))] }), _jsxs("div", { className: "space-y-4", 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(Type, { className: "h-3.5 w-3.5 text-indigo-600" }) }), _jsxs(Label, { className: "text-base font-semibold text-gray-800", children: ["\u0110\u00E1p \u00E1n cho c\u00E1c ch\u1ED7 tr\u1ED1ng ", _jsx("span", { className: "text-red-500", children: "*" })] })] }), _jsxs("div", { className: "grid gap-6 lg:grid-cols-2", children: [_jsx("div", { className: "space-y-3", children: (placeholderIndices.length > 0 ? placeholderIndices : [0]).map((idx) => {
411
+ const blankAlts = answers[idx]?.length ? answers[idx] : [''];
412
+ const hasAnswer = blankHasAnswer(blankAlts);
413
+ return (_jsxs("div", { className: `rounded-xl border-2 p-4 transition-all ${hasAnswer
414
+ ? 'border-green-200 bg-gradient-to-r from-green-50 to-emerald-50'
415
+ : 'border-gray-200 bg-white'}`, children: [_jsxs("div", { className: "mb-3 flex items-center gap-3", children: [_jsx("div", { className: `flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl font-mono font-bold transition-all ${hasAnswer
416
+ ? 'bg-gradient-to-br from-green-500 to-emerald-600 text-white shadow-md'
417
+ : 'bg-indigo-100 text-indigo-700'}`, children: `{${idx}}` }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm font-medium text-gray-700", children: ["Ch\u1ED7 tr\u1ED1ng ", idx + 1] }), _jsx("p", { className: "text-xs text-gray-500", children: "C\u00F3 th\u1EC3 th\u00EAm nhi\u1EC1u \u0111\u00E1p \u00E1n \u0111\u00FAng t\u01B0\u01A1ng \u0111\u01B0\u01A1ng" })] })] }), _jsx("div", { className: "space-y-2", children: blankAlts.map((alt, altIdx) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Input, { value: alt, onChange: (e) => updateBlankAnswer(idx, altIdx, e.target.value), placeholder: altIdx === 0 ? `Đáp án chính cho {${idx}}` : `Đáp án thay thế ${altIdx + 1}`, className: `border-gray-200 bg-white transition-all focus:border-indigo-300 focus:ring-indigo-200 ${validationErrors.answers?.[idx] ? 'border-red-400 focus:border-red-500 focus:ring-red-200' : ''}` }), blankAlts.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => removeBlankAlternative(idx, altIdx), className: "h-9 w-9 flex-shrink-0 text-red-500 hover:bg-red-50 hover:text-red-600", title: "X\u00F3a \u0111\u00E1p \u00E1n n\u00E0y", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] }, altIdx))) }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => addBlankAlternative(idx), className: "mt-2 gap-1.5 border-dashed border-indigo-200 text-indigo-600 hover:bg-indigo-50", children: [_jsx(Plus, { className: "h-3.5 w-3.5" }), "Th\u00EAm \u0111\u00E1p \u00E1n \u0111\u00FAng"] }), validationErrors.answers?.[idx] && (_jsx("p", { className: "mt-2 text-sm text-red-600", children: validationErrors.answers[idx] }))] }, idx));
418
+ }) }), showHints && (_jsxs("div", { className: "space-y-4 rounded-xl border-2 border-purple-200 bg-gradient-to-r from-purple-50 to-indigo-50 p-4", 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-purple-100", children: _jsx(ListChecks, { className: "h-3.5 w-3.5 text-purple-600" }) }), _jsxs(Label, { className: "text-base font-semibold text-gray-800", children: ["Danh s\u00E1ch t\u1EEB g\u1EE3i \u00FD ", _jsxs("span", { className: "text-sm font-normal text-gray-500", children: ["(", hints.length, " t\u1EEB)"] })] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Input, { value: newHint, onChange: (e) => setNewHint(e.target.value), placeholder: "Nh\u1EADp t\u1EEB g\u1EE3i \u00FD m\u1EDBi...", className: "flex-1 border-gray-200 bg-white", onKeyDown: (e) => {
446
419
  if (e.key === 'Enter' && newHint.trim()) {
447
420
  e.preventDefault();
448
421
  if (!hints.includes(newHint.trim())) {
@@ -457,7 +430,7 @@ function FillInBlankCreatorContent({ initialData, onSave, onCancel, onChange, ex
457
430
  }
458
431
  }, disabled: !newHint.trim(), className: "gap-2 border-green-200 text-green-600 hover:bg-green-50 hover:text-green-700", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm"] })] }), hints.length > 0 ? (_jsx("div", { className: "flex flex-wrap gap-2", children: hints.map((word, index) => (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-purple-200 bg-white px-3 py-2 shadow-sm transition-all hover:border-purple-300 hover:shadow", children: [_jsx("span", { className: "text-sm font-medium text-gray-700", children: word }), _jsx("button", { type: "button", onClick: () => {
459
432
  setHints(hints.filter((_, i) => i !== index));
460
- }, className: "rounded-full p-0.5 text-gray-400 transition-colors hover:bg-red-100 hover:text-red-500", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }, index))) })) : (_jsx("p", { className: "text-center text-sm text-gray-500 italic", children: "Ch\u01B0a c\u00F3 t\u1EEB g\u1EE3i \u00FD n\u00E0o. Th\u00EAm t\u1EEB \u0111\u1EC3 hi\u1EC3n th\u1ECB cho h\u1ECDc sinh." }))] }))] })] }), question && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-semibold text-gray-600", children: [_jsx(Eye, { className: "h-3.5 w-3.5" }), "Xem tr\u01B0\u1EDBc c\u00E2u ho\u00E0n ch\u1EC9nh"] }), _jsx("p", { className: "text-sm text-gray-700", children: getPreviewText() })] })), _jsxs("div", { className: "flex items-center justify-between rounded-xl border-2 border-gray-200 bg-white p-4 transition-all hover:border-gray-300", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx("div", { className: "flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100", children: _jsx(ToggleLeft, { className: "h-4 w-4 text-gray-600" }) }), _jsxs("div", { className: "space-y-0.5", children: [_jsx(Label, { htmlFor: "case-sensitive", className: "font-semibold text-gray-800", children: "Ph\u00E2n bi\u1EC7t hoa/th\u01B0\u1EDDng" }), _jsx("p", { className: "text-sm text-gray-500", children: "B\u1EADt n\u1EBFu \u0111\u00E1p \u00E1n c\u1EA7n ph\u00E2n bi\u1EC7t ch\u1EEF hoa/th\u01B0\u1EDDng" })] })] }), _jsx(Switch, { id: "case-sensitive", checked: caseSensitive, onCheckedChange: setCaseSensitive })] }), 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))) })] })] }))] })] }), _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.5 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, { id: "explanation", value: explanation, onChange: (e) => setExplanation(e.target.value), 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" }) })] })] }));
433
+ }, className: "rounded-full p-0.5 text-gray-400 transition-colors hover:bg-red-100 hover:text-red-500", children: _jsx(Trash2, { className: "h-3.5 w-3.5" }) })] }, index))) })) : (_jsx("p", { className: "text-center text-sm text-gray-500 italic", children: "Ch\u01B0a c\u00F3 t\u1EEB g\u1EE3i \u00FD n\u00E0o. Th\u00EAm t\u1EEB \u0111\u1EC3 hi\u1EC3n th\u1ECB cho h\u1ECDc sinh." }))] }))] })] }), question && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-xs font-semibold text-gray-600", children: [_jsx(Eye, { className: "h-3.5 w-3.5" }), "Xem tr\u01B0\u1EDBc c\u00E2u ho\u00E0n ch\u1EC9nh"] }), _jsx("p", { className: "text-sm text-gray-700", children: getPreviewText() })] })), 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))) })] })] }))] })] }), _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.5 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, { id: "explanation", value: explanation, onChange: (e) => setExplanation(e.target.value), 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" }) })] })] }));
461
434
  }
462
435
  export function FillInBlankCreator(props) {
463
436
  return _jsx(FillInBlankCreatorContent, { ...props });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * FILL_IN_BLANK helpers for the authoring UI.
3
+ *
4
+ * Parsing and answer matching live in `@/shared/lib/utils/fill-in-blank` so the
5
+ * creator, the exam player and the review screen can never drift apart. The extra
6
+ * helpers here only exist because the editor needs empty slots (`['']`) to render
7
+ * inputs, while grading drops empty alternatives.
8
+ *
9
+ * Canonical schema per blank: string[] (equivalent correct answers)
10
+ * Full answers: string[][] e.g. [["is", "'s"], ["are"]]
11
+ * Legacy shapes accepted when loading: string[] and plain string.
12
+ */
13
+ import { extractPlaceholderIndices, formatAcceptedAnswers, getPrimaryAnswer } from '../../../../shared/lib/utils/fill-in-blank';
14
+ export { extractPlaceholderIndices };
15
+ export type FillInBlankAnswers = string[][];
16
+ /** Normalize one blank's correct value into a list of alternatives (never empty). */
17
+ export declare function normalizeBlankAnswers(value: unknown): string[];
18
+ /** Get maximum placeholder index from question text, or -1 if no placeholders */
19
+ export declare function getMaxPlaceholderIndex(question: string): number;
20
+ /**
21
+ * Normalize full answers payload into canonical string[][], padded to
22
+ * `requiredLength` so every placeholder gets an editable slot.
23
+ */
24
+ export declare function normalizeFillInBlankAnswers(raw: unknown, requiredLength?: number): FillInBlankAnswers;
25
+ /** True when a blank has at least one non-empty correct answer. */
26
+ export declare function blankHasAnswer(blankAnswers: string[] | undefined): boolean;
27
+ /** Primary (first) correct answer for a blank — used for auto-fill / example display. */
28
+ export declare const getPrimaryBlankAnswer: typeof getPrimaryAnswer;
29
+ /** Join all alternatives for display, e.g. "is / 's". */
30
+ export declare const formatBlankAnswersDisplay: typeof formatAcceptedAnswers;
31
+ /** Compare student input against any accepted alternative. */
32
+ export declare const isBlankInputCorrect: (userInput: string | undefined, blankAnswers: string[] | undefined) => boolean;
33
+ /** Compact answers for API save: drop empty alternatives, keep at least one slot per blank. */
34
+ export declare function compactFillInBlankAnswers(answers: FillInBlankAnswers, requiredLength?: number): FillInBlankAnswers;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * FILL_IN_BLANK helpers for the authoring UI.
3
+ *
4
+ * Parsing and answer matching live in `@/shared/lib/utils/fill-in-blank` so the
5
+ * creator, the exam player and the review screen can never drift apart. The extra
6
+ * helpers here only exist because the editor needs empty slots (`['']`) to render
7
+ * inputs, while grading drops empty alternatives.
8
+ *
9
+ * Canonical schema per blank: string[] (equivalent correct answers)
10
+ * Full answers: string[][] e.g. [["is", "'s"], ["are"]]
11
+ * Legacy shapes accepted when loading: string[] and plain string.
12
+ */
13
+ import { extractPlaceholderIndices, formatAcceptedAnswers, getPrimaryAnswer, isBlankCorrect, } from '../../../../shared/lib/utils/fill-in-blank';
14
+ export { extractPlaceholderIndices };
15
+ /** Normalize one blank's correct value into a list of alternatives (never empty). */
16
+ export function normalizeBlankAnswers(value) {
17
+ const list = Array.isArray(value) ? value : [value];
18
+ const cleaned = list.map((item) => String(item ?? '').trim()).filter(Boolean);
19
+ return cleaned.length > 0 ? cleaned : [''];
20
+ }
21
+ /** Get maximum placeholder index from question text, or -1 if no placeholders */
22
+ export function getMaxPlaceholderIndex(question) {
23
+ const indices = extractPlaceholderIndices(question);
24
+ return indices.length > 0 ? Math.max(...indices) : -1;
25
+ }
26
+ /**
27
+ * Normalize full answers payload into canonical string[][], padded to
28
+ * `requiredLength` so every placeholder gets an editable slot.
29
+ */
30
+ export function normalizeFillInBlankAnswers(raw, requiredLength) {
31
+ let result = Array.isArray(raw)
32
+ ? raw.map((item) => normalizeBlankAnswers(item))
33
+ : [];
34
+ if (typeof requiredLength === 'number' && requiredLength > 0) {
35
+ result = result.slice(0, requiredLength);
36
+ while (result.length < requiredLength) {
37
+ result.push(['']);
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+ /** True when a blank has at least one non-empty correct answer. */
43
+ export function blankHasAnswer(blankAnswers) {
44
+ return Boolean(blankAnswers?.some((a) => a.trim().length > 0));
45
+ }
46
+ /** Primary (first) correct answer for a blank — used for auto-fill / example display. */
47
+ export const getPrimaryBlankAnswer = getPrimaryAnswer;
48
+ /** Join all alternatives for display, e.g. "is / 's". */
49
+ export const formatBlankAnswersDisplay = formatAcceptedAnswers;
50
+ /** Compare student input against any accepted alternative. */
51
+ export const isBlankInputCorrect = (userInput, blankAnswers) => isBlankCorrect(userInput ?? '', blankAnswers);
52
+ /** Compact answers for API save: drop empty alternatives, keep at least one slot per blank. */
53
+ export function compactFillInBlankAnswers(answers, requiredLength) {
54
+ const list = typeof requiredLength === 'number' && requiredLength > 0
55
+ ? answers.slice(0, requiredLength)
56
+ : answers;
57
+ return list.map((blank) => normalizeBlankAnswers(blank));
58
+ }