@hydralms/components 0.2.0 → 0.3.0

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 (283) hide show
  1. package/dist/StudentProfile-BVfZMbnV.cjs +1 -0
  2. package/dist/StudentProfile-DeMxdrL3.js +3275 -0
  3. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  4. package/dist/assessment-toolbar/timer-display.d.ts +1 -1
  5. package/dist/common/index.d.ts +2 -1
  6. package/dist/common/pagination.d.ts +26 -0
  7. package/dist/common/types.d.ts +1 -0
  8. package/dist/components.css +1 -1
  9. package/dist/content/audio-player.d.ts +22 -0
  10. package/dist/content/code-block.d.ts +30 -0
  11. package/dist/content/embed-block.d.ts +28 -0
  12. package/dist/content/index.d.ts +6 -0
  13. package/dist/content/types.d.ts +24 -0
  14. package/dist/curriculum/course-card.d.ts +51 -0
  15. package/dist/curriculum/index.d.ts +2 -0
  16. package/dist/curriculum/types.d.ts +2 -2
  17. package/dist/index.cjs +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +494 -444
  20. package/dist/license/HydraContext.d.ts +16 -0
  21. package/dist/license/ProBadge.d.ts +6 -0
  22. package/dist/license/index.d.ts +7 -0
  23. package/dist/license/tiers.d.ts +3 -0
  24. package/dist/license/useHydraLicense.d.ts +6 -0
  25. package/dist/license/validate.d.ts +13 -0
  26. package/dist/license/withProGate.d.ts +6 -0
  27. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +4 -7
  28. package/dist/modules/AssignmentModule/types.d.ts +5 -1
  29. package/dist/modules/CertificateModule/CertificateModule.d.ts +4 -8
  30. package/dist/modules/CertificateModule/types.d.ts +6 -4
  31. package/dist/modules/CourseCatalogModule/CourseCatalogModule.d.ts +5 -0
  32. package/dist/modules/CourseCatalogModule/types.d.ts +43 -0
  33. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +4 -1
  34. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +4 -7
  35. package/dist/modules/ExamModule/ExamModule.d.ts +4 -7
  36. package/dist/modules/ExamModule/types.d.ts +5 -14
  37. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +4 -1
  38. package/dist/modules/FlashcardLab/types.d.ts +2 -0
  39. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +4 -8
  40. package/dist/modules/GradeCenterModule/types.d.ts +2 -0
  41. package/dist/modules/QuizModule/QuizModule.d.ts +4 -1
  42. package/dist/modules/QuizModule/types.d.ts +5 -14
  43. package/dist/modules/StudentDashboardModule/StudentDashboardModule.d.ts +5 -0
  44. package/dist/modules/StudentDashboardModule/types.d.ts +54 -0
  45. package/dist/modules/StudentProfileModule/StudentProfileModule.d.ts +5 -0
  46. package/dist/modules/StudentProfileModule/types.d.ts +43 -0
  47. package/dist/modules/SurveyModule/SurveyModule.d.ts +4 -6
  48. package/dist/modules/SurveyModule/types.d.ts +2 -0
  49. package/dist/modules/_shared/assessment-intro.d.ts +16 -0
  50. package/dist/modules/_shared/assessment-results.d.ts +23 -0
  51. package/dist/modules/_shared/types.d.ts +10 -0
  52. package/dist/modules/_shared/use-timer.d.ts +9 -0
  53. package/dist/modules/index.d.ts +6 -0
  54. package/dist/modules.cjs +1 -1
  55. package/dist/modules.js +1266 -854
  56. package/dist/progress/types.d.ts +2 -0
  57. package/dist/provider/HydraProvider.d.ts +5 -1
  58. package/dist/questions/choice.d.ts +1 -1
  59. package/dist/questions/confidence-indicator.d.ts +37 -0
  60. package/dist/questions/essay.d.ts +1 -1
  61. package/dist/questions/fill-in-the-blank.d.ts +1 -1
  62. package/dist/questions/hotspot.d.ts +1 -1
  63. package/dist/questions/index.d.ts +2 -0
  64. package/dist/questions/inline-choice.d.ts +1 -1
  65. package/dist/questions/matching.d.ts +1 -1
  66. package/dist/questions/multiple-choice.d.ts +1 -1
  67. package/dist/questions/numeric.d.ts +1 -1
  68. package/dist/questions/ordering.d.ts +1 -1
  69. package/dist/questions/question-renderer.d.ts +1 -1
  70. package/dist/questions/scenario.d.ts +1 -1
  71. package/dist/questions/spreadsheet.d.ts +1 -1
  72. package/dist/questions/true-false.d.ts +1 -1
  73. package/dist/sections/AnnouncementFeed/AnnouncementFeed.d.ts +1 -1
  74. package/dist/sections/AnnouncementFeed/types.d.ts +15 -1
  75. package/dist/sections/AssessmentReview/AssessmentReview.d.ts +1 -1
  76. package/dist/sections/AssessmentReview/types.d.ts +6 -0
  77. package/dist/sections/AssignmentSubmission/AssignmentSubmission.d.ts +1 -1
  78. package/dist/sections/AssignmentSubmission/types.d.ts +6 -0
  79. package/dist/sections/CertificateViewer/CertificateViewer.d.ts +1 -1
  80. package/dist/sections/CertificateViewer/certificate-variants.d.ts +42 -0
  81. package/dist/sections/CertificateViewer/types.d.ts +6 -0
  82. package/dist/sections/CourseCatalog/CourseCatalog.d.ts +2 -0
  83. package/dist/sections/CourseCatalog/types.d.ts +80 -0
  84. package/dist/sections/CourseOutline/CourseOutline.d.ts +1 -1
  85. package/dist/sections/CourseOutline/types.d.ts +6 -0
  86. package/dist/sections/DiscussionThread/DiscussionThread.d.ts +1 -1
  87. package/dist/sections/DiscussionThread/types.d.ts +6 -0
  88. package/dist/sections/EnrollmentWizard/EnrollmentWizard.d.ts +2 -0
  89. package/dist/sections/EnrollmentWizard/types.d.ts +66 -0
  90. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  91. package/dist/sections/ExamSession/types.d.ts +6 -0
  92. package/dist/sections/FlashcardStudySession/FlashcardStudySession.d.ts +1 -1
  93. package/dist/sections/FlashcardStudySession/types.d.ts +6 -0
  94. package/dist/sections/ForumBoard/ForumBoard.d.ts +1 -1
  95. package/dist/sections/ForumBoard/types.d.ts +14 -0
  96. package/dist/sections/GradebookTable/GradebookTable.d.ts +1 -1
  97. package/dist/sections/GradebookTable/types.d.ts +14 -0
  98. package/dist/sections/LecturePlayer/LecturePlayer.d.ts +1 -1
  99. package/dist/sections/LecturePlayer/types.d.ts +8 -0
  100. package/dist/sections/LessonPage/LessonPage.d.ts +1 -1
  101. package/dist/sections/LessonPage/types.d.ts +6 -0
  102. package/dist/sections/PracticeQuiz/PracticeQuiz.d.ts +1 -1
  103. package/dist/sections/PracticeQuiz/types.d.ts +6 -0
  104. package/dist/sections/ProgressDashboard/ProgressDashboard.d.ts +1 -1
  105. package/dist/sections/ProgressDashboard/types.d.ts +6 -0
  106. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  107. package/dist/sections/QuizSession/types.d.ts +6 -0
  108. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +1 -1
  109. package/dist/sections/RequirementsChecklist/types.d.ts +6 -0
  110. package/dist/sections/ResourceLibrary/ResourceLibrary.d.ts +1 -1
  111. package/dist/sections/ResourceLibrary/types.d.ts +15 -1
  112. package/dist/sections/RubricView/RubricView.d.ts +1 -1
  113. package/dist/sections/RubricView/types.d.ts +6 -0
  114. package/dist/sections/ScrollableQuiz/ScrollableQuiz.d.ts +1 -1
  115. package/dist/sections/ScrollableQuiz/types.d.ts +6 -0
  116. package/dist/sections/StudentProfile/StudentProfile.d.ts +2 -0
  117. package/dist/sections/StudentProfile/types.d.ts +98 -0
  118. package/dist/sections/SurveyForm/SurveyForm.d.ts +1 -1
  119. package/dist/sections/SurveyForm/types.d.ts +6 -0
  120. package/dist/sections/_shared/merge-answers.d.ts +9 -0
  121. package/dist/sections/_shared/section-shell.d.ts +20 -0
  122. package/dist/sections/_shared/use-assessment-session.d.ts +30 -0
  123. package/dist/sections/index.d.ts +6 -0
  124. package/dist/sections.cjs +1 -1
  125. package/dist/sections.js +268 -307
  126. package/dist/tabs-BsfVo2Bl.cjs +173 -0
  127. package/dist/{tabs-Wf3h_Cx3.js → tabs-BuY1iNJE.js} +7532 -6807
  128. package/dist/ui/badge.d.ts +1 -1
  129. package/dist/ui/index.d.ts +2 -0
  130. package/dist/ui/progress.d.ts +1 -1
  131. package/dist/ui/rich-text-editor.d.ts +3 -1
  132. package/dist/ui/toast.d.ts +43 -0
  133. package/dist/utils/debounce.d.ts +5 -1
  134. package/dist/utils/pick-palette-color.d.ts +19 -0
  135. package/dist/video/types.d.ts +15 -0
  136. package/dist/video/video-player.d.ts +1 -1
  137. package/dist/withProGate-BWqcKdPM.js +137 -0
  138. package/dist/withProGate-DX6XqKLp.cjs +1 -0
  139. package/package.json +34 -220
  140. package/src/assessment-toolbar/question-navigator.tsx +10 -5
  141. package/src/assessment-toolbar/timer-display.tsx +4 -3
  142. package/src/assessment-toolbar/use-countdown.ts +1 -1
  143. package/src/common/empty-state.tsx +1 -0
  144. package/src/common/index.ts +2 -0
  145. package/src/common/pagination.tsx +135 -0
  146. package/src/common/search-input.tsx +2 -1
  147. package/src/common/types.ts +2 -0
  148. package/src/content/attachment-list.tsx +2 -0
  149. package/src/content/audio-player.tsx +196 -0
  150. package/src/content/code-block.tsx +113 -0
  151. package/src/content/content-block.tsx +64 -0
  152. package/src/content/embed-block.tsx +78 -0
  153. package/src/content/file-upload-zone.tsx +10 -0
  154. package/src/content/index.ts +6 -0
  155. package/src/content/types.ts +5 -0
  156. package/src/curriculum/course-card.tsx +199 -0
  157. package/src/curriculum/curriculum-item.tsx +3 -3
  158. package/src/curriculum/curriculum-tree.tsx +20 -13
  159. package/src/curriculum/index.ts +2 -0
  160. package/src/curriculum/types.ts +2 -2
  161. package/src/flashcards/flashcard.tsx +28 -8
  162. package/src/index.ts +3 -0
  163. package/src/license/HydraContext.tsx +62 -0
  164. package/src/license/ProBadge.tsx +43 -0
  165. package/src/license/index.ts +7 -0
  166. package/src/license/tiers.ts +24 -0
  167. package/src/license/useHydraLicense.ts +10 -0
  168. package/src/license/validate.ts +90 -0
  169. package/src/license/withProGate.tsx +21 -0
  170. package/src/modules/AssignmentModule/AssignmentModule.tsx +17 -8
  171. package/src/modules/AssignmentModule/types.ts +5 -1
  172. package/src/modules/CertificateModule/CertificateModule.tsx +21 -9
  173. package/src/modules/CertificateModule/types.ts +6 -4
  174. package/src/modules/CourseCatalogModule/CourseCatalogModule.tsx +126 -0
  175. package/src/modules/CourseCatalogModule/types.ts +47 -0
  176. package/src/modules/CoursePlayer/CoursePlayer.tsx +37 -22
  177. package/src/modules/DiscussionModule/DiscussionModule.tsx +57 -22
  178. package/src/modules/ExamModule/ExamModule.tsx +64 -198
  179. package/src/modules/ExamModule/types.ts +5 -14
  180. package/src/modules/FlashcardLab/FlashcardLab.tsx +10 -5
  181. package/src/modules/FlashcardLab/types.ts +2 -0
  182. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +7 -2
  183. package/src/modules/GradeCenterModule/types.ts +2 -0
  184. package/src/modules/QuizModule/QuizModule.tsx +49 -169
  185. package/src/modules/QuizModule/types.ts +5 -15
  186. package/src/modules/StudentDashboardModule/StudentDashboardModule.tsx +117 -0
  187. package/src/modules/StudentDashboardModule/types.ts +56 -0
  188. package/src/modules/StudentProfileModule/StudentProfileModule.tsx +289 -0
  189. package/src/modules/StudentProfileModule/types.ts +45 -0
  190. package/src/modules/SurveyModule/SurveyModule.tsx +9 -4
  191. package/src/modules/SurveyModule/types.ts +2 -0
  192. package/src/modules/_shared/assessment-intro.tsx +75 -0
  193. package/src/modules/_shared/assessment-results.tsx +133 -0
  194. package/src/modules/_shared/types.ts +11 -0
  195. package/src/modules/_shared/use-timer.ts +49 -0
  196. package/src/modules/index.ts +9 -0
  197. package/src/progress/achievement-badge.tsx +3 -3
  198. package/src/progress/grade-indicator.tsx +9 -1
  199. package/src/progress/progress-ring.tsx +2 -1
  200. package/src/progress/stat-card.tsx +8 -1
  201. package/src/progress/types.ts +2 -0
  202. package/src/provider/HydraProvider.tsx +15 -6
  203. package/src/questions/choice.tsx +13 -6
  204. package/src/questions/confidence-indicator.tsx +107 -0
  205. package/src/questions/essay.tsx +6 -4
  206. package/src/questions/fill-in-the-blank.tsx +8 -4
  207. package/src/questions/hotspot.tsx +4 -4
  208. package/src/questions/index.ts +2 -0
  209. package/src/questions/inline-choice.tsx +5 -4
  210. package/src/questions/matching.tsx +5 -4
  211. package/src/questions/multiple-choice.tsx +13 -6
  212. package/src/questions/numeric.tsx +8 -4
  213. package/src/questions/ordering.tsx +12 -4
  214. package/src/questions/question-renderer.tsx +3 -2
  215. package/src/questions/scenario.tsx +4 -4
  216. package/src/questions/spreadsheet.tsx +5 -4
  217. package/src/questions/true-false.tsx +13 -6
  218. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +64 -8
  219. package/src/sections/AnnouncementFeed/types.ts +15 -1
  220. package/src/sections/AssessmentReview/AssessmentReview.tsx +37 -0
  221. package/src/sections/AssessmentReview/types.ts +6 -0
  222. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +37 -1
  223. package/src/sections/AssignmentSubmission/types.ts +6 -0
  224. package/src/sections/CertificateViewer/CertificateViewer.tsx +29 -227
  225. package/src/sections/CertificateViewer/certificate-variants.tsx +170 -0
  226. package/src/sections/CertificateViewer/types.ts +6 -0
  227. package/src/sections/CourseCatalog/CourseCatalog.tsx +220 -0
  228. package/src/sections/CourseCatalog/types.ts +76 -0
  229. package/src/sections/CourseOutline/CourseOutline.tsx +41 -0
  230. package/src/sections/CourseOutline/types.ts +6 -0
  231. package/src/sections/DiscussionThread/DiscussionThread.tsx +42 -1
  232. package/src/sections/DiscussionThread/types.ts +6 -0
  233. package/src/sections/EnrollmentWizard/EnrollmentWizard.tsx +343 -0
  234. package/src/sections/EnrollmentWizard/types.ts +65 -0
  235. package/src/sections/ExamSession/ExamSession.tsx +100 -94
  236. package/src/sections/ExamSession/types.ts +6 -0
  237. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +53 -36
  238. package/src/sections/FlashcardStudySession/types.ts +6 -0
  239. package/src/sections/ForumBoard/ForumBoard.tsx +59 -1
  240. package/src/sections/ForumBoard/types.ts +14 -0
  241. package/src/sections/GradebookTable/GradebookTable.tsx +54 -1
  242. package/src/sections/GradebookTable/types.ts +14 -0
  243. package/src/sections/LecturePlayer/LecturePlayer.tsx +63 -37
  244. package/src/sections/LecturePlayer/types.ts +8 -0
  245. package/src/sections/LessonPage/LessonPage.tsx +36 -5
  246. package/src/sections/LessonPage/types.ts +6 -0
  247. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +106 -74
  248. package/src/sections/PracticeQuiz/types.ts +6 -0
  249. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +64 -10
  250. package/src/sections/ProgressDashboard/types.ts +6 -0
  251. package/src/sections/QuizSession/QuizSession.tsx +71 -82
  252. package/src/sections/QuizSession/types.ts +6 -0
  253. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +41 -1
  254. package/src/sections/RequirementsChecklist/types.ts +6 -0
  255. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +64 -8
  256. package/src/sections/ResourceLibrary/types.ts +15 -1
  257. package/src/sections/RubricView/RubricView.tsx +37 -1
  258. package/src/sections/RubricView/types.ts +6 -0
  259. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +36 -15
  260. package/src/sections/ScrollableQuiz/types.ts +6 -0
  261. package/src/sections/StudentProfile/StudentProfile.tsx +279 -0
  262. package/src/sections/StudentProfile/types.ts +99 -0
  263. package/src/sections/SurveyForm/SurveyForm.tsx +32 -5
  264. package/src/sections/SurveyForm/types.ts +6 -0
  265. package/src/sections/_shared/merge-answers.ts +22 -0
  266. package/src/sections/_shared/section-shell.tsx +64 -0
  267. package/src/sections/_shared/use-assessment-session.ts +125 -0
  268. package/src/sections/index.ts +22 -0
  269. package/src/social/user-avatar.tsx +9 -5
  270. package/src/styles/globals.css +39 -41
  271. package/src/ui/badge.tsx +8 -0
  272. package/src/ui/index.ts +2 -0
  273. package/src/ui/progress.tsx +4 -0
  274. package/src/ui/rich-text-editor.tsx +10 -0
  275. package/src/ui/rich-text-toolbar.tsx +2 -1
  276. package/src/ui/toast.tsx +170 -0
  277. package/src/utils/debounce.ts +8 -2
  278. package/src/utils/pick-palette-color.ts +33 -0
  279. package/src/video/types.ts +16 -0
  280. package/src/video/video-player.tsx +13 -1
  281. package/dist/ForumBoard-CHXU3mjC.js +0 -2207
  282. package/dist/ForumBoard-d1w5-r6n.cjs +0 -1
  283. package/dist/tabs-DRM2Iq_J.cjs +0 -172
@@ -1,10 +1,12 @@
1
- import { useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { CheckCircle } from "lucide-react";
3
3
  import { QuestionRenderer, scoreQuestion } from "../../questions";
4
4
  import { FeedbackBanner } from "../../feedback";
5
5
  import { Button } from "../../ui/button";
6
6
  import { Card, CardContent } from "../../ui/card";
7
7
  import { Progress } from "../../ui/progress";
8
+ import { Skeleton } from "../../ui/skeleton";
9
+ import { SectionShell } from "../_shared/section-shell";
8
10
  import type { PracticeQuizProps, PracticeQuizStats } from "./types";
9
11
  import { shuffle } from "../../utils/shuffle";
10
12
 
@@ -15,11 +17,17 @@ export function PracticeQuiz({
15
17
  onComplete,
16
18
  shuffled = false,
17
19
  readOnly = false,
20
+ isLoading,
21
+ error,
22
+ onRetry,
18
23
  className,
19
24
  style,
20
25
  }: PracticeQuizProps) {
21
26
  const questions = useMemo(
22
- () => (shuffled ? shuffle(questionsProp) : questionsProp),
27
+ () => {
28
+ const qs = questionsProp ?? [];
29
+ return shuffled ? shuffle(qs) : qs;
30
+ },
23
31
  // eslint-disable-next-line react-hooks/exhaustive-deps
24
32
  [questionsProp, shuffled],
25
33
  );
@@ -30,6 +38,16 @@ export function PracticeQuiz({
30
38
  const [firstAttemptCorrect, setFirstAttemptCorrect] = useState<Set<string>>(new Set());
31
39
  const [currentAnswer, setCurrentAnswer] = useState<{ uid: string; content?: string }[] | null>(null);
32
40
  const [isComplete, setIsComplete] = useState(false);
41
+ const questionAreaRef = useRef<HTMLDivElement>(null);
42
+ const isFirstRender = useRef(true);
43
+
44
+ useEffect(() => {
45
+ if (isFirstRender.current) {
46
+ isFirstRender.current = false;
47
+ return;
48
+ }
49
+ questionAreaRef.current?.focus();
50
+ }, [currentIndex]);
33
51
 
34
52
  const currentQuestion = questions[currentIndex];
35
53
  const isRevealed = currentQuestion ? revealedUids.has(currentQuestion.uid) : false;
@@ -78,27 +96,45 @@ export function PracticeQuiz({
78
96
  }
79
97
  }
80
98
 
81
- const isCurrentCorrect = useMemo(() => {
82
- if (!currentQuestion || !currentAnswer) return false;
83
- const sessionAnswers = currentAnswer.map((a) => ({
99
+ const currentSessionAnswers = useMemo(() => {
100
+ if (!currentQuestion || !currentAnswer) return [];
101
+ return currentAnswer.map((a) => ({
84
102
  uid: currentQuestion.uid,
85
103
  answerUid: a.uid,
86
104
  content: a.content,
87
105
  }));
88
- return scoreQuestion(currentQuestion, sessionAnswers) === true;
89
106
  }, [currentQuestion, currentAnswer]);
90
107
 
91
- if (isComplete) {
92
- const percentage = questions.length > 0
93
- ? Math.round((firstAttemptCorrect.size / questions.length) * 100)
94
- : 0;
95
- return (
108
+ const isCurrentCorrect = useMemo(
109
+ () => currentQuestion ? scoreQuestion(currentQuestion, currentSessionAnswers) === true : false,
110
+ [currentQuestion, currentSessionAnswers],
111
+ );
112
+
113
+ const completionPercentage = questions.length > 0
114
+ ? Math.round((firstAttemptCorrect.size / questions.length) * 100)
115
+ : 0;
116
+
117
+ return (
118
+ <SectionShell
119
+ isLoading={isLoading}
120
+ error={error}
121
+ onRetry={onRetry}
122
+ className={className}
123
+ style={style}
124
+ skeleton={
125
+ <>
126
+ <Skeleton className="h-2 w-full" />
127
+ <Skeleton className="h-48 w-full" />
128
+ </>
129
+ }
130
+ >
131
+ {isComplete ? (
96
132
  <Card className={className} style={style}>
97
133
  <CardContent className="pt-6 text-center">
98
134
  <CheckCircle size={48} className="text-success mx-auto mb-4" />
99
135
  <p className="text-xl font-bold mb-1 text-foreground">Practice Complete!</p>
100
136
  <p className="text-muted-foreground mb-2">
101
- {firstAttemptCorrect.size} of {questions.length} correct on first attempt ({percentage}%)
137
+ {firstAttemptCorrect.size} of {questions.length} correct on first attempt ({completionPercentage}%)
102
138
  </p>
103
139
  <div className="flex justify-center">
104
140
  <Button
@@ -117,72 +153,68 @@ export function PracticeQuiz({
117
153
  </div>
118
154
  </CardContent>
119
155
  </Card>
120
- );
121
- }
156
+ ) : (
157
+ <div className={className} style={style}>
158
+ <div className="flex justify-between items-center mb-2">
159
+ <span className="font-semibold text-sm text-foreground">
160
+ Question {currentIndex + 1} of {questions.length}
161
+ </span>
162
+ <span className="text-xs text-muted-foreground">
163
+ {firstAttemptCorrect.size} correct on first try
164
+ </span>
165
+ </div>
166
+ <Progress
167
+ value={currentIndex + (isRevealed ? 1 : 0)}
168
+ max={questions.length}
169
+ size="sm"
170
+ className="mb-3"
171
+ />
122
172
 
123
- return (
124
- <div className={className} style={style}>
125
- <div className="flex justify-between items-center mb-2">
126
- <span className="font-semibold text-sm text-foreground">
173
+ <span className="sr-only" aria-live="polite">
127
174
  Question {currentIndex + 1} of {questions.length}
128
175
  </span>
129
- <span className="text-xs text-muted-foreground">
130
- {firstAttemptCorrect.size} correct on first try
131
- </span>
132
- </div>
133
- <Progress
134
- value={currentIndex + (isRevealed ? 1 : 0)}
135
- max={questions.length}
136
- size="sm"
137
- className="mb-3"
138
- />
139
-
140
- {currentQuestion && (
141
- <Card>
142
- <CardContent className="pt-6">
143
- <QuestionRenderer
144
- question={currentQuestion}
145
- sessionAnswers={
146
- currentAnswer?.map((a) => ({
147
- uid: currentQuestion.uid,
148
- answerUid: a.uid,
149
- content: a.content,
150
- })) ?? []
151
- }
152
- onAnswer={(answers) => setCurrentAnswer(answers)}
153
- readOnly={readOnly || isRevealed}
154
- showCorrectAnswers={isRevealed}
155
- />
156
-
157
- {instantFeedback && isRevealed && (
158
- <FeedbackBanner
159
- isCorrect={isCurrentCorrect}
160
- explanation={currentQuestion.explanation}
161
- onRetry={allowRetry && !isCurrentCorrect ? handleRetry : undefined}
176
+ {currentQuestion && (
177
+ <Card ref={questionAreaRef} tabIndex={-1}>
178
+ <CardContent className="pt-6">
179
+ <QuestionRenderer
180
+ question={currentQuestion}
181
+ sessionAnswers={currentSessionAnswers}
182
+ onAnswer={setCurrentAnswer}
183
+ readOnly={readOnly || isRevealed}
184
+ showCorrectAnswers={isRevealed}
162
185
  />
163
- )}
164
-
165
- <div className="flex justify-end gap-2 mt-2">
166
- {!isRevealed && instantFeedback && (
167
- <Button
168
- onClick={checkAnswer}
169
- disabled={!currentAnswer || currentAnswer.length === 0 || readOnly}
170
- >
171
- Check Answer
172
- </Button>
173
- )}
174
- {(!instantFeedback || isRevealed) && (
175
- <Button
176
- onClick={handleNext}
177
- disabled={readOnly}
178
- >
179
- {currentIndex < questions.length - 1 ? "Next Question" : "Finish"}
180
- </Button>
186
+
187
+ {instantFeedback && isRevealed && (
188
+ <FeedbackBanner
189
+ isCorrect={isCurrentCorrect}
190
+ explanation={currentQuestion.explanation}
191
+ onRetry={allowRetry && !isCurrentCorrect ? handleRetry : undefined}
192
+ />
181
193
  )}
182
- </div>
183
- </CardContent>
184
- </Card>
185
- )}
186
- </div>
194
+
195
+ <div className="flex justify-end gap-2 mt-2">
196
+ {!isRevealed && instantFeedback && (
197
+ <Button
198
+ onClick={checkAnswer}
199
+ disabled={!currentAnswer || currentAnswer.length === 0 || readOnly}
200
+ >
201
+ Check Answer
202
+ </Button>
203
+ )}
204
+ {(!instantFeedback || isRevealed) && (
205
+ <Button
206
+ onClick={handleNext}
207
+ disabled={readOnly}
208
+ >
209
+ {currentIndex < questions.length - 1 ? "Next Question" : "Finish"}
210
+ </Button>
211
+ )}
212
+ </div>
213
+ </CardContent>
214
+ </Card>
215
+ )}
216
+ </div>
217
+ )}
218
+ </SectionShell>
187
219
  );
188
220
  }
@@ -28,6 +28,12 @@ export interface PracticeQuizProps {
28
28
  shuffled?: boolean;
29
29
  /** When true, all inputs are disabled */
30
30
  readOnly?: boolean;
31
+ /** Render skeleton placeholders instead of content */
32
+ isLoading?: boolean;
33
+ /** Error message — renders an error state with optional retry */
34
+ error?: string | null;
35
+ /** Called when the user clicks retry in the error state */
36
+ onRetry?: () => void;
31
37
  /** CSS class name for the root element */
32
38
  className?: string;
33
39
  /** Inline styles for the root element */
@@ -1,4 +1,5 @@
1
- import { BookOpen, Clock } from "lucide-react";
1
+ import { useMemo } from "react";
2
+ import { BookOpen, Clock, AlertCircle } from "lucide-react";
2
3
  import {
3
4
  ProgressRing,
4
5
  StatCard,
@@ -10,7 +11,11 @@ import type { TimelineEvent } from "../../progress";
10
11
  import { Progress } from "../../ui/progress";
11
12
  import { Card, CardContent } from "../../ui/card";
12
13
  import { Separator } from "../../ui/separator";
14
+ import { Skeleton } from "../../ui/skeleton";
15
+ import { Button } from "../../ui/button";
16
+ import { EmptyState } from "../../common/empty-state";
13
17
  import { formatDuration } from "../../utils/format-duration";
18
+ import { pickPaletteVariant } from "../../utils/pick-palette-color";
14
19
  import type { ProgressDashboardProps } from "./types";
15
20
  import { cn } from "../../lib/utils";
16
21
 
@@ -23,9 +28,63 @@ export function ProgressDashboard({
23
28
  achievements,
24
29
  recentActivityLimit = 5,
25
30
  onModuleClick,
31
+ isLoading,
32
+ error,
33
+ onRetry,
26
34
  className,
27
35
  style,
28
36
  }: ProgressDashboardProps) {
37
+ const completedModuleCount = useMemo(
38
+ () => modules.filter((m) => m.completedItems === m.totalItems).length,
39
+ [modules],
40
+ );
41
+
42
+ const timelineEvents = useMemo<TimelineEvent[]>(
43
+ () =>
44
+ recentActivity?.map((activity) => ({
45
+ uid: activity.uid,
46
+ type: activity.type,
47
+ title: activity.description,
48
+ timestamp: activity.timestamp,
49
+ })) ?? [],
50
+ [recentActivity],
51
+ );
52
+
53
+ if (isLoading) {
54
+ return (
55
+ <div className={cn("space-y-4", className)} style={style}>
56
+ <div className="grid grid-cols-3 gap-3">
57
+ <Skeleton className="h-24" />
58
+ <Skeleton className="h-24" />
59
+ <Skeleton className="h-24" />
60
+ </div>
61
+ <Skeleton className="h-32 w-32 rounded-full mx-auto" />
62
+ <Skeleton className="h-8 w-full" />
63
+ <Skeleton className="h-8 w-full" />
64
+ <Skeleton className="h-8 w-full" />
65
+ </div>
66
+ );
67
+ }
68
+
69
+ if (error) {
70
+ return (
71
+ <div className={cn("py-12", className)} style={style}>
72
+ <EmptyState
73
+ icon={<AlertCircle className="size-10 text-destructive" />}
74
+ title="Something went wrong"
75
+ description={error}
76
+ action={
77
+ onRetry ? (
78
+ <Button variant="outline" onClick={onRetry}>
79
+ Retry
80
+ </Button>
81
+ ) : undefined
82
+ }
83
+ />
84
+ </div>
85
+ );
86
+ }
87
+
29
88
  return (
30
89
  <div className={className} style={style}>
31
90
  {/* Stats row */}
@@ -56,7 +115,7 @@ export function ProgressDashboard({
56
115
  icon={<BookOpen size={24} />}
57
116
  label="Modules"
58
117
  description="Course progress"
59
- value={`${modules.filter((m) => m.completedItems === m.totalItems).length} / ${modules.length}`}
118
+ value={`${completedModuleCount} / ${modules.length}`}
60
119
  subtitle="completed"
61
120
  />
62
121
  </div>
@@ -65,7 +124,7 @@ export function ProgressDashboard({
65
124
  <Separator className="mb-3" />
66
125
  <p className="text-lg font-semibold mb-2 text-foreground">Module Progress</p>
67
126
  <div className="flex flex-col gap-2 mb-4">
68
- {modules.map((mod) => {
127
+ {modules.map((mod, index) => {
69
128
  const pct = mod.totalItems > 0 ? (mod.completedItems / mod.totalItems) * 100 : 0;
70
129
  return (
71
130
  <Card
@@ -83,7 +142,7 @@ export function ProgressDashboard({
83
142
  {mod.completedItems} / {mod.totalItems}
84
143
  </span>
85
144
  </div>
86
- <Progress value={pct} size="sm" />
145
+ <Progress value={pct} size="sm" variant={pct >= 100 ? "success" : pickPaletteVariant(index)} />
87
146
  </CardContent>
88
147
  </Card>
89
148
  );
@@ -97,12 +156,7 @@ export function ProgressDashboard({
97
156
  <p className="text-lg font-semibold mb-2 text-foreground">Recent Activity</p>
98
157
  <div className="mb-4">
99
158
  <ActivityTimeline
100
- events={recentActivity.map<TimelineEvent>((activity) => ({
101
- uid: activity.uid,
102
- type: activity.type,
103
- title: activity.description,
104
- timestamp: activity.timestamp,
105
- }))}
159
+ events={timelineEvents}
106
160
  limit={recentActivityLimit}
107
161
  />
108
162
  </div>
@@ -30,6 +30,12 @@ export interface ProgressDashboardProps {
30
30
  recentActivityLimit?: number;
31
31
  /** Called when the user clicks a module */
32
32
  onModuleClick?: (moduleUid: string) => void;
33
+ /** Render skeleton placeholders instead of content */
34
+ isLoading?: boolean;
35
+ /** Error message — renders an error state with optional retry */
36
+ error?: string | null;
37
+ /** Called when the user clicks retry in the error state */
38
+ onRetry?: () => void;
33
39
  /** CSS class name for the root element */
34
40
  className?: string;
35
41
  /** Inline styles for the root element */
@@ -1,13 +1,14 @@
1
- import { useMemo, useState } from "react";
1
+ import { useCallback, useRef } from "react";
2
2
  import { ChevronLeft, ChevronRight, Send } from "lucide-react";
3
3
  import { AssessmentToolbar, QuestionHeaderBar, QuestionMaterialsDrawer } from "../../assessment-toolbar";
4
- import type { QuestionNavigatorItem } from "../../assessment-toolbar/types";
5
4
  import { QuestionRenderer } from "../../questions";
6
- import type { SessionAnswer } from "../../questions/types";
7
5
  import { Button } from "../../ui/button";
8
6
  import { Card, CardHeader, CardContent } from "../../ui/card";
7
+ import { Skeleton } from "../../ui/skeleton";
8
+
9
+ import { useAssessmentSession } from "../_shared/use-assessment-session";
10
+ import { SectionShell } from "../_shared/section-shell";
9
11
  import type { QuizSessionProps } from "./types";
10
- import { cn } from "../../lib/utils";
11
12
 
12
13
  export function QuizSession({
13
14
  questions,
@@ -19,88 +20,74 @@ export function QuizSession({
19
20
  questionMaterials,
20
21
  isSubmitting = false,
21
22
  readOnly = false,
23
+ isLoading,
24
+ error,
25
+ onRetry,
22
26
  className,
23
27
  style,
24
28
  }: QuizSessionProps) {
25
- const [currentIndex, setCurrentIndex] = useState(0);
26
- const [sessionAnswers, setSessionAnswers] =
27
- useState<SessionAnswer[]>(initialAnswers);
28
- const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
29
- const [materialsOpen, setMaterialsOpen] = useState(false);
29
+ const {
30
+ currentIndex,
31
+ currentQuestion,
32
+ sessionAnswers,
33
+ flaggedUids,
34
+ materialsOpen,
35
+ setMaterialsOpen,
36
+ questionAreaRef,
37
+ currentQuestionAnswers,
38
+ currentMaterials,
39
+ navigatorItems,
40
+ handleAnswer,
41
+ handleNavigate,
42
+ handleToggleFlag,
43
+ goNext,
44
+ goPrevious,
45
+ hasNext,
46
+ hasPrevious,
47
+ } = useAssessmentSession({ questions, initialAnswers, onAnswerChange, questionMaterials });
30
48
 
31
- const currentQuestion = questions[currentIndex];
49
+ const sessionAnswersRef = useRef(sessionAnswers);
50
+ sessionAnswersRef.current = sessionAnswers;
51
+ const onSubmitRef = useRef(onSubmit);
52
+ onSubmitRef.current = onSubmit;
32
53
 
33
- const currentQuestionAnswers = useMemo(
34
- () =>
35
- currentQuestion
36
- ? sessionAnswers.filter((a) => a.uid === currentQuestion.uid)
37
- : [],
38
- [sessionAnswers, currentQuestion],
39
- );
54
+ const handleSubmit = useCallback(() => {
55
+ onSubmitRef.current(sessionAnswersRef.current);
56
+ }, []);
40
57
 
41
- const currentMaterials = useMemo(
42
- () => questionMaterials?.filter((m) => m.questionUid === currentQuestion?.uid) ?? [],
43
- [questionMaterials, currentQuestion],
44
- );
45
-
46
- const navigatorItems = useMemo<QuestionNavigatorItem[]>(
47
- () =>
48
- questions.map((q, idx) => ({
49
- uid: q.uid,
50
- sequence: idx,
51
- isFlagged: flaggedUids.has(q.uid),
52
- isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
53
- isSkipped: false,
54
- })),
55
- [questions, sessionAnswers, flaggedUids],
56
- );
58
+ const currentQuestionUidRef = useRef(currentQuestion?.uid);
59
+ currentQuestionUidRef.current = currentQuestion?.uid;
57
60
 
58
- function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
59
- if (!currentQuestion) return;
60
- const questionUid = currentQuestion.uid;
61
- const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
62
- uid: questionUid,
63
- answerUid: a.uid,
64
- content: a.content,
65
- }));
66
- setSessionAnswers((prev) => {
67
- const filtered = prev.filter((a) => a.uid !== questionUid);
68
- const merged = [...filtered, ...newAnswers];
69
- onAnswerChange?.(merged);
70
- return merged;
71
- });
72
- }
61
+ const toggleCurrentFlag = useCallback(() => {
62
+ const uid = currentQuestionUidRef.current;
63
+ if (uid) handleToggleFlag(uid);
64
+ }, [handleToggleFlag]);
73
65
 
74
- function handleNavigate(uid: string) {
75
- const idx = questions.findIndex((q) => q.uid === uid);
76
- if (idx !== -1) setCurrentIndex(idx);
77
- }
78
-
79
- function handleToggleFlag(uid: string) {
80
- setFlaggedUids((prev) => {
81
- const next = new Set(prev);
82
- if (next.has(uid)) {
83
- next.delete(uid);
84
- } else {
85
- next.add(uid);
86
- }
87
- return next;
88
- });
89
- }
90
-
91
- function handleSubmit() {
92
- onSubmit(sessionAnswers);
93
- }
66
+ const openMaterials = useCallback(() => setMaterialsOpen(true), [setMaterialsOpen]);
94
67
 
95
68
  return (
96
- <div className={cn(className)} style={style}>
69
+ <SectionShell
70
+ isLoading={isLoading}
71
+ error={error}
72
+ onRetry={onRetry}
73
+ className={className}
74
+ style={style}
75
+ skeleton={
76
+ <>
77
+ <Skeleton className="h-10 w-full" />
78
+ <Skeleton className="h-48 w-full" />
79
+ <Skeleton className="h-12 w-full" />
80
+ </>
81
+ }
82
+ >
83
+ <div>
97
84
  <AssessmentToolbar
98
85
  currentQuestionIndex={currentIndex}
99
86
  totalQuestions={questions.length}
100
- hasNext={currentIndex < questions.length - 1}
101
- hasPrevious={currentIndex > 0}
102
- onNext={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
103
- onPrevious={() => setCurrentIndex((i) => Math.max(i - 1, 0))}
87
+ hasNext={hasNext}
88
+ hasPrevious={hasPrevious}
89
+ onNext={goNext}
90
+ onPrevious={goPrevious}
104
91
  onSubmit={handleSubmit}
105
92
  timeElapsedSeconds={timeElapsedSeconds}
106
93
  timeLimitSeconds={timeLimitSeconds}
@@ -110,16 +97,19 @@ export function QuizSession({
110
97
  isSubmitting={isSubmitting}
111
98
  readOnly={readOnly}
112
99
  />
100
+ <span className="sr-only" aria-live="polite">
101
+ Question {currentIndex + 1} of {questions.length}
102
+ </span>
113
103
  {currentQuestion && (
114
- <Card className="mt-3">
104
+ <Card className="mt-3" ref={questionAreaRef} tabIndex={-1}>
115
105
  <CardHeader className="pb-0">
116
106
  <QuestionHeaderBar
117
107
  questionNumber={currentIndex + 1}
118
108
  totalQuestions={questions.length}
119
109
  isFlagged={flaggedUids.has(currentQuestion.uid)}
120
- onToggleFlag={() => handleToggleFlag(currentQuestion.uid)}
110
+ onToggleFlag={toggleCurrentFlag}
121
111
  hasMaterials={currentMaterials.length > 0}
122
- onOpenMaterials={() => setMaterialsOpen(true)}
112
+ onOpenMaterials={openMaterials}
123
113
  readOnly={readOnly}
124
114
  />
125
115
  </CardHeader>
@@ -139,16 +129,14 @@ export function QuizSession({
139
129
  <div className="flex items-center justify-between gap-3 mt-3">
140
130
  <Button
141
131
  variant="outline"
142
- disabled={currentIndex <= 0}
143
- onClick={() => setCurrentIndex((i) => Math.max(i - 1, 0))}
132
+ disabled={!hasPrevious}
133
+ onClick={goPrevious}
144
134
  >
145
135
  <ChevronLeft className="size-4 mr-1" />
146
136
  Previous
147
137
  </Button>
148
- {currentIndex < questions.length - 1 ? (
149
- <Button
150
- onClick={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
151
- >
138
+ {hasNext ? (
139
+ <Button onClick={goNext}>
152
140
  Next
153
141
  <ChevronRight className="size-4 ml-1" />
154
142
  </Button>
@@ -168,5 +156,6 @@ export function QuizSession({
168
156
  questionNumber={currentIndex + 1}
169
157
  />
170
158
  </div>
159
+ </SectionShell>
171
160
  );
172
161
  }
@@ -45,6 +45,12 @@ export interface QuizSessionProps {
45
45
  questionMaterials?: QuestionMaterial[];
46
46
  /** When true, all inputs are disabled (e.g. after submission) */
47
47
  readOnly?: boolean;
48
+ /** Render skeleton placeholders instead of content */
49
+ isLoading?: boolean;
50
+ /** Error message — renders an error state with optional retry */
51
+ error?: string | null;
52
+ /** Called when the user clicks retry in the error state */
53
+ onRetry?: () => void;
48
54
  /** CSS class name for the root element */
49
55
  className?: string;
50
56
  /** Inline styles for the root element */
@@ -1,5 +1,8 @@
1
1
  import { useMemo } from "react";
2
- import { CheckCircle2, Circle, ChevronRight } from "lucide-react";
2
+ import { AlertCircle, CheckCircle2, Circle, ChevronRight } from "lucide-react";
3
+ import { Skeleton } from "../../ui/skeleton";
4
+ import { EmptyState } from "../../common/empty-state";
5
+ import { Button } from "../../ui/button";
3
6
  import { Progress } from "../../ui/progress";
4
7
  import { cn } from "../../lib/utils";
5
8
  import type { RequirementsChecklistProps } from "./types";
@@ -14,6 +17,9 @@ export function RequirementsChecklist({
14
17
  title,
15
18
  requirements,
16
19
  onRequirementClick,
20
+ isLoading,
21
+ error,
22
+ onRetry,
17
23
  className,
18
24
  style,
19
25
  }: RequirementsChecklistProps) {
@@ -26,6 +32,40 @@ export function RequirementsChecklist({
26
32
  ? Math.round((completedCount / requirements.length) * 100)
27
33
  : 0;
28
34
 
35
+ if (isLoading) {
36
+ return (
37
+ <div className={cn("space-y-4", className)} style={style}>
38
+ <Skeleton className="h-6 w-48" />
39
+ <Skeleton className="h-2 w-full" />
40
+ {Array.from({ length: 4 }).map((_, i) => (
41
+ <div key={i} className="flex items-center gap-3">
42
+ <Skeleton className="h-5 w-5 rounded-full" />
43
+ <Skeleton className="h-4 w-3/4" />
44
+ </div>
45
+ ))}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ if (error) {
51
+ return (
52
+ <div className={cn("py-12", className)} style={style}>
53
+ <EmptyState
54
+ icon={<AlertCircle className="size-10 text-destructive" />}
55
+ title="Something went wrong"
56
+ description={error}
57
+ action={
58
+ onRetry ? (
59
+ <Button variant="outline" onClick={onRetry}>
60
+ Retry
61
+ </Button>
62
+ ) : undefined
63
+ }
64
+ />
65
+ </div>
66
+ );
67
+ }
68
+
29
69
  return (
30
70
  <div className={cn("flex flex-col gap-4", className)} style={style}>
31
71
  {/* Header */}