@tinyweb_dev/oe-exam-sdk 0.2.12 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
  3. package/dist/components/exams/take/utils/question-transformers.d.ts +1 -1
  4. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +3 -0
  5. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +12 -1
  6. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
  7. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +4 -1
  8. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
  9. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +12 -1
  10. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +6 -2
  11. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +5 -0
  12. package/dist/components/questions/types/answer-the-question-group/transform.js +3 -0
  13. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
  14. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
  15. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
  16. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
  17. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
  18. package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
  19. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +6 -6
  20. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -7
  21. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +4 -0
  22. package/dist/components/questions/types/match-word-to-picture-group/transform.js +9 -1
  23. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
  24. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
  25. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
  26. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
  27. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
  28. package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
  29. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
  30. package/dist/shared/lib/utils/question-reverse-transform.js +29 -5
  31. package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
  32. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +3 -1
  33. package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
  34. package/package.json +1 -1
@@ -9,7 +9,7 @@ interface MoversFillInBlankGroupRendererProps {
9
9
  questionCount: number;
10
10
  instruction: string;
11
11
  audioUrl?: string;
12
- imageUrl?: string;
12
+ imageUrl?: string | string[];
13
13
  showHints?: boolean;
14
14
  hints?: string[];
15
15
  questions: FillInBlankGroupQuestion[];
@@ -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 justify-center", children: _jsx(ResolvedImage, { src: imageUrl, alt: "Shared illustration", className: "max-h-[180px] rounded-lg object-contain" }) }))] })), 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))) })] }));
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
  }
@@ -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
  };
@@ -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: string;
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: string;
5
+ text?: string;
6
+ imageUrl?: string;
6
7
  }
7
8
  export interface MatchingWithLinesGroupItemData {
8
- imageUrl: string;
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;
@@ -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() !== '')
@@ -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 justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(MediaPreview, { src: questionData.imageUrl, kind: "image", alt: "Group image" }) })), 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) => {
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: "flex items-center gap-3 p-3", children: [item.imageUrl && (_jsx(MediaPreview, { src: item.imageUrl, kind: "image", alt: `Item ${item.questionNumber}` })), _jsxs("div", { className: "min-w-0 flex-1 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) => {
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
- 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: cn('mx-1 inline-block w-16 rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', item.isExample
54
- ? 'border-amber-300 bg-amber-50 text-amber-800'
55
- : correct
56
- ? 'border-emerald-400 bg-emerald-50 text-emerald-800'
57
- : wrong
58
- ? 'border-rose-400 bg-rose-50 text-rose-800'
59
- : canEdit
60
- ? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
61
- : 'border-blue-200 bg-blue-50 text-blue-900') }, partIndex));
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
- instruction: nextInstruction,
73
- audioUrl: nextAudioUrl,
74
- imageUrl: nextImageUrl,
75
- showHints: nextShowHints,
76
- hints: parseHints(nextHintsText),
77
- items: nextItems,
78
- explanation: nextExplanation,
79
- points: sumPoints(nextItems),
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
- const next = [...item.answers];
155
- next[blankIndex] = parseSlashAnswers(e.target.value);
156
- updateItem(index, { answers: next });
157
- }, placeholder: "is / 's", 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: 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 }) })] })] }))] }));
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
  }
@@ -1,3 +1,4 @@
1
+ import type { FillInBlankWordBankEntry } from '../../_shared/types/fill-in-blank-group.type';
1
2
  import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
2
3
  export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
3
4
  export type FillBlankStemPart = {
@@ -15,6 +16,28 @@ export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: nu
15
16
  export declare function joinSlashAnswers(alts: string[] | undefined): string;
16
17
  export declare function parseSlashAnswers(text: string): string[];
17
18
  export declare function splitStem(question: string): FillBlankStemPart[];
19
+ export type StemTextDecoration = {
20
+ type: 'text';
21
+ value: string;
22
+ } | {
23
+ type: 'tick';
24
+ value: string;
25
+ } | {
26
+ type: 'cross';
27
+ value: string;
28
+ } | {
29
+ type: 'br';
30
+ };
31
+ /** Split a stem text fragment into ticks, crosses, line breaks, and plain text. */
32
+ export declare function splitStemTextDecorations(text: string): StemTextDecoration[];
18
33
  export declare function normalizeStudentText(value: string): string;
19
34
  export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
35
+ export declare function parseWordBankEntry(raw: unknown): FillInBlankWordBankEntry;
36
+ export declare function normalizeWordBank(raw: unknown, requiredLength?: number): FillInBlankWordBankEntry[] | undefined;
37
+ export declare function resolveBlankOptions(blankIndex: number, itemWordBank?: FillInBlankWordBankEntry[], groupWordBank?: FillInBlankWordBankEntry[]): string[] | undefined;
38
+ export declare function hasAnyBlankOptions(items: Array<{
39
+ wordBank?: FillInBlankWordBankEntry[];
40
+ }>, groupWordBank?: FillInBlankWordBankEntry[]): boolean;
41
+ export declare function toFillInBlankImageUrls(value: unknown): string[];
42
+ export declare function fromFillInBlankImageUrls(urls: string[]): string | string[] | undefined;
20
43
  export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];