@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import type { QuestionProps, InlineBlank } from "./types";
|
|
5
|
+
|
|
6
|
+
type ContentPart =
|
|
7
|
+
| { type: "text"; html: string }
|
|
8
|
+
| { type: "blank"; uid: string };
|
|
9
|
+
|
|
10
|
+
function parseInlineContent(html: string): ContentPart[] {
|
|
11
|
+
const parts: ContentPart[] = [];
|
|
12
|
+
const regex = /\{\{blank:([\w-]+)\}\}/g;
|
|
13
|
+
let lastIndex = 0;
|
|
14
|
+
let match;
|
|
15
|
+
while ((match = regex.exec(html)) !== null) {
|
|
16
|
+
if (match.index > lastIndex) {
|
|
17
|
+
parts.push({ type: "text", html: html.slice(lastIndex, match.index) });
|
|
18
|
+
}
|
|
19
|
+
parts.push({ type: "blank", uid: match[1] });
|
|
20
|
+
lastIndex = regex.lastIndex;
|
|
21
|
+
}
|
|
22
|
+
if (lastIndex < html.length) {
|
|
23
|
+
parts.push({ type: "text", html: html.slice(lastIndex) });
|
|
24
|
+
}
|
|
25
|
+
return parts;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* InlineChoice renders a cloze-style passage with embedded dropdown blanks.
|
|
30
|
+
*
|
|
31
|
+
* Content should contain `{{blank:uid}}` markers that correspond to entries in `question.inlineBlanks`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <InlineChoice
|
|
35
|
+
* question={{
|
|
36
|
+
* uid: "q1",
|
|
37
|
+
* type: "inline_choice",
|
|
38
|
+
* content: "The capital of France is {{blank:b1}}.",
|
|
39
|
+
* inlineBlanks: [{ uid: "b1", sequence: 0, options: [
|
|
40
|
+
* { uid: "o1", content: "Paris", isCorrect: true },
|
|
41
|
+
* { uid: "o2", content: "London" },
|
|
42
|
+
* ]}],
|
|
43
|
+
* }}
|
|
44
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
45
|
+
* />
|
|
46
|
+
*/
|
|
47
|
+
export const InlineChoice = ({
|
|
48
|
+
question,
|
|
49
|
+
sessionAnswers,
|
|
50
|
+
onAnswer,
|
|
51
|
+
readOnly = false,
|
|
52
|
+
showCorrectAnswers = false,
|
|
53
|
+
disabled = false,
|
|
54
|
+
}: QuestionProps) => {
|
|
55
|
+
const blanksMap = useMemo(() => {
|
|
56
|
+
const map = new Map<string, InlineBlank>();
|
|
57
|
+
for (const blank of question.inlineBlanks ?? []) {
|
|
58
|
+
map.set(blank.uid, blank);
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}, [question.inlineBlanks]);
|
|
62
|
+
|
|
63
|
+
const [selections, setSelections] = useState<Map<string, string>>(() => {
|
|
64
|
+
const map = new Map<string, string>();
|
|
65
|
+
for (const sa of sessionAnswers ?? []) {
|
|
66
|
+
if (sa.content) map.set(sa.answerUid, sa.content);
|
|
67
|
+
}
|
|
68
|
+
return map;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const parts = useMemo(
|
|
72
|
+
() => parseInlineContent(question.content),
|
|
73
|
+
[question.content],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const handleSelect = (blankUid: string, optionUid: string) => {
|
|
77
|
+
if (readOnly || disabled) return;
|
|
78
|
+
|
|
79
|
+
const next = new Map(selections);
|
|
80
|
+
next.set(blankUid, optionUid);
|
|
81
|
+
setSelections(next);
|
|
82
|
+
|
|
83
|
+
const answers = [...next.entries()].map(([uid, content]) => ({
|
|
84
|
+
uid,
|
|
85
|
+
content,
|
|
86
|
+
}));
|
|
87
|
+
onAnswer?.(answers);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const getSelectClasses = (blankUid: string) => {
|
|
91
|
+
if (!showCorrectAnswers) return "";
|
|
92
|
+
const blank = blanksMap.get(blankUid);
|
|
93
|
+
const selected = selections.get(blankUid);
|
|
94
|
+
if (!blank || !selected) return "";
|
|
95
|
+
const correctOption = blank.options.find((o) => o.isCorrect);
|
|
96
|
+
if (!correctOption) return "";
|
|
97
|
+
return selected === correctOption.uid
|
|
98
|
+
? "border-success/50 bg-success/5 text-success"
|
|
99
|
+
: "border-destructive/50 bg-destructive/5 text-destructive";
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex flex-col gap-4">
|
|
104
|
+
<div className="leading-relaxed text-foreground">
|
|
105
|
+
{parts.map((part, i) => {
|
|
106
|
+
if (part.type === "text") {
|
|
107
|
+
return (
|
|
108
|
+
<span
|
|
109
|
+
key={i}
|
|
110
|
+
dangerouslySetInnerHTML={{ __html: part.html }}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const blank = blanksMap.get(part.uid);
|
|
116
|
+
if (!blank) return null;
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<select
|
|
120
|
+
key={part.uid}
|
|
121
|
+
value={selections.get(part.uid) || ""}
|
|
122
|
+
onChange={(e) => handleSelect(part.uid, e.target.value)}
|
|
123
|
+
disabled={readOnly || disabled}
|
|
124
|
+
className={cn(
|
|
125
|
+
"inline-block mx-1 px-2 py-0.5 rounded border border-border bg-background text-foreground text-sm align-baseline",
|
|
126
|
+
"disabled:opacity-60 disabled:cursor-default",
|
|
127
|
+
getSelectClasses(part.uid),
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
<option value="">Select...</option>
|
|
131
|
+
{blank.options.map((opt) => (
|
|
132
|
+
<option key={opt.uid} value={opt.uid}>
|
|
133
|
+
{opt.content}
|
|
134
|
+
</option>
|
|
135
|
+
))}
|
|
136
|
+
</select>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{showCorrectAnswers && question.explanation && (
|
|
142
|
+
<Alert className="mt-2">
|
|
143
|
+
<AlertDescription>
|
|
144
|
+
<strong>Explanation:</strong>{" "}
|
|
145
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
146
|
+
</AlertDescription>
|
|
147
|
+
</Alert>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import type { QuestionProps } from "./types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Matching renders a two-column matching question with dropdown selects and optional drag-and-drop.
|
|
8
|
+
*
|
|
9
|
+
* Each item (left column) must be matched to its correct target (right column).
|
|
10
|
+
* The dropdown is the primary accessible interaction; drag-and-drop is progressive enhancement.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Matching
|
|
14
|
+
* question={{
|
|
15
|
+
* uid: "q1",
|
|
16
|
+
* type: "matching",
|
|
17
|
+
* content: "Match each country to its capital.",
|
|
18
|
+
* matchingPairs: [
|
|
19
|
+
* { uid: "p1", item: "France", target: "Paris", sequence: 0 },
|
|
20
|
+
* { uid: "p2", item: "Germany", target: "Berlin", sequence: 1 },
|
|
21
|
+
* ],
|
|
22
|
+
* }}
|
|
23
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
24
|
+
* />
|
|
25
|
+
*/
|
|
26
|
+
export const Matching = ({
|
|
27
|
+
question,
|
|
28
|
+
sessionAnswers,
|
|
29
|
+
onAnswer,
|
|
30
|
+
readOnly = false,
|
|
31
|
+
showCorrectAnswers = false,
|
|
32
|
+
disabled = false,
|
|
33
|
+
}: QuestionProps) => {
|
|
34
|
+
const sortedPairs = useMemo(
|
|
35
|
+
() =>
|
|
36
|
+
[...(question.matchingPairs ?? [])].sort(
|
|
37
|
+
(a, b) => a.sequence - b.sequence,
|
|
38
|
+
),
|
|
39
|
+
[question.matchingPairs],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Shuffled targets for display
|
|
43
|
+
const shuffledTargets = useMemo(
|
|
44
|
+
() => [...sortedPairs].sort(() => Math.random() - 0.5),
|
|
45
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
46
|
+
[sortedPairs.map((p) => p.uid).join()],
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Map: item pair uid → selected target pair uid
|
|
50
|
+
const [matches, setMatches] = useState<Map<string, string>>(() => {
|
|
51
|
+
const map = new Map<string, string>();
|
|
52
|
+
for (const sa of sessionAnswers ?? []) {
|
|
53
|
+
if (sa.content) map.set(sa.answerUid, sa.content);
|
|
54
|
+
}
|
|
55
|
+
return map;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const [dragItemUid, setDragItemUid] = useState<string | null>(null);
|
|
59
|
+
const [dragOverTargetUid, setDragOverTargetUid] = useState<string | null>(
|
|
60
|
+
null,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const emitAnswer = useCallback(
|
|
64
|
+
(next: Map<string, string>) => {
|
|
65
|
+
onAnswer?.(
|
|
66
|
+
[...next.entries()].map(([uid, content]) => ({ uid, content })),
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
[onAnswer],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const handleSelect = (itemUid: string, targetUid: string) => {
|
|
73
|
+
if (readOnly || disabled) return;
|
|
74
|
+
const next = new Map(matches);
|
|
75
|
+
if (targetUid) {
|
|
76
|
+
next.set(itemUid, targetUid);
|
|
77
|
+
} else {
|
|
78
|
+
next.delete(itemUid);
|
|
79
|
+
}
|
|
80
|
+
setMatches(next);
|
|
81
|
+
emitAnswer(next);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Drag-and-drop handlers (progressive enhancement)
|
|
85
|
+
const handleDragStartItem = (pairUid: string) => (e: React.DragEvent) => {
|
|
86
|
+
if (readOnly || disabled) {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setDragItemUid(pairUid);
|
|
91
|
+
e.dataTransfer.effectAllowed = "link";
|
|
92
|
+
e.dataTransfer.setData("text/plain", pairUid);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const handleDragOverTarget = (targetUid: string) => (e: React.DragEvent) => {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
e.dataTransfer.dropEffect = "link";
|
|
98
|
+
setDragOverTargetUid(targetUid);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleDropTarget = (targetUid: string) => (e: React.DragEvent) => {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
if (dragItemUid) {
|
|
104
|
+
handleSelect(dragItemUid, targetUid);
|
|
105
|
+
}
|
|
106
|
+
setDragItemUid(null);
|
|
107
|
+
setDragOverTargetUid(null);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleDragEnd = () => {
|
|
111
|
+
setDragItemUid(null);
|
|
112
|
+
setDragOverTargetUid(null);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const getMatchClasses = (pairUid: string) => {
|
|
116
|
+
if (!showCorrectAnswers) return "";
|
|
117
|
+
const selected = matches.get(pairUid);
|
|
118
|
+
if (!selected) return "";
|
|
119
|
+
return selected === pairUid
|
|
120
|
+
? "bg-success/10 border-success/30"
|
|
121
|
+
: "bg-destructive/10 border-destructive/30";
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Find which item is matched to a given target
|
|
125
|
+
const getTargetMatchedBy = (targetUid: string): string | undefined => {
|
|
126
|
+
for (const [itemUid, matchedTarget] of matches) {
|
|
127
|
+
if (matchedTarget === targetUid) return itemUid;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex flex-col gap-4">
|
|
134
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
135
|
+
|
|
136
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
137
|
+
{/* Left column: items */}
|
|
138
|
+
<div className="flex flex-col gap-2">
|
|
139
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
|
|
140
|
+
Items
|
|
141
|
+
</div>
|
|
142
|
+
{sortedPairs.map((pair) => (
|
|
143
|
+
<div
|
|
144
|
+
key={pair.uid}
|
|
145
|
+
draggable={!(readOnly || disabled)}
|
|
146
|
+
onDragStart={handleDragStartItem(pair.uid)}
|
|
147
|
+
onDragEnd={handleDragEnd}
|
|
148
|
+
className={cn(
|
|
149
|
+
"rounded-md border px-3 py-2 transition-colors",
|
|
150
|
+
dragItemUid === pair.uid && "opacity-50",
|
|
151
|
+
!(readOnly || disabled) && "cursor-grab active:cursor-grabbing",
|
|
152
|
+
getMatchClasses(pair.uid),
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<div className="flex items-center gap-2">
|
|
156
|
+
<span
|
|
157
|
+
className="flex-1 text-foreground"
|
|
158
|
+
dangerouslySetInnerHTML={{ __html: pair.item }}
|
|
159
|
+
/>
|
|
160
|
+
<select
|
|
161
|
+
value={matches.get(pair.uid) || ""}
|
|
162
|
+
onChange={(e) => handleSelect(pair.uid, e.target.value)}
|
|
163
|
+
disabled={readOnly || disabled}
|
|
164
|
+
className={cn(
|
|
165
|
+
"px-2 py-1 rounded border border-border bg-background text-foreground text-sm",
|
|
166
|
+
"disabled:opacity-60 disabled:cursor-default",
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
<option value="">—</option>
|
|
170
|
+
{shuffledTargets.map((target) => (
|
|
171
|
+
<option key={target.uid} value={target.uid}>
|
|
172
|
+
{target.target.replace(/<[^>]+>/g, "")}
|
|
173
|
+
</option>
|
|
174
|
+
))}
|
|
175
|
+
</select>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
))}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Right column: targets (drop zones) */}
|
|
182
|
+
<div className="flex flex-col gap-2">
|
|
183
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-1">
|
|
184
|
+
Targets
|
|
185
|
+
</div>
|
|
186
|
+
{shuffledTargets.map((pair) => {
|
|
187
|
+
const matchedBy = getTargetMatchedBy(pair.uid);
|
|
188
|
+
const matchedPair = matchedBy
|
|
189
|
+
? sortedPairs.find((p) => p.uid === matchedBy)
|
|
190
|
+
: null;
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div
|
|
194
|
+
key={pair.uid}
|
|
195
|
+
onDragOver={handleDragOverTarget(pair.uid)}
|
|
196
|
+
onDrop={handleDropTarget(pair.uid)}
|
|
197
|
+
className={cn(
|
|
198
|
+
"rounded-md border border-dashed px-3 py-2 transition-colors min-h-10",
|
|
199
|
+
dragOverTargetUid === pair.uid && "ring-2 ring-primary",
|
|
200
|
+
matchedBy && "border-solid",
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
<span
|
|
204
|
+
className="text-foreground"
|
|
205
|
+
dangerouslySetInnerHTML={{ __html: pair.target }}
|
|
206
|
+
/>
|
|
207
|
+
{matchedPair && (
|
|
208
|
+
<div className="mt-1 text-xs text-muted-foreground">
|
|
209
|
+
← matched
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{showCorrectAnswers && question.explanation && (
|
|
219
|
+
<Alert className="mt-2">
|
|
220
|
+
<AlertDescription>
|
|
221
|
+
<strong>Explanation:</strong>{" "}
|
|
222
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
223
|
+
</AlertDescription>
|
|
224
|
+
</Alert>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
@@ -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 MultipleChoice = ({
|
|
|
20
20
|
showCorrectAnswers = false,
|
|
21
21
|
disabled = false,
|
|
22
22
|
}: QuestionProps) => {
|
|
23
|
-
const [selectedAnswers, setSelectedAnswers] = useState<string[]>(
|
|
23
|
+
const [selectedAnswers, setSelectedAnswers] = useState<string[]>(
|
|
24
|
+
() => sessionAnswers?.map((sa) => sa.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) => {
|
|
@@ -39,11 +42,6 @@ export const MultipleChoice = ({
|
|
|
39
42
|
});
|
|
40
43
|
};
|
|
41
44
|
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
const current = sessionAnswers?.map((sa) => sa.answerUid) || [];
|
|
44
|
-
setSelectedAnswers(current);
|
|
45
|
-
}, [sessionAnswers]);
|
|
46
|
-
|
|
47
45
|
const getAnswerClasses = (answerUid: string) => {
|
|
48
46
|
if (!showCorrectAnswers) return "";
|
|
49
47
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useState, useMemo, useRef } from "react";
|
|
2
|
+
import { debounce } from "../utils/debounce";
|
|
3
|
+
import { Input } from "../ui/input";
|
|
4
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import type { QuestionProps } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Numeric renders a number input question with optional tolerance and unit display.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <Numeric
|
|
13
|
+
* question={{ uid: "q1", type: "numeric", content: "What is pi to 2 decimal places?", numericAnswer: 3.14, numericTolerance: 0.01 }}
|
|
14
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
15
|
+
* />
|
|
16
|
+
*/
|
|
17
|
+
export const Numeric = ({
|
|
18
|
+
question,
|
|
19
|
+
sessionAnswers,
|
|
20
|
+
onAnswer,
|
|
21
|
+
readOnly = false,
|
|
22
|
+
showCorrectAnswers = false,
|
|
23
|
+
disabled = false,
|
|
24
|
+
}: QuestionProps) => {
|
|
25
|
+
const [value, setValue] = useState(() => sessionAnswers?.[0]?.content || "");
|
|
26
|
+
|
|
27
|
+
const onAnswerRef = useRef(onAnswer);
|
|
28
|
+
onAnswerRef.current = onAnswer;
|
|
29
|
+
const questionUidRef = useRef(question.uid);
|
|
30
|
+
questionUidRef.current = question.uid;
|
|
31
|
+
|
|
32
|
+
const debouncedAnswer = useMemo(
|
|
33
|
+
() =>
|
|
34
|
+
debounce((content: string) => {
|
|
35
|
+
onAnswerRef.current?.([{ uid: questionUidRef.current, content }]);
|
|
36
|
+
}, 300),
|
|
37
|
+
[],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
41
|
+
const newValue = e.target.value;
|
|
42
|
+
setValue(newValue);
|
|
43
|
+
debouncedAnswer(newValue);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const isCorrect = (() => {
|
|
47
|
+
if (!showCorrectAnswers || question.numericAnswer === undefined) return null;
|
|
48
|
+
if (value === "") return false;
|
|
49
|
+
const userNum = parseFloat(value);
|
|
50
|
+
if (isNaN(userNum)) return false;
|
|
51
|
+
const tolerance = question.numericTolerance ?? 0;
|
|
52
|
+
return Math.abs(userNum - question.numericAnswer) <= tolerance;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
const inputClasses = (() => {
|
|
56
|
+
if (!showCorrectAnswers || isCorrect === null) return "";
|
|
57
|
+
return isCorrect
|
|
58
|
+
? "border-success/50 bg-success/5"
|
|
59
|
+
: "border-destructive/50 bg-destructive/5";
|
|
60
|
+
})();
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="flex flex-col gap-2">
|
|
64
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
65
|
+
|
|
66
|
+
<div className="flex items-center gap-2">
|
|
67
|
+
<Input
|
|
68
|
+
type="number"
|
|
69
|
+
value={value}
|
|
70
|
+
onChange={handleChange}
|
|
71
|
+
placeholder="Enter a number..."
|
|
72
|
+
disabled={readOnly || disabled}
|
|
73
|
+
className={cn("max-w-48", inputClasses)}
|
|
74
|
+
step="any"
|
|
75
|
+
/>
|
|
76
|
+
{question.numericUnit && (
|
|
77
|
+
<span className="text-muted-foreground text-sm">
|
|
78
|
+
{question.numericUnit}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{showCorrectAnswers && question.numericAnswer !== undefined && (
|
|
84
|
+
<p className="text-sm text-muted-foreground">
|
|
85
|
+
Correct answer: {question.numericAnswer}
|
|
86
|
+
{(question.numericTolerance ?? 0) > 0 && (
|
|
87
|
+
<span> (± {question.numericTolerance})</span>
|
|
88
|
+
)}
|
|
89
|
+
</p>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{showCorrectAnswers && question.explanation && (
|
|
93
|
+
<Alert className="mt-1">
|
|
94
|
+
<AlertDescription>
|
|
95
|
+
<strong>Explanation:</strong>{" "}
|
|
96
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
97
|
+
</AlertDescription>
|
|
98
|
+
</Alert>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { ChevronUp, ChevronDown, GripVertical } from "lucide-react";
|
|
3
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { useDragReorder } from "./use-drag-reorder";
|
|
6
|
+
import type { QuestionProps, AnswerOption } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ordering renders a drag-and-drop reorderable list of items.
|
|
10
|
+
* The correct order is determined by each answer's `sequence` field.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <Ordering
|
|
14
|
+
* question={question}
|
|
15
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
16
|
+
* />
|
|
17
|
+
*/
|
|
18
|
+
export const Ordering = ({
|
|
19
|
+
question,
|
|
20
|
+
sessionAnswers,
|
|
21
|
+
onAnswer,
|
|
22
|
+
readOnly = false,
|
|
23
|
+
showCorrectAnswers = false,
|
|
24
|
+
disabled = false,
|
|
25
|
+
}: QuestionProps) => {
|
|
26
|
+
const correctOrder = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
[...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
|
|
29
|
+
[question.answers],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const [items, setItems] = useState<AnswerOption[]>(() => {
|
|
33
|
+
if (sessionAnswers && sessionAnswers.length > 0) {
|
|
34
|
+
// Rebuild order from sessionAnswers position indices
|
|
35
|
+
const posMap = new Map<string, number>();
|
|
36
|
+
for (const sa of sessionAnswers) {
|
|
37
|
+
posMap.set(sa.answerUid, parseInt(sa.content || "0", 10));
|
|
38
|
+
}
|
|
39
|
+
return [...correctOrder].sort(
|
|
40
|
+
(a, b) => (posMap.get(a.uid) ?? 0) - (posMap.get(b.uid) ?? 0),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
// Shuffle for initial display so correct order isn't given away
|
|
44
|
+
return [...correctOrder].sort(() => Math.random() - 0.5);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const emitAnswer = (orderedItems: AnswerOption[]) => {
|
|
48
|
+
onAnswer?.(
|
|
49
|
+
orderedItems.map((item, index) => ({
|
|
50
|
+
uid: item.uid,
|
|
51
|
+
content: String(index),
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleReorder = (next: AnswerOption[]) => {
|
|
57
|
+
setItems(next);
|
|
58
|
+
emitAnswer(next);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const moveItem = (fromIndex: number, toIndex: number) => {
|
|
62
|
+
if (readOnly || disabled) return;
|
|
63
|
+
if (toIndex < 0 || toIndex >= items.length) return;
|
|
64
|
+
const next = [...items];
|
|
65
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
66
|
+
next.splice(toIndex, 0, moved);
|
|
67
|
+
setItems(next);
|
|
68
|
+
emitAnswer(next);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const { getDragProps, dragIndex, dragOverIndex } = useDragReorder({
|
|
72
|
+
items,
|
|
73
|
+
onReorder: handleReorder,
|
|
74
|
+
disabled: readOnly || disabled,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const getItemClasses = (item: AnswerOption, index: number) => {
|
|
78
|
+
if (!showCorrectAnswers) return "";
|
|
79
|
+
const correctIndex = correctOrder.findIndex((a) => a.uid === item.uid);
|
|
80
|
+
return correctIndex === index
|
|
81
|
+
? "bg-success/10 border-success/30"
|
|
82
|
+
: "bg-destructive/10 border-destructive/30";
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="flex flex-col gap-4">
|
|
87
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
88
|
+
|
|
89
|
+
<div className="flex flex-col gap-1.5">
|
|
90
|
+
{items.map((item, index) => (
|
|
91
|
+
<div
|
|
92
|
+
key={item.uid}
|
|
93
|
+
{...getDragProps(index)}
|
|
94
|
+
className={cn(
|
|
95
|
+
"flex items-center gap-2 rounded-md border px-3 py-2 transition-all select-none",
|
|
96
|
+
dragIndex === index && "opacity-50",
|
|
97
|
+
dragOverIndex === index &&
|
|
98
|
+
dragIndex !== index &&
|
|
99
|
+
"border-t-2 border-t-primary",
|
|
100
|
+
!(readOnly || disabled) && "cursor-grab active:cursor-grabbing",
|
|
101
|
+
getItemClasses(item, index),
|
|
102
|
+
)}
|
|
103
|
+
>
|
|
104
|
+
{!(readOnly || disabled) && (
|
|
105
|
+
<GripVertical className="size-4 text-muted-foreground shrink-0" />
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<span className="text-sm font-medium text-muted-foreground w-6 shrink-0">
|
|
109
|
+
{index + 1}.
|
|
110
|
+
</span>
|
|
111
|
+
|
|
112
|
+
<span
|
|
113
|
+
className="flex-1 text-foreground"
|
|
114
|
+
dangerouslySetInnerHTML={{ __html: item.content }}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
{!(readOnly || disabled) && (
|
|
118
|
+
<div className="flex flex-col shrink-0">
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={() => moveItem(index, index - 1)}
|
|
122
|
+
disabled={index === 0}
|
|
123
|
+
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
|
|
124
|
+
aria-label={`Move ${item.content} up`}
|
|
125
|
+
>
|
|
126
|
+
<ChevronUp className="size-4" />
|
|
127
|
+
</button>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
onClick={() => moveItem(index, index + 1)}
|
|
131
|
+
disabled={index === items.length - 1}
|
|
132
|
+
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default"
|
|
133
|
+
aria-label={`Move ${item.content} down`}
|
|
134
|
+
>
|
|
135
|
+
<ChevronDown className="size-4" />
|
|
136
|
+
</button>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{showCorrectAnswers && (
|
|
141
|
+
<span className="text-xs text-muted-foreground shrink-0">
|
|
142
|
+
(correct: {correctOrder.findIndex((a) => a.uid === item.uid) + 1})
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{showCorrectAnswers && question.explanation && (
|
|
150
|
+
<Alert className="mt-2">
|
|
151
|
+
<AlertDescription>
|
|
152
|
+
<strong>Explanation:</strong>{" "}
|
|
153
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
154
|
+
</AlertDescription>
|
|
155
|
+
</Alert>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
};
|