@hydralms/components 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/components.css +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +442 -110
  4. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
  5. package/dist/modules/CoursePlayer/types.d.ts +59 -0
  6. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
  7. package/dist/modules/FlashcardLab/types.d.ts +55 -0
  8. package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
  9. package/dist/modules/QuizModule/types.d.ts +54 -0
  10. package/dist/modules/index.d.ts +6 -0
  11. package/dist/provider/HydraProvider.d.ts +1 -1
  12. package/dist/sections.cjs +1 -1
  13. package/dist/sections.js +261 -291
  14. package/dist/table-BrS5cDQu.js +2510 -0
  15. package/dist/table-D6AkBBEo.cjs +1 -0
  16. package/dist/ui/alert-dialog.d.ts +14 -8
  17. package/dist/ui/button.d.ts +1 -1
  18. package/dist/ui/tabs.d.ts +15 -5
  19. package/dist/ui/tooltip.d.ts +12 -5
  20. package/dist/video/index.d.ts +6 -1
  21. package/dist/video/types.d.ts +167 -0
  22. package/dist/video/video-bookmark.d.ts +2 -0
  23. package/dist/video/video-chapter-list.d.ts +2 -0
  24. package/dist/video/video-playlist-item.d.ts +2 -0
  25. package/dist/video/video-thumbnail-card.d.ts +2 -0
  26. package/dist/video/video-transcript.d.ts +2 -0
  27. package/package.json +135 -24
  28. package/src/__tests__/setup.ts +1 -0
  29. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  30. package/src/assessment-toolbar/index.ts +10 -0
  31. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  32. package/src/assessment-toolbar/timer-display.tsx +73 -0
  33. package/src/assessment-toolbar/types.ts +92 -0
  34. package/src/assets/hydra-icon.png +0 -0
  35. package/src/assets/hydra-icon.svg +18 -0
  36. package/src/assets/hydra-lms-icon.png +0 -0
  37. package/src/assets/hydra-lms-icon.svg +9 -0
  38. package/src/common/confirm-dialog.tsx +60 -0
  39. package/src/common/due-date-display.tsx +64 -0
  40. package/src/common/empty-state.tsx +24 -0
  41. package/src/common/index.ts +12 -0
  42. package/src/common/search-input.tsx +68 -0
  43. package/src/common/status-badge.test.tsx +43 -0
  44. package/src/common/status-badge.tsx +81 -0
  45. package/src/common/types.ts +129 -0
  46. package/src/content/content-block.tsx +116 -0
  47. package/src/content/file-upload-zone.tsx +109 -0
  48. package/src/content/index.ts +7 -0
  49. package/src/content/types.ts +76 -0
  50. package/src/curriculum/curriculum-item.tsx +81 -0
  51. package/src/curriculum/curriculum-tree.tsx +69 -0
  52. package/src/curriculum/index.ts +11 -0
  53. package/src/curriculum/learning-object-icon.tsx +44 -0
  54. package/src/curriculum/types.ts +83 -0
  55. package/src/feedback/feedback-banner.tsx +46 -0
  56. package/src/feedback/index.ts +8 -0
  57. package/src/feedback/likert-scale.tsx +58 -0
  58. package/src/feedback/star-rating.tsx +65 -0
  59. package/src/feedback/types.ts +86 -0
  60. package/src/flashcards/flashcard-deck.tsx +130 -0
  61. package/src/flashcards/flashcard.tsx +108 -0
  62. package/src/flashcards/index.ts +3 -0
  63. package/src/flashcards/types.ts +60 -0
  64. package/src/index.ts +38 -0
  65. package/src/lib/utils.ts +6 -0
  66. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  67. package/src/modules/CoursePlayer/types.ts +48 -0
  68. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  69. package/src/modules/FlashcardLab/types.ts +58 -0
  70. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  71. package/src/modules/QuizModule/types.ts +56 -0
  72. package/src/modules/index.ts +12 -0
  73. package/src/progress/grade-indicator.tsx +65 -0
  74. package/src/progress/index.ts +8 -0
  75. package/src/progress/progress-ring.tsx +56 -0
  76. package/src/progress/stat-card.tsx +42 -0
  77. package/src/progress/types.ts +73 -0
  78. package/src/provider/HydraProvider.tsx +26 -0
  79. package/src/provider/index.ts +2 -0
  80. package/src/questions/choice.tsx +90 -0
  81. package/src/questions/essay.tsx +59 -0
  82. package/src/questions/fill-in-the-blank.tsx +69 -0
  83. package/src/questions/index.ts +14 -0
  84. package/src/questions/multiple-choice.test.tsx +104 -0
  85. package/src/questions/multiple-choice.tsx +97 -0
  86. package/src/questions/question-renderer.tsx +37 -0
  87. package/src/questions/true-false.test.tsx +89 -0
  88. package/src/questions/true-false.tsx +90 -0
  89. package/src/questions/types.ts +53 -0
  90. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  91. package/src/sections/AnnouncementFeed/types.ts +50 -0
  92. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  93. package/src/sections/AssessmentReview/types.ts +61 -0
  94. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  95. package/src/sections/AssignmentSubmission/types.ts +60 -0
  96. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  97. package/src/sections/CertificateViewer/types.ts +45 -0
  98. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  99. package/src/sections/CourseOutline/types.ts +53 -0
  100. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  101. package/src/sections/DiscussionThread/types.ts +77 -0
  102. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  103. package/src/sections/ExamSession/types.ts +64 -0
  104. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  105. package/src/sections/FlashcardStudySession/types.ts +42 -0
  106. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  107. package/src/sections/GradebookTable/types.ts +75 -0
  108. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  109. package/src/sections/LecturePlayer/types.ts +48 -0
  110. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  111. package/src/sections/LessonPage/types.ts +41 -0
  112. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  113. package/src/sections/PracticeQuiz/types.ts +44 -0
  114. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  115. package/src/sections/ProgressDashboard/types.ts +74 -0
  116. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  117. package/src/sections/QuizSession/types.ts +47 -0
  118. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  119. package/src/sections/ResourceLibrary/types.ts +57 -0
  120. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  121. package/src/sections/ScrollableQuiz/types.ts +40 -0
  122. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  123. package/src/sections/SurveyForm/types.ts +69 -0
  124. package/src/sections/index.ts +90 -0
  125. package/src/social/index.ts +3 -0
  126. package/src/social/post-card.tsx +91 -0
  127. package/src/social/types.ts +57 -0
  128. package/src/social/user-avatar.tsx +76 -0
  129. package/src/styles/globals.css +125 -0
  130. package/src/ui/alert-dialog.tsx +343 -0
  131. package/src/ui/alert.tsx +65 -0
  132. package/src/ui/avatar.tsx +52 -0
  133. package/src/ui/badge.tsx +53 -0
  134. package/src/ui/button.tsx +62 -0
  135. package/src/ui/card.tsx +92 -0
  136. package/src/ui/index.ts +44 -0
  137. package/src/ui/input.tsx +21 -0
  138. package/src/ui/progress.tsx +73 -0
  139. package/src/ui/separator.tsx +29 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/slot.tsx +48 -0
  142. package/src/ui/table.tsx +108 -0
  143. package/src/ui/tabs.tsx +147 -0
  144. package/src/ui/textarea.tsx +20 -0
  145. package/src/ui/tooltip.tsx +177 -0
  146. package/src/utils/debounce.test.ts +59 -0
  147. package/src/utils/debounce.ts +10 -0
  148. package/src/utils/format-duration.test.ts +55 -0
  149. package/src/utils/format-duration.ts +30 -0
  150. package/src/video/index.ts +17 -0
  151. package/src/video/types.ts +216 -0
  152. package/src/video/video-bookmark.tsx +76 -0
  153. package/src/video/video-chapter-list.tsx +93 -0
  154. package/src/video/video-player.tsx +103 -0
  155. package/src/video/video-playlist-item.tsx +90 -0
  156. package/src/video/video-thumbnail-card.tsx +74 -0
  157. package/src/video/video-transcript.tsx +102 -0
  158. package/dist/table-CW4_BYny.js +0 -9869
  159. package/dist/table-DSBBqb9X.cjs +0 -56
@@ -0,0 +1,40 @@
1
+ import type { QuestionData, SessionAnswer } from "../../questions/types";
2
+
3
+ /**
4
+ * ScrollableQuiz section — all questions on a single scrollable page.
5
+ *
6
+ * Renders every question in a vertical list with a sticky sidebar navigator
7
+ * that highlights the currently visible question. Supports optional question
8
+ * grouping by section labels.
9
+ *
10
+ * @example
11
+ * <ScrollableQuiz
12
+ * questions={questions}
13
+ * onSubmit={(answers) => submit(answers)}
14
+ * showNavigator
15
+ * />
16
+ */
17
+ export interface ScrollableQuizProps {
18
+ /** Ordered list of questions to present */
19
+ questions: QuestionData[];
20
+ /** Pre-populated answers for resuming */
21
+ initialAnswers?: SessionAnswer[];
22
+ /** Called when the user clicks Submit with the full answers array */
23
+ onSubmit: (answers: SessionAnswer[]) => void;
24
+ /** Called whenever the user changes any answer (useful for auto-save) */
25
+ onAnswerChange?: (answers: SessionAnswer[]) => void;
26
+ /** Whether to show the sticky sidebar navigator */
27
+ showNavigator?: boolean;
28
+ /** Whether to show question numbers beside each question */
29
+ showQuestionNumbers?: boolean;
30
+ /** Optional grouping of questions into labeled sections */
31
+ questionGroups?: { label: string; questionUids: string[] }[];
32
+ /** Whether the submit action is in flight */
33
+ isSubmitting?: boolean;
34
+ /** When true, all inputs are disabled */
35
+ readOnly?: boolean;
36
+ /** CSS class name for the root element */
37
+ className?: string;
38
+ /** Inline styles for the root element */
39
+ style?: React.CSSProperties;
40
+ }
@@ -0,0 +1,180 @@
1
+ import { useState, useMemo } from "react";
2
+ import { LikertScale, StarRating } from "../../feedback";
3
+ import { Button } from "../../ui/button";
4
+ import { Card, CardContent } from "../../ui/card";
5
+ import { Progress } from "../../ui/progress";
6
+ import { Textarea } from "../../ui/textarea";
7
+ import type { SurveyFormProps, SurveyAnswer } from "./types";
8
+
9
+ export function SurveyForm({
10
+ title,
11
+ description,
12
+ questions,
13
+ initialAnswers = [],
14
+ onSubmit,
15
+ onAnswerChange,
16
+ showProgress = true,
17
+ requireAll = false,
18
+ submitLabel = "Submit Survey",
19
+ isSubmitting = false,
20
+ readOnly = false,
21
+ className,
22
+ style,
23
+ }: SurveyFormProps) {
24
+ const [answers, setAnswers] = useState<SurveyAnswer[]>(initialAnswers);
25
+
26
+ const answeredCount = useMemo(() => {
27
+ const answered = new Set(answers.map((a) => a.questionUid));
28
+ return questions.filter((q) => answered.has(q.uid)).length;
29
+ }, [questions, answers]);
30
+
31
+ const canSubmit = !requireAll || answeredCount === questions.length;
32
+
33
+ function setAnswer(questionUid: string, value: string | number) {
34
+ setAnswers((prev) => {
35
+ const filtered = prev.filter((a) => a.questionUid !== questionUid);
36
+ const next = [...filtered, { questionUid, value }];
37
+ onAnswerChange?.(next);
38
+ return next;
39
+ });
40
+ }
41
+
42
+ function getAnswer(questionUid: string): SurveyAnswer | undefined {
43
+ return answers.find((a) => a.questionUid === questionUid);
44
+ }
45
+
46
+ return (
47
+ <div className={className} style={style}>
48
+ <p className="text-xl font-bold text-foreground mb-2">{title}</p>
49
+ {description && (
50
+ <div className="mb-2 text-muted-foreground text-sm">
51
+ {typeof description === "string" ? (
52
+ <span>{description}</span>
53
+ ) : (
54
+ description
55
+ )}
56
+ </div>
57
+ )}
58
+ {showProgress && (
59
+ <div className="mb-3">
60
+ <Progress value={(answeredCount / questions.length) * 100} />
61
+ <span className="block text-xs text-muted-foreground mt-0.5">
62
+ {answeredCount} of {questions.length} answered
63
+ </span>
64
+ </div>
65
+ )}
66
+
67
+ {questions.map((q, index) => {
68
+ const answer = getAnswer(q.uid);
69
+ return (
70
+ <Card key={q.uid} className="mb-2"><CardContent className="pt-6">
71
+ <p className="font-medium text-foreground mb-2">
72
+ {index + 1}. {q.content}
73
+ {q.required && (
74
+ <span className="text-destructive ml-0.5">*</span>
75
+ )}
76
+ </p>
77
+
78
+ {q.type === "likert" && (
79
+ <LikertScale
80
+ value={answer ? Number(answer.value) : null}
81
+ onChange={(v) => setAnswer(q.uid, v)}
82
+ points={q.scalePoints}
83
+ lowLabel={q.scaleLabels?.low}
84
+ highLabel={q.scaleLabels?.high}
85
+ readOnly={readOnly}
86
+ />
87
+ )}
88
+
89
+ {q.type === "rating" && (
90
+ <StarRating
91
+ value={answer ? Number(answer.value) : 0}
92
+ onChange={(v) => setAnswer(q.uid, v)}
93
+ readOnly={readOnly}
94
+ />
95
+ )}
96
+
97
+ {q.type === "open_text" && (
98
+ <Textarea
99
+ placeholder="Type your response..."
100
+ value={(answer?.value as string) ?? ""}
101
+ onChange={(e) => setAnswer(q.uid, e.target.value)}
102
+ disabled={readOnly}
103
+ className="min-h-24"
104
+ />
105
+ )}
106
+
107
+ {q.type === "choice" && q.answers && (
108
+ <div className="flex flex-col gap-2">
109
+ {q.answers.map((opt) => (
110
+ <label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
111
+ <input
112
+ type="radio"
113
+ className="accent-primary m-0 shrink-0"
114
+ name={`survey-q-${q.uid}`}
115
+ value={opt.uid}
116
+ checked={(answer?.value as string) === opt.uid}
117
+ onChange={() => setAnswer(q.uid, opt.uid)}
118
+ disabled={readOnly}
119
+ />
120
+ <span>{opt.content}</span>
121
+ </label>
122
+ ))}
123
+ </div>
124
+ )}
125
+
126
+ {q.type === "multiple_choice" && q.answers && (
127
+ <div className="flex flex-col gap-2">
128
+ {q.answers.map((opt) => {
129
+ const selected = answers
130
+ .filter((a) => a.questionUid === q.uid)
131
+ .map((a) => String(a.value));
132
+ return (
133
+ <label key={opt.uid} className="flex items-center gap-2 cursor-pointer py-1 text-sm text-foreground has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
134
+ <input
135
+ type="checkbox"
136
+ className="accent-primary m-0 shrink-0"
137
+ checked={selected.includes(opt.uid)}
138
+ disabled={readOnly}
139
+ onChange={(e) => {
140
+ const prev = answers.filter((a) => a.questionUid === q.uid);
141
+ let next: SurveyAnswer[];
142
+ if (e.target.checked) {
143
+ next = [...prev, { questionUid: q.uid, value: opt.uid }];
144
+ } else {
145
+ next = prev.filter((a) => String(a.value) !== opt.uid);
146
+ }
147
+ setAnswers((all) => [
148
+ ...all.filter((a) => a.questionUid !== q.uid),
149
+ ...next,
150
+ ]);
151
+ onAnswerChange?.([
152
+ ...answers.filter((a) => a.questionUid !== q.uid),
153
+ ...next,
154
+ ]);
155
+ }}
156
+ />
157
+ <span>{opt.content}</span>
158
+ </label>
159
+ );
160
+ })}
161
+ </div>
162
+ )}
163
+ </CardContent></Card>
164
+ );
165
+ })}
166
+
167
+ <div className="flex justify-between items-center mt-3">
168
+ <span className="text-sm text-muted-foreground">
169
+ {answeredCount} of {questions.length} answered
170
+ </span>
171
+ <Button
172
+ onClick={() => onSubmit(answers)}
173
+ disabled={!canSubmit || isSubmitting || readOnly}
174
+ >
175
+ {isSubmitting ? "Submitting..." : submitLabel}
176
+ </Button>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from "react";
2
+ import type { AnswerOption } from "../../questions/types";
3
+
4
+ /**
5
+ * SurveyForm section — an ungraded survey/feedback form.
6
+ *
7
+ * Supports Likert scale, star rating, open-text, single-choice,
8
+ * and multiple-choice question types. Questions can be displayed
9
+ * all at once or one at a time.
10
+ *
11
+ * @example
12
+ * <SurveyForm
13
+ * title="Course Evaluation"
14
+ * questions={surveyQuestions}
15
+ * onSubmit={(answers) => submitSurvey(answers)}
16
+ * />
17
+ */
18
+ export interface SurveyFormProps {
19
+ /** Survey title displayed at the top */
20
+ title: string;
21
+ /** Optional introductory text or description */
22
+ description?: ReactNode;
23
+ /** Survey questions */
24
+ questions: SurveyQuestion[];
25
+ /** Pre-populated answers */
26
+ initialAnswers?: SurveyAnswer[];
27
+ /** Called on submission with all answers */
28
+ onSubmit: (answers: SurveyAnswer[]) => void;
29
+ /** Called whenever any answer changes */
30
+ onAnswerChange?: (answers: SurveyAnswer[]) => void;
31
+ /** Whether to show a progress indicator */
32
+ showProgress?: boolean;
33
+ /** Whether all questions must be answered before submit */
34
+ requireAll?: boolean;
35
+ /** Label for the submit button */
36
+ submitLabel?: string;
37
+ /** Whether the submit action is in flight */
38
+ isSubmitting?: boolean;
39
+ /** When true, all inputs are disabled */
40
+ readOnly?: boolean;
41
+ /** CSS class name for the root element */
42
+ className?: string;
43
+ /** Inline styles for the root element */
44
+ style?: React.CSSProperties;
45
+ }
46
+
47
+ export interface SurveyQuestion {
48
+ /** Unique identifier */
49
+ uid: string;
50
+ /** Question type */
51
+ type: "likert" | "rating" | "open_text" | "choice" | "multiple_choice";
52
+ /** Question text */
53
+ content: string;
54
+ /** For likert: labels for scale endpoints */
55
+ scaleLabels?: { low: string; high: string };
56
+ /** For likert: number of scale points */
57
+ scalePoints?: 5 | 7;
58
+ /** For choice/multiple_choice: answer options */
59
+ answers?: AnswerOption[];
60
+ /** Whether this question is required */
61
+ required?: boolean;
62
+ }
63
+
64
+ export interface SurveyAnswer {
65
+ /** Question UID */
66
+ questionUid: string;
67
+ /** Answer value — string for text, number for likert/rating, uid for choice */
68
+ value: string | number;
69
+ }
@@ -0,0 +1,90 @@
1
+ export { QuizSession } from "./QuizSession/QuizSession";
2
+ export type { QuizSessionProps } from "./QuizSession/types";
3
+
4
+ export { LecturePlayer } from "./LecturePlayer/LecturePlayer";
5
+ export type { LecturePlayerProps } from "./LecturePlayer/types";
6
+
7
+ export { FlashcardStudySession } from "./FlashcardStudySession/FlashcardStudySession";
8
+ export type {
9
+ FlashcardStudySessionProps,
10
+ FlashcardSessionStats,
11
+ } from "./FlashcardStudySession/types";
12
+
13
+ export { AssessmentReview } from "./AssessmentReview/AssessmentReview";
14
+ export type {
15
+ AssessmentReviewProps,
16
+ AssessmentScore,
17
+ AssessmentReviewGroup,
18
+ } from "./AssessmentReview/types";
19
+
20
+ export { CourseOutline } from "./CourseOutline/CourseOutline";
21
+ export type { CourseOutlineProps } from "./CourseOutline/types";
22
+
23
+ export { ScrollableQuiz } from "./ScrollableQuiz/ScrollableQuiz";
24
+ export type { ScrollableQuizProps } from "./ScrollableQuiz/types";
25
+
26
+ export { PracticeQuiz } from "./PracticeQuiz/PracticeQuiz";
27
+ export type {
28
+ PracticeQuizProps,
29
+ PracticeQuizStats,
30
+ } from "./PracticeQuiz/types";
31
+
32
+ export { ExamSession } from "./ExamSession/ExamSession";
33
+ export type {
34
+ ExamSessionProps,
35
+ ExamSubmitMetadata,
36
+ } from "./ExamSession/types";
37
+
38
+ export { SurveyForm } from "./SurveyForm/SurveyForm";
39
+ export type {
40
+ SurveyFormProps,
41
+ SurveyQuestion,
42
+ SurveyAnswer,
43
+ } from "./SurveyForm/types";
44
+
45
+ export { LessonPage } from "./LessonPage/LessonPage";
46
+ export type { LessonPageProps } from "./LessonPage/types";
47
+
48
+ export { ResourceLibrary } from "./ResourceLibrary/ResourceLibrary";
49
+ export type {
50
+ ResourceLibraryProps,
51
+ Resource,
52
+ } from "./ResourceLibrary/types";
53
+
54
+ export { AnnouncementFeed } from "./AnnouncementFeed/AnnouncementFeed";
55
+ export type {
56
+ AnnouncementFeedProps,
57
+ Announcement,
58
+ } from "./AnnouncementFeed/types";
59
+
60
+ export { DiscussionThread } from "./DiscussionThread/DiscussionThread";
61
+ export type {
62
+ DiscussionThreadProps,
63
+ DiscussionPost,
64
+ DiscussionUser,
65
+ } from "./DiscussionThread/types";
66
+
67
+ export { AssignmentSubmission } from "./AssignmentSubmission/AssignmentSubmission";
68
+ export type {
69
+ AssignmentSubmissionProps,
70
+ SubmissionData,
71
+ } from "./AssignmentSubmission/types";
72
+
73
+ export { GradebookTable } from "./GradebookTable/GradebookTable";
74
+ export type {
75
+ GradebookTableProps,
76
+ GradeItem,
77
+ GradeCategory,
78
+ OverallGrade,
79
+ } from "./GradebookTable/types";
80
+
81
+ export { ProgressDashboard } from "./ProgressDashboard/ProgressDashboard";
82
+ export type {
83
+ ProgressDashboardProps,
84
+ ModuleProgress,
85
+ ActivityItem,
86
+ Achievement,
87
+ } from "./ProgressDashboard/types";
88
+
89
+ export { CertificateViewer } from "./CertificateViewer/CertificateViewer";
90
+ export type { CertificateViewerProps } from "./CertificateViewer/types";
@@ -0,0 +1,3 @@
1
+ export { UserAvatar } from "./user-avatar";
2
+ export { PostCard } from "./post-card";
3
+ export type { UserAvatarProps, PostCardProps } from "./types";
@@ -0,0 +1,91 @@
1
+ import { Pin } from "lucide-react";
2
+ import { UserAvatar } from "./user-avatar";
3
+ import { Separator } from "../ui/separator";
4
+ import type { PostCardProps } from "./types";
5
+ import { cn } from "../lib/utils";
6
+
7
+ function formatTimestamp(iso: string): string {
8
+ const date = new Date(iso);
9
+ const now = new Date();
10
+ const diffMs = now.getTime() - date.getTime();
11
+ const diffMins = Math.floor(diffMs / 60000);
12
+ if (diffMins < 1) return "Just now";
13
+ if (diffMins < 60) return `${diffMins}m ago`;
14
+ const diffHours = Math.floor(diffMins / 60);
15
+ if (diffHours < 24) return `${diffHours}h ago`;
16
+ const diffDays = Math.floor(diffHours / 24);
17
+ if (diffDays < 7) return `${diffDays}d ago`;
18
+ return date.toLocaleDateString();
19
+ }
20
+
21
+ const HIGHLIGHT_BORDERS: Record<string, string> = {
22
+ pinned: "var(--warning)",
23
+ answer: "var(--success)",
24
+ };
25
+
26
+ export function PostCard({
27
+ author,
28
+ content,
29
+ createdAt,
30
+ updatedAt,
31
+ actions,
32
+ highlight = "none",
33
+ indentLevel = 0,
34
+ className,
35
+ style,
36
+ }: PostCardProps) {
37
+ const borderColor = HIGHLIGHT_BORDERS[highlight];
38
+
39
+ return (
40
+ <div
41
+ className={cn("rounded-md border border-border p-3", className)}
42
+ style={{
43
+ marginLeft: indentLevel ? `calc(${indentLevel} * 1rem)` : undefined,
44
+ ...(borderColor && {
45
+ borderLeftWidth: "3px",
46
+ borderLeftColor: borderColor,
47
+ }),
48
+ ...style,
49
+ }}
50
+ >
51
+ <div className="flex gap-2">
52
+ <UserAvatar
53
+ displayName={author.displayName}
54
+ avatarUrl={author.avatarUrl}
55
+ role={author.role as "student" | "instructor" | "ta" | "admin" | undefined}
56
+ size="medium"
57
+ />
58
+ <div className="flex-1 min-w-0">
59
+ <div className="flex items-center gap-1.5 flex-wrap text-sm">
60
+ <span className="font-semibold">{author.displayName}</span>
61
+ {author.role && author.role !== "student" && (
62
+ <span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-xs font-medium text-primary capitalize">
63
+ {author.role}
64
+ </span>
65
+ )}
66
+ <span className="text-xs text-muted-foreground">
67
+ {formatTimestamp(createdAt)}
68
+ </span>
69
+ {updatedAt && (
70
+ <span className="text-xs text-muted-foreground italic">
71
+ (edited)
72
+ </span>
73
+ )}
74
+ {highlight === "pinned" && <Pin size={14} />}
75
+ </div>
76
+ <p className="mt-1 text-sm whitespace-pre-wrap">
77
+ {content}
78
+ </p>
79
+ {actions && (
80
+ <>
81
+ <Separator className="my-2" />
82
+ <div className="flex items-center gap-1">
83
+ {actions}
84
+ </div>
85
+ </>
86
+ )}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,57 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * UserAvatar renders a user avatar image with an initials fallback
5
+ * and an optional role badge.
6
+ *
7
+ * @example
8
+ * <UserAvatar displayName="Jane Smith" role="instructor" />
9
+ */
10
+ export interface UserAvatarProps {
11
+ /** User's display name (used for initials fallback) */
12
+ displayName: string;
13
+ /** URL of the avatar image */
14
+ avatarUrl?: string;
15
+ /** User role — shown as a tooltip or small badge */
16
+ role?: "student" | "instructor" | "ta" | "admin";
17
+ /** Whether to show the role badge */
18
+ showRoleBadge?: boolean;
19
+ /** Avatar size */
20
+ size?: "small" | "medium" | "large";
21
+ /** CSS class name for the root element */
22
+ className?: string;
23
+ /** Inline styles for the root element */
24
+ style?: React.CSSProperties;
25
+ }
26
+
27
+ /**
28
+ * PostCard renders a single discussion post or announcement with author info,
29
+ * timestamp, content body, and optional action buttons.
30
+ *
31
+ * @example
32
+ * <PostCard
33
+ * author={{ displayName: "Jane Smith", role: "instructor" }}
34
+ * content="Welcome to the course!"
35
+ * createdAt="2025-01-15T10:30:00Z"
36
+ * />
37
+ */
38
+ export interface PostCardProps {
39
+ /** Post author information */
40
+ author: { displayName: string; avatarUrl?: string; role?: string };
41
+ /** Post body content */
42
+ content: string;
43
+ /** Creation timestamp as ISO string */
44
+ createdAt: string;
45
+ /** Last update timestamp */
46
+ updatedAt?: string;
47
+ /** Action buttons rendered in the card footer */
48
+ actions?: ReactNode;
49
+ /** Highlight variant for special posts */
50
+ highlight?: "pinned" | "answer" | "none";
51
+ /** Indentation level for nested replies */
52
+ indentLevel?: number;
53
+ /** CSS class name for the root element */
54
+ className?: string;
55
+ /** Inline styles for the root element */
56
+ style?: React.CSSProperties;
57
+ }
@@ -0,0 +1,76 @@
1
+ import { useState } from "react";
2
+ import type { UserAvatarProps } from "./types";
3
+ import { cn } from "../lib/utils";
4
+
5
+ const SIZES = { small: 28, medium: 36, large: 48 };
6
+
7
+ const ROLE_LABELS: Record<string, string> = {
8
+ student: "Student",
9
+ instructor: "Instructor",
10
+ ta: "Teaching Assistant",
11
+ admin: "Admin",
12
+ };
13
+
14
+ const ROLE_COLORS: Record<string, string> = {
15
+ instructor: "var(--primary)",
16
+ ta: "var(--purple)",
17
+ admin: "var(--destructive)",
18
+ student: "var(--muted-foreground)",
19
+ };
20
+
21
+ function getInitials(name: string): string {
22
+ return name
23
+ .split(" ")
24
+ .filter(Boolean)
25
+ .map((w) => w[0])
26
+ .slice(0, 2)
27
+ .join("")
28
+ .toUpperCase();
29
+ }
30
+
31
+ export function UserAvatar({
32
+ displayName,
33
+ avatarUrl,
34
+ role,
35
+ showRoleBadge = false,
36
+ size = "medium",
37
+ className,
38
+ style,
39
+ }: UserAvatarProps) {
40
+ const px = SIZES[size];
41
+ const fontSize = px * 0.4;
42
+ const [imgError, setImgError] = useState(false);
43
+
44
+ const showBadge = showRoleBadge && role && role !== "student";
45
+ const title = showBadge ? ROLE_LABELS[role] ?? role : undefined;
46
+
47
+ return (
48
+ <div
49
+ className={cn("relative inline-flex", className)}
50
+ style={style}
51
+ title={title}
52
+ >
53
+ <div
54
+ className="flex items-center justify-center rounded-full bg-muted text-muted-foreground overflow-hidden"
55
+ style={{ width: `${px}px`, height: `${px}px`, fontSize: `${fontSize}px` }}
56
+ >
57
+ {avatarUrl && !imgError ? (
58
+ <img
59
+ className="size-full object-cover"
60
+ src={avatarUrl}
61
+ alt={displayName}
62
+ onError={() => setImgError(true)}
63
+ />
64
+ ) : (
65
+ <span>{getInitials(displayName)}</span>
66
+ )}
67
+ </div>
68
+ {showBadge && (
69
+ <span
70
+ className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-background"
71
+ style={{ background: ROLE_COLORS[role] ?? "var(--muted-foreground)" }}
72
+ />
73
+ )}
74
+ </div>
75
+ );
76
+ }