@hydralms/components 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +442 -110
- package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
- package/dist/modules/CoursePlayer/types.d.ts +59 -0
- package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
- package/dist/modules/FlashcardLab/types.d.ts +55 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
- package/dist/modules/QuizModule/types.d.ts +54 -0
- package/dist/modules/index.d.ts +6 -0
- package/dist/provider/HydraProvider.d.ts +1 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +261 -291
- package/dist/table-BrS5cDQu.js +2510 -0
- package/dist/table-D6AkBBEo.cjs +1 -0
- package/dist/ui/alert-dialog.d.ts +14 -8
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/tabs.d.ts +15 -5
- package/dist/ui/tooltip.d.ts +12 -5
- package/dist/video/index.d.ts +6 -1
- package/dist/video/types.d.ts +167 -0
- package/dist/video/video-bookmark.d.ts +2 -0
- package/dist/video/video-chapter-list.d.ts +2 -0
- package/dist/video/video-playlist-item.d.ts +2 -0
- package/dist/video/video-thumbnail-card.d.ts +2 -0
- package/dist/video/video-transcript.d.ts +2 -0
- package/package.json +135 -24
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
- package/dist/table-CW4_BYny.js +0 -9869
- package/dist/table-DSBBqb9X.cjs +0 -56
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { FlashcardData } from "../../flashcards/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FlashcardLab — a guided multi-step flashcard study session.
|
|
5
|
+
*
|
|
6
|
+
* Steps: Setup (deck selection) -> Study -> Completion
|
|
7
|
+
*
|
|
8
|
+
* Composes FlashcardStudySession, Card, Button, Badge, StatCard, ProgressRing.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <FlashcardLab
|
|
12
|
+
* decks={[
|
|
13
|
+
* { uid: "d1", title: "React Basics", cards: reactCards },
|
|
14
|
+
* { uid: "d2", title: "TypeScript", cards: tsCards },
|
|
15
|
+
* ]}
|
|
16
|
+
* onComplete={(result) => trackStudy(result)}
|
|
17
|
+
* />
|
|
18
|
+
*/
|
|
19
|
+
export interface FlashcardLabProps {
|
|
20
|
+
/** Available decks for the user to choose from */
|
|
21
|
+
decks: FlashcardDeckOption[];
|
|
22
|
+
/** Whether to show a shuffle toggle on the setup screen. @default true */
|
|
23
|
+
showShuffleToggle?: boolean;
|
|
24
|
+
/** Default shuffle setting. @default false */
|
|
25
|
+
defaultShuffled?: boolean;
|
|
26
|
+
/** Whether the user can select multiple decks to combine. @default true */
|
|
27
|
+
allowMultiSelect?: boolean;
|
|
28
|
+
/** Called when the user completes a study session */
|
|
29
|
+
onComplete?: (result: FlashcardLabResult) => void;
|
|
30
|
+
/** CSS class name for the root element */
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Inline styles for the root element */
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FlashcardDeckOption {
|
|
37
|
+
/** Unique deck identifier */
|
|
38
|
+
uid: string;
|
|
39
|
+
/** Deck title */
|
|
40
|
+
title: string;
|
|
41
|
+
/** Optional description */
|
|
42
|
+
description?: string;
|
|
43
|
+
/** The flashcard data */
|
|
44
|
+
cards: FlashcardData[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FlashcardLabResult {
|
|
48
|
+
/** Total cards studied */
|
|
49
|
+
totalCards: number;
|
|
50
|
+
/** Number of decks selected */
|
|
51
|
+
decksStudied: number;
|
|
52
|
+
/** UIDs of the decks that were studied */
|
|
53
|
+
deckUids: string[];
|
|
54
|
+
/** Whether cards were shuffled */
|
|
55
|
+
wasShuffled: boolean;
|
|
56
|
+
/** Total time spent studying in seconds */
|
|
57
|
+
timeElapsedSeconds: number;
|
|
58
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Play,
|
|
4
|
+
RotateCcw,
|
|
5
|
+
Clock,
|
|
6
|
+
HelpCircle,
|
|
7
|
+
CheckCircle2,
|
|
8
|
+
XCircle,
|
|
9
|
+
Trophy,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import { QuizSession } from "../../sections/QuizSession/QuizSession";
|
|
12
|
+
import { AssessmentReview } from "../../sections/AssessmentReview/AssessmentReview";
|
|
13
|
+
import { ProgressRing } from "../../progress/progress-ring";
|
|
14
|
+
import { StatCard } from "../../progress/stat-card";
|
|
15
|
+
import { Button } from "../../ui/button";
|
|
16
|
+
import { Badge } from "../../ui/badge";
|
|
17
|
+
import { Card, CardContent } from "../../ui/card";
|
|
18
|
+
import { formatDuration } from "../../utils/format-duration";
|
|
19
|
+
import { cn } from "../../lib/utils";
|
|
20
|
+
import type { SessionAnswer } from "../../questions/types";
|
|
21
|
+
import type { QuizModuleProps, QuizModuleResult } from "./types";
|
|
22
|
+
|
|
23
|
+
type InternalStep =
|
|
24
|
+
| { tag: "intro" }
|
|
25
|
+
| { tag: "quiz" }
|
|
26
|
+
| { tag: "results"; result: QuizModuleResult };
|
|
27
|
+
|
|
28
|
+
export function QuizModule({
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
questions,
|
|
32
|
+
timeLimitSeconds,
|
|
33
|
+
passingScore,
|
|
34
|
+
allowRetake = true,
|
|
35
|
+
onComplete,
|
|
36
|
+
showReview = true,
|
|
37
|
+
className,
|
|
38
|
+
style,
|
|
39
|
+
}: QuizModuleProps) {
|
|
40
|
+
const [step, setStep] = useState<InternalStep>({ tag: "intro" });
|
|
41
|
+
const [timeElapsed, setTimeElapsed] = useState(0);
|
|
42
|
+
const startTimeRef = useRef<number | null>(null);
|
|
43
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
44
|
+
|
|
45
|
+
// Timer for quiz step
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (step.tag === "quiz") {
|
|
48
|
+
startTimeRef.current = Date.now();
|
|
49
|
+
intervalRef.current = setInterval(() => {
|
|
50
|
+
if (startTimeRef.current) {
|
|
51
|
+
setTimeElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
52
|
+
}
|
|
53
|
+
}, 1000);
|
|
54
|
+
} else {
|
|
55
|
+
if (intervalRef.current) {
|
|
56
|
+
clearInterval(intervalRef.current);
|
|
57
|
+
intervalRef.current = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return () => {
|
|
61
|
+
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
62
|
+
};
|
|
63
|
+
}, [step.tag]);
|
|
64
|
+
|
|
65
|
+
function scoreAnswers(answers: SessionAnswer[]): QuizModuleResult {
|
|
66
|
+
let correct = 0;
|
|
67
|
+
for (const q of questions) {
|
|
68
|
+
const userAnswers = answers.filter((a) => a.uid === q.uid);
|
|
69
|
+
const correctUids = new Set(
|
|
70
|
+
(q.answers ?? []).filter((a) => a.isCorrect).map((a) => a.uid),
|
|
71
|
+
);
|
|
72
|
+
const userUids = new Set(userAnswers.map((a) => a.answerUid));
|
|
73
|
+
|
|
74
|
+
if (
|
|
75
|
+
correctUids.size > 0 &&
|
|
76
|
+
correctUids.size === userUids.size &&
|
|
77
|
+
[...correctUids].every((uid) => userUids.has(uid))
|
|
78
|
+
) {
|
|
79
|
+
correct++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const total = questions.length;
|
|
84
|
+
const percentage = total > 0 ? Math.round((correct / total) * 100) : 0;
|
|
85
|
+
const elapsed = startTimeRef.current
|
|
86
|
+
? Math.floor((Date.now() - startTimeRef.current) / 1000)
|
|
87
|
+
: timeElapsed;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
answers,
|
|
91
|
+
correct,
|
|
92
|
+
total,
|
|
93
|
+
percentage,
|
|
94
|
+
passed: passingScore !== undefined ? percentage >= passingScore : true,
|
|
95
|
+
timeElapsedSeconds: elapsed,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleSubmit(answers: SessionAnswer[]) {
|
|
100
|
+
const result = scoreAnswers(answers);
|
|
101
|
+
setStep({ tag: "results", result });
|
|
102
|
+
onComplete?.(result);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleRetake() {
|
|
106
|
+
setTimeElapsed(0);
|
|
107
|
+
startTimeRef.current = null;
|
|
108
|
+
setStep({ tag: "intro" });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ─── Intro Screen ───
|
|
112
|
+
if (step.tag === "intro") {
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn("max-w-2xl mx-auto", className)} style={style}>
|
|
115
|
+
<Card>
|
|
116
|
+
<CardContent className="pt-8 pb-8 text-center">
|
|
117
|
+
<div className="mx-auto mb-4 w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
|
|
118
|
+
<Trophy className="size-7 text-primary" />
|
|
119
|
+
</div>
|
|
120
|
+
<h2 className="text-2xl font-bold text-foreground mb-2">{title}</h2>
|
|
121
|
+
{description && (
|
|
122
|
+
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
|
123
|
+
{description}
|
|
124
|
+
</p>
|
|
125
|
+
)}
|
|
126
|
+
<div className="flex flex-wrap justify-center gap-4 mb-8">
|
|
127
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
128
|
+
<HelpCircle className="size-4" />
|
|
129
|
+
<span>{questions.length} questions</span>
|
|
130
|
+
</div>
|
|
131
|
+
{timeLimitSeconds && (
|
|
132
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
133
|
+
<Clock className="size-4" />
|
|
134
|
+
<span>{formatDuration(timeLimitSeconds)} time limit</span>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{passingScore !== undefined && (
|
|
138
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
139
|
+
<CheckCircle2 className="size-4" />
|
|
140
|
+
<span>{passingScore}% to pass</span>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
<Button size="lg" onClick={() => setStep({ tag: "quiz" })}>
|
|
145
|
+
<Play className="size-4 mr-2" />
|
|
146
|
+
Start Quiz
|
|
147
|
+
</Button>
|
|
148
|
+
</CardContent>
|
|
149
|
+
</Card>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Quiz Screen ───
|
|
155
|
+
if (step.tag === "quiz") {
|
|
156
|
+
return (
|
|
157
|
+
<div className={cn(className)} style={style}>
|
|
158
|
+
<QuizSession
|
|
159
|
+
questions={questions}
|
|
160
|
+
onSubmit={handleSubmit}
|
|
161
|
+
timeElapsedSeconds={timeElapsed}
|
|
162
|
+
timeLimitSeconds={timeLimitSeconds}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Results Screen ───
|
|
169
|
+
const { result } = step;
|
|
170
|
+
const passed = result.passed;
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className={cn(className)} style={style}>
|
|
174
|
+
{/* Score summary */}
|
|
175
|
+
<div className="text-center mb-8">
|
|
176
|
+
<ProgressRing
|
|
177
|
+
value={result.percentage}
|
|
178
|
+
size={140}
|
|
179
|
+
strokeWidth={10}
|
|
180
|
+
color={passed ? "var(--success)" : "var(--destructive)"}
|
|
181
|
+
className="mx-auto mb-4 text-foreground"
|
|
182
|
+
/>
|
|
183
|
+
<Badge
|
|
184
|
+
variant={passed ? "success" : "destructive"}
|
|
185
|
+
className="text-sm px-3 py-1 mb-2"
|
|
186
|
+
>
|
|
187
|
+
{passed ? "Passed" : "Failed"}
|
|
188
|
+
</Badge>
|
|
189
|
+
<h2 className="text-xl font-bold text-foreground">{title}</h2>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Stats grid */}
|
|
193
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
|
|
194
|
+
<StatCard
|
|
195
|
+
icon={<CheckCircle2 />}
|
|
196
|
+
label="Correct"
|
|
197
|
+
value={`${result.correct}/${result.total}`}
|
|
198
|
+
/>
|
|
199
|
+
<StatCard
|
|
200
|
+
icon={<XCircle />}
|
|
201
|
+
label="Incorrect"
|
|
202
|
+
value={`${result.total - result.correct}/${result.total}`}
|
|
203
|
+
/>
|
|
204
|
+
<StatCard
|
|
205
|
+
icon={<Trophy />}
|
|
206
|
+
label="Score"
|
|
207
|
+
value={`${result.percentage}%`}
|
|
208
|
+
/>
|
|
209
|
+
<StatCard
|
|
210
|
+
icon={<Clock />}
|
|
211
|
+
label="Time"
|
|
212
|
+
value={formatDuration(result.timeElapsedSeconds)}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{/* Actions */}
|
|
217
|
+
{allowRetake && (
|
|
218
|
+
<div className="flex justify-center mb-8">
|
|
219
|
+
<Button variant="outline" onClick={handleRetake}>
|
|
220
|
+
<RotateCcw className="size-4 mr-2" />
|
|
221
|
+
Retake Quiz
|
|
222
|
+
</Button>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{/* Per-question review */}
|
|
227
|
+
{showReview && (
|
|
228
|
+
<div>
|
|
229
|
+
<h3 className="text-lg font-semibold text-foreground mb-4">
|
|
230
|
+
Question Review
|
|
231
|
+
</h3>
|
|
232
|
+
<AssessmentReview
|
|
233
|
+
questions={questions}
|
|
234
|
+
sessionAnswers={result.answers}
|
|
235
|
+
showCorrectAnswers
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuizModule — a complete multi-step assessment experience.
|
|
5
|
+
*
|
|
6
|
+
* Steps: Intro -> Quiz -> Results/Review
|
|
7
|
+
*
|
|
8
|
+
* Composes QuizSession, AssessmentReview, ProgressRing, StatCard, Card, and Button.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <QuizModule
|
|
12
|
+
* title="React Hooks Quiz"
|
|
13
|
+
* description="Test your knowledge of React hooks"
|
|
14
|
+
* questions={questions}
|
|
15
|
+
* passingScore={70}
|
|
16
|
+
* timeLimitSeconds={600}
|
|
17
|
+
* onComplete={(result) => saveResult(result)}
|
|
18
|
+
* />
|
|
19
|
+
*/
|
|
20
|
+
export interface QuizModuleProps {
|
|
21
|
+
/** Quiz title displayed on the intro screen */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Quiz description displayed on the intro screen */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Ordered list of questions */
|
|
26
|
+
questions: QuestionData[];
|
|
27
|
+
/** Time limit in seconds — when provided, shows countdown info on intro and enables timer */
|
|
28
|
+
timeLimitSeconds?: number;
|
|
29
|
+
/** Passing threshold as a percentage (e.g. 70 means 70%). Determines pass/fail on results. */
|
|
30
|
+
passingScore?: number;
|
|
31
|
+
/** Whether to allow retaking the quiz from the results screen. @default true */
|
|
32
|
+
allowRetake?: boolean;
|
|
33
|
+
/** Called when the user completes the quiz (submits answers) */
|
|
34
|
+
onComplete?: (result: QuizModuleResult) => void;
|
|
35
|
+
/** Whether to show correct/incorrect answer highlighting in the review. @default true */
|
|
36
|
+
showReview?: boolean;
|
|
37
|
+
/** CSS class name for the root element */
|
|
38
|
+
className?: string;
|
|
39
|
+
/** Inline styles for the root element */
|
|
40
|
+
style?: React.CSSProperties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface QuizModuleResult {
|
|
44
|
+
/** The user's submitted answers */
|
|
45
|
+
answers: SessionAnswer[];
|
|
46
|
+
/** Number of correct answers */
|
|
47
|
+
correct: number;
|
|
48
|
+
/** Total number of questions */
|
|
49
|
+
total: number;
|
|
50
|
+
/** Score as a percentage (0-100) */
|
|
51
|
+
percentage: number;
|
|
52
|
+
/** Whether the user passed (only meaningful when passingScore is set) */
|
|
53
|
+
passed: boolean;
|
|
54
|
+
/** Total time taken in seconds */
|
|
55
|
+
timeElapsedSeconds: number;
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { QuizModule } from "./QuizModule/QuizModule";
|
|
2
|
+
export type { QuizModuleProps, QuizModuleResult } from "./QuizModule/types";
|
|
3
|
+
|
|
4
|
+
export { FlashcardLab } from "./FlashcardLab/FlashcardLab";
|
|
5
|
+
export type {
|
|
6
|
+
FlashcardLabProps,
|
|
7
|
+
FlashcardDeckOption,
|
|
8
|
+
FlashcardLabResult,
|
|
9
|
+
} from "./FlashcardLab/types";
|
|
10
|
+
|
|
11
|
+
export { CoursePlayer } from "./CoursePlayer/CoursePlayer";
|
|
12
|
+
export type { CoursePlayerProps, CoursePlayerItem } from "./CoursePlayer/types";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ProgressRing } from "./progress-ring";
|
|
2
|
+
import type { GradeIndicatorProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const SIZES = { small: 48, medium: 64, large: 96 };
|
|
6
|
+
const FONT_SIZES = { small: "0.75rem", medium: "0.875rem", large: "1rem" };
|
|
7
|
+
const TRACK_HEIGHTS = { small: "4px", medium: "6px", large: "8px" };
|
|
8
|
+
|
|
9
|
+
const COLOR_MAP: Record<string, string> = {
|
|
10
|
+
primary: "var(--primary)",
|
|
11
|
+
success: "var(--success)",
|
|
12
|
+
warning: "var(--warning)",
|
|
13
|
+
destructive: "var(--destructive)",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function getColor(percentage: number, threshold?: number): string {
|
|
17
|
+
if (threshold == null) return "primary";
|
|
18
|
+
if (percentage >= threshold) return "success";
|
|
19
|
+
if (percentage >= threshold * 0.7) return "warning";
|
|
20
|
+
return "destructive";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function GradeIndicator({
|
|
24
|
+
percentage,
|
|
25
|
+
letterGrade,
|
|
26
|
+
variant = "circular",
|
|
27
|
+
size = "medium",
|
|
28
|
+
passingThreshold,
|
|
29
|
+
showLabel = true,
|
|
30
|
+
className,
|
|
31
|
+
style,
|
|
32
|
+
}: GradeIndicatorProps) {
|
|
33
|
+
const colorKey = getColor(percentage, passingThreshold);
|
|
34
|
+
const color = COLOR_MAP[colorKey] ?? "var(--primary)";
|
|
35
|
+
|
|
36
|
+
if (variant === "linear") {
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn("flex flex-row items-center gap-1", className)} style={style}>
|
|
39
|
+
<div className="flex-1 bg-muted rounded-full overflow-hidden" style={{ height: TRACK_HEIGHTS[size] }}>
|
|
40
|
+
<div
|
|
41
|
+
className="h-full rounded-full transition-[width] duration-300 ease-in-out"
|
|
42
|
+
style={{ width: `${percentage}%`, background: color }}
|
|
43
|
+
/>
|
|
44
|
+
</div>
|
|
45
|
+
{showLabel && (
|
|
46
|
+
<span className="font-semibold min-w-9" style={{ fontSize: FONT_SIZES[size] }}>
|
|
47
|
+
{letterGrade ?? `${Math.round(percentage)}%`}
|
|
48
|
+
</span>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("inline-flex flex-col items-center", className)} style={style}>
|
|
56
|
+
<ProgressRing
|
|
57
|
+
value={percentage}
|
|
58
|
+
size={SIZES[size]}
|
|
59
|
+
strokeWidth={size === "small" ? 4 : 6}
|
|
60
|
+
color={color}
|
|
61
|
+
label={letterGrade ?? `${Math.round(percentage)}%`}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ProgressRingProps } from "./types";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
export function ProgressRing({
|
|
5
|
+
value,
|
|
6
|
+
size = 120,
|
|
7
|
+
strokeWidth = 8,
|
|
8
|
+
label,
|
|
9
|
+
color,
|
|
10
|
+
className,
|
|
11
|
+
style,
|
|
12
|
+
}: ProgressRingProps) {
|
|
13
|
+
const radius = (size - strokeWidth) / 2;
|
|
14
|
+
const circumference = 2 * Math.PI * radius;
|
|
15
|
+
const offset =
|
|
16
|
+
circumference - (Math.min(Math.max(value, 0), 100) / 100) * circumference;
|
|
17
|
+
const center = size / 2;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn("relative inline-flex", className)}
|
|
22
|
+
style={{ width: `${size}px`, height: `${size}px`, ...style }}
|
|
23
|
+
>
|
|
24
|
+
<svg width={size} height={size}>
|
|
25
|
+
<circle
|
|
26
|
+
cx={center}
|
|
27
|
+
cy={center}
|
|
28
|
+
r={radius}
|
|
29
|
+
fill="none"
|
|
30
|
+
stroke="currentColor"
|
|
31
|
+
strokeWidth={strokeWidth}
|
|
32
|
+
opacity={0.15}
|
|
33
|
+
/>
|
|
34
|
+
<circle
|
|
35
|
+
cx={center}
|
|
36
|
+
cy={center}
|
|
37
|
+
r={radius}
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke={color ?? "currentColor"}
|
|
40
|
+
strokeWidth={strokeWidth}
|
|
41
|
+
strokeDasharray={circumference}
|
|
42
|
+
strokeDashoffset={offset}
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
transform={`rotate(-90 ${center} ${center})`}
|
|
45
|
+
style={{ transition: "stroke-dashoffset 0.4s ease" }}
|
|
46
|
+
/>
|
|
47
|
+
</svg>
|
|
48
|
+
<span
|
|
49
|
+
className="absolute inset-0 flex items-center justify-center font-bold text-foreground"
|
|
50
|
+
style={{ fontSize: `${size * 0.2}px` }}
|
|
51
|
+
>
|
|
52
|
+
{label ?? `${Math.round(value)}%`}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
2
|
+
import type { StatCardProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const TREND_COLORS = {
|
|
6
|
+
up: "var(--success)",
|
|
7
|
+
down: "var(--destructive)",
|
|
8
|
+
flat: "var(--muted-foreground)",
|
|
9
|
+
};
|
|
10
|
+
const TREND_ICONS = { up: TrendingUp, down: TrendingDown, flat: Minus };
|
|
11
|
+
|
|
12
|
+
export function StatCard({
|
|
13
|
+
icon,
|
|
14
|
+
label,
|
|
15
|
+
value,
|
|
16
|
+
subtitle,
|
|
17
|
+
trend,
|
|
18
|
+
className,
|
|
19
|
+
style,
|
|
20
|
+
}: StatCardProps) {
|
|
21
|
+
const TrendIcon = trend ? TREND_ICONS[trend.direction] : null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className={cn("rounded-md border border-border p-4", className)} style={style}>
|
|
25
|
+
{icon && <div className="mb-1 text-primary [&>svg]:size-6">{icon}</div>}
|
|
26
|
+
<span className="text-xs text-muted-foreground">{label}</span>
|
|
27
|
+
<div className="flex flex-row items-baseline gap-1">
|
|
28
|
+
<span className="text-2xl font-bold">{value}</span>
|
|
29
|
+
{trend && TrendIcon && (
|
|
30
|
+
<span className="flex flex-row items-center gap-px" style={{ color: TREND_COLORS[trend.direction] }}>
|
|
31
|
+
<TrendIcon size={14} />
|
|
32
|
+
<span className="text-xs font-semibold">
|
|
33
|
+
{trend.value > 0 ? "+" : ""}
|
|
34
|
+
{trend.value}%
|
|
35
|
+
</span>
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
{subtitle && <span className="text-xs text-muted-foreground">{subtitle}</span>}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProgressRing renders a circular SVG progress indicator with a centered label.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* <ProgressRing value={75} size={120} />
|
|
8
|
+
*/
|
|
9
|
+
export interface ProgressRingProps {
|
|
10
|
+
/** Progress value from 0 to 100 */
|
|
11
|
+
value: number;
|
|
12
|
+
/** Diameter of the ring in pixels */
|
|
13
|
+
size?: number;
|
|
14
|
+
/** Stroke width in pixels */
|
|
15
|
+
strokeWidth?: number;
|
|
16
|
+
/** Override text in the center of the ring */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** Ring color — defaults to the theme primary */
|
|
19
|
+
color?: string;
|
|
20
|
+
/** CSS class name for the root element */
|
|
21
|
+
className?: string;
|
|
22
|
+
/** Inline styles for the root element */
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* GradeIndicator displays a grade as a circular or linear percentage badge
|
|
28
|
+
* with pass/fail coloring based on a configurable threshold.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* <GradeIndicator percentage={85} letterGrade="B+" passingThreshold={70} />
|
|
32
|
+
*/
|
|
33
|
+
export interface GradeIndicatorProps {
|
|
34
|
+
/** Grade percentage from 0 to 100 */
|
|
35
|
+
percentage: number;
|
|
36
|
+
/** Optional letter grade label */
|
|
37
|
+
letterGrade?: string;
|
|
38
|
+
/** Display variant */
|
|
39
|
+
variant?: "circular" | "linear";
|
|
40
|
+
/** Component size */
|
|
41
|
+
size?: "small" | "medium" | "large";
|
|
42
|
+
/** Below this percentage the indicator shows warning/error colors */
|
|
43
|
+
passingThreshold?: number;
|
|
44
|
+
/** Whether to show the percentage label */
|
|
45
|
+
showLabel?: boolean;
|
|
46
|
+
/** CSS class name for the root element */
|
|
47
|
+
className?: string;
|
|
48
|
+
/** Inline styles for the root element */
|
|
49
|
+
style?: React.CSSProperties;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* StatCard displays a single key-value statistic with an icon and optional trend indicator.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* <StatCard label="Lessons Completed" value={12} />
|
|
57
|
+
*/
|
|
58
|
+
export interface StatCardProps {
|
|
59
|
+
/** Optional icon displayed at the top */
|
|
60
|
+
icon?: ReactNode;
|
|
61
|
+
/** Stat label */
|
|
62
|
+
label: string;
|
|
63
|
+
/** Stat value */
|
|
64
|
+
value: string | number;
|
|
65
|
+
/** Secondary text below the value */
|
|
66
|
+
subtitle?: string;
|
|
67
|
+
/** Optional trend data */
|
|
68
|
+
trend?: { value: number; direction: "up" | "down" | "flat" };
|
|
69
|
+
/** CSS class name for the root element */
|
|
70
|
+
className?: string;
|
|
71
|
+
/** Inline styles for the root element */
|
|
72
|
+
style?: React.CSSProperties;
|
|
73
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import "../styles/globals.css";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export interface HydraProviderProps {
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
/** Controls color mode. Defaults to `"dark"`. Set to `"light"` to use the light theme. */
|
|
7
|
+
colorMode?: "light" | "dark";
|
|
8
|
+
className?: string;
|
|
9
|
+
style?: React.CSSProperties;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function HydraProvider({
|
|
13
|
+
children,
|
|
14
|
+
colorMode = "dark",
|
|
15
|
+
className,
|
|
16
|
+
style,
|
|
17
|
+
}: HydraProviderProps) {
|
|
18
|
+
return (
|
|
19
|
+
<div
|
|
20
|
+
className={`hydra-root${colorMode === "dark" ? " dark" : ""}${className ? ` ${className}` : ""}`}
|
|
21
|
+
style={style}
|
|
22
|
+
>
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|