@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.
- package/dist/ForumBoard-CHXU3mjC.js +2207 -0
- package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
- package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
- package/dist/assessment-toolbar/index.d.ts +5 -1
- package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
- package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
- package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
- package/dist/assessment-toolbar/types.d.ts +52 -4
- package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
- package/dist/common/index.d.ts +2 -1
- package/dist/common/stepper.d.ts +6 -0
- package/dist/common/types.d.ts +37 -0
- package/dist/components.css +1 -1
- package/dist/content/attachment-list.d.ts +6 -0
- package/dist/content/content-block.d.ts +1 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/content/types.d.ts +39 -0
- package/dist/curriculum/curriculum-item.d.ts +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +551 -312
- package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
- package/dist/modules/AssignmentModule/types.d.ts +65 -0
- package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
- package/dist/modules/CertificateModule/types.d.ts +49 -0
- package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
- package/dist/modules/DiscussionModule/types.d.ts +47 -0
- package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
- package/dist/modules/ExamModule/types.d.ts +64 -0
- package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
- package/dist/modules/GradeCenterModule/types.d.ts +54 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
- package/dist/modules/QuizModule/types.d.ts +6 -1
- package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
- package/dist/modules/SurveyModule/types.d.ts +49 -0
- package/dist/modules/index.d.ts +12 -0
- package/dist/modules.cjs +1 -0
- package/dist/modules.js +1422 -0
- package/dist/progress/achievement-badge.d.ts +6 -0
- package/dist/progress/activity-timeline.d.ts +6 -0
- package/dist/progress/index.d.ts +4 -1
- package/dist/progress/stat-card.d.ts +1 -1
- package/dist/progress/streak-badge.d.ts +6 -0
- package/dist/progress/types.d.ts +97 -0
- package/dist/questions/essay.d.ts +1 -1
- package/dist/questions/hotspot.d.ts +21 -0
- package/dist/questions/index.d.ts +9 -1
- package/dist/questions/inline-choice.d.ts +21 -0
- package/dist/questions/matching.d.ts +22 -0
- package/dist/questions/numeric.d.ts +11 -0
- package/dist/questions/ordering.d.ts +12 -0
- package/dist/questions/scenario.d.ts +23 -0
- package/dist/questions/scoring.d.ts +22 -0
- package/dist/questions/spreadsheet.d.ts +29 -0
- package/dist/questions/types.d.ts +106 -1
- package/dist/questions/use-drag-reorder.d.ts +17 -0
- package/dist/sections/CertificateViewer/types.d.ts +7 -5
- package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
- package/dist/sections/ExamSession/types.d.ts +6 -1
- package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
- package/dist/sections/ForumBoard/types.d.ts +64 -0
- package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
- package/dist/sections/QuizSession/types.d.ts +6 -1
- package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
- package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
- package/dist/sections/RubricView/RubricView.d.ts +9 -0
- package/dist/sections/RubricView/types.d.ts +50 -0
- package/dist/sections/index.d.ts +7 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +250 -1715
- package/dist/social/post-card.d.ts +1 -1
- package/dist/tabs-DRM2Iq_J.cjs +172 -0
- package/dist/tabs-Wf3h_Cx3.js +21580 -0
- package/dist/ui/alert.d.ts +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/drawer.d.ts +84 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/progress.d.ts +1 -1
- package/dist/ui/rich-text-editor.d.ts +30 -0
- package/dist/ui/rich-text-toolbar.d.ts +8 -0
- package/dist/utils/array-utils.d.ts +4 -0
- package/dist/utils/flatten-leaves.d.ts +6 -0
- package/dist/utils/format-file-size.d.ts +1 -0
- package/dist/utils/format-timestamp.d.ts +1 -0
- package/dist/utils/is-empty-html.d.ts +5 -0
- package/dist/utils/shuffle.d.ts +1 -0
- package/dist/utils/string-utils.d.ts +12 -0
- package/dist/video/video-bookmark.d.ts +1 -1
- package/dist/video/video-playlist-item.d.ts +1 -1
- package/package.json +141 -3
- package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
- package/src/assessment-toolbar/index.ts +6 -0
- package/src/assessment-toolbar/question-header-bar.tsx +61 -0
- package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
- package/src/assessment-toolbar/question-navigator.tsx +3 -31
- package/src/assessment-toolbar/timer-display.tsx +2 -2
- package/src/assessment-toolbar/types.ts +54 -4
- package/src/assessment-toolbar/use-countdown.ts +153 -0
- package/src/common/index.ts +3 -0
- package/src/common/search-input.tsx +7 -6
- package/src/common/stepper.tsx +100 -0
- package/src/common/types.ts +39 -0
- package/src/content/attachment-list.tsx +90 -0
- package/src/content/content-block.tsx +4 -2
- package/src/content/file-upload-zone.tsx +1 -6
- package/src/content/index.ts +3 -0
- package/src/content/types.ts +41 -0
- package/src/curriculum/curriculum-item.tsx +7 -3
- package/src/feedback/feedback-banner.tsx +12 -14
- package/src/flashcards/flashcard-deck.tsx +1 -9
- package/src/flashcards/flashcard.tsx +1 -1
- package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
- package/src/modules/AssignmentModule/types.ts +73 -0
- package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
- package/src/modules/CertificateModule/types.ts +47 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
- package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
- package/src/modules/DiscussionModule/types.ts +54 -0
- package/src/modules/ExamModule/ExamModule.tsx +285 -0
- package/src/modules/ExamModule/types.ts +66 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
- package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
- package/src/modules/GradeCenterModule/types.ts +63 -0
- package/src/modules/QuizModule/QuizModule.tsx +88 -88
- package/src/modules/QuizModule/types.ts +6 -1
- package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
- package/src/modules/SurveyModule/types.ts +51 -0
- package/src/modules/index.ts +24 -0
- package/src/progress/achievement-badge.tsx +52 -0
- package/src/progress/activity-timeline.tsx +84 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/stat-card.tsx +30 -18
- package/src/progress/streak-badge.tsx +35 -0
- package/src/progress/types.ts +101 -0
- package/src/questions/choice.tsx +7 -9
- package/src/questions/essay.tsx +23 -25
- package/src/questions/fill-in-the-blank.tsx +13 -16
- package/src/questions/hotspot.tsx +154 -0
- package/src/questions/index.ts +16 -0
- package/src/questions/inline-choice.tsx +151 -0
- package/src/questions/matching.tsx +228 -0
- package/src/questions/multiple-choice.tsx +7 -9
- package/src/questions/numeric.tsx +102 -0
- package/src/questions/ordering.tsx +159 -0
- package/src/questions/question-renderer.tsx +21 -0
- package/src/questions/scenario.tsx +140 -0
- package/src/questions/scoring.ts +201 -0
- package/src/questions/spreadsheet.tsx +259 -0
- package/src/questions/true-false.tsx +7 -9
- package/src/questions/types.ts +123 -1
- package/src/questions/use-drag-reorder.ts +80 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
- package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
- package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
- package/src/sections/CertificateViewer/types.ts +13 -5
- package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
- package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
- package/src/sections/ExamSession/ExamSession.tsx +44 -7
- package/src/sections/ExamSession/types.ts +6 -1
- package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
- package/src/sections/ForumBoard/types.ts +67 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
- package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
- package/src/sections/LessonPage/LessonPage.tsx +5 -9
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
- package/src/sections/QuizSession/QuizSession.tsx +67 -8
- package/src/sections/QuizSession/types.ts +6 -1
- package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
- package/src/sections/RequirementsChecklist/types.ts +38 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
- package/src/sections/RubricView/RubricView.tsx +138 -0
- package/src/sections/RubricView/types.ts +52 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
- package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
- package/src/sections/index.ts +20 -1
- package/src/social/post-card.tsx +8 -19
- package/src/social/user-avatar.tsx +1 -0
- package/src/styles/globals.css +13 -0
- package/src/ui/drawer.tsx +600 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/rich-text-editor.tsx +109 -0
- package/src/ui/rich-text-toolbar.tsx +156 -0
- package/src/utils/array-utils.ts +17 -0
- package/src/utils/flatten-leaves.ts +17 -0
- package/src/utils/format-file-size.ts +5 -0
- package/src/utils/format-timestamp.ts +13 -0
- package/src/utils/is-empty-html.ts +7 -0
- package/src/utils/shuffle.ts +8 -0
- package/src/utils/string-utils.ts +30 -0
- package/src/video/video-bookmark.tsx +4 -3
- package/src/video/video-chapter-list.tsx +9 -4
- package/src/video/video-player.tsx +11 -4
- package/src/video/video-playlist-item.tsx +8 -3
- package/src/video/video-thumbnail-card.tsx +4 -0
- package/src/video/video-transcript.tsx +8 -5
- package/dist/table-BrS5cDQu.js +0 -2510
- 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
|
+
}
|
package/src/progress/index.ts
CHANGED
|
@@ -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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
+
}
|
package/src/progress/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/questions/choice.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
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 =
|
|
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
|
|
package/src/questions/essay.tsx
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useMemo, useRef } from "react";
|
|
2
2
|
import { debounce } from "../utils/debounce";
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 = (
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
<
|
|
51
|
-
className="min-h-30
|
|
46
|
+
<RichTextEditor
|
|
47
|
+
className="min-h-30"
|
|
52
48
|
value={value}
|
|
53
49
|
onChange={handleChange}
|
|
54
50
|
placeholder="Write your response here..."
|
|
55
|
-
|
|
51
|
+
readOnly={readOnly}
|
|
52
|
+
disabled={disabled}
|
|
53
|
+
variant="default"
|
|
56
54
|
/>
|
|
57
55
|
</div>
|
|
58
56
|
);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
};
|
package/src/questions/index.ts
CHANGED
|
@@ -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";
|