@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,285 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ShieldCheck,
|
|
4
|
+
RotateCcw,
|
|
5
|
+
Clock,
|
|
6
|
+
HelpCircle,
|
|
7
|
+
CheckCircle2,
|
|
8
|
+
XCircle,
|
|
9
|
+
Trophy,
|
|
10
|
+
Play,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { ExamSession } from "../../sections/ExamSession/ExamSession";
|
|
13
|
+
import { AssessmentReview } from "../../sections/AssessmentReview/AssessmentReview";
|
|
14
|
+
import { ProgressRing } from "../../progress/progress-ring";
|
|
15
|
+
import { StatCard } from "../../progress/stat-card";
|
|
16
|
+
import { Button } from "../../ui/button";
|
|
17
|
+
import { Badge } from "../../ui/badge";
|
|
18
|
+
import { Card, CardContent } from "../../ui/card";
|
|
19
|
+
import { Alert, AlertDescription } from "../../ui/alert";
|
|
20
|
+
import { Separator } from "../../ui/separator";
|
|
21
|
+
import { formatDuration } from "../../utils/format-duration";
|
|
22
|
+
import { cn } from "../../lib/utils";
|
|
23
|
+
import type { SessionAnswer } from "../../questions/types";
|
|
24
|
+
import type { ExamSubmitMetadata } from "../../sections/ExamSession/types";
|
|
25
|
+
import { scoreAssessment } from "../../questions/scoring";
|
|
26
|
+
import type { ExamModuleProps, ExamModuleResult } from "./types";
|
|
27
|
+
|
|
28
|
+
type InternalStep =
|
|
29
|
+
| { tag: "intro" }
|
|
30
|
+
| { tag: "exam" }
|
|
31
|
+
| { tag: "results"; result: ExamModuleResult };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ExamModule — a complete formal exam experience.
|
|
35
|
+
*
|
|
36
|
+
* Steps: Intro (rules/instructions) → Exam (timed ExamSession) → Results (score + review).
|
|
37
|
+
* Manages an external timer that feeds elapsed time to ExamSession.
|
|
38
|
+
*/
|
|
39
|
+
export function ExamModule({
|
|
40
|
+
title,
|
|
41
|
+
description,
|
|
42
|
+
instructions,
|
|
43
|
+
questions,
|
|
44
|
+
timeLimitSeconds,
|
|
45
|
+
passingScore,
|
|
46
|
+
allowBackNavigation = true,
|
|
47
|
+
autoSubmitOnTimeout = true,
|
|
48
|
+
timeWarningThreshold,
|
|
49
|
+
allowRetake = false,
|
|
50
|
+
showReview = true,
|
|
51
|
+
onComplete,
|
|
52
|
+
className,
|
|
53
|
+
style,
|
|
54
|
+
}: ExamModuleProps) {
|
|
55
|
+
const [step, setStep] = useState<InternalStep>({ tag: "intro" });
|
|
56
|
+
const [timeElapsed, setTimeElapsed] = useState(0);
|
|
57
|
+
const startTimeRef = useRef<number | null>(null);
|
|
58
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
59
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
contentRef.current?.focus({ preventScroll: true });
|
|
63
|
+
}, [step.tag]);
|
|
64
|
+
|
|
65
|
+
// Timer for exam step
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (step.tag === "exam") {
|
|
68
|
+
startTimeRef.current = Date.now();
|
|
69
|
+
intervalRef.current = setInterval(() => {
|
|
70
|
+
if (startTimeRef.current) {
|
|
71
|
+
setTimeElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
72
|
+
}
|
|
73
|
+
}, 1000);
|
|
74
|
+
} else {
|
|
75
|
+
if (intervalRef.current) {
|
|
76
|
+
clearInterval(intervalRef.current);
|
|
77
|
+
intervalRef.current = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return () => {
|
|
81
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
82
|
+
};
|
|
83
|
+
}, [step.tag]);
|
|
84
|
+
|
|
85
|
+
function scoreAnswers(
|
|
86
|
+
answers: SessionAnswer[],
|
|
87
|
+
metadata: ExamSubmitMetadata
|
|
88
|
+
): ExamModuleResult {
|
|
89
|
+
const { correct, total, percentage } = scoreAssessment(questions, answers);
|
|
90
|
+
return {
|
|
91
|
+
answers,
|
|
92
|
+
correct,
|
|
93
|
+
total,
|
|
94
|
+
percentage,
|
|
95
|
+
passed: passingScore !== undefined ? percentage >= passingScore : true,
|
|
96
|
+
timeElapsedSeconds: metadata.timeElapsedSeconds,
|
|
97
|
+
wasAutoSubmitted: metadata.wasAutoSubmitted,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleSubmit(answers: SessionAnswer[], metadata: ExamSubmitMetadata) {
|
|
102
|
+
const result = scoreAnswers(answers, metadata);
|
|
103
|
+
setStep({ tag: "results", result });
|
|
104
|
+
onComplete?.(result);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function handleRetake() {
|
|
108
|
+
setTimeElapsed(0);
|
|
109
|
+
startTimeRef.current = null;
|
|
110
|
+
setStep({ tag: "intro" });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ─── Intro Screen ───
|
|
114
|
+
if (step.tag === "intro") {
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
ref={contentRef}
|
|
118
|
+
tabIndex={-1}
|
|
119
|
+
className={cn("max-w-2xl mx-auto outline-none", className)}
|
|
120
|
+
style={style}
|
|
121
|
+
>
|
|
122
|
+
<Card>
|
|
123
|
+
<CardContent className="pt-8 pb-8 text-center">
|
|
124
|
+
<div className="mx-auto mb-4 w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
|
125
|
+
<ShieldCheck className="size-7 text-primary" />
|
|
126
|
+
</div>
|
|
127
|
+
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
|
|
128
|
+
{description && (
|
|
129
|
+
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
|
130
|
+
{description}
|
|
131
|
+
</p>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Instructions */}
|
|
135
|
+
{instructions && (
|
|
136
|
+
<Alert className="text-left mb-6">
|
|
137
|
+
<AlertDescription>{instructions}</AlertDescription>
|
|
138
|
+
</Alert>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{/* Metadata chips */}
|
|
142
|
+
<div className="flex flex-wrap justify-center gap-2 mb-8">
|
|
143
|
+
<Badge variant="outline" className="gap-1.5">
|
|
144
|
+
<HelpCircle className="size-3.5" />
|
|
145
|
+
{questions.length} questions
|
|
146
|
+
</Badge>
|
|
147
|
+
<Badge variant="outline" className="gap-1.5">
|
|
148
|
+
<Clock className="size-3.5" />
|
|
149
|
+
{formatDuration(timeLimitSeconds)} time limit
|
|
150
|
+
</Badge>
|
|
151
|
+
{passingScore !== undefined && (
|
|
152
|
+
<Badge variant="outline" className="gap-1.5">
|
|
153
|
+
<CheckCircle2 className="size-3.5" />
|
|
154
|
+
{passingScore}% to pass
|
|
155
|
+
</Badge>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<Button size="lg" onClick={() => setStep({ tag: "exam" })}>
|
|
160
|
+
<Play className="size-4 mr-2" />
|
|
161
|
+
Begin Exam
|
|
162
|
+
</Button>
|
|
163
|
+
</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Exam Screen ───
|
|
170
|
+
if (step.tag === "exam") {
|
|
171
|
+
return (
|
|
172
|
+
<div
|
|
173
|
+
ref={contentRef}
|
|
174
|
+
tabIndex={-1}
|
|
175
|
+
className={cn("outline-none", className)}
|
|
176
|
+
style={style}
|
|
177
|
+
>
|
|
178
|
+
<ExamSession
|
|
179
|
+
questions={questions}
|
|
180
|
+
timeLimitSeconds={timeLimitSeconds}
|
|
181
|
+
timeElapsedSeconds={timeElapsed}
|
|
182
|
+
autoSubmitOnTimeout={autoSubmitOnTimeout}
|
|
183
|
+
timeWarningThreshold={timeWarningThreshold}
|
|
184
|
+
allowBackNavigation={allowBackNavigation}
|
|
185
|
+
confirmBeforeSubmit
|
|
186
|
+
examTitle={title}
|
|
187
|
+
onSubmit={handleSubmit}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Results Screen ───
|
|
194
|
+
const { result } = step;
|
|
195
|
+
const passed = result.passed;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div
|
|
199
|
+
ref={contentRef}
|
|
200
|
+
tabIndex={-1}
|
|
201
|
+
className={cn("max-w-2xl mx-auto outline-none", className)}
|
|
202
|
+
style={style}
|
|
203
|
+
>
|
|
204
|
+
<Card>
|
|
205
|
+
<CardContent className="pt-8 pb-8">
|
|
206
|
+
{/* Score summary */}
|
|
207
|
+
<div className="text-center mb-8">
|
|
208
|
+
<ProgressRing
|
|
209
|
+
value={result.percentage}
|
|
210
|
+
size={140}
|
|
211
|
+
strokeWidth={10}
|
|
212
|
+
color={passed ? "var(--success)" : "var(--destructive)"}
|
|
213
|
+
className="mx-auto mb-4 text-foreground"
|
|
214
|
+
/>
|
|
215
|
+
<Badge
|
|
216
|
+
variant={passed ? "success" : "destructive"}
|
|
217
|
+
className="text-sm px-3 py-1 mb-2"
|
|
218
|
+
>
|
|
219
|
+
{passed ? "Passed" : "Failed"}
|
|
220
|
+
</Badge>
|
|
221
|
+
{result.wasAutoSubmitted && (
|
|
222
|
+
<Badge variant="outline" className="text-sm px-3 py-1 mb-2 ml-2">
|
|
223
|
+
Auto-submitted
|
|
224
|
+
</Badge>
|
|
225
|
+
)}
|
|
226
|
+
<h2 className="text-xl font-bold text-foreground">{title}</h2>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* Stats grid */}
|
|
230
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
|
231
|
+
<StatCard
|
|
232
|
+
icon={<CheckCircle2 />}
|
|
233
|
+
label="Correct"
|
|
234
|
+
description="Questions answered right"
|
|
235
|
+
value={`${result.correct}/${result.total}`}
|
|
236
|
+
/>
|
|
237
|
+
<StatCard
|
|
238
|
+
icon={<XCircle />}
|
|
239
|
+
label="Incorrect"
|
|
240
|
+
description="Questions to review"
|
|
241
|
+
value={`${result.total - result.correct}/${result.total}`}
|
|
242
|
+
/>
|
|
243
|
+
<StatCard
|
|
244
|
+
icon={<Trophy />}
|
|
245
|
+
label="Score"
|
|
246
|
+
description="Overall percentage"
|
|
247
|
+
value={`${result.percentage}%`}
|
|
248
|
+
/>
|
|
249
|
+
<StatCard
|
|
250
|
+
icon={<Clock />}
|
|
251
|
+
label="Time"
|
|
252
|
+
description="Total elapsed"
|
|
253
|
+
value={formatDuration(result.timeElapsedSeconds)}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* Actions */}
|
|
258
|
+
{allowRetake && (
|
|
259
|
+
<div className="flex justify-center mb-8">
|
|
260
|
+
<Button variant="outline" onClick={handleRetake}>
|
|
261
|
+
<RotateCcw className="size-4 mr-2" />
|
|
262
|
+
Retake Exam
|
|
263
|
+
</Button>
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</CardContent>
|
|
267
|
+
</Card>
|
|
268
|
+
|
|
269
|
+
{/* Per-question review */}
|
|
270
|
+
{showReview && (
|
|
271
|
+
<>
|
|
272
|
+
<Separator className="my-6" />
|
|
273
|
+
<h3 className="text-lg font-semibold text-foreground mb-4">
|
|
274
|
+
Question Review
|
|
275
|
+
</h3>
|
|
276
|
+
<AssessmentReview
|
|
277
|
+
questions={questions}
|
|
278
|
+
sessionAnswers={result.answers}
|
|
279
|
+
showCorrectAnswers
|
|
280
|
+
/>
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ExamModule — a formal timed exam experience with intro, exam, and results steps.
|
|
6
|
+
*
|
|
7
|
+
* Wraps ExamSession with an intro screen showing rules/instructions and a
|
|
8
|
+
* results screen with scoring, stats, and optional answer review.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <ExamModule
|
|
12
|
+
* title="Midterm Exam"
|
|
13
|
+
* instructions={<p>You have 60 minutes. No notes allowed.</p>}
|
|
14
|
+
* questions={questions}
|
|
15
|
+
* timeLimitSeconds={3600}
|
|
16
|
+
* passingScore={70}
|
|
17
|
+
* onComplete={(result) => saveResult(result)}
|
|
18
|
+
* />
|
|
19
|
+
*/
|
|
20
|
+
export interface ExamModuleProps {
|
|
21
|
+
/** Exam title displayed on the intro screen */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Exam description displayed on the intro screen */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Exam rules/instructions rendered on the intro screen */
|
|
26
|
+
instructions?: ReactNode;
|
|
27
|
+
/** Ordered list of questions */
|
|
28
|
+
questions: QuestionData[];
|
|
29
|
+
/** Time limit in seconds (required for exams) */
|
|
30
|
+
timeLimitSeconds: number;
|
|
31
|
+
/** Passing threshold as a percentage (e.g. 70 means 70%) */
|
|
32
|
+
passingScore?: number;
|
|
33
|
+
/** Whether the user can go back to previous questions. @default true */
|
|
34
|
+
allowBackNavigation?: boolean;
|
|
35
|
+
/** Auto-submit when time runs out. @default true */
|
|
36
|
+
autoSubmitOnTimeout?: boolean;
|
|
37
|
+
/** Seconds remaining at which to show a time warning */
|
|
38
|
+
timeWarningThreshold?: number;
|
|
39
|
+
/** Whether to allow retaking the exam from the results screen. @default false */
|
|
40
|
+
allowRetake?: boolean;
|
|
41
|
+
/** Whether to show correct/incorrect answer highlighting in the review. @default true */
|
|
42
|
+
showReview?: boolean;
|
|
43
|
+
/** Called when the exam is completed (submitted) */
|
|
44
|
+
onComplete?: (result: ExamModuleResult) => void;
|
|
45
|
+
/** CSS class name for the root element */
|
|
46
|
+
className?: string;
|
|
47
|
+
/** Inline styles for the root element */
|
|
48
|
+
style?: React.CSSProperties;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ExamModuleResult {
|
|
52
|
+
/** The user's submitted answers */
|
|
53
|
+
answers: SessionAnswer[];
|
|
54
|
+
/** Number of correct answers */
|
|
55
|
+
correct: number;
|
|
56
|
+
/** Total number of gradable questions */
|
|
57
|
+
total: number;
|
|
58
|
+
/** Score as a percentage (0-100) */
|
|
59
|
+
percentage: number;
|
|
60
|
+
/** Whether the user passed (only meaningful when passingScore is set) */
|
|
61
|
+
passed: boolean;
|
|
62
|
+
/** Total time taken in seconds */
|
|
63
|
+
timeElapsedSeconds: number;
|
|
64
|
+
/** Whether the submission was triggered by timeout */
|
|
65
|
+
wasAutoSubmitted: boolean;
|
|
66
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef } from "react";
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
2
|
import {
|
|
3
3
|
BookOpen,
|
|
4
4
|
Shuffle,
|
|
@@ -40,6 +40,11 @@ export function FlashcardLab({
|
|
|
40
40
|
const [selectedUids, setSelectedUids] = useState<Set<string>>(new Set());
|
|
41
41
|
const [shuffled, setShuffled] = useState(defaultShuffled);
|
|
42
42
|
const startTimeRef = useRef<number | null>(null);
|
|
43
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
contentRef.current?.focus({ preventScroll: true });
|
|
47
|
+
}, [step.tag]);
|
|
43
48
|
|
|
44
49
|
function toggleDeck(uid: string) {
|
|
45
50
|
setSelectedUids((prev) => {
|
|
@@ -105,12 +110,14 @@ export function FlashcardLab({
|
|
|
105
110
|
// ─── Setup Screen ───
|
|
106
111
|
if (step.tag === "setup") {
|
|
107
112
|
return (
|
|
108
|
-
<div className={cn(className)} style={style}>
|
|
113
|
+
<div ref={contentRef} tabIndex={-1} className={cn("max-w-2xl mx-auto outline-none", className)} style={style}>
|
|
114
|
+
<Card>
|
|
115
|
+
<CardContent className="pt-8 pb-8">
|
|
109
116
|
<div className="text-center mb-6">
|
|
110
|
-
<div className="mx-auto mb-
|
|
117
|
+
<div className="mx-auto mb-4 w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
|
111
118
|
<BookOpen className="size-7 text-primary" />
|
|
112
119
|
</div>
|
|
113
|
-
<h2 className="text-2xl font-bold text-foreground mb-
|
|
120
|
+
<h2 className="text-2xl font-bold text-foreground mb-2">
|
|
114
121
|
Choose Your Decks
|
|
115
122
|
</h2>
|
|
116
123
|
<p className="text-muted-foreground text-sm">
|
|
@@ -159,19 +166,15 @@ export function FlashcardLab({
|
|
|
159
166
|
<div className="flex items-center justify-between">
|
|
160
167
|
<div className="flex items-center gap-3">
|
|
161
168
|
{showShuffleToggle && (
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
shuffled
|
|
167
|
-
? "border-primary bg-primary/10 text-primary"
|
|
168
|
-
: "border-border text-muted-foreground hover:text-foreground",
|
|
169
|
-
)}
|
|
169
|
+
<Button
|
|
170
|
+
variant={shuffled ? "secondary" : "outline"}
|
|
171
|
+
size="sm"
|
|
172
|
+
className="gap-1.5"
|
|
170
173
|
onClick={() => setShuffled((s) => !s)}
|
|
171
174
|
>
|
|
172
175
|
<Shuffle className="size-3.5" />
|
|
173
176
|
Shuffle
|
|
174
|
-
</
|
|
177
|
+
</Button>
|
|
175
178
|
)}
|
|
176
179
|
{selectedUids.size > 0 && (
|
|
177
180
|
<span className="text-xs text-muted-foreground">
|
|
@@ -189,6 +192,8 @@ export function FlashcardLab({
|
|
|
189
192
|
Start Studying
|
|
190
193
|
</Button>
|
|
191
194
|
</div>
|
|
195
|
+
</CardContent>
|
|
196
|
+
</Card>
|
|
192
197
|
</div>
|
|
193
198
|
);
|
|
194
199
|
}
|
|
@@ -201,7 +206,7 @@ export function FlashcardLab({
|
|
|
201
206
|
.join(", ");
|
|
202
207
|
|
|
203
208
|
return (
|
|
204
|
-
<div className={cn(className)} style={style}>
|
|
209
|
+
<div ref={contentRef} tabIndex={-1} className={cn("outline-none", className)} style={style}>
|
|
205
210
|
<FlashcardStudySession
|
|
206
211
|
cards={step.cards}
|
|
207
212
|
title={deckNames}
|
|
@@ -216,7 +221,9 @@ export function FlashcardLab({
|
|
|
216
221
|
const { result } = step;
|
|
217
222
|
|
|
218
223
|
return (
|
|
219
|
-
<div className={cn(className)} style={style}>
|
|
224
|
+
<div ref={contentRef} tabIndex={-1} className={cn("max-w-2xl mx-auto outline-none", className)} style={style}>
|
|
225
|
+
<Card>
|
|
226
|
+
<CardContent className="pt-8 pb-8">
|
|
220
227
|
<div className="text-center mb-8">
|
|
221
228
|
<div className="relative mx-auto mb-4">
|
|
222
229
|
<ProgressRing
|
|
@@ -229,7 +236,7 @@ export function FlashcardLab({
|
|
|
229
236
|
/>
|
|
230
237
|
<CheckCircle2 className="size-8 text-success absolute inset-0 m-auto" />
|
|
231
238
|
</div>
|
|
232
|
-
<h2 className="text-xl font-bold text-foreground mb-
|
|
239
|
+
<h2 className="text-xl font-bold text-foreground mb-2">
|
|
233
240
|
Study Session Complete
|
|
234
241
|
</h2>
|
|
235
242
|
<p className="text-muted-foreground text-sm">
|
|
@@ -241,21 +248,25 @@ export function FlashcardLab({
|
|
|
241
248
|
<StatCard
|
|
242
249
|
icon={<Layers />}
|
|
243
250
|
label="Cards Studied"
|
|
251
|
+
description="Total cards reviewed"
|
|
244
252
|
value={String(result.totalCards)}
|
|
245
253
|
/>
|
|
246
254
|
<StatCard
|
|
247
255
|
icon={<BookOpen />}
|
|
248
256
|
label="Decks"
|
|
257
|
+
description="Decks completed"
|
|
249
258
|
value={String(result.decksStudied)}
|
|
250
259
|
/>
|
|
251
260
|
<StatCard
|
|
252
261
|
icon={<Clock />}
|
|
253
262
|
label="Time Spent"
|
|
263
|
+
description="Session duration"
|
|
254
264
|
value={formatDuration(result.timeElapsedSeconds)}
|
|
255
265
|
/>
|
|
256
266
|
<StatCard
|
|
257
267
|
icon={<Shuffle />}
|
|
258
268
|
label="Shuffled"
|
|
269
|
+
description="Card order randomized"
|
|
259
270
|
value={result.wasShuffled ? "Yes" : "No"}
|
|
260
271
|
/>
|
|
261
272
|
</div>
|
|
@@ -270,6 +281,8 @@ export function FlashcardLab({
|
|
|
270
281
|
Study Again
|
|
271
282
|
</Button>
|
|
272
283
|
</div>
|
|
284
|
+
</CardContent>
|
|
285
|
+
</Card>
|
|
273
286
|
</div>
|
|
274
287
|
);
|
|
275
288
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { ArrowLeft, BookOpen, BarChart3 } from "lucide-react";
|
|
3
|
+
import { GradebookTable } from "../../sections/GradebookTable/GradebookTable";
|
|
4
|
+
import { ProgressDashboard } from "../../sections/ProgressDashboard/ProgressDashboard";
|
|
5
|
+
import { AssessmentReview } from "../../sections/AssessmentReview/AssessmentReview";
|
|
6
|
+
import { GradeIndicator } from "../../progress/grade-indicator";
|
|
7
|
+
import { Button } from "../../ui/button";
|
|
8
|
+
import { Card, CardContent } from "../../ui/card";
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../ui/tabs";
|
|
10
|
+
import { Separator } from "../../ui/separator";
|
|
11
|
+
import { cn } from "../../lib/utils";
|
|
12
|
+
import type { GradeItem } from "../../sections/GradebookTable/types";
|
|
13
|
+
import type { GradeCenterModuleProps } from "./types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* GradeCenterModule — a tabbed grade center with gradebook, progress dashboard,
|
|
17
|
+
* and drill-down into individual assessment reviews.
|
|
18
|
+
*
|
|
19
|
+
* Uses a panel-based layout (like CoursePlayer) with tabs for Grades and Progress,
|
|
20
|
+
* and a slide-in detail panel for reviewing individual assessments.
|
|
21
|
+
*/
|
|
22
|
+
export function GradeCenterModule({
|
|
23
|
+
courseTitle,
|
|
24
|
+
gradeItems,
|
|
25
|
+
categories,
|
|
26
|
+
overallGrade,
|
|
27
|
+
showWeights,
|
|
28
|
+
progressData,
|
|
29
|
+
reviewData,
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
}: GradeCenterModuleProps) {
|
|
33
|
+
const [drillDownUid, setDrillDownUid] = useState<string | null>(null);
|
|
34
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
contentRef.current?.focus({ preventScroll: true });
|
|
38
|
+
}, [drillDownUid]);
|
|
39
|
+
|
|
40
|
+
function handleItemClick(item: GradeItem) {
|
|
41
|
+
if (reviewData?.[item.uid]) {
|
|
42
|
+
setDrillDownUid(item.uid);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleBack() {
|
|
47
|
+
setDrillDownUid(null);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const drillDownItem = drillDownUid
|
|
51
|
+
? gradeItems.find((i) => i.uid === drillDownUid)
|
|
52
|
+
: null;
|
|
53
|
+
const drillDownData = drillDownUid ? reviewData?.[drillDownUid] : null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
ref={contentRef}
|
|
58
|
+
tabIndex={-1}
|
|
59
|
+
className={cn("outline-none", className)}
|
|
60
|
+
style={style}
|
|
61
|
+
>
|
|
62
|
+
{/* Header */}
|
|
63
|
+
<div className="flex items-center justify-between mb-6">
|
|
64
|
+
<div>
|
|
65
|
+
{courseTitle && (
|
|
66
|
+
<h2 className="text-xl font-bold text-foreground">{courseTitle}</h2>
|
|
67
|
+
)}
|
|
68
|
+
<p className="text-sm text-muted-foreground">Grade Center</p>
|
|
69
|
+
</div>
|
|
70
|
+
{overallGrade && (
|
|
71
|
+
<div className="flex items-center gap-3">
|
|
72
|
+
<GradeIndicator
|
|
73
|
+
percentage={overallGrade.percentage}
|
|
74
|
+
letterGrade={overallGrade.letterGrade}
|
|
75
|
+
size="medium"
|
|
76
|
+
/>
|
|
77
|
+
<div className="text-right">
|
|
78
|
+
<div className="text-sm font-medium text-foreground">
|
|
79
|
+
{overallGrade.pointsEarned}/{overallGrade.pointsPossible} pts
|
|
80
|
+
</div>
|
|
81
|
+
{overallGrade.letterGrade && (
|
|
82
|
+
<div className="text-xs text-muted-foreground">
|
|
83
|
+
{overallGrade.letterGrade}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<Separator className="mb-6" />
|
|
92
|
+
|
|
93
|
+
{/* Drill-down view */}
|
|
94
|
+
{drillDownItem && drillDownData ? (
|
|
95
|
+
<div>
|
|
96
|
+
<Button
|
|
97
|
+
variant="ghost"
|
|
98
|
+
size="sm"
|
|
99
|
+
onClick={handleBack}
|
|
100
|
+
className="mb-4"
|
|
101
|
+
>
|
|
102
|
+
<ArrowLeft className="size-4 mr-1.5" />
|
|
103
|
+
Back to Grades
|
|
104
|
+
</Button>
|
|
105
|
+
<Card className="mb-4">
|
|
106
|
+
<CardContent className="py-3 px-4">
|
|
107
|
+
<h3 className="font-semibold text-foreground">
|
|
108
|
+
{drillDownItem.name}
|
|
109
|
+
</h3>
|
|
110
|
+
{drillDownItem.score !== null && (
|
|
111
|
+
<p className="text-sm text-muted-foreground">
|
|
112
|
+
Score: {drillDownItem.score}/{drillDownItem.maxScore} (
|
|
113
|
+
{Math.round(
|
|
114
|
+
(drillDownItem.score / drillDownItem.maxScore) * 100
|
|
115
|
+
)}
|
|
116
|
+
%)
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
</CardContent>
|
|
120
|
+
</Card>
|
|
121
|
+
<AssessmentReview
|
|
122
|
+
questions={drillDownData.questions}
|
|
123
|
+
sessionAnswers={drillDownData.sessionAnswers}
|
|
124
|
+
score={drillDownData.score}
|
|
125
|
+
showCorrectAnswers
|
|
126
|
+
/>
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<Tabs defaultValue="grades">
|
|
130
|
+
{progressData && (
|
|
131
|
+
<TabsList className="mb-6">
|
|
132
|
+
<TabsTrigger value="grades">
|
|
133
|
+
<BookOpen className="size-4 mr-1.5" />
|
|
134
|
+
Grades
|
|
135
|
+
</TabsTrigger>
|
|
136
|
+
<TabsTrigger value="progress">
|
|
137
|
+
<BarChart3 className="size-4 mr-1.5" />
|
|
138
|
+
Progress
|
|
139
|
+
</TabsTrigger>
|
|
140
|
+
</TabsList>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
<TabsContent value="grades">
|
|
144
|
+
<GradebookTable
|
|
145
|
+
items={gradeItems}
|
|
146
|
+
categories={categories}
|
|
147
|
+
overallGrade={overallGrade}
|
|
148
|
+
showWeights={showWeights}
|
|
149
|
+
onItemClick={handleItemClick}
|
|
150
|
+
/>
|
|
151
|
+
</TabsContent>
|
|
152
|
+
|
|
153
|
+
{progressData && (
|
|
154
|
+
<TabsContent value="progress">
|
|
155
|
+
<ProgressDashboard
|
|
156
|
+
overallProgress={progressData.overallProgress}
|
|
157
|
+
totalTimeSpent={progressData.totalTimeSpent}
|
|
158
|
+
modules={progressData.modules}
|
|
159
|
+
recentActivity={progressData.recentActivity}
|
|
160
|
+
streak={progressData.streak}
|
|
161
|
+
achievements={progressData.achievements}
|
|
162
|
+
/>
|
|
163
|
+
</TabsContent>
|
|
164
|
+
)}
|
|
165
|
+
</Tabs>
|
|
166
|
+
)}
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
}
|