@tinyweb_dev/oe-exam-sdk 0.2.11 → 0.2.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.js +1 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +2 -1
- package/dist/components/exams/take/utils/question-transformers.js +2 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +3 -0
- package/dist/components/questions/_shared/types/answer-the-question-group.type.js +12 -1
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
- package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +4 -1
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
- package/dist/components/questions/_shared/types/word-ordering-group.type.d.ts +1 -0
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +12 -1
- package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +6 -2
- package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +5 -0
- package/dist/components/questions/types/answer-the-question-group/transform.js +3 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
- package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +6 -6
- package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -7
- package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +4 -0
- package/dist/components/questions/types/match-word-to-picture-group/transform.js +9 -1
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
- package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
- package/dist/components/questions/types/word-ordering-group/WordOrderingGroupClient.js +1 -0
- package/dist/components/questions/types/word-ordering-group/WordOrderingGroupCreator.js +3 -2
- package/dist/components/questions/types/word-ordering-group/map-word-ordering-group-data.js +2 -0
- package/dist/components/questions/types/word-ordering-group/transform.js +4 -1
- package/dist/shared/lib/utils/question-reverse-transform.js +31 -5
- package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
- package/dist/shared/types/questions/match-word-to-picture-group.d.ts +3 -1
- package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
- package/dist/shared/types/questions/word-ordering-group.d.ts +1 -0
- package/package.json +1 -1
package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { ResolvedImage } from '../../../../../components/common/ResolvedImage';
|
|
|
8
8
|
import CambridgeYlePartBanner from '../../../../../components/themes/cambridge-yle/CambridgeYlePartBanner';
|
|
9
9
|
import CambridgeYleInstructionBanner from '../../../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
10
10
|
import CambridgeYleAudioPlayer from '../../../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
11
|
-
import { extractPlaceholderIndices, isBlankMatch, padStudentAnswers, splitStem, } from '../../../../questions/types/fill-in-blank-group/blank-utils';
|
|
11
|
+
import { extractPlaceholderIndices, isBlankMatch, padStudentAnswers, splitStem, toFillInBlankImageUrls, } from '../../../../questions/types/fill-in-blank-group/blank-utils';
|
|
12
12
|
function emptyAnswer(blankCount) {
|
|
13
13
|
return { answers: Array.from({ length: blankCount }, () => '') };
|
|
14
14
|
}
|
|
@@ -56,5 +56,5 @@ function ItemCard({ question, answer, onAnswerChange, isReviewMode, correctAnswe
|
|
|
56
56
|
}) })] }), isReviewMode && !question.isExample && expected.length > 0 && (_jsxs("div", { className: "mt-2 rounded-lg border border-emerald-200 bg-emerald-50 px-2.5 py-2 text-sm text-emerald-800", children: ["\u0110\u00E1p \u00E1n \u0111\u00FAng: ", expected.map((alts) => alts.join(' / ') || '(trống)').join(' | ')] }))] }));
|
|
57
57
|
}
|
|
58
58
|
export function MoversFillInBlankGroupRenderer({ partNumber, partName, questionCount, instruction, audioUrl, imageUrl, showHints = false, hints = [], questions, answers, onAnswerChange, isReviewMode = false, correctAnswerMap, }) {
|
|
59
|
-
return (_jsxs("div", { className: "relative flex h-full flex-col gap-4 overflow-y-auto custom-scrollbar sm:gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2 p-3 sm:gap-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode })] }), (audioUrl || imageUrl) && (_jsxs("div", { className: "sticky top-0 z-20 flex flex-col gap-3 border-b border-gray-100 bg-white/95 p-3 pb-2 shadow-sm backdrop-blur sm:px-6", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), imageUrl && (_jsx("div", { className: "flex
|
|
59
|
+
return (_jsxs("div", { className: "relative flex h-full flex-col gap-4 overflow-y-auto custom-scrollbar sm:gap-6", children: [_jsxs("div", { className: "flex flex-col gap-2 p-3 sm:gap-3 sm:px-6 sm:pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: isReviewMode }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, isReviewMode: isReviewMode })] }), (audioUrl || toFillInBlankImageUrls(imageUrl).length > 0) && (_jsxs("div", { className: "sticky top-0 z-20 flex flex-col gap-3 border-b border-gray-100 bg-white/95 p-3 pb-2 shadow-sm backdrop-blur sm:px-6", children: [audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true }), toFillInBlankImageUrls(imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: toFillInBlankImageUrls(imageUrl).map((url, imageIndex) => (_jsx(ResolvedImage, { src: url, alt: `Shared illustration ${imageIndex + 1}`, className: "max-h-[180px] rounded-lg object-contain" }, `group-image-${imageIndex}`))) }))] })), showHints && hints.length > 0 && !isReviewMode && (_jsx("div", { className: "flex flex-wrap gap-2 px-3 sm:px-6", children: hints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 p-3 sm:grid-cols-2 sm:px-6 lg:grid-cols-3", children: questions.map((question) => (_jsx(ItemCard, { question: question, answer: answers[question.id], onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, correctAnswer: correctAnswerMap?.[question.id] }, question.id))) })] }));
|
|
60
60
|
}
|
package/dist/components/exams/take/components/question-renderers/MoversWordOrderingGroupRenderer.js
CHANGED
|
@@ -14,6 +14,7 @@ export function MoversWordOrderingGroupRenderer({ partNumber, partName, question
|
|
|
14
14
|
items: data.questions.map((question) => ({
|
|
15
15
|
words: question.words,
|
|
16
16
|
answer: question.answer,
|
|
17
|
+
imageUrl: question.imageUrl,
|
|
17
18
|
isExample: question.isExample,
|
|
18
19
|
points: question.points,
|
|
19
20
|
questionNumber: question.questionNumber,
|
|
@@ -308,7 +308,7 @@ export declare function transformFillInBlankGroup(questions: ApiQuestion[]): {
|
|
|
308
308
|
questions: FillInBlankGroupQuestion[];
|
|
309
309
|
instruction: string;
|
|
310
310
|
audioUrl?: string;
|
|
311
|
-
imageUrl?: string;
|
|
311
|
+
imageUrl?: string | string[];
|
|
312
312
|
showHints?: boolean;
|
|
313
313
|
hints?: string[];
|
|
314
314
|
};
|
|
@@ -556,6 +556,7 @@ export interface WordOrderingGroupQuestionItem {
|
|
|
556
556
|
questionNumber: number;
|
|
557
557
|
words: string[];
|
|
558
558
|
answer: string[];
|
|
559
|
+
imageUrl?: string;
|
|
559
560
|
isExample: boolean;
|
|
560
561
|
points: number;
|
|
561
562
|
}
|
|
@@ -1424,11 +1424,13 @@ export function transformWordOrderingGroupTake(questions) {
|
|
|
1424
1424
|
const isExample = item.isExample === true;
|
|
1425
1425
|
const fromItem = Array.isArray(item.correctAnswer?.answer) ? item.correctAnswer.answer : [];
|
|
1426
1426
|
const fromParent = Array.isArray(parentCorrect[index]) ? parentCorrect[index] : [];
|
|
1427
|
+
const imageUrl = typeof item.content?.imageUrl === 'string' ? item.content.imageUrl.trim() : '';
|
|
1427
1428
|
return {
|
|
1428
1429
|
id: `${parent?.id || 'q'}-item-${index}`,
|
|
1429
1430
|
questionNumber: item.questionNumber || index + 1,
|
|
1430
1431
|
words: Array.isArray(item.content?.words) ? item.content.words : [],
|
|
1431
1432
|
answer: fromItem.length ? fromItem : fromParent,
|
|
1433
|
+
...(imageUrl ? { imageUrl } : {}),
|
|
1432
1434
|
isExample,
|
|
1433
1435
|
points: isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
1434
1436
|
};
|
|
@@ -6,9 +6,12 @@ export interface AnswerTheQuestionGroupItemData {
|
|
|
6
6
|
points: number;
|
|
7
7
|
questionNumber: number;
|
|
8
8
|
}
|
|
9
|
+
export declare function toAnswerTheQuestionGroupImageUrls(value?: unknown): string[];
|
|
9
10
|
export interface AnswerTheQuestionGroupData {
|
|
10
11
|
instruction: string;
|
|
11
12
|
passage?: string;
|
|
13
|
+
/** Shared images above the question list (1 or many). */
|
|
14
|
+
imageUrl?: string[];
|
|
12
15
|
items: AnswerTheQuestionGroupItemData[];
|
|
13
16
|
explanation?: string;
|
|
14
17
|
points?: number;
|
|
@@ -1 +1,12 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export function toAnswerTheQuestionGroupImageUrls(value) {
|
|
2
|
+
if (Array.isArray(value)) {
|
|
3
|
+
return value
|
|
4
|
+
.filter((url) => typeof url === 'string')
|
|
5
|
+
.map((url) => url.trim())
|
|
6
|
+
.filter((url) => url !== '');
|
|
7
|
+
}
|
|
8
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
9
|
+
return [value.trim()];
|
|
10
|
+
}
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
@@ -8,24 +8,36 @@ export interface FillInBlankGroupItemAnswer {
|
|
|
8
8
|
export interface FillInBlankGroupStudentItemAnswer {
|
|
9
9
|
answers: string[];
|
|
10
10
|
}
|
|
11
|
+
/** Per-blank choice list. Index i ↔ `{i}`. `null` = free-type (WFSF WITH_OPTIONS). */
|
|
12
|
+
export type FillInBlankWordBankEntry = {
|
|
13
|
+
options: string[];
|
|
14
|
+
} | null;
|
|
11
15
|
export interface FillInBlankGroupItemData {
|
|
12
16
|
question: string;
|
|
13
17
|
answers: string[][];
|
|
14
18
|
caseSensitive?: boolean;
|
|
15
|
-
imageUrl?: string;
|
|
19
|
+
imageUrl?: string | string[];
|
|
16
20
|
audioUrl?: string;
|
|
17
21
|
showHints?: boolean;
|
|
18
22
|
hints?: string[];
|
|
23
|
+
/** Per-blank dropdown options for this item. Overrides group `wordBank`. */
|
|
24
|
+
wordBank?: FillInBlankWordBankEntry[];
|
|
19
25
|
isExample?: boolean;
|
|
20
26
|
points: number;
|
|
21
27
|
questionNumber: number;
|
|
22
28
|
}
|
|
23
29
|
export interface FillInBlankGroupData {
|
|
24
30
|
instruction: string;
|
|
31
|
+
/** Shared reading passage (HTML) above the item list. */
|
|
32
|
+
passage?: string;
|
|
25
33
|
audioUrl?: string;
|
|
26
|
-
imageUrl?: string;
|
|
34
|
+
imageUrl?: string | string[];
|
|
27
35
|
showHints?: boolean;
|
|
28
36
|
hints?: string[];
|
|
37
|
+
/** When `WITH_OPTIONS`, blanks with `wordBank[i].options` render as dropdowns. */
|
|
38
|
+
viewMode?: 'WITH_OPTIONS';
|
|
39
|
+
/** Fallback per-blank options applied to every item that has no own `wordBank`. */
|
|
40
|
+
wordBank?: FillInBlankWordBankEntry[];
|
|
29
41
|
items: FillInBlankGroupItemData[];
|
|
30
42
|
explanation?: string;
|
|
31
43
|
points?: number;
|
|
@@ -2,14 +2,17 @@ import type { MutableRefObject } from 'react';
|
|
|
2
2
|
export declare const MATCH_WORD_TO_PICTURE_GROUP_DEFAULT_INSTRUCTION = "Look and complete.";
|
|
3
3
|
export declare const MATCH_WORD_TO_PICTURE_ITEM_TYPE: "MATCH_WORD_TO_PICTURE";
|
|
4
4
|
export interface MatchWordToPictureGroupItemData {
|
|
5
|
-
imageUrl
|
|
5
|
+
imageUrl?: string;
|
|
6
6
|
correctAnswer: string;
|
|
7
7
|
isExample?: boolean;
|
|
8
8
|
points: number;
|
|
9
9
|
questionNumber: number;
|
|
10
10
|
}
|
|
11
11
|
export interface MatchWordToPictureGroupData {
|
|
12
|
+
title?: string;
|
|
12
13
|
instruction: string;
|
|
14
|
+
/** Shared photo when items are numbered labels on one picture. */
|
|
15
|
+
imageUrl?: string;
|
|
13
16
|
wordBank: string[];
|
|
14
17
|
items: MatchWordToPictureGroupItemData[];
|
|
15
18
|
explanation?: string;
|
|
@@ -2,10 +2,12 @@ import type { MutableRefObject } from 'react';
|
|
|
2
2
|
export declare const MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION = "Look and match.";
|
|
3
3
|
export interface MatchingWithLinesRightItem {
|
|
4
4
|
id: string;
|
|
5
|
-
text
|
|
5
|
+
text?: string;
|
|
6
|
+
imageUrl?: string;
|
|
6
7
|
}
|
|
7
8
|
export interface MatchingWithLinesGroupItemData {
|
|
8
|
-
imageUrl
|
|
9
|
+
imageUrl?: string;
|
|
10
|
+
text?: string;
|
|
9
11
|
correctAnswer: string;
|
|
10
12
|
isExample?: boolean;
|
|
11
13
|
points: number;
|
|
@@ -14,6 +16,11 @@ export interface MatchingWithLinesGroupItemData {
|
|
|
14
16
|
export interface MatchingWithLinesGroupData {
|
|
15
17
|
title?: string;
|
|
16
18
|
instruction: string;
|
|
19
|
+
/** Shared reading passage (HTML) above the matching board. */
|
|
20
|
+
passage?: string;
|
|
21
|
+
audioUrl?: string;
|
|
22
|
+
/** Shared cover / portrait image (not a left/right match item). */
|
|
23
|
+
imageUrl?: string;
|
|
17
24
|
rightItems: MatchingWithLinesRightItem[];
|
|
18
25
|
items: MatchingWithLinesGroupItemData[];
|
|
19
26
|
explanation?: string;
|
package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { BookOpen, CircleHelp, Lightbulb, Sparkles, Star } from 'lucide-react';
|
|
4
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
5
|
+
import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
|
|
6
|
+
function ClientImageItem({ url, alt }) {
|
|
7
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(url);
|
|
8
|
+
const src = previewUrl ||
|
|
9
|
+
(typeof url === 'string' && (url.startsWith('http') || url.startsWith('blob:')) ? url : '');
|
|
10
|
+
if (!src && !isLoading) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
return (_jsx("div", { className: "max-w-sm overflow-hidden rounded-lg border border-gray-200 bg-gray-50", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-indigo-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
|
|
14
|
+
}
|
|
4
15
|
export function AnswerTheQuestionGroupClient({ questionData, isReviewMode = false, userAnswers = [], }) {
|
|
5
16
|
const items = questionData.items || [];
|
|
6
|
-
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
17
|
+
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap justify-center gap-3", children: toAnswerTheQuestionGroupImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(ClientImageItem, { url: url, alt: `Hình ảnh (${imageIndex + 1})` }, `group-image-${imageIndex}`))) })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), items.map((item, itemIndex) => {
|
|
7
18
|
const userValue = userAnswers[itemIndex] || '';
|
|
8
19
|
const expectedAnswers = Array.isArray(item.expectedAnswers)
|
|
9
20
|
? item.expectedAnswers.filter((answer) => answer.trim() !== '')
|
package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js
CHANGED
|
@@ -49,6 +49,9 @@ function isContentEmpty(html) {
|
|
|
49
49
|
export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
50
50
|
const [instruction, setInstruction] = useState(initialData?.instruction || DEFAULT_INSTRUCTION);
|
|
51
51
|
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
52
|
+
const [imageUrl] = useState(Array.isArray(initialData?.imageUrl)
|
|
53
|
+
? initialData.imageUrl.filter((url) => typeof url === 'string' && url.trim() !== '')
|
|
54
|
+
: []);
|
|
52
55
|
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
53
56
|
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
54
57
|
const [isClientMode, setIsClientMode] = useState(false);
|
|
@@ -61,6 +64,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
|
|
|
61
64
|
const buildPayload = (nextInstruction = instruction, nextPassage = passage, nextItems = items, nextExplanation = explanation) => ({
|
|
62
65
|
instruction: nextInstruction,
|
|
63
66
|
...(!isContentEmpty(nextPassage) ? { passage: nextPassage.trim() } : {}),
|
|
67
|
+
...(imageUrl.length ? { imageUrl } : {}),
|
|
64
68
|
items: nextItems,
|
|
65
69
|
explanation: nextExplanation,
|
|
66
70
|
points: sumPoints(nextItems),
|
|
@@ -77,7 +81,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
|
|
|
77
81
|
previousPayloadRef.current = serialized;
|
|
78
82
|
onUnsavedChangesChange?.(true);
|
|
79
83
|
debouncedOnChange(payload);
|
|
80
|
-
}, [instruction, passage, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
84
|
+
}, [instruction, passage, imageUrl, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
81
85
|
const validate = () => {
|
|
82
86
|
const nextErrors = [];
|
|
83
87
|
if (!instruction.trim()) {
|
|
@@ -111,7 +115,7 @@ export function AnswerTheQuestionGroupCreator({ initialData, onChange, externalE
|
|
|
111
115
|
},
|
|
112
116
|
getFormData: () => buildPayload(),
|
|
113
117
|
};
|
|
114
|
-
}, [validationRef, instruction, passage, items, explanation]);
|
|
118
|
+
}, [validationRef, instruction, passage, imageUrl, items, explanation]);
|
|
115
119
|
const updateItem = (index, patch) => {
|
|
116
120
|
setItems((prev) => {
|
|
117
121
|
const next = [...prev];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
|
|
1
2
|
const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
|
|
2
3
|
function isRecord(value) {
|
|
3
4
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -56,18 +57,22 @@ export function mapQuestionToAnswerTheQuestionGroupData(question) {
|
|
|
56
57
|
: [];
|
|
57
58
|
if (Array.isArray(content.items)) {
|
|
58
59
|
const items = content.items.map((item, index) => mapAnswerTheQuestionGroupItem(item, index, fallbackAnswers[index]?.expectedAnswers));
|
|
60
|
+
const imageUrl = toAnswerTheQuestionGroupImageUrls(asRecord(content.meta).imageUrl);
|
|
59
61
|
return {
|
|
60
62
|
instruction: asString(asRecord(content.meta).instruction) || DEFAULT_INSTRUCTION,
|
|
61
63
|
passage: asString(asRecord(content.meta).passage),
|
|
64
|
+
...(imageUrl.length ? { imageUrl } : {}),
|
|
62
65
|
items,
|
|
63
66
|
explanation,
|
|
64
67
|
points: items.reduce((total, item) => (item.isExample ? total : total + (item.points || 0)), 0),
|
|
65
68
|
};
|
|
66
69
|
}
|
|
67
70
|
if (Array.isArray(answer.items)) {
|
|
71
|
+
const imageUrl = toAnswerTheQuestionGroupImageUrls(answer.imageUrl);
|
|
68
72
|
return {
|
|
69
73
|
instruction: asString(answer.instruction, DEFAULT_INSTRUCTION),
|
|
70
74
|
passage: asString(answer.passage),
|
|
75
|
+
...(imageUrl.length ? { imageUrl } : {}),
|
|
71
76
|
items: answer.items,
|
|
72
77
|
explanation,
|
|
73
78
|
points: typeof answer.points === 'number' ? answer.points : 1,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toAnswerTheQuestionGroupImageUrls } from '../../_shared/types/answer-the-question-group.type';
|
|
1
2
|
const CHILD_QUESTION_TYPE = 'ANSWER_THE_QUESTION';
|
|
2
3
|
const GRADING_TYPE = 'AI';
|
|
3
4
|
const DEFAULT_INSTRUCTION = 'Read the text again and answer the questions.';
|
|
@@ -52,11 +53,13 @@ export const transformAnswerTheQuestionGroup = (question) => {
|
|
|
52
53
|
};
|
|
53
54
|
});
|
|
54
55
|
const passage = answerData?.passage?.trim();
|
|
56
|
+
const imageUrl = toAnswerTheQuestionGroupImageUrls(answerData?.imageUrl);
|
|
55
57
|
return {
|
|
56
58
|
apiContent: {
|
|
57
59
|
meta: {
|
|
58
60
|
instruction: answerData?.instruction || DEFAULT_INSTRUCTION,
|
|
59
61
|
...(!isContentEmpty(passage) ? { passage } : {}),
|
|
62
|
+
...(imageUrl.length ? { imageUrl } : {}),
|
|
60
63
|
gradingType: GRADING_TYPE,
|
|
61
64
|
isAiGraded: true,
|
|
62
65
|
},
|
|
@@ -1,15 +1,44 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { BookOpen, Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
4
4
|
import { cn } from '../../../../shared/lib/utils';
|
|
5
5
|
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
-
import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, splitStem, } from './blank-utils';
|
|
6
|
+
import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, resolveBlankOptions, splitStem, splitStemTextDecorations, toFillInBlankImageUrls, } from './blank-utils';
|
|
7
|
+
const STEM_MARK_STYLE = {
|
|
8
|
+
fontWeight: 700,
|
|
9
|
+
fontSize: 20,
|
|
10
|
+
lineHeight: 1,
|
|
11
|
+
};
|
|
12
|
+
function StemText({ value }) {
|
|
13
|
+
return (_jsx(_Fragment, { children: splitStemTextDecorations(value).map((fragment, fragmentIndex) => {
|
|
14
|
+
if (fragment.type === 'br') {
|
|
15
|
+
return _jsx("br", {}, `br-${fragmentIndex}`);
|
|
16
|
+
}
|
|
17
|
+
if (fragment.type === 'tick') {
|
|
18
|
+
return (_jsx("span", { style: { ...STEM_MARK_STYLE, color: '#16a34a' }, children: fragment.value }, `tick-${fragmentIndex}`));
|
|
19
|
+
}
|
|
20
|
+
if (fragment.type === 'cross') {
|
|
21
|
+
return (_jsx("span", { style: { ...STEM_MARK_STYLE, color: '#dc2626' }, children: fragment.value }, `cross-${fragmentIndex}`));
|
|
22
|
+
}
|
|
23
|
+
return _jsx("span", { children: fragment.value }, `text-${fragmentIndex}`);
|
|
24
|
+
}) }));
|
|
25
|
+
}
|
|
26
|
+
function isDisplayableMediaUrl(value) {
|
|
27
|
+
return (value.startsWith('http://')
|
|
28
|
+
|| value.startsWith('https://')
|
|
29
|
+
|| value.startsWith('blob:')
|
|
30
|
+
|| value.startsWith('data:')
|
|
31
|
+
|| value.startsWith('/'));
|
|
32
|
+
}
|
|
7
33
|
function MediaPreview({ src, kind, alt }) {
|
|
8
34
|
const { previewUrl, isLoading } = usePresignedFileUrl(src);
|
|
9
35
|
if (isLoading) {
|
|
10
36
|
return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
|
|
11
37
|
}
|
|
12
|
-
const url = previewUrl || src;
|
|
38
|
+
const url = previewUrl || (isDisplayableMediaUrl(src) ? src : '');
|
|
39
|
+
if (!url) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
13
42
|
if (kind === 'audio') {
|
|
14
43
|
return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full" });
|
|
15
44
|
}
|
|
@@ -35,30 +64,36 @@ export function FillInBlankGroupClient({ questionData, isReviewMode = false, use
|
|
|
35
64
|
next[itemIndex] = { answers: current };
|
|
36
65
|
onAnswerChange(next);
|
|
37
66
|
};
|
|
38
|
-
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), questionData.imageUrl && (_jsx("div", { className: "flex
|
|
67
|
+
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.passage && (_jsxs("div", { className: "rounded-xl border border-gray-200 bg-white p-5 shadow-xs", children: [_jsxs("div", { className: "mb-2.5 flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-indigo-700", children: [_jsx(BookOpen, { className: "h-4 w-4" }), _jsx("span", { children: "B\u00E0i \u0111\u1ECDc (Reading Passage)" })] }), _jsx("div", { className: "prose prose-sm max-w-none leading-relaxed text-gray-800", dangerouslySetInnerHTML: { __html: questionData.passage } })] })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), toFillInBlankImageUrls(questionData.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: toFillInBlankImageUrls(questionData.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, kind: "image", alt: `Group image ${imageIndex + 1}` }, `group-image-${imageIndex}`))) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
|
|
39
68
|
const blankCount = blankSlotCount(item.question || '{0}');
|
|
40
69
|
const placeholderIndices = extractPlaceholderIndices(item.question || '{0}');
|
|
41
70
|
const userValue = padStudentAnswers(userAnswers[itemIndex], blankCount);
|
|
42
71
|
const stem = splitStem(item.question || '{0}');
|
|
43
72
|
const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
|
|
44
|
-
return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: item.questionNumber || itemIndex + 1 }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "
|
|
73
|
+
return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: item.questionNumber || itemIndex + 1 }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "space-y-3 p-3", children: [toFillInBlankImageUrls(item.imageUrl).length > 0 && (_jsx("div", { className: "flex flex-wrap items-center gap-2", children: toFillInBlankImageUrls(item.imageUrl).map((url, imageIndex) => (_jsx(MediaPreview, { src: url, kind: "image", alt: `Item ${item.questionNumber} image ${imageIndex + 1}` }, `${item.questionNumber}-${imageIndex}`))) })), _jsxs("div", { className: "min-w-0 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
|
|
45
74
|
if (part.type === 'text') {
|
|
46
|
-
return _jsx("span", { children: part.value }, partIndex);
|
|
75
|
+
return (_jsx("span", { children: _jsx(StemText, { value: part.value }) }, partIndex));
|
|
47
76
|
}
|
|
48
77
|
const alts = item.answers[part.index] || [];
|
|
49
78
|
const student = userValue[part.index] || '';
|
|
50
79
|
const canEdit = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
51
80
|
const correct = isReviewMode && isBlankMatch(student, alts);
|
|
52
81
|
const wrong = isReviewMode && student.trim().length > 0 && !correct;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
const blankOptions = resolveBlankOptions(part.index, item.wordBank, questionData.wordBank);
|
|
83
|
+
const useOptions = Boolean(blankOptions);
|
|
84
|
+
const fieldClass = cn('mx-1 inline-block rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', useOptions ? 'min-w-[5.5rem]' : 'w-16', item.isExample
|
|
85
|
+
? 'border-amber-300 bg-amber-50 text-amber-800'
|
|
86
|
+
: correct
|
|
87
|
+
? 'border-emerald-400 bg-emerald-50 text-emerald-800'
|
|
88
|
+
: wrong
|
|
89
|
+
? 'border-rose-400 bg-rose-50 text-rose-800'
|
|
90
|
+
: canEdit
|
|
91
|
+
? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
|
|
92
|
+
: 'border-blue-200 bg-blue-50 text-blue-900');
|
|
93
|
+
if (useOptions && blankOptions) {
|
|
94
|
+
return (_jsxs("select", { disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), className: fieldClass, children: [!item.isExample && _jsx("option", { value: "", children: "..." }), blankOptions.map((option) => (_jsx("option", { value: option, children: option }, option)))] }, partIndex));
|
|
95
|
+
}
|
|
96
|
+
return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: fieldClass }, partIndex));
|
|
62
97
|
})] })] }), isReviewMode && !item.isExample && (_jsxs("div", { className: "border-t border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["\u0110\u00E1p \u00E1n: ", placeholderIndices.map((index) => joinSlashAnswers(item.answers[index]) || '(trống)').join(' | ')] }))] }, `${item.questionNumber}-${itemIndex}`));
|
|
63
98
|
}) }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
|
|
64
99
|
}
|
|
@@ -8,12 +8,13 @@ import { Textarea } from '../../../../components/ui/textarea';
|
|
|
8
8
|
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
9
|
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
10
|
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
|
|
11
12
|
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
12
13
|
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
13
14
|
import { FillInBlankGroupClient } from './FillInBlankGroupClient';
|
|
14
15
|
import { FillInBlankGroupMediaField } from './FillInBlankGroupMediaField';
|
|
15
16
|
import { createEmptyFillInBlankGroupItem } from './map-fill-in-blank-group-data';
|
|
16
|
-
import { blankSlotCount, extractPlaceholderIndices, joinSlashAnswers, normalizeAnswersMatrix, parseSlashAnswers, } from './blank-utils';
|
|
17
|
+
import { blankSlotCount, extractPlaceholderIndices, fromFillInBlankImageUrls, hasAnyBlankOptions, joinSlashAnswers, normalizeAnswersMatrix, normalizeWordBank, parseSlashAnswers, toFillInBlankImageUrls, } from './blank-utils';
|
|
17
18
|
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
18
19
|
function parsePointsValue(raw, allowZero = false) {
|
|
19
20
|
if (raw === '')
|
|
@@ -27,6 +28,7 @@ function parsePointsValue(raw, allowZero = false) {
|
|
|
27
28
|
function syncItemAnswers(item) {
|
|
28
29
|
const indices = extractPlaceholderIndices(item.question || '');
|
|
29
30
|
const requiredLength = blankSlotCount(item.question || '');
|
|
31
|
+
const wordBank = normalizeWordBank(item.wordBank, requiredLength);
|
|
30
32
|
return {
|
|
31
33
|
...item,
|
|
32
34
|
answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
|
|
@@ -34,6 +36,7 @@ function syncItemAnswers(item) {
|
|
|
34
36
|
return [];
|
|
35
37
|
return alts.length > 0 ? alts : [''];
|
|
36
38
|
}),
|
|
39
|
+
...(wordBank ? { wordBank } : { wordBank: undefined }),
|
|
37
40
|
};
|
|
38
41
|
}
|
|
39
42
|
function normalizeItems(rawItems) {
|
|
@@ -54,8 +57,9 @@ function sumPoints(items) {
|
|
|
54
57
|
}
|
|
55
58
|
export function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
56
59
|
const [instruction, setInstruction] = useState(initialData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION);
|
|
60
|
+
const [passage, setPassage] = useState(initialData?.passage || '');
|
|
57
61
|
const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
|
|
58
|
-
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl
|
|
62
|
+
const [imageUrl, setImageUrl] = useState(fromFillInBlankImageUrls(toFillInBlankImageUrls(initialData?.imageUrl)) ?? '');
|
|
59
63
|
const [showHints, setShowHints] = useState(initialData?.showHints === true);
|
|
60
64
|
const [hintsText, setHintsText] = useState((initialData?.hints || []).join(' / '));
|
|
61
65
|
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
@@ -68,16 +72,22 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
|
|
|
68
72
|
onChangeRef.current = onChange;
|
|
69
73
|
}, [onChange]);
|
|
70
74
|
const parseHints = (value) => parseSlashAnswers(value);
|
|
71
|
-
const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText) =>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText, nextPassage = passage) => {
|
|
76
|
+
const viewMode = hasAnyBlankOptions(nextItems) ? 'WITH_OPTIONS' : undefined;
|
|
77
|
+
const groupImageUrl = fromFillInBlankImageUrls(toFillInBlankImageUrls(nextImageUrl));
|
|
78
|
+
return {
|
|
79
|
+
instruction: nextInstruction,
|
|
80
|
+
...(nextPassage.trim() ? { passage: nextPassage } : {}),
|
|
81
|
+
audioUrl: nextAudioUrl,
|
|
82
|
+
...(groupImageUrl !== undefined ? { imageUrl: groupImageUrl } : {}),
|
|
83
|
+
showHints: nextShowHints,
|
|
84
|
+
hints: parseHints(nextHintsText),
|
|
85
|
+
...(viewMode ? { viewMode } : {}),
|
|
86
|
+
items: nextItems,
|
|
87
|
+
explanation: nextExplanation,
|
|
88
|
+
points: sumPoints(nextItems),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
81
91
|
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
82
92
|
onChangeRef.current?.(payload);
|
|
83
93
|
}, 300);
|
|
@@ -89,7 +99,7 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
|
|
|
89
99
|
debouncedOnChange(payload);
|
|
90
100
|
onUnsavedChangesChange?.(true);
|
|
91
101
|
}
|
|
92
|
-
}, [instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
102
|
+
}, [instruction, passage, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
93
103
|
const validate = () => {
|
|
94
104
|
const nextErrors = [];
|
|
95
105
|
if (!instruction.trim()) {
|
|
@@ -111,6 +121,16 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
|
|
|
111
121
|
if (!alts.some((alt) => alt.trim())) {
|
|
112
122
|
nextErrors.push(`Câu ${index + 1}: Blank {${blankIndex}} cần ít nhất một đáp án.`);
|
|
113
123
|
}
|
|
124
|
+
const options = item.wordBank?.[blankIndex]?.options || [];
|
|
125
|
+
if (options.length >= 2) {
|
|
126
|
+
const missing = alts
|
|
127
|
+
.map((alt) => alt.trim())
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.filter((alt) => !options.some((option) => option.toLowerCase() === alt.toLowerCase()));
|
|
130
|
+
if (missing.length > 0) {
|
|
131
|
+
nextErrors.push(`Câu ${index + 1}: Đáp án {${blankIndex}} phải nằm trong options (${options.join(' / ')}).`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
114
134
|
});
|
|
115
135
|
});
|
|
116
136
|
setErrors(nextErrors);
|
|
@@ -123,7 +143,7 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
|
|
|
123
143
|
getFormData: () => buildPayload(),
|
|
124
144
|
};
|
|
125
145
|
}
|
|
126
|
-
}, [validationRef, instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
|
|
146
|
+
}, [validationRef, instruction, passage, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
|
|
127
147
|
const updateItem = (index, patch) => {
|
|
128
148
|
setItems((prev) => {
|
|
129
149
|
const next = [...prev];
|
|
@@ -148,11 +168,23 @@ export function FillInBlankGroupCreator({ initialData, onChange, externalErrors,
|
|
|
148
168
|
};
|
|
149
169
|
const currentPayload = buildPayload();
|
|
150
170
|
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
151
|
-
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: imageUrl, onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
171
|
+
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx("div", { className: "mt-1", children: _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" }) })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: toFillInBlankImageUrls(imageUrl)[0] || '', onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
|
|
152
172
|
points: parsePointsValue(event.target.value, true),
|
|
153
|
-
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { className: "grid gap-3 md:grid-cols-2", children: [_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
|
|
174
|
+
const next = [...item.answers];
|
|
175
|
+
next[blankIndex] = parseSlashAnswers(e.target.value);
|
|
176
|
+
updateItem(index, { answers: next });
|
|
177
|
+
}, placeholder: "is / 's", className: "mt-1" })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `options-${index}-${blankIndex}`, children: ["Options blank ", '{' + blankIndex + '}', " (dropdown, c\u00E1ch nhau /)"] }), _jsx(Input, { id: `options-${index}-${blankIndex}`, value: joinSlashAnswers(item.wordBank?.[blankIndex]?.options), onChange: (e) => {
|
|
178
|
+
const requiredLength = blankSlotCount(item.question);
|
|
179
|
+
const next = [...(item.wordBank || [])];
|
|
180
|
+
while (next.length < requiredLength)
|
|
181
|
+
next.push(null);
|
|
182
|
+
const options = parseSlashAnswers(e.target.value);
|
|
183
|
+
next[blankIndex] = options.length >= 2 ? { options } : null;
|
|
184
|
+
updateItem(index, {
|
|
185
|
+
wordBank: next.some((entry) => entry && entry.options.length >= 2)
|
|
186
|
+
? next
|
|
187
|
+
: undefined,
|
|
188
|
+
});
|
|
189
|
+
}, placeholder: "can / can't", className: "mt-1" })] })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: Array.isArray(item.imageUrl) ? (item.imageUrl[0] || '') : (item.imageUrl || ''), onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, index))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
|
|
158
190
|
}
|