@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,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
+ }
@@ -0,0 +1,74 @@
1
+
2
+ /**
3
+ * ProgressDashboard section — a visual course progress overview.
4
+ *
5
+ * Shows overall completion, time spent, streak data, per-module progress,
6
+ * recent activity log, and earned achievements.
7
+ *
8
+ * @example
9
+ * <ProgressDashboard
10
+ * overallProgress={72}
11
+ * totalTimeSpent={14400}
12
+ * modules={modules}
13
+ * streak={{ currentDays: 5, longestDays: 12 }}
14
+ * />
15
+ */
16
+ export interface ProgressDashboardProps {
17
+ /** Overall course progress percentage (0-100) */
18
+ overallProgress: number;
19
+ /** Total time spent in seconds */
20
+ totalTimeSpent: number;
21
+ /** Per-module progress */
22
+ modules: ModuleProgress[];
23
+ /** Recent activity items */
24
+ recentActivity?: ActivityItem[];
25
+ /** Streak data */
26
+ streak?: { currentDays: number; longestDays: number };
27
+ /** Earned achievements/badges */
28
+ achievements?: Achievement[];
29
+ /** Number of recent activity items to show */
30
+ recentActivityLimit?: number;
31
+ /** Called when the user clicks a module */
32
+ onModuleClick?: (moduleUid: string) => void;
33
+ /** CSS class name for the root element */
34
+ className?: string;
35
+ /** Inline styles for the root element */
36
+ style?: React.CSSProperties;
37
+ }
38
+
39
+ export interface ModuleProgress {
40
+ /** Unique identifier */
41
+ uid: string;
42
+ /** Module name */
43
+ name: string;
44
+ /** Completed items count */
45
+ completedItems: number;
46
+ /** Total items in the module */
47
+ totalItems: number;
48
+ /** Time spent in seconds */
49
+ timeSpent: number;
50
+ }
51
+
52
+ export interface ActivityItem {
53
+ /** Unique identifier */
54
+ uid: string;
55
+ /** Activity description */
56
+ description: string;
57
+ /** Activity timestamp */
58
+ timestamp: string;
59
+ /** Activity type */
60
+ type: "lesson_completed" | "quiz_passed" | "assignment_submitted" | "badge_earned";
61
+ }
62
+
63
+ export interface Achievement {
64
+ /** Unique identifier */
65
+ uid: string;
66
+ /** Achievement name */
67
+ name: string;
68
+ /** Achievement description */
69
+ description: string;
70
+ /** Icon URL */
71
+ iconUrl?: string;
72
+ /** Date earned */
73
+ earnedAt: string;
74
+ }
@@ -0,0 +1,113 @@
1
+ import { useMemo, useState } from "react";
2
+ import { AssessmentToolbar } from "../../assessment-toolbar";
3
+ import type { QuestionNavigatorItem } from "../../assessment-toolbar/types";
4
+ import { QuestionRenderer } from "../../questions";
5
+ import type { SessionAnswer } from "../../questions/types";
6
+ import { Card, CardContent } from "../../ui/card";
7
+ import type { QuizSessionProps } from "./types";
8
+ import { cn } from "../../lib/utils";
9
+
10
+ export function QuizSession({
11
+ questions,
12
+ initialAnswers = [],
13
+ onSubmit,
14
+ onAnswerChange,
15
+ timeElapsedSeconds,
16
+ timeLimitSeconds,
17
+ isSubmitting = false,
18
+ readOnly = false,
19
+ className,
20
+ style,
21
+ }: QuizSessionProps) {
22
+ const [currentIndex, setCurrentIndex] = useState(0);
23
+ const [sessionAnswers, setSessionAnswers] =
24
+ useState<SessionAnswer[]>(initialAnswers);
25
+ const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
26
+
27
+ const currentQuestion = questions[currentIndex];
28
+
29
+ const navigatorItems = useMemo<QuestionNavigatorItem[]>(
30
+ () =>
31
+ questions.map((q, idx) => ({
32
+ uid: q.uid,
33
+ sequence: idx + 1,
34
+ isFlagged: flaggedUids.has(q.uid),
35
+ isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
36
+ isSkipped: false,
37
+ })),
38
+ [questions, sessionAnswers, flaggedUids],
39
+ );
40
+
41
+ function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
42
+ if (!currentQuestion) return;
43
+ const questionUid = currentQuestion.uid;
44
+ const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
45
+ uid: questionUid,
46
+ answerUid: a.uid,
47
+ content: a.content,
48
+ }));
49
+ setSessionAnswers((prev) => {
50
+ const filtered = prev.filter((a) => a.uid !== questionUid);
51
+ const merged = [...filtered, ...newAnswers];
52
+ onAnswerChange?.(merged);
53
+ return merged;
54
+ });
55
+ }
56
+
57
+ function handleNavigate(uid: string) {
58
+ const idx = questions.findIndex((q) => q.uid === uid);
59
+ if (idx !== -1) setCurrentIndex(idx);
60
+ }
61
+
62
+ function handleToggleFlag(uid: string) {
63
+ setFlaggedUids((prev) => {
64
+ const next = new Set(prev);
65
+ if (next.has(uid)) {
66
+ next.delete(uid);
67
+ } else {
68
+ next.add(uid);
69
+ }
70
+ return next;
71
+ });
72
+ }
73
+
74
+ function handleSubmit() {
75
+ onSubmit(sessionAnswers);
76
+ }
77
+
78
+ return (
79
+ <div className={cn(className)} style={style}>
80
+ <AssessmentToolbar
81
+ currentQuestionIndex={currentIndex}
82
+ totalQuestions={questions.length}
83
+ hasNext={currentIndex < questions.length - 1}
84
+ hasPrevious={currentIndex > 0}
85
+ onNext={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
86
+ onPrevious={() => setCurrentIndex((i) => Math.max(i - 1, 0))}
87
+ onSubmit={handleSubmit}
88
+ timeElapsedSeconds={timeElapsedSeconds}
89
+ timeLimitSeconds={timeLimitSeconds}
90
+ questions={navigatorItems}
91
+ onNavigateToQuestion={handleNavigate}
92
+ onToggleFlag={handleToggleFlag}
93
+ currentQuestionUid={currentQuestion?.uid}
94
+ isSubmitting={isSubmitting}
95
+ readOnly={readOnly}
96
+ />
97
+ {currentQuestion && (
98
+ <Card className="mt-3">
99
+ <CardContent className="pt-6">
100
+ <QuestionRenderer
101
+ question={currentQuestion}
102
+ sessionAnswers={sessionAnswers.filter(
103
+ (a) => a.uid === currentQuestion.uid,
104
+ )}
105
+ onAnswer={handleAnswer}
106
+ readOnly={readOnly}
107
+ />
108
+ </CardContent>
109
+ </Card>
110
+ )}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,47 @@
1
+ import type { QuestionData, SessionAnswer } from "../../questions/types";
2
+
3
+ /**
4
+ * QuizSession section — a complete assessment session experience.
5
+ *
6
+ * Manages question navigation, per-question answer accumulation, and
7
+ * flag toggling. Combines AssessmentToolbar with QuestionRenderer.
8
+ * Pass your questions array and an onSubmit callback — everything
9
+ * else is handled internally.
10
+ *
11
+ * @example
12
+ * <QuizSession
13
+ * questions={questions}
14
+ * onSubmit={(answers) => submitAssessment(answers)}
15
+ * timeLimitSeconds={1800}
16
+ * />
17
+ */
18
+ export interface QuizSessionProps {
19
+ /** Ordered list of questions to present */
20
+ questions: QuestionData[];
21
+ /**
22
+ * Pre-populated answers — use to resume a session already in progress.
23
+ */
24
+ initialAnswers?: SessionAnswer[];
25
+ /**
26
+ * Called when the user clicks Submit.
27
+ * Receives the final accumulated answers array.
28
+ */
29
+ onSubmit: (answers: SessionAnswer[]) => void;
30
+ /**
31
+ * Called whenever the user changes an answer (useful for auto-save).
32
+ * Fires with the full answers array after each mutation.
33
+ */
34
+ onAnswerChange?: (answers: SessionAnswer[]) => void;
35
+ /** Elapsed time in seconds — renders the timer in the toolbar when provided */
36
+ timeElapsedSeconds?: number;
37
+ /** Time limit in seconds — enables countdown mode in the timer */
38
+ timeLimitSeconds?: number;
39
+ /** Whether the submit action is currently in flight */
40
+ isSubmitting?: boolean;
41
+ /** When true, all inputs are disabled (e.g. after submission) */
42
+ readOnly?: boolean;
43
+ /** CSS class name for the root element */
44
+ className?: string;
45
+ /** Inline styles for the root element */
46
+ style?: React.CSSProperties;
47
+ }