@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,3 +1,4 @@
1
+ import { memo } from "react";
1
2
  import { Clock } from "lucide-react";
2
3
  import { cn } from "../lib/utils";
3
4
  import { formatTimer } from "../utils/format-duration";
@@ -22,11 +23,11 @@ function getTimerClasses(isDanger: boolean, isWarning: boolean) {
22
23
  };
23
24
  }
24
25
 
25
- export const TimerDisplay = ({
26
+ export const TimerDisplay = memo(function TimerDisplay({
26
27
  timeElapsedSeconds,
27
28
  timeLimitSeconds,
28
29
  variant = "compact",
29
- }: TimerDisplayProps) => {
30
+ }: TimerDisplayProps) {
30
31
  const hasTimeLimit = timeLimitSeconds != null && timeLimitSeconds > 0;
31
32
  const remainingSeconds = hasTimeLimit
32
33
  ? Math.max(0, timeLimitSeconds - timeElapsedSeconds)
@@ -70,4 +71,4 @@ export const TimerDisplay = ({
70
71
  </span>
71
72
  </div>
72
73
  );
73
- };
74
+ });
@@ -134,7 +134,7 @@ export function useCountdown({
134
134
  startInterval();
135
135
  }
136
136
  return clearTimer;
137
- }, []); // eslint-disable-line react-hooks/exhaustive-deps
137
+ }, [autoStart, startInterval, clearTimer]);
138
138
 
139
139
  const state = stateRef.current;
140
140
 
@@ -6,6 +6,7 @@ export function EmptyState({ icon, title, description, action, className, style
6
6
  <div
7
7
  className={cn("flex flex-col items-center justify-center text-center px-3 py-6", className)}
8
8
  style={style}
9
+ role="status"
9
10
  >
10
11
  {icon && (
11
12
  <div className="mb-2 text-muted-foreground [&>svg]:size-12">
@@ -4,6 +4,7 @@ export { SearchInput } from "./search-input";
4
4
  export { StatusBadge } from "./status-badge";
5
5
  export { DueDateDisplay } from "./due-date-display";
6
6
  export { Stepper } from "./stepper";
7
+ export { Pagination } from "./pagination";
7
8
  export type {
8
9
  EmptyStateProps,
9
10
  ConfirmDialogProps,
@@ -12,4 +13,5 @@ export type {
12
13
  DueDateDisplayProps,
13
14
  StepperProps,
14
15
  StepDefinition,
16
+ PaginationProps,
15
17
  } from "./types";
@@ -0,0 +1,135 @@
1
+ import { memo, useMemo } from "react";
2
+ import { ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { Button } from "../ui/button";
5
+
6
+ /**
7
+ * Pagination provides page navigation controls with page numbers,
8
+ * previous/next buttons, and ellipsis for large page ranges.
9
+ *
10
+ * @example
11
+ * <Pagination
12
+ * currentPage={3}
13
+ * totalPages={10}
14
+ * onPageChange={(page) => setPage(page)}
15
+ * />
16
+ */
17
+ export interface PaginationProps {
18
+ /** Current active page (1-indexed) */
19
+ currentPage: number;
20
+ /** Total number of pages */
21
+ totalPages: number;
22
+ /** Called when the user navigates to a page */
23
+ onPageChange: (page: number) => void;
24
+ /** Number of page buttons shown on each side of the current page (default 1) */
25
+ siblingCount?: number;
26
+ /** CSS class name for the root element */
27
+ className?: string;
28
+ /** Inline styles for the root element */
29
+ style?: React.CSSProperties;
30
+ }
31
+
32
+ function getPageRange(
33
+ current: number,
34
+ total: number,
35
+ siblings: number,
36
+ ): (number | "ellipsis")[] {
37
+ const totalSlots = siblings * 2 + 5;
38
+ if (total <= totalSlots) {
39
+ return Array.from({ length: total }, (_, i) => i + 1);
40
+ }
41
+
42
+ const leftSibling = Math.max(current - siblings, 1);
43
+ const rightSibling = Math.min(current + siblings, total);
44
+ const showLeftEllipsis = leftSibling > 2;
45
+ const showRightEllipsis = rightSibling < total - 1;
46
+
47
+ if (!showLeftEllipsis && showRightEllipsis) {
48
+ const leftCount = siblings * 2 + 3;
49
+ const leftRange = Array.from({ length: leftCount }, (_, i) => i + 1);
50
+ return [...leftRange, "ellipsis", total];
51
+ }
52
+
53
+ if (showLeftEllipsis && !showRightEllipsis) {
54
+ const rightCount = siblings * 2 + 3;
55
+ const rightRange = Array.from(
56
+ { length: rightCount },
57
+ (_, i) => total - rightCount + i + 1,
58
+ );
59
+ return [1, "ellipsis", ...rightRange];
60
+ }
61
+
62
+ const middleRange = Array.from(
63
+ { length: rightSibling - leftSibling + 1 },
64
+ (_, i) => leftSibling + i,
65
+ );
66
+ return [1, "ellipsis", ...middleRange, "ellipsis", total];
67
+ }
68
+
69
+ export const Pagination = memo(function Pagination({
70
+ currentPage,
71
+ totalPages,
72
+ onPageChange,
73
+ siblingCount = 1,
74
+ className,
75
+ style,
76
+ }: PaginationProps) {
77
+ const pages = useMemo(
78
+ () => getPageRange(currentPage, totalPages, siblingCount),
79
+ [currentPage, totalPages, siblingCount],
80
+ );
81
+
82
+ if (totalPages <= 1) return null;
83
+
84
+ return (
85
+ <nav
86
+ data-slot="pagination"
87
+ aria-label="Pagination"
88
+ className={cn("flex items-center justify-center gap-1", className)}
89
+ style={style}
90
+ >
91
+ <Button
92
+ variant="ghost"
93
+ size="icon-sm"
94
+ onClick={() => onPageChange(currentPage - 1)}
95
+ disabled={currentPage <= 1}
96
+ aria-label="Previous page"
97
+ >
98
+ <ChevronLeft className="size-4" />
99
+ </Button>
100
+
101
+ {pages.map((page, i) =>
102
+ page === "ellipsis" ? (
103
+ <span
104
+ key={`ellipsis-${i}`}
105
+ className="flex items-center justify-center size-8 text-xs text-muted-foreground"
106
+ >
107
+ <span aria-hidden="true">...</span>
108
+ <span className="sr-only">More pages</span>
109
+ </span>
110
+ ) : (
111
+ <Button
112
+ key={page}
113
+ variant={page === currentPage ? "default" : "ghost"}
114
+ size="icon-sm"
115
+ onClick={() => onPageChange(page)}
116
+ aria-label={`Page ${page}`}
117
+ aria-current={page === currentPage ? "page" : undefined}
118
+ >
119
+ <span className="text-xs">{page}</span>
120
+ </Button>
121
+ ),
122
+ )}
123
+
124
+ <Button
125
+ variant="ghost"
126
+ size="icon-sm"
127
+ onClick={() => onPageChange(currentPage + 1)}
128
+ disabled={currentPage >= totalPages}
129
+ aria-label="Next page"
130
+ >
131
+ <ChevronRight className="size-4" />
132
+ </Button>
133
+ </nav>
134
+ );
135
+ });
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useRef } from "react";
1
+ import { useState, useMemo, useRef, useEffect } from "react";
2
2
  import { Search, X } from "lucide-react";
3
3
  import { debounce } from "../utils/debounce";
4
4
  import type { SearchInputProps } from "./types";
@@ -23,6 +23,7 @@ export function SearchInput({
23
23
  () => debounce((val: string) => onChangeRef.current(val), debounceMs),
24
24
  [debounceMs],
25
25
  );
26
+ useEffect(() => () => debouncedOnChange.cancel(), [debouncedOnChange]);
26
27
 
27
28
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
28
29
  const next = e.target.value;
@@ -166,3 +166,5 @@ export interface StepperProps {
166
166
  /** Inline styles for the root element */
167
167
  style?: React.CSSProperties;
168
168
  }
169
+
170
+ export type { PaginationProps } from "./pagination";
@@ -68,6 +68,7 @@ export function AttachmentList({
68
68
  size="sm"
69
69
  className="shrink-0 h-7 w-7 p-0"
70
70
  onClick={() => onDownload(file)}
71
+ aria-label={`Download ${file.name}`}
71
72
  >
72
73
  <Download size={14} />
73
74
  </Button>
@@ -78,6 +79,7 @@ export function AttachmentList({
78
79
  size="sm"
79
80
  className="shrink-0 h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
80
81
  onClick={() => onRemove(file)}
82
+ aria-label={`Remove ${file.name}`}
81
83
  >
82
84
  <X size={14} />
83
85
  </Button>
@@ -0,0 +1,196 @@
1
+ import { memo, useCallback, useRef, useState } from "react";
2
+ import { Play, Pause, Volume2, VolumeX } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { Button } from "../ui/button";
5
+ import { formatTimer } from "../utils/format-duration";
6
+
7
+ /**
8
+ * AudioPlayer provides an HTML5 audio player with custom controls
9
+ * including play/pause, seek, speed adjustment, and mute toggle.
10
+ *
11
+ * @example
12
+ * <AudioPlayer src="/audio/lecture-1.mp3" title="Lecture 1: Introduction" />
13
+ */
14
+ export interface AudioPlayerProps {
15
+ /** Audio source URL */
16
+ src: string;
17
+ /** Optional title displayed above the player */
18
+ title?: string;
19
+ /** Called when playback ends */
20
+ onEnded?: () => void;
21
+ /** Called on time update with current time and duration */
22
+ onTimeUpdate?: (currentTime: number, duration: number) => void;
23
+ /** CSS class name for the root element */
24
+ className?: string;
25
+ /** Inline styles for the root element */
26
+ style?: React.CSSProperties;
27
+ }
28
+
29
+ const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
30
+
31
+ export const AudioPlayer = memo(function AudioPlayer({
32
+ src,
33
+ title,
34
+ onEnded,
35
+ onTimeUpdate,
36
+ className,
37
+ style,
38
+ }: AudioPlayerProps) {
39
+ const audioRef = useRef<HTMLAudioElement>(null);
40
+ const [isPlaying, setIsPlaying] = useState(false);
41
+ const [isMuted, setIsMuted] = useState(false);
42
+ const [currentTime, setCurrentTime] = useState(0);
43
+ const [duration, setDuration] = useState(0);
44
+ const [speed, setSpeed] = useState(1);
45
+ const lastReportedTime = useRef(-1);
46
+ const onTimeUpdateRef = useRef(onTimeUpdate);
47
+ onTimeUpdateRef.current = onTimeUpdate;
48
+
49
+ const togglePlay = useCallback(() => {
50
+ const audio = audioRef.current;
51
+ if (!audio) return;
52
+ if (audio.paused) {
53
+ audio.play();
54
+ } else {
55
+ audio.pause();
56
+ }
57
+ }, []);
58
+
59
+ const toggleMute = useCallback(() => {
60
+ const audio = audioRef.current;
61
+ if (!audio) return;
62
+ audio.muted = !audio.muted;
63
+ setIsMuted((prev) => !prev);
64
+ }, []);
65
+
66
+ const cycleSpeed = useCallback(() => {
67
+ const audio = audioRef.current;
68
+ if (!audio) return;
69
+ setSpeed((prev) => {
70
+ const idx = SPEEDS.indexOf(prev);
71
+ const next = SPEEDS[(idx + 1) % SPEEDS.length];
72
+ audio.playbackRate = next;
73
+ return next;
74
+ });
75
+ }, []);
76
+
77
+ const handleSeek = useCallback(
78
+ (e: React.ChangeEvent<HTMLInputElement>) => {
79
+ const audio = audioRef.current;
80
+ if (!audio) return;
81
+ const time = Number(e.target.value);
82
+ audio.currentTime = time;
83
+ lastReportedTime.current = Math.floor(time * 4) / 4;
84
+ setCurrentTime(time);
85
+ },
86
+ [],
87
+ );
88
+
89
+ const handleTimeUpdate = useCallback(() => {
90
+ const audio = audioRef.current;
91
+ if (!audio) return;
92
+ const rounded = Math.floor(audio.currentTime * 4) / 4;
93
+ if (rounded !== lastReportedTime.current) {
94
+ lastReportedTime.current = rounded;
95
+ setCurrentTime(audio.currentTime);
96
+ onTimeUpdateRef.current?.(audio.currentTime, audio.duration);
97
+ }
98
+ }, []);
99
+
100
+ const handleLoadedMetadata = useCallback(() => {
101
+ const audio = audioRef.current;
102
+ if (audio && !isNaN(audio.duration)) setDuration(audio.duration);
103
+ }, []);
104
+
105
+ const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
106
+
107
+ return (
108
+ <div
109
+ data-slot="audio-player"
110
+ className={cn("rounded-lg border bg-card p-4 space-y-3", className)}
111
+ style={style}
112
+ >
113
+ {title && (
114
+ <p className="text-sm font-medium text-foreground">{title}</p>
115
+ )}
116
+ <audio
117
+ ref={audioRef}
118
+ src={src}
119
+ onPlay={() => setIsPlaying(true)}
120
+ onPause={() => setIsPlaying(false)}
121
+ onEnded={() => {
122
+ setIsPlaying(false);
123
+ onEnded?.();
124
+ }}
125
+ onTimeUpdate={handleTimeUpdate}
126
+ onLoadedMetadata={handleLoadedMetadata}
127
+ preload="metadata"
128
+ />
129
+ <div className="flex items-center gap-3">
130
+ <Button
131
+ variant="ghost"
132
+ size="icon-sm"
133
+ onClick={togglePlay}
134
+ aria-label={isPlaying ? "Pause" : "Play"}
135
+ >
136
+ {isPlaying ? (
137
+ <Pause className="size-4" />
138
+ ) : (
139
+ <Play className="size-4" />
140
+ )}
141
+ </Button>
142
+
143
+ <span className="text-xs tabular-nums text-muted-foreground w-12 shrink-0">
144
+ {formatTimer(Math.floor(currentTime))}
145
+ </span>
146
+
147
+ <div className="relative flex-1 h-5 flex items-center">
148
+ <div className="absolute inset-y-0 left-0 flex items-center w-full">
149
+ <div className="w-full h-1 rounded-full bg-muted overflow-hidden">
150
+ <div
151
+ className="h-full bg-primary rounded-full transition-[width] duration-100"
152
+ style={{ width: `${progress}%` }}
153
+ />
154
+ </div>
155
+ </div>
156
+ <input
157
+ type="range"
158
+ min={0}
159
+ max={duration || 0}
160
+ step={0.1}
161
+ value={currentTime}
162
+ onChange={handleSeek}
163
+ className="absolute inset-0 w-full opacity-0 cursor-pointer"
164
+ aria-label="Seek"
165
+ />
166
+ </div>
167
+
168
+ <span className="text-xs tabular-nums text-muted-foreground w-12 shrink-0 text-right">
169
+ {formatTimer(Math.floor(duration))}
170
+ </span>
171
+
172
+ <Button
173
+ variant="ghost"
174
+ size="icon-xs"
175
+ onClick={toggleMute}
176
+ aria-label={isMuted ? "Unmute" : "Mute"}
177
+ >
178
+ {isMuted ? (
179
+ <VolumeX className="size-3.5" />
180
+ ) : (
181
+ <Volume2 className="size-3.5" />
182
+ )}
183
+ </Button>
184
+
185
+ <button
186
+ type="button"
187
+ onClick={cycleSpeed}
188
+ className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors tabular-nums w-8 text-center"
189
+ aria-label={`Playback speed ${speed}x`}
190
+ >
191
+ {speed}x
192
+ </button>
193
+ </div>
194
+ </div>
195
+ );
196
+ });
@@ -0,0 +1,113 @@
1
+ import { memo, useCallback, useEffect, useRef, useState } from "react";
2
+ import { Copy, Check, FileCode } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { Button } from "../ui/button";
5
+
6
+ /**
7
+ * CodeBlock renders source code with optional line numbers, a filename header,
8
+ * and a copy-to-clipboard button. Does not bundle a syntax highlighter —
9
+ * consumers can apply their own (Prism, Shiki, etc.) via the `language-*` class on the `<code>` element.
10
+ *
11
+ * @example
12
+ * <CodeBlock
13
+ * code="console.log('hello');"
14
+ * language="javascript"
15
+ * filename="example.js"
16
+ * showLineNumbers
17
+ * />
18
+ */
19
+ export interface CodeBlockProps {
20
+ /** The code content to display */
21
+ code: string;
22
+ /** Programming language identifier (applied as `language-*` class on `<code>`) */
23
+ language?: string;
24
+ /** Optional filename shown in the header bar */
25
+ filename?: string;
26
+ /** Whether to display line numbers */
27
+ showLineNumbers?: boolean;
28
+ /** Called after code is copied to clipboard */
29
+ onCopy?: () => void;
30
+ /** CSS class name for the root element */
31
+ className?: string;
32
+ /** Inline styles for the root element */
33
+ style?: React.CSSProperties;
34
+ }
35
+
36
+ export const CodeBlock = memo(function CodeBlock({
37
+ code,
38
+ language,
39
+ filename,
40
+ showLineNumbers = false,
41
+ onCopy,
42
+ className,
43
+ style,
44
+ }: CodeBlockProps) {
45
+ const [copied, setCopied] = useState(false);
46
+ const hasHeader = !!(filename || language);
47
+ const copyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
48
+ useEffect(() => () => clearTimeout(copyTimerRef.current), []);
49
+
50
+ const handleCopy = useCallback(async () => {
51
+ try {
52
+ await navigator.clipboard.writeText(code);
53
+ setCopied(true);
54
+ onCopy?.();
55
+ clearTimeout(copyTimerRef.current);
56
+ copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
57
+ } catch {
58
+ // Clipboard API not available
59
+ }
60
+ }, [code, onCopy]);
61
+
62
+ const lines = code.split("\n");
63
+
64
+ const copyButton = (
65
+ <Button
66
+ variant="ghost"
67
+ size="icon-xs"
68
+ onClick={handleCopy}
69
+ aria-label={copied ? "Copied" : "Copy code"}
70
+ >
71
+ {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
72
+ </Button>
73
+ );
74
+
75
+ return (
76
+ <div
77
+ data-slot="code-block"
78
+ className={cn(
79
+ "relative rounded-lg border bg-card overflow-hidden text-sm",
80
+ className,
81
+ )}
82
+ style={style}
83
+ >
84
+ {hasHeader ? (
85
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
86
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
87
+ <FileCode className="size-3.5" />
88
+ <span>{filename ?? language}</span>
89
+ </div>
90
+ {copyButton}
91
+ </div>
92
+ ) : (
93
+ <div className="absolute top-2 right-2 z-10">{copyButton}</div>
94
+ )}
95
+ <div className="overflow-x-auto">
96
+ <pre className="p-4 m-0">
97
+ <code className={language ? `language-${language}` : undefined}>
98
+ {showLineNumbers
99
+ ? lines.map((line, i) => (
100
+ <div key={i} className="flex">
101
+ <span className="select-none text-muted-foreground/50 w-8 shrink-0 text-right pr-4 tabular-nums">
102
+ {i + 1}
103
+ </span>
104
+ <span>{line}</span>
105
+ </div>
106
+ ))
107
+ : code}
108
+ </code>
109
+ </pre>
110
+ </div>
111
+ </div>
112
+ );
113
+ });
@@ -5,6 +5,19 @@ import { QuestionRenderer } from "../questions";
5
5
  import { FlashcardDeck } from "../flashcards";
6
6
  import { Alert, AlertDescription } from "../ui/alert";
7
7
  import { Separator } from "../ui/separator";
8
+ import {
9
+ Table,
10
+ TableHeader,
11
+ TableBody,
12
+ TableRow,
13
+ TableHead,
14
+ TableCell,
15
+ TableCaption,
16
+ } from "../ui/table";
17
+ import { AudioPlayer } from "./audio-player";
18
+ import { CodeBlock } from "./code-block";
19
+ import { EmbedBlock } from "./embed-block";
20
+ import { AttachmentList } from "./attachment-list";
8
21
  import type { ContentBlockProps } from "./types";
9
22
  import { cn } from "../lib/utils";
10
23
 
@@ -106,6 +119,57 @@ export const ContentBlock = memo(function ContentBlock({
106
119
  />
107
120
  );
108
121
 
122
+ case "audio":
123
+ return wrapper(
124
+ <AudioPlayer src={block.src} title={block.title} />
125
+ );
126
+
127
+ case "code":
128
+ return wrapper(
129
+ <CodeBlock
130
+ code={block.code}
131
+ language={block.language}
132
+ filename={block.filename}
133
+ showLineNumbers={block.showLineNumbers}
134
+ />
135
+ );
136
+
137
+ case "embed":
138
+ return wrapper(
139
+ <EmbedBlock
140
+ src={block.src}
141
+ title={block.title}
142
+ aspectRatio={block.aspectRatio}
143
+ allowFullscreen={block.allowFullscreen}
144
+ />
145
+ );
146
+
147
+ case "table":
148
+ return wrapper(
149
+ <Table>
150
+ {block.caption && <TableCaption>{block.caption}</TableCaption>}
151
+ <TableHeader>
152
+ <TableRow>
153
+ {block.headers.map((header, i) => (
154
+ <TableHead key={i}>{header}</TableHead>
155
+ ))}
156
+ </TableRow>
157
+ </TableHeader>
158
+ <TableBody>
159
+ {block.rows.map((row, i) => (
160
+ <TableRow key={i}>
161
+ {row.map((cell, j) => (
162
+ <TableCell key={j}>{cell}</TableCell>
163
+ ))}
164
+ </TableRow>
165
+ ))}
166
+ </TableBody>
167
+ </Table>
168
+ );
169
+
170
+ case "file":
171
+ return wrapper(<AttachmentList files={block.files} readOnly />);
172
+
109
173
  case "divider":
110
174
  return wrapper(<Separator />);
111
175
 
@@ -0,0 +1,78 @@
1
+ import { memo, useState } from "react";
2
+ import { ExternalLink } from "lucide-react";
3
+ import { cn } from "../lib/utils";
4
+ import { Skeleton } from "../ui/skeleton";
5
+
6
+ export type EmbedAspectRatio = "16/9" | "4/3" | "1/1";
7
+
8
+ /**
9
+ * EmbedBlock renders a responsive iframe wrapper with an optional title bar
10
+ * and loading skeleton. Ideal for embedding YouTube videos, SCORM objects,
11
+ * Google Slides, and other external content.
12
+ *
13
+ * @example
14
+ * <EmbedBlock
15
+ * src="https://www.youtube.com/embed/dQw4w9WgXcQ"
16
+ * title="Introduction Video"
17
+ * aspectRatio="16/9"
18
+ * />
19
+ */
20
+ export interface EmbedBlockProps {
21
+ /** iframe source URL */
22
+ src: string;
23
+ /** Optional title for the embed */
24
+ title?: string;
25
+ /** Aspect ratio of the embed container */
26
+ aspectRatio?: EmbedAspectRatio;
27
+ /** Whether to allow fullscreen */
28
+ allowFullscreen?: boolean;
29
+ /** CSS class name for the root element */
30
+ className?: string;
31
+ /** Inline styles for the root element */
32
+ style?: React.CSSProperties;
33
+ }
34
+
35
+ const ASPECT_CLASSES: Record<EmbedAspectRatio, string> = {
36
+ "16/9": "aspect-video",
37
+ "4/3": "aspect-[4/3]",
38
+ "1/1": "aspect-square",
39
+ };
40
+
41
+ export const EmbedBlock = memo(function EmbedBlock({
42
+ src,
43
+ title,
44
+ aspectRatio = "16/9",
45
+ allowFullscreen = true,
46
+ className,
47
+ style,
48
+ }: EmbedBlockProps) {
49
+ const [isLoaded, setIsLoaded] = useState(false);
50
+
51
+ return (
52
+ <div
53
+ data-slot="embed-block"
54
+ className={cn("rounded-lg border bg-card overflow-hidden", className)}
55
+ style={style}
56
+ >
57
+ {title && (
58
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
59
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
60
+ <ExternalLink className="size-3.5" />
61
+ <span>{title}</span>
62
+ </div>
63
+ </div>
64
+ )}
65
+ <div className={cn("relative w-full", ASPECT_CLASSES[aspectRatio])}>
66
+ {!isLoaded && <Skeleton className="absolute inset-0 rounded-none" />}
67
+ <iframe
68
+ src={src}
69
+ title={title ?? "Embedded content"}
70
+ allowFullScreen={allowFullscreen}
71
+ onLoad={() => setIsLoaded(true)}
72
+ className="absolute inset-0 w-full h-full border-0"
73
+ sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
74
+ />
75
+ </div>
76
+ </div>
77
+ );
78
+ });