@tinyweb_dev/oe-exam-sdk 0.2.6 → 0.2.7
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.
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
- package/dist/components/exams/take/types.d.ts +12 -5
- package/dist/components/exams/take/utils/answer-transformers.js +1 -1
- package/dist/components/exams/take/utils/question-transformers.js +19 -53
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
- package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
- package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
- package/dist/components/questions/types/fill-in-blank/register.js +19 -6
- package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
- package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
- package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
- package/dist/shared/lib/utils/fill-in-blank.js +96 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +64 -3
- 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,
|
|
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
|
-
|
|
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
|
-
//
|
|
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?.
|
|
121
|
-
return
|
|
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
|
|
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,
|
|
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,
|
|
328
|
-
//
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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[
|
|
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, {
|
|
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, {
|
|
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) =>
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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() })] })),
|
|
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
|
+
}
|