@hydralms/components 0.1.2 → 0.2.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 (199) hide show
  1. package/dist/ForumBoard-CHXU3mjC.js +2207 -0
  2. package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
  3. package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
  4. package/dist/assessment-toolbar/index.d.ts +5 -1
  5. package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
  6. package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
  7. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  8. package/dist/assessment-toolbar/types.d.ts +52 -4
  9. package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
  10. package/dist/common/index.d.ts +2 -1
  11. package/dist/common/stepper.d.ts +6 -0
  12. package/dist/common/types.d.ts +37 -0
  13. package/dist/components.css +1 -1
  14. package/dist/content/attachment-list.d.ts +6 -0
  15. package/dist/content/content-block.d.ts +1 -1
  16. package/dist/content/index.d.ts +2 -1
  17. package/dist/content/types.d.ts +39 -0
  18. package/dist/curriculum/curriculum-item.d.ts +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.js +551 -312
  21. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
  22. package/dist/modules/AssignmentModule/types.d.ts +65 -0
  23. package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
  24. package/dist/modules/CertificateModule/types.d.ts +49 -0
  25. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
  26. package/dist/modules/DiscussionModule/types.d.ts +47 -0
  27. package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
  28. package/dist/modules/ExamModule/types.d.ts +64 -0
  29. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
  30. package/dist/modules/GradeCenterModule/types.d.ts +54 -0
  31. package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
  32. package/dist/modules/QuizModule/types.d.ts +6 -1
  33. package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
  34. package/dist/modules/SurveyModule/types.d.ts +49 -0
  35. package/dist/modules/index.d.ts +12 -0
  36. package/dist/modules.cjs +1 -0
  37. package/dist/modules.js +1422 -0
  38. package/dist/progress/achievement-badge.d.ts +6 -0
  39. package/dist/progress/activity-timeline.d.ts +6 -0
  40. package/dist/progress/index.d.ts +4 -1
  41. package/dist/progress/stat-card.d.ts +1 -1
  42. package/dist/progress/streak-badge.d.ts +6 -0
  43. package/dist/progress/types.d.ts +97 -0
  44. package/dist/questions/essay.d.ts +1 -1
  45. package/dist/questions/hotspot.d.ts +21 -0
  46. package/dist/questions/index.d.ts +9 -1
  47. package/dist/questions/inline-choice.d.ts +21 -0
  48. package/dist/questions/matching.d.ts +22 -0
  49. package/dist/questions/numeric.d.ts +11 -0
  50. package/dist/questions/ordering.d.ts +12 -0
  51. package/dist/questions/scenario.d.ts +23 -0
  52. package/dist/questions/scoring.d.ts +22 -0
  53. package/dist/questions/spreadsheet.d.ts +29 -0
  54. package/dist/questions/types.d.ts +106 -1
  55. package/dist/questions/use-drag-reorder.d.ts +17 -0
  56. package/dist/sections/CertificateViewer/types.d.ts +7 -5
  57. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  58. package/dist/sections/ExamSession/types.d.ts +6 -1
  59. package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
  60. package/dist/sections/ForumBoard/types.d.ts +64 -0
  61. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  62. package/dist/sections/QuizSession/types.d.ts +6 -1
  63. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
  64. package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
  65. package/dist/sections/RubricView/RubricView.d.ts +9 -0
  66. package/dist/sections/RubricView/types.d.ts +50 -0
  67. package/dist/sections/index.d.ts +7 -1
  68. package/dist/sections.cjs +1 -1
  69. package/dist/sections.js +250 -1715
  70. package/dist/social/post-card.d.ts +1 -1
  71. package/dist/tabs-DRM2Iq_J.cjs +172 -0
  72. package/dist/tabs-Wf3h_Cx3.js +21580 -0
  73. package/dist/ui/alert.d.ts +1 -1
  74. package/dist/ui/badge.d.ts +1 -1
  75. package/dist/ui/button.d.ts +1 -1
  76. package/dist/ui/drawer.d.ts +84 -0
  77. package/dist/ui/index.d.ts +3 -0
  78. package/dist/ui/progress.d.ts +1 -1
  79. package/dist/ui/rich-text-editor.d.ts +30 -0
  80. package/dist/ui/rich-text-toolbar.d.ts +8 -0
  81. package/dist/utils/array-utils.d.ts +4 -0
  82. package/dist/utils/flatten-leaves.d.ts +6 -0
  83. package/dist/utils/format-file-size.d.ts +1 -0
  84. package/dist/utils/format-timestamp.d.ts +1 -0
  85. package/dist/utils/is-empty-html.d.ts +5 -0
  86. package/dist/utils/shuffle.d.ts +1 -0
  87. package/dist/utils/string-utils.d.ts +12 -0
  88. package/dist/video/video-bookmark.d.ts +1 -1
  89. package/dist/video/video-playlist-item.d.ts +1 -1
  90. package/package.json +141 -3
  91. package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
  92. package/src/assessment-toolbar/index.ts +6 -0
  93. package/src/assessment-toolbar/question-header-bar.tsx +61 -0
  94. package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
  95. package/src/assessment-toolbar/question-navigator.tsx +3 -31
  96. package/src/assessment-toolbar/timer-display.tsx +2 -2
  97. package/src/assessment-toolbar/types.ts +54 -4
  98. package/src/assessment-toolbar/use-countdown.ts +153 -0
  99. package/src/common/index.ts +3 -0
  100. package/src/common/search-input.tsx +7 -6
  101. package/src/common/stepper.tsx +100 -0
  102. package/src/common/types.ts +39 -0
  103. package/src/content/attachment-list.tsx +90 -0
  104. package/src/content/content-block.tsx +4 -2
  105. package/src/content/file-upload-zone.tsx +1 -6
  106. package/src/content/index.ts +3 -0
  107. package/src/content/types.ts +41 -0
  108. package/src/curriculum/curriculum-item.tsx +7 -3
  109. package/src/feedback/feedback-banner.tsx +12 -14
  110. package/src/flashcards/flashcard-deck.tsx +1 -9
  111. package/src/flashcards/flashcard.tsx +1 -1
  112. package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
  113. package/src/modules/AssignmentModule/types.ts +73 -0
  114. package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
  115. package/src/modules/CertificateModule/types.ts +47 -0
  116. package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
  117. package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
  118. package/src/modules/DiscussionModule/types.ts +54 -0
  119. package/src/modules/ExamModule/ExamModule.tsx +285 -0
  120. package/src/modules/ExamModule/types.ts +66 -0
  121. package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
  122. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
  123. package/src/modules/GradeCenterModule/types.ts +63 -0
  124. package/src/modules/QuizModule/QuizModule.tsx +88 -88
  125. package/src/modules/QuizModule/types.ts +6 -1
  126. package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
  127. package/src/modules/SurveyModule/types.ts +51 -0
  128. package/src/modules/index.ts +24 -0
  129. package/src/progress/achievement-badge.tsx +52 -0
  130. package/src/progress/activity-timeline.tsx +84 -0
  131. package/src/progress/index.ts +7 -0
  132. package/src/progress/stat-card.tsx +30 -18
  133. package/src/progress/streak-badge.tsx +35 -0
  134. package/src/progress/types.ts +101 -0
  135. package/src/questions/choice.tsx +7 -9
  136. package/src/questions/essay.tsx +23 -25
  137. package/src/questions/fill-in-the-blank.tsx +13 -16
  138. package/src/questions/hotspot.tsx +154 -0
  139. package/src/questions/index.ts +16 -0
  140. package/src/questions/inline-choice.tsx +151 -0
  141. package/src/questions/matching.tsx +228 -0
  142. package/src/questions/multiple-choice.tsx +7 -9
  143. package/src/questions/numeric.tsx +102 -0
  144. package/src/questions/ordering.tsx +159 -0
  145. package/src/questions/question-renderer.tsx +21 -0
  146. package/src/questions/scenario.tsx +140 -0
  147. package/src/questions/scoring.ts +201 -0
  148. package/src/questions/spreadsheet.tsx +259 -0
  149. package/src/questions/true-false.tsx +7 -9
  150. package/src/questions/types.ts +123 -1
  151. package/src/questions/use-drag-reorder.ts +80 -0
  152. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
  153. package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
  154. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
  155. package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
  156. package/src/sections/CertificateViewer/types.ts +13 -5
  157. package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
  158. package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
  159. package/src/sections/ExamSession/ExamSession.tsx +44 -7
  160. package/src/sections/ExamSession/types.ts +6 -1
  161. package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
  162. package/src/sections/ForumBoard/types.ts +67 -0
  163. package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
  164. package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
  165. package/src/sections/LessonPage/LessonPage.tsx +5 -9
  166. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
  167. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
  168. package/src/sections/QuizSession/QuizSession.tsx +67 -8
  169. package/src/sections/QuizSession/types.ts +6 -1
  170. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
  171. package/src/sections/RequirementsChecklist/types.ts +38 -0
  172. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
  173. package/src/sections/RubricView/RubricView.tsx +138 -0
  174. package/src/sections/RubricView/types.ts +52 -0
  175. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
  176. package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
  177. package/src/sections/index.ts +20 -1
  178. package/src/social/post-card.tsx +8 -19
  179. package/src/social/user-avatar.tsx +1 -0
  180. package/src/styles/globals.css +13 -0
  181. package/src/ui/drawer.tsx +600 -0
  182. package/src/ui/index.ts +19 -0
  183. package/src/ui/rich-text-editor.tsx +109 -0
  184. package/src/ui/rich-text-toolbar.tsx +156 -0
  185. package/src/utils/array-utils.ts +17 -0
  186. package/src/utils/flatten-leaves.ts +17 -0
  187. package/src/utils/format-file-size.ts +5 -0
  188. package/src/utils/format-timestamp.ts +13 -0
  189. package/src/utils/is-empty-html.ts +7 -0
  190. package/src/utils/shuffle.ts +8 -0
  191. package/src/utils/string-utils.ts +30 -0
  192. package/src/video/video-bookmark.tsx +4 -3
  193. package/src/video/video-chapter-list.tsx +9 -4
  194. package/src/video/video-player.tsx +11 -4
  195. package/src/video/video-playlist-item.tsx +8 -3
  196. package/src/video/video-thumbnail-card.tsx +4 -0
  197. package/src/video/video-transcript.tsx +8 -5
  198. package/dist/table-BrS5cDQu.js +0 -2510
  199. package/dist/table-D6AkBBEo.cjs +0 -1
@@ -0,0 +1,84 @@
1
+ import { BookOpen, CheckCircle, Send, Award, Circle } from "lucide-react";
2
+ import { Button } from "../ui/button";
3
+ import { formatTimestamp } from "../utils/format-timestamp";
4
+ import { cn } from "../lib/utils";
5
+ import type { ActivityTimelineProps } from "./types";
6
+
7
+ const DEFAULT_ICONS: Record<string, React.ElementType> = {
8
+ lesson_completed: BookOpen,
9
+ quiz_passed: CheckCircle,
10
+ assignment_submitted: Send,
11
+ badge_earned: Award,
12
+ };
13
+
14
+ /**
15
+ * ActivityTimeline renders a vertical timeline of activity events
16
+ * with icons, timestamps, and an optional "load more" action.
17
+ */
18
+ export function ActivityTimeline({
19
+ events,
20
+ limit,
21
+ showLoadMore = false,
22
+ onLoadMore,
23
+ emptyMessage = "No recent activity",
24
+ className,
25
+ style,
26
+ }: ActivityTimelineProps) {
27
+ const displayedEvents = limit ? events.slice(0, limit) : events;
28
+
29
+ if (events.length === 0) {
30
+ return (
31
+ <p className={cn("text-sm text-muted-foreground", className)} style={style}>
32
+ {emptyMessage}
33
+ </p>
34
+ );
35
+ }
36
+
37
+ return (
38
+ <div className={cn("relative", className)} style={style}>
39
+ {/* Vertical line */}
40
+ <div className="absolute left-3.5 top-0 bottom-0 w-px bg-border" />
41
+
42
+ {displayedEvents.map((event) => {
43
+ const Icon = DEFAULT_ICONS[event.type] ?? Circle;
44
+
45
+ return (
46
+ <div
47
+ key={event.uid}
48
+ className="relative flex gap-3 pb-4 last:pb-0"
49
+ >
50
+ {/* Dot / Icon */}
51
+ <div className="relative z-10 shrink-0 w-7 h-7 rounded-full bg-background border border-border flex items-center justify-center">
52
+ {event.icon ?? (
53
+ <Icon size={14} className="text-muted-foreground" />
54
+ )}
55
+ </div>
56
+
57
+ {/* Content */}
58
+ <div className="flex-1 min-w-0 pt-0.5">
59
+ <p className="text-sm font-medium text-foreground">
60
+ {event.title}
61
+ </p>
62
+ {event.description && (
63
+ <p className="text-xs text-muted-foreground mt-0.5">
64
+ {event.description}
65
+ </p>
66
+ )}
67
+ <span className="text-xs text-muted-foreground">
68
+ {formatTimestamp(event.timestamp)}
69
+ </span>
70
+ </div>
71
+ </div>
72
+ );
73
+ })}
74
+
75
+ {showLoadMore && onLoadMore && (
76
+ <div className="pl-10 pt-1">
77
+ <Button variant="ghost" size="sm" onClick={onLoadMore}>
78
+ Load more
79
+ </Button>
80
+ </div>
81
+ )}
82
+ </div>
83
+ );
84
+ }
@@ -1,8 +1,15 @@
1
1
  export { ProgressRing } from "./progress-ring";
2
2
  export { GradeIndicator } from "./grade-indicator";
3
3
  export { StatCard } from "./stat-card";
4
+ export { AchievementBadge } from "./achievement-badge";
5
+ export { StreakBadge } from "./streak-badge";
6
+ export { ActivityTimeline } from "./activity-timeline";
4
7
  export type {
5
8
  ProgressRingProps,
6
9
  GradeIndicatorProps,
7
10
  StatCardProps,
11
+ AchievementBadgeProps,
12
+ StreakBadgeProps,
13
+ ActivityTimelineProps,
14
+ TimelineEvent,
8
15
  } from "./types";
@@ -1,4 +1,6 @@
1
+ import { memo } from "react";
1
2
  import { TrendingUp, TrendingDown, Minus } from "lucide-react";
3
+ import { Card, CardContent } from "../ui/card";
2
4
  import type { StatCardProps } from "./types";
3
5
  import { cn } from "../lib/utils";
4
6
 
@@ -9,9 +11,10 @@ const TREND_COLORS = {
9
11
  };
10
12
  const TREND_ICONS = { up: TrendingUp, down: TrendingDown, flat: Minus };
11
13
 
12
- export function StatCard({
14
+ export const StatCard = memo(function StatCard({
13
15
  icon,
14
16
  label,
17
+ description,
15
18
  value,
16
19
  subtitle,
17
20
  trend,
@@ -21,22 +24,31 @@ export function StatCard({
21
24
  const TrendIcon = trend ? TREND_ICONS[trend.direction] : null;
22
25
 
23
26
  return (
24
- <div className={cn("rounded-md border border-border p-4", className)} style={style}>
25
- {icon && <div className="mb-1 text-primary [&>svg]:size-6">{icon}</div>}
26
- <span className="text-xs text-muted-foreground">{label}</span>
27
- <div className="flex flex-row items-baseline gap-1">
28
- <span className="text-2xl font-bold">{value}</span>
29
- {trend && TrendIcon && (
30
- <span className="flex flex-row items-center gap-px" style={{ color: TREND_COLORS[trend.direction] }}>
31
- <TrendIcon size={14} />
32
- <span className="text-xs font-semibold">
33
- {trend.value > 0 ? "+" : ""}
34
- {trend.value}%
35
- </span>
36
- </span>
27
+ <Card className={cn(className)} style={style}>
28
+ <CardContent className="p-4">
29
+ {icon && (
30
+ <div className="mb-2 w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center text-primary [&>svg]:size-5">
31
+ {icon}
32
+ </div>
33
+ )}
34
+ <span className="text-sm font-medium text-foreground">{label}</span>
35
+ {description && (
36
+ <p className="text-xs text-muted-foreground mt-0.5 leading-snug">{description}</p>
37
37
  )}
38
- </div>
39
- {subtitle && <span className="text-xs text-muted-foreground">{subtitle}</span>}
40
- </div>
38
+ <div className="flex flex-row items-baseline gap-1.5 mt-1">
39
+ <span className="text-3xl font-bold tracking-tight">{value}</span>
40
+ {trend && TrendIcon && (
41
+ <span className="flex flex-row items-center gap-px" style={{ color: TREND_COLORS[trend.direction] }}>
42
+ <TrendIcon size={14} />
43
+ <span className="text-xs font-semibold">
44
+ {trend.value > 0 ? "+" : ""}
45
+ {trend.value}%
46
+ </span>
47
+ </span>
48
+ )}
49
+ </div>
50
+ {subtitle && <span className="text-xs text-muted-foreground">{subtitle}</span>}
51
+ </CardContent>
52
+ </Card>
41
53
  );
42
- }
54
+ });
@@ -0,0 +1,35 @@
1
+ import { Flame } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import type { StreakBadgeProps } from "./types";
4
+
5
+ /**
6
+ * StreakBadge displays a compact learning streak indicator with
7
+ * a fire icon and day count.
8
+ */
9
+ export function StreakBadge({
10
+ currentStreak,
11
+ longestStreak,
12
+ unit = "days",
13
+ showLongest = false,
14
+ className,
15
+ style,
16
+ }: StreakBadgeProps) {
17
+ return (
18
+ <div
19
+ className={cn("flex items-center gap-2", className)}
20
+ style={style}
21
+ >
22
+ <Flame size={20} className="text-warning shrink-0" />
23
+ <div>
24
+ <span className="text-sm font-bold text-foreground">
25
+ {currentStreak} {unit}
26
+ </span>
27
+ {showLongest && longestStreak != null && (
28
+ <span className="block text-xs text-muted-foreground">
29
+ Longest: {longestStreak} {unit}
30
+ </span>
31
+ )}
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -60,6 +60,8 @@ export interface StatCardProps {
60
60
  icon?: ReactNode;
61
61
  /** Stat label */
62
62
  label: string;
63
+ /** Short description displayed below the label */
64
+ description?: string;
63
65
  /** Stat value */
64
66
  value: string | number;
65
67
  /** Secondary text below the value */
@@ -71,3 +73,102 @@ export interface StatCardProps {
71
73
  /** Inline styles for the root element */
72
74
  style?: React.CSSProperties;
73
75
  }
76
+
77
+ /**
78
+ * AchievementBadge displays a single achievement or badge earned by a learner,
79
+ * with support for locked/earned states and metal-tier variants.
80
+ *
81
+ * @example
82
+ * <AchievementBadge
83
+ * title="Quiz Master"
84
+ * description="Pass 10 quizzes with 90%+ score"
85
+ * variant="gold"
86
+ * earnedDate="2025-12-01T00:00:00Z"
87
+ * />
88
+ */
89
+ export interface AchievementBadgeProps {
90
+ /** Achievement title */
91
+ title: string;
92
+ /** Optional description of how to earn the achievement */
93
+ description?: string;
94
+ /** Custom icon to display. Falls back to Trophy icon */
95
+ icon?: ReactNode;
96
+ /** ISO date string of when the achievement was earned */
97
+ earnedDate?: string;
98
+ /** Whether the achievement is locked (not yet earned). @default false */
99
+ locked?: boolean;
100
+ /** Visual tier variant. @default 'default' */
101
+ variant?: "default" | "gold" | "silver" | "bronze";
102
+ /** CSS class name for the root element */
103
+ className?: string;
104
+ /** Inline styles for the root element */
105
+ style?: React.CSSProperties;
106
+ }
107
+
108
+ /**
109
+ * StreakBadge displays a compact learning streak indicator with
110
+ * a fire icon and day count.
111
+ *
112
+ * @example
113
+ * <StreakBadge currentStreak={7} longestStreak={14} showLongest />
114
+ */
115
+ export interface StreakBadgeProps {
116
+ /** Current active streak count */
117
+ currentStreak: number;
118
+ /** All-time longest streak count */
119
+ longestStreak?: number;
120
+ /** Unit label for the streak count. @default "days" */
121
+ unit?: string;
122
+ /** Whether to show the longest streak subtitle. @default false */
123
+ showLongest?: boolean;
124
+ /** CSS class name for the root element */
125
+ className?: string;
126
+ /** Inline styles for the root element */
127
+ style?: React.CSSProperties;
128
+ }
129
+
130
+ /**
131
+ * A single event in an ActivityTimeline.
132
+ */
133
+ export interface TimelineEvent {
134
+ /** Unique identifier */
135
+ uid: string;
136
+ /** Event type — used for default icon selection */
137
+ type: string;
138
+ /** Event title */
139
+ title: string;
140
+ /** Optional longer description */
141
+ description?: string;
142
+ /** ISO 8601 timestamp */
143
+ timestamp: string;
144
+ /** Optional custom icon (overrides type-based default) */
145
+ icon?: ReactNode;
146
+ }
147
+
148
+ /**
149
+ * ActivityTimeline renders a vertical timeline of activity events
150
+ * with icons, timestamps, and an optional "load more" action.
151
+ *
152
+ * @example
153
+ * <ActivityTimeline
154
+ * events={[
155
+ * { uid: "1", type: "lesson_completed", title: "Completed Lesson 3", timestamp: "2025-03-01T12:00:00Z" },
156
+ * ]}
157
+ * />
158
+ */
159
+ export interface ActivityTimelineProps {
160
+ /** List of activity events to display */
161
+ events: TimelineEvent[];
162
+ /** Maximum number of events to display. When set, truncates the list. */
163
+ limit?: number;
164
+ /** Whether to show a "Load more" button at the bottom. @default false */
165
+ showLoadMore?: boolean;
166
+ /** Called when the user clicks "Load more" */
167
+ onLoadMore?: () => void;
168
+ /** Message displayed when the events list is empty. @default "No recent activity" */
169
+ emptyMessage?: string;
170
+ /** CSS class name for the root element */
171
+ className?: string;
172
+ /** Inline styles for the root element */
173
+ style?: React.CSSProperties;
174
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect } from "react";
1
+ import { useState, useMemo } from "react";
2
2
  import type { QuestionProps } from "./types";
3
3
  import { Alert, AlertDescription } from "../ui/alert";
4
4
  import { cn } from "../lib/utils";
@@ -20,10 +20,13 @@ export const Choice = ({
20
20
  showCorrectAnswers = false,
21
21
  disabled = false,
22
22
  }: QuestionProps) => {
23
- const [selectedAnswer, setSelectedAnswer] = useState<string>("");
23
+ const [selectedAnswer, setSelectedAnswer] = useState<string>(
24
+ () => sessionAnswers?.[0]?.answerUid || "",
25
+ );
24
26
 
25
- const sortedAnswers = [...(question.answers || [])].sort(
26
- (a, b) => a.sequence - b.sequence,
27
+ const sortedAnswers = useMemo(
28
+ () => [...(question.answers || [])].sort((a, b) => a.sequence - b.sequence),
29
+ [question.answers],
27
30
  );
28
31
 
29
32
  const handleChange = (uid: string) => {
@@ -33,11 +36,6 @@ export const Choice = ({
33
36
  onAnswer?.([{ uid }]);
34
37
  };
35
38
 
36
- useEffect(() => {
37
- const current = sessionAnswers?.[0]?.answerUid || "";
38
- setSelectedAnswer(current);
39
- }, [sessionAnswers]);
40
-
41
39
  const getAnswerClasses = (answerUid: string) => {
42
40
  if (!showCorrectAnswers) return "px-2";
43
41
 
@@ -1,10 +1,10 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useMemo, useRef } from "react";
2
2
  import { debounce } from "../utils/debounce";
3
- import { Textarea } from "../ui/textarea";
3
+ import { RichTextEditor } from "../ui/rich-text-editor";
4
4
  import type { QuestionProps } from "./types";
5
5
 
6
6
  /**
7
- * Essay renders a long-form text input question with a multiline textarea.
7
+ * Essay renders a long-form rich text input question.
8
8
  *
9
9
  * @example
10
10
  * <Essay
@@ -19,40 +19,38 @@ export const Essay = ({
19
19
  readOnly = false,
20
20
  disabled = false,
21
21
  }: QuestionProps) => {
22
- const [value, setValue] = useState("");
22
+ const [value, setValue] = useState(() => sessionAnswers?.[0]?.content || "");
23
23
 
24
- const debouncedAnswer = useCallback(
25
- debounce((content: string) => {
26
- onAnswer?.([
27
- {
28
- uid: question.answers?.[0]?.uid || question.uid,
29
- content,
30
- },
31
- ]);
32
- }, 500),
33
- [onAnswer, question.answers, question.uid],
24
+ const onAnswerRef = useRef(onAnswer);
25
+ onAnswerRef.current = onAnswer;
26
+ const answerUidRef = useRef(question.answers?.[0]?.uid || question.uid);
27
+ answerUidRef.current = question.answers?.[0]?.uid || question.uid;
28
+
29
+ const debouncedAnswer = useMemo(
30
+ () =>
31
+ debounce((content: string) => {
32
+ onAnswerRef.current?.([{ uid: answerUidRef.current, content }]);
33
+ }, 500),
34
+ [],
34
35
  );
35
36
 
36
- const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
37
- const newValue = e.target.value;
38
- setValue(newValue);
39
- debouncedAnswer(newValue);
37
+ const handleChange = (html: string) => {
38
+ setValue(html);
39
+ debouncedAnswer(html);
40
40
  };
41
41
 
42
- useEffect(() => {
43
- setValue(sessionAnswers?.[0]?.content || "");
44
- }, [sessionAnswers]);
45
-
46
42
  return (
47
43
  <div className="flex flex-col gap-2">
48
44
  <div dangerouslySetInnerHTML={{ __html: question.content }} />
49
45
 
50
- <Textarea
51
- className="min-h-30 resize-y"
46
+ <RichTextEditor
47
+ className="min-h-30"
52
48
  value={value}
53
49
  onChange={handleChange}
54
50
  placeholder="Write your response here..."
55
- disabled={readOnly || disabled}
51
+ readOnly={readOnly}
52
+ disabled={disabled}
53
+ variant="default"
56
54
  />
57
55
  </div>
58
56
  );
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useMemo, useRef } from "react";
2
2
  import { debounce } from "../utils/debounce";
3
3
  import { Input } from "../ui/input";
4
4
  import { Alert, AlertDescription } from "../ui/alert";
@@ -21,18 +21,19 @@ export const FillInTheBlank = ({
21
21
  showCorrectAnswers = false,
22
22
  disabled = false,
23
23
  }: QuestionProps) => {
24
- const [value, setValue] = useState("");
24
+ const [value, setValue] = useState(() => sessionAnswers?.[0]?.content || "");
25
25
 
26
- const debouncedAnswer = useCallback(
27
- debounce((content: string) => {
28
- onAnswer?.([
29
- {
30
- uid: question.answers?.[0]?.uid || "",
31
- content,
32
- },
33
- ]);
34
- }, 300),
35
- [onAnswer, question.answers],
26
+ const onAnswerRef = useRef(onAnswer);
27
+ onAnswerRef.current = onAnswer;
28
+ const answerUidRef = useRef(question.answers?.[0]?.uid || "");
29
+ answerUidRef.current = question.answers?.[0]?.uid || "";
30
+
31
+ const debouncedAnswer = useMemo(
32
+ () =>
33
+ debounce((content: string) => {
34
+ onAnswerRef.current?.([{ uid: answerUidRef.current, content }]);
35
+ }, 300),
36
+ [],
36
37
  );
37
38
 
38
39
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -41,10 +42,6 @@ export const FillInTheBlank = ({
41
42
  debouncedAnswer(newValue);
42
43
  };
43
44
 
44
- useEffect(() => {
45
- setValue(sessionAnswers?.[0]?.content || "");
46
- }, [sessionAnswers]);
47
-
48
45
  return (
49
46
  <div className="flex flex-col gap-2">
50
47
  <div dangerouslySetInnerHTML={{ __html: question.content }} />
@@ -0,0 +1,154 @@
1
+ import { useState, useMemo } from "react";
2
+ import { Alert, AlertDescription } from "../ui/alert";
3
+ import { cn } from "../lib/utils";
4
+ import type { QuestionProps, HotspotRegion } from "./types";
5
+
6
+ function getRegionStyle(region: HotspotRegion): React.CSSProperties {
7
+ if (region.shape === "rect") {
8
+ const [x, y, w, h] = region.coords;
9
+ return {
10
+ left: `${x}%`,
11
+ top: `${y}%`,
12
+ width: `${w}%`,
13
+ height: `${h}%`,
14
+ };
15
+ }
16
+ // circle: [cx, cy, r]
17
+ const [cx, cy, r] = region.coords;
18
+ return {
19
+ left: `${cx - r}%`,
20
+ top: `${cy - r}%`,
21
+ width: `${r * 2}%`,
22
+ height: `${r * 2}%`,
23
+ borderRadius: "50%",
24
+ };
25
+ }
26
+
27
+ /**
28
+ * Hotspot renders an image with clickable regions for selection.
29
+ *
30
+ * Regions are positioned using percentage-based coordinates so they scale with the image.
31
+ *
32
+ * @example
33
+ * <Hotspot
34
+ * question={{
35
+ * uid: "q1",
36
+ * type: "hotspot",
37
+ * content: "Click on the heart.",
38
+ * hotspotImageUrl: "/anatomy.png",
39
+ * hotspotRegions: [
40
+ * { uid: "r1", shape: "circle", coords: [50, 40, 8], isCorrect: true, label: "Heart" },
41
+ * ],
42
+ * }}
43
+ * onAnswer={(answers) => handleAnswer(answers)}
44
+ * />
45
+ */
46
+ export const Hotspot = ({
47
+ question,
48
+ sessionAnswers,
49
+ onAnswer,
50
+ readOnly = false,
51
+ showCorrectAnswers = false,
52
+ disabled = false,
53
+ }: QuestionProps) => {
54
+ const multiSelect = question.hotspotMultiSelect ?? false;
55
+
56
+ const [selected, setSelected] = useState<Set<string>>(() => {
57
+ const set = new Set<string>();
58
+ for (const sa of sessionAnswers ?? []) {
59
+ set.add(sa.answerUid);
60
+ }
61
+ return set;
62
+ });
63
+
64
+ const regions = useMemo(
65
+ () => question.hotspotRegions ?? [],
66
+ [question.hotspotRegions],
67
+ );
68
+
69
+ const handleClick = (regionUid: string) => {
70
+ if (readOnly || disabled) return;
71
+
72
+ let next: Set<string>;
73
+ if (multiSelect) {
74
+ next = new Set(selected);
75
+ if (next.has(regionUid)) {
76
+ next.delete(regionUid);
77
+ } else {
78
+ next.add(regionUid);
79
+ }
80
+ } else {
81
+ next = selected.has(regionUid) ? new Set() : new Set([regionUid]);
82
+ }
83
+
84
+ setSelected(next);
85
+ onAnswer?.([...next].map((uid) => ({ uid })));
86
+ };
87
+
88
+ const getRegionClasses = (region: HotspotRegion) => {
89
+ const isSelected = selected.has(region.uid);
90
+
91
+ if (showCorrectAnswers) {
92
+ if (region.isCorrect && isSelected) {
93
+ return "ring-3 ring-success bg-success/20 border-2 border-success";
94
+ }
95
+ if (region.isCorrect && !isSelected) {
96
+ return "ring-2 ring-success/50 border-2 border-dashed border-success/50";
97
+ }
98
+ if (!region.isCorrect && isSelected) {
99
+ return "ring-3 ring-destructive bg-destructive/20 border-2 border-destructive";
100
+ }
101
+ return "";
102
+ }
103
+
104
+ if (isSelected) {
105
+ return "ring-3 ring-primary bg-primary/20 border-2 border-primary";
106
+ }
107
+
108
+ return "hover:bg-primary/10";
109
+ };
110
+
111
+ return (
112
+ <div className="flex flex-col gap-4">
113
+ <div dangerouslySetInnerHTML={{ __html: question.content }} />
114
+
115
+ <div className="relative inline-block max-w-full">
116
+ {question.hotspotImageUrl && (
117
+ <img
118
+ src={question.hotspotImageUrl}
119
+ alt="Hotspot question image"
120
+ className="w-full h-auto rounded-md"
121
+ draggable={false}
122
+ />
123
+ )}
124
+
125
+ {regions.map((region, i) => (
126
+ <button
127
+ key={region.uid}
128
+ type="button"
129
+ onClick={() => handleClick(region.uid)}
130
+ disabled={readOnly || disabled}
131
+ aria-label={region.label || `Region ${i + 1}`}
132
+ aria-pressed={selected.has(region.uid)}
133
+ className={cn(
134
+ "absolute transition-all cursor-pointer",
135
+ "disabled:cursor-default",
136
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
137
+ getRegionClasses(region),
138
+ )}
139
+ style={getRegionStyle(region)}
140
+ />
141
+ ))}
142
+ </div>
143
+
144
+ {showCorrectAnswers && question.explanation && (
145
+ <Alert className="mt-2">
146
+ <AlertDescription>
147
+ <strong>Explanation:</strong>{" "}
148
+ <span dangerouslySetInnerHTML={{ __html: question.explanation }} />
149
+ </AlertDescription>
150
+ </Alert>
151
+ )}
152
+ </div>
153
+ );
154
+ };
@@ -4,11 +4,27 @@ export { Choice } from "./choice";
4
4
  export { TrueFalse } from "./true-false";
5
5
  export { FillInTheBlank } from "./fill-in-the-blank";
6
6
  export { Essay } from "./essay";
7
+ export { Numeric } from "./numeric";
8
+ export { Ordering } from "./ordering";
9
+ export { Matching } from "./matching";
10
+ export { Hotspot } from "./hotspot";
11
+ export { InlineChoice } from "./inline-choice";
12
+ export { Scenario } from "./scenario";
13
+ export { Spreadsheet } from "./spreadsheet";
14
+ export { scoreQuestion, scoreScenarioSubQuestions } from "./scoring";
7
15
 
8
16
  export type {
9
17
  QuestionProps,
10
18
  QuestionData,
11
19
  QuestionTypeEnum,
20
+ QuestionMaterial,
12
21
  AnswerOption,
13
22
  SessionAnswer,
23
+ MatchingPair,
24
+ HotspotRegion,
25
+ InlineBlank,
26
+ ScenarioScoringMode,
27
+ SpreadsheetColumn,
28
+ SpreadsheetCell,
29
+ SpreadsheetRow,
14
30
  } from "./types";