@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.
Files changed (159) hide show
  1. package/dist/components.css +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +442 -110
  4. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
  5. package/dist/modules/CoursePlayer/types.d.ts +59 -0
  6. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
  7. package/dist/modules/FlashcardLab/types.d.ts +55 -0
  8. package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
  9. package/dist/modules/QuizModule/types.d.ts +54 -0
  10. package/dist/modules/index.d.ts +6 -0
  11. package/dist/provider/HydraProvider.d.ts +1 -1
  12. package/dist/sections.cjs +1 -1
  13. package/dist/sections.js +261 -291
  14. package/dist/table-BrS5cDQu.js +2510 -0
  15. package/dist/table-D6AkBBEo.cjs +1 -0
  16. package/dist/ui/alert-dialog.d.ts +14 -8
  17. package/dist/ui/button.d.ts +1 -1
  18. package/dist/ui/tabs.d.ts +15 -5
  19. package/dist/ui/tooltip.d.ts +12 -5
  20. package/dist/video/index.d.ts +6 -1
  21. package/dist/video/types.d.ts +167 -0
  22. package/dist/video/video-bookmark.d.ts +2 -0
  23. package/dist/video/video-chapter-list.d.ts +2 -0
  24. package/dist/video/video-playlist-item.d.ts +2 -0
  25. package/dist/video/video-thumbnail-card.d.ts +2 -0
  26. package/dist/video/video-transcript.d.ts +2 -0
  27. package/package.json +135 -24
  28. package/src/__tests__/setup.ts +1 -0
  29. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  30. package/src/assessment-toolbar/index.ts +10 -0
  31. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  32. package/src/assessment-toolbar/timer-display.tsx +73 -0
  33. package/src/assessment-toolbar/types.ts +92 -0
  34. package/src/assets/hydra-icon.png +0 -0
  35. package/src/assets/hydra-icon.svg +18 -0
  36. package/src/assets/hydra-lms-icon.png +0 -0
  37. package/src/assets/hydra-lms-icon.svg +9 -0
  38. package/src/common/confirm-dialog.tsx +60 -0
  39. package/src/common/due-date-display.tsx +64 -0
  40. package/src/common/empty-state.tsx +24 -0
  41. package/src/common/index.ts +12 -0
  42. package/src/common/search-input.tsx +68 -0
  43. package/src/common/status-badge.test.tsx +43 -0
  44. package/src/common/status-badge.tsx +81 -0
  45. package/src/common/types.ts +129 -0
  46. package/src/content/content-block.tsx +116 -0
  47. package/src/content/file-upload-zone.tsx +109 -0
  48. package/src/content/index.ts +7 -0
  49. package/src/content/types.ts +76 -0
  50. package/src/curriculum/curriculum-item.tsx +81 -0
  51. package/src/curriculum/curriculum-tree.tsx +69 -0
  52. package/src/curriculum/index.ts +11 -0
  53. package/src/curriculum/learning-object-icon.tsx +44 -0
  54. package/src/curriculum/types.ts +83 -0
  55. package/src/feedback/feedback-banner.tsx +46 -0
  56. package/src/feedback/index.ts +8 -0
  57. package/src/feedback/likert-scale.tsx +58 -0
  58. package/src/feedback/star-rating.tsx +65 -0
  59. package/src/feedback/types.ts +86 -0
  60. package/src/flashcards/flashcard-deck.tsx +130 -0
  61. package/src/flashcards/flashcard.tsx +108 -0
  62. package/src/flashcards/index.ts +3 -0
  63. package/src/flashcards/types.ts +60 -0
  64. package/src/index.ts +38 -0
  65. package/src/lib/utils.ts +6 -0
  66. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  67. package/src/modules/CoursePlayer/types.ts +48 -0
  68. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  69. package/src/modules/FlashcardLab/types.ts +58 -0
  70. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  71. package/src/modules/QuizModule/types.ts +56 -0
  72. package/src/modules/index.ts +12 -0
  73. package/src/progress/grade-indicator.tsx +65 -0
  74. package/src/progress/index.ts +8 -0
  75. package/src/progress/progress-ring.tsx +56 -0
  76. package/src/progress/stat-card.tsx +42 -0
  77. package/src/progress/types.ts +73 -0
  78. package/src/provider/HydraProvider.tsx +26 -0
  79. package/src/provider/index.ts +2 -0
  80. package/src/questions/choice.tsx +90 -0
  81. package/src/questions/essay.tsx +59 -0
  82. package/src/questions/fill-in-the-blank.tsx +69 -0
  83. package/src/questions/index.ts +14 -0
  84. package/src/questions/multiple-choice.test.tsx +104 -0
  85. package/src/questions/multiple-choice.tsx +97 -0
  86. package/src/questions/question-renderer.tsx +37 -0
  87. package/src/questions/true-false.test.tsx +89 -0
  88. package/src/questions/true-false.tsx +90 -0
  89. package/src/questions/types.ts +53 -0
  90. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  91. package/src/sections/AnnouncementFeed/types.ts +50 -0
  92. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  93. package/src/sections/AssessmentReview/types.ts +61 -0
  94. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  95. package/src/sections/AssignmentSubmission/types.ts +60 -0
  96. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  97. package/src/sections/CertificateViewer/types.ts +45 -0
  98. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  99. package/src/sections/CourseOutline/types.ts +53 -0
  100. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  101. package/src/sections/DiscussionThread/types.ts +77 -0
  102. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  103. package/src/sections/ExamSession/types.ts +64 -0
  104. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  105. package/src/sections/FlashcardStudySession/types.ts +42 -0
  106. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  107. package/src/sections/GradebookTable/types.ts +75 -0
  108. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  109. package/src/sections/LecturePlayer/types.ts +48 -0
  110. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  111. package/src/sections/LessonPage/types.ts +41 -0
  112. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  113. package/src/sections/PracticeQuiz/types.ts +44 -0
  114. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  115. package/src/sections/ProgressDashboard/types.ts +74 -0
  116. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  117. package/src/sections/QuizSession/types.ts +47 -0
  118. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  119. package/src/sections/ResourceLibrary/types.ts +57 -0
  120. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  121. package/src/sections/ScrollableQuiz/types.ts +40 -0
  122. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  123. package/src/sections/SurveyForm/types.ts +69 -0
  124. package/src/sections/index.ts +90 -0
  125. package/src/social/index.ts +3 -0
  126. package/src/social/post-card.tsx +91 -0
  127. package/src/social/types.ts +57 -0
  128. package/src/social/user-avatar.tsx +76 -0
  129. package/src/styles/globals.css +125 -0
  130. package/src/ui/alert-dialog.tsx +343 -0
  131. package/src/ui/alert.tsx +65 -0
  132. package/src/ui/avatar.tsx +52 -0
  133. package/src/ui/badge.tsx +53 -0
  134. package/src/ui/button.tsx +62 -0
  135. package/src/ui/card.tsx +92 -0
  136. package/src/ui/index.ts +44 -0
  137. package/src/ui/input.tsx +21 -0
  138. package/src/ui/progress.tsx +73 -0
  139. package/src/ui/separator.tsx +29 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/slot.tsx +48 -0
  142. package/src/ui/table.tsx +108 -0
  143. package/src/ui/tabs.tsx +147 -0
  144. package/src/ui/textarea.tsx +20 -0
  145. package/src/ui/tooltip.tsx +177 -0
  146. package/src/utils/debounce.test.ts +59 -0
  147. package/src/utils/debounce.ts +10 -0
  148. package/src/utils/format-duration.test.ts +55 -0
  149. package/src/utils/format-duration.ts +30 -0
  150. package/src/video/index.ts +17 -0
  151. package/src/video/types.ts +216 -0
  152. package/src/video/video-bookmark.tsx +76 -0
  153. package/src/video/video-chapter-list.tsx +93 -0
  154. package/src/video/video-player.tsx +103 -0
  155. package/src/video/video-playlist-item.tsx +90 -0
  156. package/src/video/video-thumbnail-card.tsx +74 -0
  157. package/src/video/video-transcript.tsx +102 -0
  158. package/dist/table-CW4_BYny.js +0 -9869
  159. package/dist/table-DSBBqb9X.cjs +0 -56
@@ -0,0 +1,60 @@
1
+ import { VideoPlayer } from "../../video";
2
+ import { Card, CardHeader, CardTitle, CardContent } from "../../ui/card";
3
+ import type { LecturePlayerProps } from "./types";
4
+ import { cn } from "../../lib/utils";
5
+
6
+ export function LecturePlayer({
7
+ video,
8
+ notes,
9
+ layout = "horizontal",
10
+ notesPanelWidth = "340px",
11
+ notesPanelHeight = "240px",
12
+ className,
13
+ style,
14
+ }: LecturePlayerProps) {
15
+ const isHorizontal = layout === "horizontal";
16
+
17
+ if (!notes) {
18
+ return (
19
+ <div className={className} style={style}>
20
+ <VideoPlayer {...video} />
21
+ </div>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "flex overflow-hidden",
29
+ isHorizontal ? "flex-row" : "flex-col",
30
+ className,
31
+ )}
32
+ style={style}
33
+ >
34
+ <div className="flex-1 min-w-0 min-h-0">
35
+ <VideoPlayer {...video} />
36
+ </div>
37
+ <Card
38
+ className={cn(
39
+ "overflow-auto shrink-0 rounded-none border-0",
40
+ isHorizontal ? "border-l border-border" : "border-t border-border w-full",
41
+ )}
42
+ style={{
43
+ width: isHorizontal ? notesPanelWidth : undefined,
44
+ height: isHorizontal ? undefined : notesPanelHeight,
45
+ }}
46
+ >
47
+ <CardHeader>
48
+ <CardTitle>Notes</CardTitle>
49
+ </CardHeader>
50
+ <CardContent>
51
+ {typeof notes === "string" ? (
52
+ <span className="text-sm text-foreground">{notes}</span>
53
+ ) : (
54
+ notes
55
+ )}
56
+ </CardContent>
57
+ </Card>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from "react";
2
+ import type { VideoPlayerProps } from "../../video/types";
3
+
4
+ /**
5
+ * LecturePlayer section — video player paired with an optional notes/transcript panel.
6
+ *
7
+ * Supports horizontal (side-by-side) and vertical (stacked) layouts with
8
+ * configurable panel dimensions. When notes are omitted, the video fills
9
+ * the full container.
10
+ *
11
+ * @example
12
+ * <LecturePlayer
13
+ * video={{ src: "https://example.com/lecture.mp4", title: "React Hooks" }}
14
+ * notes={<TranscriptPanel />}
15
+ * layout="horizontal"
16
+ * />
17
+ */
18
+ export interface LecturePlayerProps {
19
+ /** Props passed directly to the underlying VideoPlayer */
20
+ video: VideoPlayerProps;
21
+ /**
22
+ * Content rendered in the companion panel.
23
+ * Can be any ReactNode — transcript, MDX, note editor, etc.
24
+ * If omitted, the player fills the full container with no panel.
25
+ */
26
+ notes?: ReactNode;
27
+ /**
28
+ * Layout direction of the two-pane split.
29
+ * - "horizontal": video left, notes right
30
+ * - "vertical": video top, notes bottom
31
+ * @default "horizontal"
32
+ */
33
+ layout?: "horizontal" | "vertical";
34
+ /**
35
+ * Width of the notes panel when layout is "horizontal".
36
+ * @default "340px"
37
+ */
38
+ notesPanelWidth?: string | number;
39
+ /**
40
+ * Height of the notes panel when layout is "vertical".
41
+ * @default "240px"
42
+ */
43
+ notesPanelHeight?: string | number;
44
+ /** CSS class name for the root element */
45
+ className?: string;
46
+ /** Inline styles for the root element */
47
+ style?: React.CSSProperties;
48
+ }
@@ -0,0 +1,91 @@
1
+ import { useState } from "react";
2
+ import { Check, ChevronRight, Clock } from "lucide-react";
3
+ import { ContentBlock } from "../../content";
4
+ import type { SessionAnswer } from "../../questions/types";
5
+ import { Button } from "../../ui/button";
6
+ import { Separator } from "../../ui/separator";
7
+ import { formatDuration } from "../../utils/format-duration";
8
+ import type { LessonPageProps } from "./types";
9
+ import { cn } from "../../lib/utils";
10
+
11
+ export function LessonPage({
12
+ title,
13
+ blocks,
14
+ isCompleted = false,
15
+ onMarkComplete,
16
+ onNextLesson,
17
+ nextLessonTitle,
18
+ estimatedDuration,
19
+ showDuration = true,
20
+ readOnly = false,
21
+ className,
22
+ style,
23
+ }: LessonPageProps) {
24
+ const [completed, setCompleted] = useState(isCompleted);
25
+ const [, setQuestionAnswers] = useState<Map<string, SessionAnswer[]>>(new Map());
26
+
27
+ function handleQuestionAnswer(questionUid: string, answers: SessionAnswer[]) {
28
+ setQuestionAnswers((prev) => new Map(prev).set(questionUid, answers));
29
+ }
30
+
31
+ function handleMarkComplete() {
32
+ setCompleted(true);
33
+ onMarkComplete?.();
34
+ }
35
+
36
+ return (
37
+ <div className={cn(className)} style={style}>
38
+ {/* Header */}
39
+ <div className="mb-3">
40
+ <p className="text-2xl font-bold mb-0.5 text-foreground">{title}</p>
41
+ {showDuration && estimatedDuration != null && (
42
+ <div className="flex items-center gap-0.5 text-muted-foreground">
43
+ <Clock size={16} />
44
+ <span className="text-sm">{formatDuration(estimatedDuration)}</span>
45
+ </div>
46
+ )}
47
+ </div>
48
+
49
+ <Separator className="mb-3" />
50
+
51
+ {/* Content blocks */}
52
+ <div className="flex flex-col gap-3">
53
+ {blocks.map((block, i) => (
54
+ <ContentBlock
55
+ key={i}
56
+ block={block}
57
+ onQuestionAnswer={handleQuestionAnswer}
58
+ readOnly={readOnly}
59
+ />
60
+ ))}
61
+ </div>
62
+
63
+ {/* Completion bar */}
64
+ <div className="border border-border rounded-md px-4 py-3 mt-4 sticky bottom-0 bg-background z-10">
65
+ <div className="flex justify-between items-center">
66
+ {completed ? (
67
+ <div className="flex items-center gap-1 text-success">
68
+ <Check size={20} />
69
+ <span className="text-sm font-semibold">Lesson Complete</span>
70
+ </div>
71
+ ) : (
72
+ <Button
73
+ onClick={handleMarkComplete}
74
+ disabled={readOnly}
75
+ >
76
+ <Check size={18} /> Mark Complete
77
+ </Button>
78
+ )}
79
+ {onNextLesson && (
80
+ <Button
81
+ variant={completed ? "default" : "outline"}
82
+ onClick={onNextLesson}
83
+ >
84
+ {nextLessonTitle ? `Next: ${nextLessonTitle}` : "Next Lesson"} <ChevronRight size={18} />
85
+ </Button>
86
+ )}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,41 @@
1
+ import type { LessonBlock } from "../../content/types";
2
+
3
+ /**
4
+ * LessonPage section — a multi-content lesson experience.
5
+ *
6
+ * Sequences video, rich text, images, embedded quizzes, flashcards,
7
+ * and callouts into a single scrollable page with a sticky completion
8
+ * bar at the bottom.
9
+ *
10
+ * @example
11
+ * <LessonPage
12
+ * title="React Hooks Deep Dive"
13
+ * blocks={lessonBlocks}
14
+ * onMarkComplete={() => completeLesson()}
15
+ * onNextLesson={() => navigate(nextLesson)}
16
+ * />
17
+ */
18
+ export interface LessonPageProps {
19
+ /** Lesson title */
20
+ title: string;
21
+ /** Ordered content blocks */
22
+ blocks: LessonBlock[];
23
+ /** Whether the lesson is already marked complete */
24
+ isCompleted?: boolean;
25
+ /** Called when the user marks the lesson complete */
26
+ onMarkComplete?: () => void;
27
+ /** Called when the user clicks "Next Lesson" */
28
+ onNextLesson?: () => void;
29
+ /** Next lesson title shown on the completion bar */
30
+ nextLessonTitle?: string;
31
+ /** Estimated duration in seconds */
32
+ estimatedDuration?: number;
33
+ /** Whether to show estimated duration */
34
+ showDuration?: boolean;
35
+ /** When true, disables interactive elements */
36
+ readOnly?: boolean;
37
+ /** CSS class name for the root element */
38
+ className?: string;
39
+ /** Inline styles for the root element */
40
+ style?: React.CSSProperties;
41
+ }
@@ -0,0 +1,199 @@
1
+ import { useMemo, useState } from "react";
2
+ import { CheckCircle } from "lucide-react";
3
+ import { QuestionRenderer } from "../../questions";
4
+ import { FeedbackBanner } from "../../feedback";
5
+ import { Button } from "../../ui/button";
6
+ import { Card, CardContent } from "../../ui/card";
7
+ import { Progress } from "../../ui/progress";
8
+ import type { PracticeQuizProps, PracticeQuizStats } from "./types";
9
+
10
+ function shuffleArray<T>(arr: T[]): T[] {
11
+ const copy = [...arr];
12
+ for (let i = copy.length - 1; i > 0; i--) {
13
+ const j = Math.floor(Math.random() * (i + 1));
14
+ [copy[i], copy[j]] = [copy[j], copy[i]];
15
+ }
16
+ return copy;
17
+ }
18
+
19
+ export function PracticeQuiz({
20
+ questions: questionsProp,
21
+ instantFeedback = true,
22
+ allowRetry = true,
23
+ onComplete,
24
+ shuffled = false,
25
+ readOnly = false,
26
+ className,
27
+ style,
28
+ }: PracticeQuizProps) {
29
+ const questions = useMemo(
30
+ () => (shuffled ? shuffleArray(questionsProp) : questionsProp),
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ [questionsProp, shuffled],
33
+ );
34
+
35
+ const [currentIndex, setCurrentIndex] = useState(0);
36
+ const [revealedUids, setRevealedUids] = useState<Set<string>>(new Set());
37
+ const [attemptCounts, setAttemptCounts] = useState<Map<string, number>>(new Map());
38
+ const [firstAttemptCorrect, setFirstAttemptCorrect] = useState<Set<string>>(new Set());
39
+ const [currentAnswer, setCurrentAnswer] = useState<{ uid: string; content?: string }[] | null>(null);
40
+ const [isComplete, setIsComplete] = useState(false);
41
+
42
+ const currentQuestion = questions[currentIndex];
43
+ const isRevealed = currentQuestion ? revealedUids.has(currentQuestion.uid) : false;
44
+
45
+ function checkAnswer() {
46
+ if (!currentQuestion || !currentAnswer) return;
47
+
48
+ const attempts = (attemptCounts.get(currentQuestion.uid) ?? 0) + 1;
49
+ setAttemptCounts((prev) => new Map(prev).set(currentQuestion.uid, attempts));
50
+ setRevealedUids((prev) => new Set(prev).add(currentQuestion.uid));
51
+
52
+ const correctUids = new Set(
53
+ currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
54
+ );
55
+ const selectedUids = new Set(currentAnswer.map((a) => a.uid));
56
+ const isCorrect =
57
+ correctUids.size === selectedUids.size &&
58
+ [...correctUids].every((uid) => selectedUids.has(uid));
59
+
60
+ if (isCorrect && attempts === 1) {
61
+ setFirstAttemptCorrect((prev) => new Set(prev).add(currentQuestion.uid));
62
+ }
63
+ }
64
+
65
+ function handleRetry() {
66
+ if (!currentQuestion) return;
67
+ setRevealedUids((prev) => {
68
+ const next = new Set(prev);
69
+ next.delete(currentQuestion.uid);
70
+ return next;
71
+ });
72
+ setCurrentAnswer(null);
73
+ }
74
+
75
+ function handleNext() {
76
+ if (currentIndex < questions.length - 1) {
77
+ setCurrentIndex((i) => i + 1);
78
+ setCurrentAnswer(null);
79
+ } else {
80
+ const stats: PracticeQuizStats = {
81
+ totalQuestions: questions.length,
82
+ correctOnFirstAttempt: firstAttemptCorrect.size,
83
+ totalAttempts: Array.from(attemptCounts.values()).reduce((a, b) => a + b, 0),
84
+ };
85
+ setIsComplete(true);
86
+ onComplete?.(stats);
87
+ }
88
+ }
89
+
90
+ const isCurrentCorrect = useMemo(() => {
91
+ if (!currentQuestion || !currentAnswer) return false;
92
+ const correctUids = new Set(
93
+ currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
94
+ );
95
+ const selectedUids = new Set(currentAnswer.map((a) => a.uid));
96
+ return (
97
+ correctUids.size === selectedUids.size &&
98
+ [...correctUids].every((uid) => selectedUids.has(uid))
99
+ );
100
+ }, [currentQuestion, currentAnswer]);
101
+
102
+ if (isComplete) {
103
+ const percentage = questions.length > 0
104
+ ? Math.round((firstAttemptCorrect.size / questions.length) * 100)
105
+ : 0;
106
+ return (
107
+ <Card className={className} style={style}>
108
+ <CardContent className="pt-6 text-center">
109
+ <CheckCircle size={48} className="text-success mx-auto mb-4" />
110
+ <p className="text-xl font-bold mb-1 text-foreground">Practice Complete!</p>
111
+ <p className="text-muted-foreground mb-2">
112
+ {firstAttemptCorrect.size} of {questions.length} correct on first attempt ({percentage}%)
113
+ </p>
114
+ <div className="flex justify-center">
115
+ <Button
116
+ variant="outline"
117
+ onClick={() => {
118
+ setCurrentIndex(0);
119
+ setRevealedUids(new Set());
120
+ setAttemptCounts(new Map());
121
+ setFirstAttemptCorrect(new Set());
122
+ setCurrentAnswer(null);
123
+ setIsComplete(false);
124
+ }}
125
+ >
126
+ Practice Again
127
+ </Button>
128
+ </div>
129
+ </CardContent>
130
+ </Card>
131
+ );
132
+ }
133
+
134
+ return (
135
+ <div className={className} style={style}>
136
+ <div className="flex justify-between items-center mb-2">
137
+ <span className="font-semibold text-sm text-foreground">
138
+ Question {currentIndex + 1} of {questions.length}
139
+ </span>
140
+ <span className="text-xs text-muted-foreground">
141
+ {firstAttemptCorrect.size} correct on first try
142
+ </span>
143
+ </div>
144
+ <Progress
145
+ value={currentIndex + (isRevealed ? 1 : 0)}
146
+ max={questions.length}
147
+ size="sm"
148
+ className="mb-3"
149
+ />
150
+
151
+ {currentQuestion && (
152
+ <Card>
153
+ <CardContent className="pt-6">
154
+ <QuestionRenderer
155
+ question={currentQuestion}
156
+ sessionAnswers={
157
+ currentAnswer?.map((a) => ({
158
+ uid: currentQuestion.uid,
159
+ answerUid: a.uid,
160
+ content: a.content,
161
+ })) ?? []
162
+ }
163
+ onAnswer={(answers) => setCurrentAnswer(answers)}
164
+ readOnly={readOnly || isRevealed}
165
+ showCorrectAnswers={isRevealed}
166
+ />
167
+
168
+ {instantFeedback && isRevealed && (
169
+ <FeedbackBanner
170
+ isCorrect={isCurrentCorrect}
171
+ explanation={currentQuestion.explanation}
172
+ onRetry={allowRetry && !isCurrentCorrect ? handleRetry : undefined}
173
+ />
174
+ )}
175
+
176
+ <div className="flex justify-end gap-2 mt-2">
177
+ {!isRevealed && instantFeedback && (
178
+ <Button
179
+ onClick={checkAnswer}
180
+ disabled={!currentAnswer || currentAnswer.length === 0 || readOnly}
181
+ >
182
+ Check Answer
183
+ </Button>
184
+ )}
185
+ {(!instantFeedback || isRevealed) && (
186
+ <Button
187
+ onClick={handleNext}
188
+ disabled={readOnly}
189
+ >
190
+ {currentIndex < questions.length - 1 ? "Next Question" : "Finish"}
191
+ </Button>
192
+ )}
193
+ </div>
194
+ </CardContent>
195
+ </Card>
196
+ )}
197
+ </div>
198
+ );
199
+ }
@@ -0,0 +1,44 @@
1
+ import type { QuestionData } from "../../questions/types";
2
+
3
+ /**
4
+ * PracticeQuiz section — a low-stakes practice quiz with instant feedback.
5
+ *
6
+ * After each answer the correct answer is revealed with an explanation.
7
+ * Users can retry incorrect questions. A completion screen shows stats
8
+ * when all questions have been attempted.
9
+ *
10
+ * @example
11
+ * <PracticeQuiz
12
+ * questions={questions}
13
+ * onComplete={(stats) => trackPractice(stats)}
14
+ * instantFeedback
15
+ * allowRetry
16
+ * />
17
+ */
18
+ export interface PracticeQuizProps {
19
+ /** Ordered list of questions to practice */
20
+ questions: QuestionData[];
21
+ /** Whether to reveal the correct answer immediately after answering */
22
+ instantFeedback?: boolean;
23
+ /** Whether to allow retrying incorrect answers */
24
+ allowRetry?: boolean;
25
+ /** Called when the user completes all questions */
26
+ onComplete?: (stats: PracticeQuizStats) => void;
27
+ /** Whether to shuffle the question order */
28
+ shuffled?: boolean;
29
+ /** When true, all inputs are disabled */
30
+ readOnly?: boolean;
31
+ /** CSS class name for the root element */
32
+ className?: string;
33
+ /** Inline styles for the root element */
34
+ style?: React.CSSProperties;
35
+ }
36
+
37
+ export interface PracticeQuizStats {
38
+ /** Total number of questions */
39
+ totalQuestions: number;
40
+ /** Questions answered correctly on the first attempt */
41
+ correctOnFirstAttempt: number;
42
+ /** Total answer attempts across all questions */
43
+ totalAttempts: number;
44
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ Award,
3
+ BookOpen,
4
+ CheckCircle,
5
+ Clock,
6
+ Flame,
7
+ Send,
8
+ Trophy,
9
+ } from "lucide-react";
10
+ import { ProgressRing, StatCard } from "../../progress";
11
+ import { Progress } from "../../ui/progress";
12
+ import { Card } from "../../ui/card";
13
+ import { formatDuration } from "../../utils/format-duration";
14
+ import type { ProgressDashboardProps } from "./types";
15
+ import { cn } from "../../lib/utils";
16
+
17
+ const ACTIVITY_ICONS = {
18
+ lesson_completed: BookOpen,
19
+ quiz_passed: CheckCircle,
20
+ assignment_submitted: Send,
21
+ badge_earned: Award,
22
+ };
23
+
24
+ export function ProgressDashboard({
25
+ overallProgress,
26
+ totalTimeSpent,
27
+ modules,
28
+ recentActivity,
29
+ streak,
30
+ achievements,
31
+ recentActivityLimit = 5,
32
+ onModuleClick,
33
+ className,
34
+ style,
35
+ }: ProgressDashboardProps) {
36
+ return (
37
+ <div className={className} style={style}>
38
+ {/* Stats row */}
39
+ <div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-2 mb-3">
40
+ <Card className="p-2 flex justify-center">
41
+ <ProgressRing value={overallProgress} size={100} />
42
+ </Card>
43
+ <StatCard
44
+ icon={<Clock size={24} />}
45
+ label="Time Spent"
46
+ value={formatDuration(totalTimeSpent)}
47
+ />
48
+ {streak && (
49
+ <StatCard
50
+ icon={<Flame size={24} />}
51
+ label="Current Streak"
52
+ value={`${streak.currentDays} days`}
53
+ subtitle={`Longest: ${streak.longestDays} days`}
54
+ />
55
+ )}
56
+ <StatCard
57
+ icon={<BookOpen size={24} />}
58
+ label="Modules"
59
+ value={`${modules.filter((m) => m.completedItems === m.totalItems).length} / ${modules.length}`}
60
+ subtitle="completed"
61
+ />
62
+ </div>
63
+
64
+ {/* Module progress */}
65
+ <p className="text-lg font-semibold mb-2 text-foreground">Module Progress</p>
66
+ <div className="flex flex-col gap-2 mb-3">
67
+ {modules.map((mod) => {
68
+ const pct = mod.totalItems > 0 ? (mod.completedItems / mod.totalItems) * 100 : 0;
69
+ return (
70
+ <Card
71
+ key={mod.uid}
72
+ className={cn(
73
+ "p-2 transition-colors",
74
+ onModuleClick && "cursor-pointer hover:border-primary",
75
+ )}
76
+ onClick={() => onModuleClick?.(mod.uid)}
77
+ >
78
+ <div className="flex justify-between items-center mb-0.5">
79
+ <span className="font-semibold text-sm text-foreground">{mod.name}</span>
80
+ <span className="text-xs text-muted-foreground">
81
+ {mod.completedItems} / {mod.totalItems}
82
+ </span>
83
+ </div>
84
+ <Progress value={pct} size="sm" />
85
+ </Card>
86
+ );
87
+ })}
88
+ </div>
89
+
90
+ {/* Recent activity */}
91
+ {recentActivity && recentActivity.length > 0 && (
92
+ <>
93
+ <p className="text-lg font-semibold mb-2 text-foreground">Recent Activity</p>
94
+ <div className="flex flex-col gap-1.5 mb-3">
95
+ {recentActivity.slice(0, recentActivityLimit).map((activity) => {
96
+ const Icon = ACTIVITY_ICONS[activity.type] ?? CheckCircle;
97
+ return (
98
+ <div key={activity.uid} className="flex gap-1.5 items-center">
99
+ <Icon size={16} />
100
+ <span className="flex-1 text-sm text-foreground">
101
+ {activity.description}
102
+ </span>
103
+ <span className="text-xs text-muted-foreground">
104
+ {new Date(activity.timestamp).toLocaleDateString()}
105
+ </span>
106
+ </div>
107
+ );
108
+ })}
109
+ </div>
110
+ </>
111
+ )}
112
+
113
+ {/* Achievements */}
114
+ {achievements && achievements.length > 0 && (
115
+ <>
116
+ <p className="text-lg font-semibold mb-2 text-foreground">Achievements</p>
117
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
118
+ {achievements.map((badge) => (
119
+ <Card key={badge.uid} className="p-2 text-center">
120
+ {badge.iconUrl ? (
121
+ <img
122
+ src={badge.iconUrl}
123
+ alt={badge.name}
124
+ className="w-12 h-12 mb-1 mx-auto"
125
+ />
126
+ ) : (
127
+ <Trophy size={32} className="mx-auto mb-2 text-warning" />
128
+ )}
129
+ <p className="font-semibold text-sm text-foreground">{badge.name}</p>
130
+ <p className="text-xs text-muted-foreground">
131
+ {badge.description}
132
+ </p>
133
+ </Card>
134
+ ))}
135
+ </div>
136
+ </>
137
+ )}
138
+ </div>
139
+ );
140
+ }