@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.
- package/package.json +3 -1
- 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
|
@@ -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";
|