@hydralms/components 0.1.3 → 0.2.0
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/ForumBoard-CHXU3mjC.js +2207 -0
- package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
- package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
- package/dist/assessment-toolbar/index.d.ts +5 -1
- package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
- package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
- package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
- package/dist/assessment-toolbar/types.d.ts +52 -4
- package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
- package/dist/common/index.d.ts +2 -1
- package/dist/common/stepper.d.ts +6 -0
- package/dist/common/types.d.ts +37 -0
- package/dist/components.css +1 -1
- package/dist/content/attachment-list.d.ts +6 -0
- package/dist/content/content-block.d.ts +1 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/content/types.d.ts +39 -0
- package/dist/curriculum/curriculum-item.d.ts +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +551 -312
- package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
- package/dist/modules/AssignmentModule/types.d.ts +65 -0
- package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
- package/dist/modules/CertificateModule/types.d.ts +49 -0
- package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
- package/dist/modules/DiscussionModule/types.d.ts +47 -0
- package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
- package/dist/modules/ExamModule/types.d.ts +64 -0
- package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
- package/dist/modules/GradeCenterModule/types.d.ts +54 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
- package/dist/modules/QuizModule/types.d.ts +6 -1
- package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
- package/dist/modules/SurveyModule/types.d.ts +49 -0
- package/dist/modules/index.d.ts +12 -0
- package/dist/modules.cjs +1 -0
- package/dist/modules.js +1422 -0
- package/dist/progress/achievement-badge.d.ts +6 -0
- package/dist/progress/activity-timeline.d.ts +6 -0
- package/dist/progress/index.d.ts +4 -1
- package/dist/progress/stat-card.d.ts +1 -1
- package/dist/progress/streak-badge.d.ts +6 -0
- package/dist/progress/types.d.ts +97 -0
- package/dist/questions/essay.d.ts +1 -1
- package/dist/questions/hotspot.d.ts +21 -0
- package/dist/questions/index.d.ts +9 -1
- package/dist/questions/inline-choice.d.ts +21 -0
- package/dist/questions/matching.d.ts +22 -0
- package/dist/questions/numeric.d.ts +11 -0
- package/dist/questions/ordering.d.ts +12 -0
- package/dist/questions/scenario.d.ts +23 -0
- package/dist/questions/scoring.d.ts +22 -0
- package/dist/questions/spreadsheet.d.ts +29 -0
- package/dist/questions/types.d.ts +106 -1
- package/dist/questions/use-drag-reorder.d.ts +17 -0
- package/dist/sections/CertificateViewer/types.d.ts +7 -5
- package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
- package/dist/sections/ExamSession/types.d.ts +6 -1
- package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
- package/dist/sections/ForumBoard/types.d.ts +64 -0
- package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
- package/dist/sections/QuizSession/types.d.ts +6 -1
- package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
- package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
- package/dist/sections/RubricView/RubricView.d.ts +9 -0
- package/dist/sections/RubricView/types.d.ts +50 -0
- package/dist/sections/index.d.ts +7 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +250 -1715
- package/dist/social/post-card.d.ts +1 -1
- package/dist/tabs-DRM2Iq_J.cjs +172 -0
- package/dist/tabs-Wf3h_Cx3.js +21580 -0
- package/dist/ui/alert.d.ts +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/drawer.d.ts +84 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/progress.d.ts +1 -1
- package/dist/ui/rich-text-editor.d.ts +30 -0
- package/dist/ui/rich-text-toolbar.d.ts +8 -0
- package/dist/utils/array-utils.d.ts +4 -0
- package/dist/utils/flatten-leaves.d.ts +6 -0
- package/dist/utils/format-file-size.d.ts +1 -0
- package/dist/utils/format-timestamp.d.ts +1 -0
- package/dist/utils/is-empty-html.d.ts +5 -0
- package/dist/utils/shuffle.d.ts +1 -0
- package/dist/utils/string-utils.d.ts +12 -0
- package/dist/video/video-bookmark.d.ts +1 -1
- package/dist/video/video-playlist-item.d.ts +1 -1
- package/package.json +92 -3
- package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
- package/src/assessment-toolbar/index.ts +6 -0
- package/src/assessment-toolbar/question-header-bar.tsx +61 -0
- package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
- package/src/assessment-toolbar/question-navigator.tsx +3 -31
- package/src/assessment-toolbar/timer-display.tsx +2 -2
- package/src/assessment-toolbar/types.ts +54 -4
- package/src/assessment-toolbar/use-countdown.ts +153 -0
- package/src/common/index.ts +3 -0
- package/src/common/search-input.tsx +7 -6
- package/src/common/stepper.tsx +100 -0
- package/src/common/types.ts +39 -0
- package/src/content/attachment-list.tsx +90 -0
- package/src/content/content-block.tsx +4 -2
- package/src/content/file-upload-zone.tsx +1 -6
- package/src/content/index.ts +3 -0
- package/src/content/types.ts +41 -0
- package/src/curriculum/curriculum-item.tsx +7 -3
- package/src/feedback/feedback-banner.tsx +12 -14
- package/src/flashcards/flashcard-deck.tsx +1 -9
- package/src/flashcards/flashcard.tsx +1 -1
- package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
- package/src/modules/AssignmentModule/types.ts +73 -0
- package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
- package/src/modules/CertificateModule/types.ts +47 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
- package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
- package/src/modules/DiscussionModule/types.ts +54 -0
- package/src/modules/ExamModule/ExamModule.tsx +285 -0
- package/src/modules/ExamModule/types.ts +66 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
- package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
- package/src/modules/GradeCenterModule/types.ts +63 -0
- package/src/modules/QuizModule/QuizModule.tsx +88 -88
- package/src/modules/QuizModule/types.ts +6 -1
- package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
- package/src/modules/SurveyModule/types.ts +51 -0
- package/src/modules/index.ts +24 -0
- package/src/progress/achievement-badge.tsx +52 -0
- package/src/progress/activity-timeline.tsx +84 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/stat-card.tsx +30 -18
- package/src/progress/streak-badge.tsx +35 -0
- package/src/progress/types.ts +101 -0
- package/src/questions/choice.tsx +7 -9
- package/src/questions/essay.tsx +23 -25
- package/src/questions/fill-in-the-blank.tsx +13 -16
- package/src/questions/hotspot.tsx +154 -0
- package/src/questions/index.ts +16 -0
- package/src/questions/inline-choice.tsx +151 -0
- package/src/questions/matching.tsx +228 -0
- package/src/questions/multiple-choice.tsx +7 -9
- package/src/questions/numeric.tsx +102 -0
- package/src/questions/ordering.tsx +159 -0
- package/src/questions/question-renderer.tsx +21 -0
- package/src/questions/scenario.tsx +140 -0
- package/src/questions/scoring.ts +201 -0
- package/src/questions/spreadsheet.tsx +259 -0
- package/src/questions/true-false.tsx +7 -9
- package/src/questions/types.ts +123 -1
- package/src/questions/use-drag-reorder.ts +80 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
- package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
- package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
- package/src/sections/CertificateViewer/types.ts +13 -5
- package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
- package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
- package/src/sections/ExamSession/ExamSession.tsx +44 -7
- package/src/sections/ExamSession/types.ts +6 -1
- package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
- package/src/sections/ForumBoard/types.ts +67 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
- package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
- package/src/sections/LessonPage/LessonPage.tsx +5 -9
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
- package/src/sections/QuizSession/QuizSession.tsx +67 -8
- package/src/sections/QuizSession/types.ts +6 -1
- package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
- package/src/sections/RequirementsChecklist/types.ts +38 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
- package/src/sections/RubricView/RubricView.tsx +138 -0
- package/src/sections/RubricView/types.ts +52 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
- package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
- package/src/sections/index.ts +20 -1
- package/src/social/post-card.tsx +8 -19
- package/src/social/user-avatar.tsx +1 -0
- package/src/styles/globals.css +13 -0
- package/src/ui/drawer.tsx +600 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/rich-text-editor.tsx +109 -0
- package/src/ui/rich-text-toolbar.tsx +156 -0
- package/src/utils/array-utils.ts +17 -0
- package/src/utils/flatten-leaves.ts +17 -0
- package/src/utils/format-file-size.ts +5 -0
- package/src/utils/format-timestamp.ts +13 -0
- package/src/utils/is-empty-html.ts +7 -0
- package/src/utils/shuffle.ts +8 -0
- package/src/utils/string-utils.ts +30 -0
- package/src/video/video-bookmark.tsx +4 -3
- package/src/video/video-chapter-list.tsx +9 -4
- package/src/video/video-player.tsx +11 -4
- package/src/video/video-playlist-item.tsx +8 -3
- package/src/video/video-thumbnail-card.tsx +4 -0
- package/src/video/video-transcript.tsx +8 -5
- package/dist/table-BrS5cDQu.js +0 -2510
- package/dist/table-D6AkBBEo.cjs +0 -1
|
@@ -4,6 +4,13 @@ import { Choice } from "./choice";
|
|
|
4
4
|
import { TrueFalse } from "./true-false";
|
|
5
5
|
import { FillInTheBlank } from "./fill-in-the-blank";
|
|
6
6
|
import { Essay } from "./essay";
|
|
7
|
+
import { Numeric } from "./numeric";
|
|
8
|
+
import { Ordering } from "./ordering";
|
|
9
|
+
import { Matching } from "./matching";
|
|
10
|
+
import { Hotspot } from "./hotspot";
|
|
11
|
+
import { InlineChoice } from "./inline-choice";
|
|
12
|
+
import { Scenario } from "./scenario";
|
|
13
|
+
import { Spreadsheet } from "./spreadsheet";
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* QuestionRenderer dispatches to the appropriate question component based on question type.
|
|
@@ -27,6 +34,20 @@ export const QuestionRenderer = (props: QuestionProps) => {
|
|
|
27
34
|
return <FillInTheBlank {...props} />;
|
|
28
35
|
case "essay":
|
|
29
36
|
return <Essay {...props} />;
|
|
37
|
+
case "numeric":
|
|
38
|
+
return <Numeric {...props} />;
|
|
39
|
+
case "ordering":
|
|
40
|
+
return <Ordering {...props} />;
|
|
41
|
+
case "matching":
|
|
42
|
+
return <Matching {...props} />;
|
|
43
|
+
case "hotspot":
|
|
44
|
+
return <Hotspot {...props} />;
|
|
45
|
+
case "inline_choice":
|
|
46
|
+
return <InlineChoice {...props} />;
|
|
47
|
+
case "scenario":
|
|
48
|
+
return <Scenario {...props} />;
|
|
49
|
+
case "spreadsheet":
|
|
50
|
+
return <Spreadsheet {...props} />;
|
|
30
51
|
default:
|
|
31
52
|
return (
|
|
32
53
|
<p className="text-muted-foreground">
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
3
|
+
import { Separator } from "../ui/separator";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { QuestionRenderer } from "./question-renderer";
|
|
6
|
+
import type { QuestionProps, SessionAnswer } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Scenario renders a shared context/stimulus with multiple sub-questions.
|
|
10
|
+
*
|
|
11
|
+
* Sub-questions can be any existing question type (choice, numeric, matching, etc.).
|
|
12
|
+
* The shared context is displayed prominently above all sub-questions.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <Scenario
|
|
16
|
+
* question={{
|
|
17
|
+
* uid: "s1",
|
|
18
|
+
* type: "scenario",
|
|
19
|
+
* content: "<p>Read the following passage and answer the questions below.</p>",
|
|
20
|
+
* scenarioQuestions: [
|
|
21
|
+
* { uid: "sq1", type: "choice", content: "What is the main idea?", answers: [...] },
|
|
22
|
+
* { uid: "sq2", type: "true_false", content: "The author agrees.", answers: [...] },
|
|
23
|
+
* ],
|
|
24
|
+
* scenarioScoringMode: "per_question",
|
|
25
|
+
* }}
|
|
26
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
27
|
+
* />
|
|
28
|
+
*/
|
|
29
|
+
export const Scenario = ({
|
|
30
|
+
question,
|
|
31
|
+
sessionAnswers,
|
|
32
|
+
onAnswer,
|
|
33
|
+
readOnly = false,
|
|
34
|
+
showCorrectAnswers = false,
|
|
35
|
+
disabled = false,
|
|
36
|
+
}: QuestionProps) => {
|
|
37
|
+
const subQuestions = question.scenarioQuestions ?? [];
|
|
38
|
+
|
|
39
|
+
// Track the latest answers from each sub-question in a ref so
|
|
40
|
+
// handleSubAnswer always merges against the freshest state.
|
|
41
|
+
const latestBySubQ = useRef<Map<string, { uid: string; content?: string }[]>>(
|
|
42
|
+
new Map(),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// Parse session answers into per-sub-question groups.
|
|
46
|
+
// SessionAnswer.answerUid format: "subQUid::originalAnswerUid"
|
|
47
|
+
const answersBySubQ = useMemo(() => {
|
|
48
|
+
const map = new Map<string, SessionAnswer[]>();
|
|
49
|
+
for (const sa of sessionAnswers ?? []) {
|
|
50
|
+
const sepIdx = sa.answerUid.indexOf("::");
|
|
51
|
+
if (sepIdx === -1) continue;
|
|
52
|
+
const subQUid = sa.answerUid.slice(0, sepIdx);
|
|
53
|
+
const originalAnswerUid = sa.answerUid.slice(sepIdx + 2);
|
|
54
|
+
const list = map.get(subQUid) ?? [];
|
|
55
|
+
list.push({ ...sa, uid: subQUid, answerUid: originalAnswerUid });
|
|
56
|
+
map.set(subQUid, list);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sync the ref with what we parsed from props
|
|
60
|
+
latestBySubQ.current = new Map();
|
|
61
|
+
for (const [subQUid, answers] of map) {
|
|
62
|
+
latestBySubQ.current.set(
|
|
63
|
+
subQUid,
|
|
64
|
+
answers.map((a) => ({ uid: `${subQUid}::${a.answerUid}`, content: a.content })),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return map;
|
|
69
|
+
}, [sessionAnswers]);
|
|
70
|
+
|
|
71
|
+
const handleSubAnswer = (
|
|
72
|
+
subQuestionUid: string,
|
|
73
|
+
rawAnswers: { uid: string; content?: string }[],
|
|
74
|
+
) => {
|
|
75
|
+
// Prefix each answer uid with the sub-question uid
|
|
76
|
+
const prefixed = rawAnswers.map((a) => ({
|
|
77
|
+
uid: `${subQuestionUid}::${a.uid}`,
|
|
78
|
+
content: a.content,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// Update our ref
|
|
82
|
+
latestBySubQ.current.set(subQuestionUid, prefixed);
|
|
83
|
+
|
|
84
|
+
// Merge all sub-question answers into a single flat array
|
|
85
|
+
const merged: { uid: string; content?: string }[] = [];
|
|
86
|
+
for (const answers of latestBySubQ.current.values()) {
|
|
87
|
+
merged.push(...answers);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onAnswer?.(merged);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex flex-col gap-6">
|
|
95
|
+
{/* Shared context / stimulus */}
|
|
96
|
+
<div
|
|
97
|
+
className={cn(
|
|
98
|
+
"rounded-lg border border-border bg-muted/30 px-4 py-3",
|
|
99
|
+
"prose prose-sm max-w-none text-foreground",
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
|
103
|
+
Scenario
|
|
104
|
+
</div>
|
|
105
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<Separator />
|
|
109
|
+
|
|
110
|
+
{/* Sub-questions */}
|
|
111
|
+
<div className="flex flex-col gap-5">
|
|
112
|
+
{subQuestions.map((sq, idx) => (
|
|
113
|
+
<div key={sq.uid} className="flex flex-col gap-1">
|
|
114
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
115
|
+
Part {String.fromCharCode(65 + idx)}
|
|
116
|
+
</span>
|
|
117
|
+
<QuestionRenderer
|
|
118
|
+
question={sq}
|
|
119
|
+
sessionAnswers={answersBySubQ.get(sq.uid) ?? []}
|
|
120
|
+
onAnswer={(answers) => handleSubAnswer(sq.uid, answers)}
|
|
121
|
+
readOnly={readOnly}
|
|
122
|
+
showCorrectAnswers={showCorrectAnswers}
|
|
123
|
+
disabled={disabled}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Scenario-level explanation */}
|
|
130
|
+
{showCorrectAnswers && question.explanation && (
|
|
131
|
+
<Alert className="mt-2">
|
|
132
|
+
<AlertDescription>
|
|
133
|
+
<strong>Scenario Explanation:</strong>{" "}
|
|
134
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
135
|
+
</AlertDescription>
|
|
136
|
+
</Alert>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { QuestionData, SessionAnswer } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scores a single question against the user's answers.
|
|
5
|
+
*
|
|
6
|
+
* @returns `true` if correct, `false` if incorrect, `null` if the question is not auto-gradable.
|
|
7
|
+
*/
|
|
8
|
+
export function scoreQuestion(
|
|
9
|
+
question: QuestionData,
|
|
10
|
+
userAnswers: SessionAnswer[],
|
|
11
|
+
): boolean | null {
|
|
12
|
+
switch (question.type) {
|
|
13
|
+
case "choice":
|
|
14
|
+
case "multiple_choice":
|
|
15
|
+
case "true_false": {
|
|
16
|
+
const correctUids = new Set(
|
|
17
|
+
(question.answers ?? []).filter((a) => a.isCorrect).map((a) => a.uid),
|
|
18
|
+
);
|
|
19
|
+
const userUids = new Set(userAnswers.map((a) => a.answerUid));
|
|
20
|
+
if (correctUids.size === 0) return null;
|
|
21
|
+
return (
|
|
22
|
+
correctUids.size === userUids.size &&
|
|
23
|
+
[...correctUids].every((uid) => userUids.has(uid))
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
case "fill_in_the_blank":
|
|
28
|
+
case "essay":
|
|
29
|
+
return null;
|
|
30
|
+
|
|
31
|
+
case "ordering": {
|
|
32
|
+
const answers = question.answers ?? [];
|
|
33
|
+
if (answers.length === 0) return null;
|
|
34
|
+
const sorted = [...answers].sort((a, b) => a.sequence - b.sequence);
|
|
35
|
+
return sorted.every((answer, correctIndex) => {
|
|
36
|
+
const ua = userAnswers.find((a) => a.answerUid === answer.uid);
|
|
37
|
+
return ua?.content === String(correctIndex);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
case "matching": {
|
|
42
|
+
const pairs = question.matchingPairs ?? [];
|
|
43
|
+
if (pairs.length === 0) return null;
|
|
44
|
+
return pairs.every((pair) => {
|
|
45
|
+
const ua = userAnswers.find((a) => a.answerUid === pair.uid);
|
|
46
|
+
return ua?.content === pair.uid;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
case "numeric": {
|
|
51
|
+
if (question.numericAnswer === undefined) return null;
|
|
52
|
+
const raw = userAnswers[0]?.content;
|
|
53
|
+
if (raw === undefined || raw === "") return false;
|
|
54
|
+
const userNum = parseFloat(raw);
|
|
55
|
+
if (isNaN(userNum)) return false;
|
|
56
|
+
const tolerance = question.numericTolerance ?? 0;
|
|
57
|
+
return Math.abs(userNum - question.numericAnswer) <= tolerance;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
case "hotspot": {
|
|
61
|
+
const regions = question.hotspotRegions ?? [];
|
|
62
|
+
const correctUids = new Set(
|
|
63
|
+
regions.filter((r) => r.isCorrect).map((r) => r.uid),
|
|
64
|
+
);
|
|
65
|
+
if (correctUids.size === 0) return null;
|
|
66
|
+
const userUids = new Set(userAnswers.map((a) => a.answerUid));
|
|
67
|
+
return (
|
|
68
|
+
correctUids.size === userUids.size &&
|
|
69
|
+
[...correctUids].every((uid) => userUids.has(uid))
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case "inline_choice": {
|
|
74
|
+
const blanks = question.inlineBlanks ?? [];
|
|
75
|
+
if (blanks.length === 0) return null;
|
|
76
|
+
return blanks.every((blank) => {
|
|
77
|
+
const correctOption = blank.options.find((o) => o.isCorrect);
|
|
78
|
+
if (!correctOption) return true;
|
|
79
|
+
const ua = userAnswers.find((a) => a.answerUid === blank.uid);
|
|
80
|
+
return ua?.content === correctOption.uid;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case "spreadsheet": {
|
|
85
|
+
const rows = question.spreadsheetRows ?? [];
|
|
86
|
+
const cols = question.spreadsheetColumns ?? [];
|
|
87
|
+
const colMap = new Map(cols.map((c) => [c.uid, c]));
|
|
88
|
+
const editableCells = rows.flatMap((r) =>
|
|
89
|
+
r.cells.filter((c) => !c.locked && c.correctAnswer !== undefined),
|
|
90
|
+
);
|
|
91
|
+
if (editableCells.length === 0) return null;
|
|
92
|
+
return editableCells.every((cell) => {
|
|
93
|
+
const ua = userAnswers.find((a) => a.answerUid === cell.uid);
|
|
94
|
+
const userValue = ua?.content?.trim() ?? "";
|
|
95
|
+
if (!userValue) return false;
|
|
96
|
+
const col = colMap.get(cell.columnUid);
|
|
97
|
+
if (!col || col.type === "text") {
|
|
98
|
+
return userValue.toLowerCase() === cell.correctAnswer!.trim().toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
const userNum = parseFloat(userValue.replace(/,/g, ""));
|
|
101
|
+
const correctNum = parseFloat(cell.correctAnswer!.replace(/,/g, ""));
|
|
102
|
+
if (isNaN(userNum) || isNaN(correctNum)) return false;
|
|
103
|
+
const tol = cell.tolerance ?? 0;
|
|
104
|
+
return Math.abs(userNum - correctNum) <= tol;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
case "scenario": {
|
|
109
|
+
const subQuestions = question.scenarioQuestions ?? [];
|
|
110
|
+
if (subQuestions.length === 0) return null;
|
|
111
|
+
|
|
112
|
+
const mode = question.scenarioScoringMode ?? "per_question";
|
|
113
|
+
|
|
114
|
+
if (mode === "all_or_nothing") {
|
|
115
|
+
const grouped = groupScenarioAnswers(userAnswers);
|
|
116
|
+
const results = subQuestions.map((sq) => {
|
|
117
|
+
const sqAnswers = grouped.get(sq.uid) ?? [];
|
|
118
|
+
return scoreQuestion(sq, sqAnswers);
|
|
119
|
+
});
|
|
120
|
+
if (results.some((r) => r === null)) return null;
|
|
121
|
+
return results.every((r) => r === true);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// "per_question" mode has no single aggregate score
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Scores an entire set of questions against user answers.
|
|
135
|
+
*
|
|
136
|
+
* @returns An object with correct count, total gradable, and percentage.
|
|
137
|
+
*/
|
|
138
|
+
export function scoreAssessment(
|
|
139
|
+
questions: QuestionData[],
|
|
140
|
+
answers: SessionAnswer[],
|
|
141
|
+
): { correct: number; total: number; percentage: number } {
|
|
142
|
+
let correct = 0;
|
|
143
|
+
let gradable = 0;
|
|
144
|
+
for (const q of questions) {
|
|
145
|
+
const userAnswers = answers.filter((a) => a.uid === q.uid);
|
|
146
|
+
if (q.type === "scenario" && q.scenarioScoringMode !== "all_or_nothing") {
|
|
147
|
+
const subResults = scoreScenarioSubQuestions(q, userAnswers);
|
|
148
|
+
for (const [, result] of subResults) {
|
|
149
|
+
if (result !== null) {
|
|
150
|
+
gradable++;
|
|
151
|
+
if (result) correct++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const result = scoreQuestion(q, userAnswers);
|
|
157
|
+
if (result !== null) {
|
|
158
|
+
gradable++;
|
|
159
|
+
if (result) correct++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const total = gradable > 0 ? gradable : questions.length;
|
|
163
|
+
const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
164
|
+
return { correct, total, percentage };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Parse scenario answer UIDs (format "subQUid::originalAnswerUid") and group by sub-question. */
|
|
168
|
+
function groupScenarioAnswers(
|
|
169
|
+
userAnswers: SessionAnswer[],
|
|
170
|
+
): Map<string, SessionAnswer[]> {
|
|
171
|
+
const map = new Map<string, SessionAnswer[]>();
|
|
172
|
+
for (const ua of userAnswers) {
|
|
173
|
+
const sepIdx = ua.answerUid.indexOf("::");
|
|
174
|
+
if (sepIdx === -1) continue;
|
|
175
|
+
const subQUid = ua.answerUid.slice(0, sepIdx);
|
|
176
|
+
const originalAnswerUid = ua.answerUid.slice(sepIdx + 2);
|
|
177
|
+
const list = map.get(subQUid) ?? [];
|
|
178
|
+
list.push({ ...ua, answerUid: originalAnswerUid });
|
|
179
|
+
map.set(subQUid, list);
|
|
180
|
+
}
|
|
181
|
+
return map;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Scores individual sub-questions within a scenario.
|
|
186
|
+
* Returns a Map of sub-question UID to score (`true`/`false`/`null`).
|
|
187
|
+
*/
|
|
188
|
+
export function scoreScenarioSubQuestions(
|
|
189
|
+
question: QuestionData,
|
|
190
|
+
userAnswers: SessionAnswer[],
|
|
191
|
+
): Map<string, boolean | null> {
|
|
192
|
+
const results = new Map<string, boolean | null>();
|
|
193
|
+
const subQuestions = question.scenarioQuestions ?? [];
|
|
194
|
+
const grouped = groupScenarioAnswers(userAnswers);
|
|
195
|
+
|
|
196
|
+
for (const sq of subQuestions) {
|
|
197
|
+
const sqAnswers = grouped.get(sq.uid) ?? [];
|
|
198
|
+
results.set(sq.uid, scoreQuestion(sq, sqAnswers));
|
|
199
|
+
}
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { useState, useMemo, useRef } from "react";
|
|
2
|
+
import { debounce } from "../utils/debounce";
|
|
3
|
+
import { Input } from "../ui/input";
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableHeader,
|
|
7
|
+
TableBody,
|
|
8
|
+
TableFooter,
|
|
9
|
+
TableRow,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableCell,
|
|
12
|
+
} from "../ui/table";
|
|
13
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
14
|
+
import { cn } from "../lib/utils";
|
|
15
|
+
import type {
|
|
16
|
+
QuestionProps,
|
|
17
|
+
SpreadsheetColumn,
|
|
18
|
+
SpreadsheetCell as SpreadsheetCellType,
|
|
19
|
+
} from "./types";
|
|
20
|
+
|
|
21
|
+
function getCellStatus(
|
|
22
|
+
cell: SpreadsheetCellType,
|
|
23
|
+
col: SpreadsheetColumn,
|
|
24
|
+
userValue: string,
|
|
25
|
+
): "correct" | "incorrect" | "empty" | null {
|
|
26
|
+
if (cell.locked || !cell.correctAnswer) return null;
|
|
27
|
+
if (!userValue || userValue.trim() === "") return "empty";
|
|
28
|
+
|
|
29
|
+
if (col.type === "numeric" || col.type === "currency") {
|
|
30
|
+
const user = parseFloat(userValue.replace(/,/g, ""));
|
|
31
|
+
const correct = parseFloat(cell.correctAnswer);
|
|
32
|
+
if (isNaN(user) || isNaN(correct)) return "incorrect";
|
|
33
|
+
const tol = cell.tolerance ?? 0;
|
|
34
|
+
return Math.abs(user - correct) <= tol ? "correct" : "incorrect";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return userValue.trim().toLowerCase() === cell.correctAnswer.trim().toLowerCase()
|
|
38
|
+
? "correct"
|
|
39
|
+
: "incorrect";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Spreadsheet renders a grid-based question for accounting and tabular data entry.
|
|
44
|
+
* Students fill in editable cells while locked cells display pre-filled values.
|
|
45
|
+
* Each cell is graded independently with support for numeric tolerance.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* <Spreadsheet
|
|
49
|
+
* question={{
|
|
50
|
+
* uid: "q1",
|
|
51
|
+
* type: "spreadsheet",
|
|
52
|
+
* content: "Complete the trial balance:",
|
|
53
|
+
* spreadsheetColumns: [
|
|
54
|
+
* { uid: "col-acct", label: "Account", type: "text", width: 14 },
|
|
55
|
+
* { uid: "col-dr", label: "Debit ($)", type: "currency", width: 9 },
|
|
56
|
+
* { uid: "col-cr", label: "Credit ($)", type: "currency", width: 9 },
|
|
57
|
+
* ],
|
|
58
|
+
* spreadsheetRows: [
|
|
59
|
+
* { uid: "r1", cells: [
|
|
60
|
+
* { uid: "r1-acct", columnUid: "col-acct", locked: true, value: "Cash" },
|
|
61
|
+
* { uid: "r1-dr", columnUid: "col-dr", locked: false, correctAnswer: "5000" },
|
|
62
|
+
* { uid: "r1-cr", columnUid: "col-cr", locked: true, value: "" },
|
|
63
|
+
* ]},
|
|
64
|
+
* ],
|
|
65
|
+
* }}
|
|
66
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
67
|
+
* />
|
|
68
|
+
*/
|
|
69
|
+
export const Spreadsheet = ({
|
|
70
|
+
question,
|
|
71
|
+
sessionAnswers,
|
|
72
|
+
onAnswer,
|
|
73
|
+
readOnly = false,
|
|
74
|
+
showCorrectAnswers = false,
|
|
75
|
+
disabled = false,
|
|
76
|
+
}: QuestionProps) => {
|
|
77
|
+
const columns = question.spreadsheetColumns ?? [];
|
|
78
|
+
const rows = question.spreadsheetRows ?? [];
|
|
79
|
+
|
|
80
|
+
const colMap = useMemo(() => {
|
|
81
|
+
const m = new Map<string, SpreadsheetColumn>();
|
|
82
|
+
for (const col of columns) m.set(col.uid, col);
|
|
83
|
+
return m;
|
|
84
|
+
}, [columns]);
|
|
85
|
+
|
|
86
|
+
const editableCellUids = useMemo(
|
|
87
|
+
() => rows.flatMap((r) => r.cells.filter((c) => !c.locked).map((c) => c.uid)),
|
|
88
|
+
[rows],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const hasRowLabels = useMemo(() => rows.some((r) => r.label != null), [rows]);
|
|
92
|
+
const bodyRows = useMemo(() => rows.filter((r) => !r.isTotals), [rows]);
|
|
93
|
+
const totalsRows = useMemo(() => rows.filter((r) => r.isTotals), [rows]);
|
|
94
|
+
|
|
95
|
+
const onAnswerRef = useRef(onAnswer);
|
|
96
|
+
onAnswerRef.current = onAnswer;
|
|
97
|
+
|
|
98
|
+
const debouncedEmit = useMemo(
|
|
99
|
+
() =>
|
|
100
|
+
debounce((next: Map<string, string>) => {
|
|
101
|
+
onAnswerRef.current?.(
|
|
102
|
+
[...next.entries()].map(([uid, content]) => ({ uid, content })),
|
|
103
|
+
);
|
|
104
|
+
}, 300),
|
|
105
|
+
[],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const [values, setValues] = useState<Map<string, string>>(() => {
|
|
109
|
+
const map = new Map<string, string>();
|
|
110
|
+
for (const sa of sessionAnswers ?? []) {
|
|
111
|
+
if (sa.content !== undefined) map.set(sa.answerUid, sa.content);
|
|
112
|
+
}
|
|
113
|
+
for (const row of rows) {
|
|
114
|
+
for (const cell of row.cells) {
|
|
115
|
+
if (!cell.locked && !map.has(cell.uid)) {
|
|
116
|
+
map.set(cell.uid, cell.value ?? "");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const handleCellChange = (cellUid: string, raw: string) => {
|
|
124
|
+
if (readOnly || disabled) return;
|
|
125
|
+
const next = new Map(values);
|
|
126
|
+
next.set(cellUid, raw);
|
|
127
|
+
setValues(next);
|
|
128
|
+
debouncedEmit(next);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleKeyDown = (cellUid: string, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
132
|
+
if (e.key !== "Tab") return;
|
|
133
|
+
const idx = editableCellUids.indexOf(cellUid);
|
|
134
|
+
const nextIdx = e.shiftKey ? idx - 1 : idx + 1;
|
|
135
|
+
if (nextIdx < 0 || nextIdx >= editableCellUids.length) return;
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
const nextUid = editableCellUids[nextIdx];
|
|
138
|
+
const nextInput = document.querySelector<HTMLInputElement>(
|
|
139
|
+
`[data-cell-uid="${nextUid}"]`,
|
|
140
|
+
);
|
|
141
|
+
nextInput?.focus();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const renderCell = (cell: SpreadsheetCellType) => {
|
|
145
|
+
const col = colMap.get(cell.columnUid);
|
|
146
|
+
if (!col) return null;
|
|
147
|
+
|
|
148
|
+
const userValue = values.get(cell.uid) ?? "";
|
|
149
|
+
const status = showCorrectAnswers ? getCellStatus(cell, col, userValue) : null;
|
|
150
|
+
|
|
151
|
+
const statusClasses =
|
|
152
|
+
status === "correct"
|
|
153
|
+
? "bg-success/10 border-l-2 border-l-success/50"
|
|
154
|
+
: status === "incorrect" || status === "empty"
|
|
155
|
+
? "bg-destructive/10 border-l-2 border-l-destructive/50"
|
|
156
|
+
: "";
|
|
157
|
+
|
|
158
|
+
if (cell.locked) {
|
|
159
|
+
return (
|
|
160
|
+
<TableCell
|
|
161
|
+
key={cell.uid}
|
|
162
|
+
className={cn(
|
|
163
|
+
"px-2 py-1.5 text-sm text-muted-foreground bg-muted/30",
|
|
164
|
+
col.type !== "text" && "text-right tabular-nums",
|
|
165
|
+
)}
|
|
166
|
+
>
|
|
167
|
+
{cell.value ?? ""}
|
|
168
|
+
</TableCell>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<TableCell
|
|
174
|
+
key={cell.uid}
|
|
175
|
+
className={cn("p-0 relative", statusClasses)}
|
|
176
|
+
>
|
|
177
|
+
<Input
|
|
178
|
+
type={col.type === "numeric" ? "number" : "text"}
|
|
179
|
+
inputMode={col.type === "currency" ? "decimal" : undefined}
|
|
180
|
+
step={col.type === "numeric" ? "any" : undefined}
|
|
181
|
+
value={userValue}
|
|
182
|
+
onChange={(e) => handleCellChange(cell.uid, e.target.value)}
|
|
183
|
+
onKeyDown={(e) => handleKeyDown(cell.uid, e)}
|
|
184
|
+
disabled={readOnly || disabled}
|
|
185
|
+
data-cell-uid={cell.uid}
|
|
186
|
+
placeholder={col.type === "text" ? "" : "0.00"}
|
|
187
|
+
className={cn(
|
|
188
|
+
"h-8 rounded-none border-0 shadow-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
189
|
+
col.type !== "text" && "text-right tabular-nums",
|
|
190
|
+
)}
|
|
191
|
+
/>
|
|
192
|
+
{showCorrectAnswers && status !== "correct" && status !== null && cell.correctAnswer && (
|
|
193
|
+
<div className="px-2 pb-1 text-xs">
|
|
194
|
+
<span className="text-success">{cell.correctAnswer}</span>
|
|
195
|
+
{cell.formula && (
|
|
196
|
+
<span className="ml-1 text-muted-foreground/70">({cell.formula})</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</TableCell>
|
|
201
|
+
);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const renderRow = (row: (typeof rows)[number]) => (
|
|
205
|
+
<TableRow
|
|
206
|
+
key={row.uid}
|
|
207
|
+
className={cn(
|
|
208
|
+
row.isHeader && "bg-muted/50 font-semibold hover:bg-muted/50",
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
{hasRowLabels && (
|
|
212
|
+
<TableCell className="px-2 py-1.5 font-medium text-sm text-muted-foreground whitespace-nowrap">
|
|
213
|
+
{row.label ?? ""}
|
|
214
|
+
</TableCell>
|
|
215
|
+
)}
|
|
216
|
+
{row.cells.map(renderCell)}
|
|
217
|
+
</TableRow>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className="flex flex-col gap-3">
|
|
222
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
223
|
+
|
|
224
|
+
<div className="rounded-md border border-border overflow-auto">
|
|
225
|
+
<Table>
|
|
226
|
+
<TableHeader>
|
|
227
|
+
<TableRow className="hover:bg-transparent">
|
|
228
|
+
{hasRowLabels && <TableHead className="w-auto" />}
|
|
229
|
+
{columns.map((col) => (
|
|
230
|
+
<TableHead
|
|
231
|
+
key={col.uid}
|
|
232
|
+
style={{ width: col.width ? `${col.width}rem` : undefined }}
|
|
233
|
+
className={cn(
|
|
234
|
+
col.type !== "text" && "text-right",
|
|
235
|
+
)}
|
|
236
|
+
>
|
|
237
|
+
{col.label}
|
|
238
|
+
</TableHead>
|
|
239
|
+
))}
|
|
240
|
+
</TableRow>
|
|
241
|
+
</TableHeader>
|
|
242
|
+
<TableBody>{bodyRows.map(renderRow)}</TableBody>
|
|
243
|
+
{totalsRows.length > 0 && (
|
|
244
|
+
<TableFooter>{totalsRows.map(renderRow)}</TableFooter>
|
|
245
|
+
)}
|
|
246
|
+
</Table>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
{showCorrectAnswers && question.explanation && (
|
|
250
|
+
<Alert className="mt-1">
|
|
251
|
+
<AlertDescription>
|
|
252
|
+
<strong>Explanation:</strong>{" "}
|
|
253
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
254
|
+
</AlertDescription>
|
|
255
|
+
</Alert>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
2
|
import type { QuestionProps } from "./types";
|
|
3
3
|
import { Alert, AlertDescription } from "../ui/alert";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
@@ -20,10 +20,13 @@ export const TrueFalse = ({
|
|
|
20
20
|
showCorrectAnswers = false,
|
|
21
21
|
disabled = false,
|
|
22
22
|
}: QuestionProps) => {
|
|
23
|
-
const [selectedAnswer, setSelectedAnswer] = useState<string>(
|
|
23
|
+
const [selectedAnswer, setSelectedAnswer] = useState<string>(
|
|
24
|
+
() => sessionAnswers?.[0]?.answerUid || "",
|
|
25
|
+
);
|
|
24
26
|
|
|
25
|
-
const sortedAnswers =
|
|
26
|
-
(a, b) => a.sequence - b.sequence,
|
|
27
|
+
const sortedAnswers = useMemo(
|
|
28
|
+
() => [...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
|
|
29
|
+
[question.answers],
|
|
27
30
|
);
|
|
28
31
|
|
|
29
32
|
const handleChange = (uid: string) => {
|
|
@@ -33,11 +36,6 @@ export const TrueFalse = ({
|
|
|
33
36
|
onAnswer?.([{ uid }]);
|
|
34
37
|
};
|
|
35
38
|
|
|
36
|
-
useEffect(() => {
|
|
37
|
-
const current = sessionAnswers?.[0]?.answerUid || "";
|
|
38
|
-
setSelectedAnswer(current);
|
|
39
|
-
}, [sessionAnswers]);
|
|
40
|
-
|
|
41
39
|
const getAnswerClasses = (answerUid: string) => {
|
|
42
40
|
if (!showCorrectAnswers) return "px-2";
|
|
43
41
|
|