@hydralms/components 0.1.1 → 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.
Files changed (131) hide show
  1. package/package.json +3 -1
  2. package/src/__tests__/setup.ts +1 -0
  3. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  4. package/src/assessment-toolbar/index.ts +10 -0
  5. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  6. package/src/assessment-toolbar/timer-display.tsx +73 -0
  7. package/src/assessment-toolbar/types.ts +92 -0
  8. package/src/assets/hydra-icon.png +0 -0
  9. package/src/assets/hydra-icon.svg +18 -0
  10. package/src/assets/hydra-lms-icon.png +0 -0
  11. package/src/assets/hydra-lms-icon.svg +9 -0
  12. package/src/common/confirm-dialog.tsx +60 -0
  13. package/src/common/due-date-display.tsx +64 -0
  14. package/src/common/empty-state.tsx +24 -0
  15. package/src/common/index.ts +12 -0
  16. package/src/common/search-input.tsx +68 -0
  17. package/src/common/status-badge.test.tsx +43 -0
  18. package/src/common/status-badge.tsx +81 -0
  19. package/src/common/types.ts +129 -0
  20. package/src/content/content-block.tsx +116 -0
  21. package/src/content/file-upload-zone.tsx +109 -0
  22. package/src/content/index.ts +7 -0
  23. package/src/content/types.ts +76 -0
  24. package/src/curriculum/curriculum-item.tsx +81 -0
  25. package/src/curriculum/curriculum-tree.tsx +69 -0
  26. package/src/curriculum/index.ts +11 -0
  27. package/src/curriculum/learning-object-icon.tsx +44 -0
  28. package/src/curriculum/types.ts +83 -0
  29. package/src/feedback/feedback-banner.tsx +46 -0
  30. package/src/feedback/index.ts +8 -0
  31. package/src/feedback/likert-scale.tsx +58 -0
  32. package/src/feedback/star-rating.tsx +65 -0
  33. package/src/feedback/types.ts +86 -0
  34. package/src/flashcards/flashcard-deck.tsx +130 -0
  35. package/src/flashcards/flashcard.tsx +108 -0
  36. package/src/flashcards/index.ts +3 -0
  37. package/src/flashcards/types.ts +60 -0
  38. package/src/index.ts +38 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  41. package/src/modules/CoursePlayer/types.ts +48 -0
  42. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  43. package/src/modules/FlashcardLab/types.ts +58 -0
  44. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  45. package/src/modules/QuizModule/types.ts +56 -0
  46. package/src/modules/index.ts +12 -0
  47. package/src/progress/grade-indicator.tsx +65 -0
  48. package/src/progress/index.ts +8 -0
  49. package/src/progress/progress-ring.tsx +56 -0
  50. package/src/progress/stat-card.tsx +42 -0
  51. package/src/progress/types.ts +73 -0
  52. package/src/provider/HydraProvider.tsx +26 -0
  53. package/src/provider/index.ts +2 -0
  54. package/src/questions/choice.tsx +90 -0
  55. package/src/questions/essay.tsx +59 -0
  56. package/src/questions/fill-in-the-blank.tsx +69 -0
  57. package/src/questions/index.ts +14 -0
  58. package/src/questions/multiple-choice.test.tsx +104 -0
  59. package/src/questions/multiple-choice.tsx +97 -0
  60. package/src/questions/question-renderer.tsx +37 -0
  61. package/src/questions/true-false.test.tsx +89 -0
  62. package/src/questions/true-false.tsx +90 -0
  63. package/src/questions/types.ts +53 -0
  64. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  65. package/src/sections/AnnouncementFeed/types.ts +50 -0
  66. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  67. package/src/sections/AssessmentReview/types.ts +61 -0
  68. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  69. package/src/sections/AssignmentSubmission/types.ts +60 -0
  70. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  71. package/src/sections/CertificateViewer/types.ts +45 -0
  72. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  73. package/src/sections/CourseOutline/types.ts +53 -0
  74. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  75. package/src/sections/DiscussionThread/types.ts +77 -0
  76. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  77. package/src/sections/ExamSession/types.ts +64 -0
  78. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  79. package/src/sections/FlashcardStudySession/types.ts +42 -0
  80. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  81. package/src/sections/GradebookTable/types.ts +75 -0
  82. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  83. package/src/sections/LecturePlayer/types.ts +48 -0
  84. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  85. package/src/sections/LessonPage/types.ts +41 -0
  86. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  87. package/src/sections/PracticeQuiz/types.ts +44 -0
  88. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  89. package/src/sections/ProgressDashboard/types.ts +74 -0
  90. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  91. package/src/sections/QuizSession/types.ts +47 -0
  92. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  93. package/src/sections/ResourceLibrary/types.ts +57 -0
  94. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  95. package/src/sections/ScrollableQuiz/types.ts +40 -0
  96. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  97. package/src/sections/SurveyForm/types.ts +69 -0
  98. package/src/sections/index.ts +90 -0
  99. package/src/social/index.ts +3 -0
  100. package/src/social/post-card.tsx +91 -0
  101. package/src/social/types.ts +57 -0
  102. package/src/social/user-avatar.tsx +76 -0
  103. package/src/styles/globals.css +125 -0
  104. package/src/ui/alert-dialog.tsx +343 -0
  105. package/src/ui/alert.tsx +65 -0
  106. package/src/ui/avatar.tsx +52 -0
  107. package/src/ui/badge.tsx +53 -0
  108. package/src/ui/button.tsx +62 -0
  109. package/src/ui/card.tsx +92 -0
  110. package/src/ui/index.ts +44 -0
  111. package/src/ui/input.tsx +21 -0
  112. package/src/ui/progress.tsx +73 -0
  113. package/src/ui/separator.tsx +29 -0
  114. package/src/ui/skeleton.tsx +15 -0
  115. package/src/ui/slot.tsx +48 -0
  116. package/src/ui/table.tsx +108 -0
  117. package/src/ui/tabs.tsx +147 -0
  118. package/src/ui/textarea.tsx +20 -0
  119. package/src/ui/tooltip.tsx +177 -0
  120. package/src/utils/debounce.test.ts +59 -0
  121. package/src/utils/debounce.ts +10 -0
  122. package/src/utils/format-duration.test.ts +55 -0
  123. package/src/utils/format-duration.ts +30 -0
  124. package/src/video/index.ts +17 -0
  125. package/src/video/types.ts +216 -0
  126. package/src/video/video-bookmark.tsx +76 -0
  127. package/src/video/video-chapter-list.tsx +93 -0
  128. package/src/video/video-player.tsx +103 -0
  129. package/src/video/video-playlist-item.tsx +90 -0
  130. package/src/video/video-thumbnail-card.tsx +74 -0
  131. package/src/video/video-transcript.tsx +102 -0
@@ -0,0 +1,275 @@
1
+ import { useState, useRef } from "react";
2
+ import {
3
+ BookOpen,
4
+ Shuffle,
5
+ RotateCcw,
6
+ CheckCircle2,
7
+ Layers,
8
+ Clock,
9
+ ArrowLeft,
10
+ } from "lucide-react";
11
+ import { FlashcardStudySession } from "../../sections/FlashcardStudySession/FlashcardStudySession";
12
+ import { ProgressRing } from "../../progress/progress-ring";
13
+ import { StatCard } from "../../progress/stat-card";
14
+ import { Button } from "../../ui/button";
15
+ import { Badge } from "../../ui/badge";
16
+ import { Card, CardContent } from "../../ui/card";
17
+ import { formatDuration } from "../../utils/format-duration";
18
+ import { cn } from "../../lib/utils";
19
+ import type { FlashcardData } from "../../flashcards/types";
20
+ import type {
21
+ FlashcardLabProps,
22
+ FlashcardLabResult,
23
+ } from "./types";
24
+
25
+ type InternalStep =
26
+ | { tag: "setup" }
27
+ | { tag: "study"; cards: FlashcardData[]; deckUids: string[]; shuffled: boolean }
28
+ | { tag: "completion"; result: FlashcardLabResult };
29
+
30
+ export function FlashcardLab({
31
+ decks,
32
+ showShuffleToggle = true,
33
+ defaultShuffled = false,
34
+ allowMultiSelect = true,
35
+ onComplete,
36
+ className,
37
+ style,
38
+ }: FlashcardLabProps) {
39
+ const [step, setStep] = useState<InternalStep>({ tag: "setup" });
40
+ const [selectedUids, setSelectedUids] = useState<Set<string>>(new Set());
41
+ const [shuffled, setShuffled] = useState(defaultShuffled);
42
+ const startTimeRef = useRef<number | null>(null);
43
+
44
+ function toggleDeck(uid: string) {
45
+ setSelectedUids((prev) => {
46
+ const next = new Set(prev);
47
+ if (next.has(uid)) {
48
+ next.delete(uid);
49
+ } else {
50
+ if (!allowMultiSelect) next.clear();
51
+ next.add(uid);
52
+ }
53
+ return next;
54
+ });
55
+ }
56
+
57
+ function startStudying() {
58
+ const selected = decks.filter((d) => selectedUids.has(d.uid));
59
+ const cards = selected.flatMap((d) => d.cards);
60
+ startTimeRef.current = Date.now();
61
+ setStep({
62
+ tag: "study",
63
+ cards,
64
+ deckUids: selected.map((d) => d.uid),
65
+ shuffled,
66
+ });
67
+ }
68
+
69
+ function handleStudyComplete() {
70
+ if (step.tag !== "study") return;
71
+ const elapsed = startTimeRef.current
72
+ ? Math.floor((Date.now() - startTimeRef.current) / 1000)
73
+ : 0;
74
+
75
+ const result: FlashcardLabResult = {
76
+ totalCards: step.cards.length,
77
+ decksStudied: step.deckUids.length,
78
+ deckUids: step.deckUids,
79
+ wasShuffled: step.shuffled,
80
+ timeElapsedSeconds: elapsed,
81
+ };
82
+ setStep({ tag: "completion", result });
83
+ onComplete?.(result);
84
+ }
85
+
86
+ function handleStudyAgain() {
87
+ if (step.tag !== "completion") return;
88
+ const selected = decks.filter((d) => step.result.deckUids.includes(d.uid));
89
+ const cards = selected.flatMap((d) => d.cards);
90
+ startTimeRef.current = Date.now();
91
+ setStep({
92
+ tag: "study",
93
+ cards,
94
+ deckUids: step.result.deckUids,
95
+ shuffled: step.result.wasShuffled,
96
+ });
97
+ }
98
+
99
+ function handlePickNewDeck() {
100
+ setSelectedUids(new Set());
101
+ setShuffled(defaultShuffled);
102
+ setStep({ tag: "setup" });
103
+ }
104
+
105
+ // ─── Setup Screen ───
106
+ if (step.tag === "setup") {
107
+ return (
108
+ <div className={cn(className)} style={style}>
109
+ <div className="text-center mb-6">
110
+ <div className="mx-auto mb-3 w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
111
+ <BookOpen className="size-7 text-primary" />
112
+ </div>
113
+ <h2 className="text-2xl font-bold text-foreground mb-1">
114
+ Choose Your Decks
115
+ </h2>
116
+ <p className="text-muted-foreground text-sm">
117
+ {allowMultiSelect
118
+ ? "Select one or more decks to study"
119
+ : "Select a deck to study"}
120
+ </p>
121
+ </div>
122
+
123
+ {/* Deck grid */}
124
+ <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-6">
125
+ {decks.map((deck) => {
126
+ const isSelected = selectedUids.has(deck.uid);
127
+ return (
128
+ <Card
129
+ key={deck.uid}
130
+ className={cn(
131
+ "cursor-pointer transition-all py-0",
132
+ isSelected
133
+ ? "border-primary ring-1 ring-primary"
134
+ : "hover:border-muted-foreground/30",
135
+ )}
136
+ onClick={() => toggleDeck(deck.uid)}
137
+ >
138
+ <CardContent className="py-4">
139
+ <div className="flex items-start justify-between mb-2">
140
+ <h3 className="font-semibold text-foreground text-sm">
141
+ {deck.title}
142
+ </h3>
143
+ <Badge variant="secondary" className="text-xs shrink-0">
144
+ {deck.cards.length} cards
145
+ </Badge>
146
+ </div>
147
+ {deck.description && (
148
+ <p className="text-xs text-muted-foreground line-clamp-2">
149
+ {deck.description}
150
+ </p>
151
+ )}
152
+ </CardContent>
153
+ </Card>
154
+ );
155
+ })}
156
+ </div>
157
+
158
+ {/* Options + start */}
159
+ <div className="flex items-center justify-between">
160
+ <div className="flex items-center gap-3">
161
+ {showShuffleToggle && (
162
+ <button
163
+ type="button"
164
+ className={cn(
165
+ "flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors",
166
+ shuffled
167
+ ? "border-primary bg-primary/10 text-primary"
168
+ : "border-border text-muted-foreground hover:text-foreground",
169
+ )}
170
+ onClick={() => setShuffled((s) => !s)}
171
+ >
172
+ <Shuffle className="size-3.5" />
173
+ Shuffle
174
+ </button>
175
+ )}
176
+ {selectedUids.size > 0 && (
177
+ <span className="text-xs text-muted-foreground">
178
+ {decks
179
+ .filter((d) => selectedUids.has(d.uid))
180
+ .reduce((sum, d) => sum + d.cards.length, 0)}{" "}
181
+ cards selected
182
+ </span>
183
+ )}
184
+ </div>
185
+ <Button
186
+ onClick={startStudying}
187
+ disabled={selectedUids.size === 0}
188
+ >
189
+ Start Studying
190
+ </Button>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ // ─── Study Screen ───
197
+ if (step.tag === "study") {
198
+ const deckNames = decks
199
+ .filter((d) => step.deckUids.includes(d.uid))
200
+ .map((d) => d.title)
201
+ .join(", ");
202
+
203
+ return (
204
+ <div className={cn(className)} style={style}>
205
+ <FlashcardStudySession
206
+ cards={step.cards}
207
+ title={deckNames}
208
+ shuffled={step.shuffled}
209
+ onComplete={handleStudyComplete}
210
+ />
211
+ </div>
212
+ );
213
+ }
214
+
215
+ // ─── Completion Screen ───
216
+ const { result } = step;
217
+
218
+ return (
219
+ <div className={cn(className)} style={style}>
220
+ <div className="text-center mb-8">
221
+ <div className="relative mx-auto mb-4">
222
+ <ProgressRing
223
+ value={100}
224
+ size={120}
225
+ strokeWidth={10}
226
+ color="var(--success)"
227
+ className="text-foreground"
228
+ label=""
229
+ />
230
+ <CheckCircle2 className="size-8 text-success absolute inset-0 m-auto" />
231
+ </div>
232
+ <h2 className="text-xl font-bold text-foreground mb-1">
233
+ Study Session Complete
234
+ </h2>
235
+ <p className="text-muted-foreground text-sm">
236
+ Great work! Here's your session summary.
237
+ </p>
238
+ </div>
239
+
240
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
241
+ <StatCard
242
+ icon={<Layers />}
243
+ label="Cards Studied"
244
+ value={String(result.totalCards)}
245
+ />
246
+ <StatCard
247
+ icon={<BookOpen />}
248
+ label="Decks"
249
+ value={String(result.decksStudied)}
250
+ />
251
+ <StatCard
252
+ icon={<Clock />}
253
+ label="Time Spent"
254
+ value={formatDuration(result.timeElapsedSeconds)}
255
+ />
256
+ <StatCard
257
+ icon={<Shuffle />}
258
+ label="Shuffled"
259
+ value={result.wasShuffled ? "Yes" : "No"}
260
+ />
261
+ </div>
262
+
263
+ <div className="flex justify-center gap-3">
264
+ <Button variant="outline" onClick={handlePickNewDeck}>
265
+ <ArrowLeft className="size-4 mr-2" />
266
+ Pick New Deck
267
+ </Button>
268
+ <Button onClick={handleStudyAgain}>
269
+ <RotateCcw className="size-4 mr-2" />
270
+ Study Again
271
+ </Button>
272
+ </div>
273
+ </div>
274
+ );
275
+ }
@@ -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";