@hydralms/components 0.1.3 → 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 +92 -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
@@ -1,11 +1,11 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
- import { AssessmentToolbar } from "../../assessment-toolbar";
2
+ import { AssessmentToolbar, QuestionHeaderBar, QuestionMaterialsDrawer } from "../../assessment-toolbar";
3
3
  import type { QuestionNavigatorItem } from "../../assessment-toolbar/types";
4
4
  import { QuestionRenderer } from "../../questions";
5
5
  import type { SessionAnswer } from "../../questions/types";
6
6
  import { ConfirmDialog } from "../../common";
7
7
  import { Alert, AlertDescription } from "../../ui/alert";
8
- import { Card, CardContent } from "../../ui/card";
8
+ import { Card, CardHeader, CardContent } from "../../ui/card";
9
9
  import { cn } from "../../lib/utils";
10
10
  import type { ExamSessionProps, ExamSubmitMetadata } from "./types";
11
11
 
@@ -22,6 +22,7 @@ export function ExamSession({
22
22
  confirmBeforeSubmit = true,
23
23
  examTitle,
24
24
  instructions,
25
+ questionMaterials,
25
26
  isSubmitting = false,
26
27
  readOnly = false,
27
28
  className,
@@ -32,11 +33,25 @@ export function ExamSession({
32
33
  const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
33
34
  const [showConfirm, setShowConfirm] = useState(false);
34
35
  const [showTimeWarning, setShowTimeWarning] = useState(false);
36
+ const [materialsOpen, setMaterialsOpen] = useState(false);
35
37
  const hasAutoSubmitted = useRef(false);
36
38
 
37
39
  const currentQuestion = questions[currentIndex];
38
40
  const remainingSeconds = timeLimitSeconds - timeElapsedSeconds;
39
41
 
42
+ const currentQuestionAnswers = useMemo(
43
+ () =>
44
+ currentQuestion
45
+ ? sessionAnswers.filter((a) => a.uid === currentQuestion.uid)
46
+ : [],
47
+ [sessionAnswers, currentQuestion],
48
+ );
49
+
50
+ const currentMaterials = useMemo(
51
+ () => questionMaterials?.filter((m) => m.questionUid === currentQuestion?.uid) ?? [],
52
+ [questionMaterials, currentQuestion],
53
+ );
54
+
40
55
  useEffect(() => {
41
56
  if (remainingSeconds <= timeWarningThreshold && remainingSeconds > 0) {
42
57
  setShowTimeWarning(true);
@@ -55,7 +70,7 @@ export function ExamSession({
55
70
  () =>
56
71
  questions.map((q, idx) => ({
57
72
  uid: q.uid,
58
- sequence: idx + 1,
73
+ sequence: idx,
59
74
  isFlagged: flaggedUids.has(q.uid),
60
75
  isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
61
76
  isSkipped: false,
@@ -63,6 +78,11 @@ export function ExamSession({
63
78
  [questions, sessionAnswers, flaggedUids],
64
79
  );
65
80
 
81
+ const answeredCount = useMemo(
82
+ () => navigatorItems.filter((q) => q.isAnswered).length,
83
+ [navigatorItems],
84
+ );
85
+
66
86
  function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
67
87
  if (!currentQuestion) return;
68
88
  const questionUid = currentQuestion.uid;
@@ -138,7 +158,6 @@ export function ExamSession({
138
158
  timeLimitSeconds={timeLimitSeconds}
139
159
  questions={navigatorItems}
140
160
  onNavigateToQuestion={handleNavigate}
141
- onToggleFlag={handleToggleFlag}
142
161
  currentQuestionUid={currentQuestion?.uid}
143
162
  isSubmitting={isSubmitting}
144
163
  readOnly={readOnly}
@@ -152,10 +171,21 @@ export function ExamSession({
152
171
 
153
172
  {currentQuestion && (
154
173
  <Card className="mt-3">
155
- <CardContent className="pt-6">
174
+ <CardHeader className="pb-0">
175
+ <QuestionHeaderBar
176
+ questionNumber={currentIndex + 1}
177
+ totalQuestions={questions.length}
178
+ isFlagged={flaggedUids.has(currentQuestion.uid)}
179
+ onToggleFlag={() => handleToggleFlag(currentQuestion.uid)}
180
+ hasMaterials={currentMaterials.length > 0}
181
+ onOpenMaterials={() => setMaterialsOpen(true)}
182
+ readOnly={readOnly}
183
+ />
184
+ </CardHeader>
185
+ <CardContent>
156
186
  <QuestionRenderer
157
187
  question={currentQuestion}
158
- sessionAnswers={sessionAnswers.filter((a) => a.uid === currentQuestion.uid)}
188
+ sessionAnswers={currentQuestionAnswers}
159
189
  onAnswer={handleAnswer}
160
190
  readOnly={readOnly}
161
191
  />
@@ -163,10 +193,17 @@ export function ExamSession({
163
193
  </Card>
164
194
  )}
165
195
 
196
+ <QuestionMaterialsDrawer
197
+ open={materialsOpen}
198
+ onOpenChange={setMaterialsOpen}
199
+ materials={currentMaterials}
200
+ questionNumber={currentIndex + 1}
201
+ />
202
+
166
203
  <ConfirmDialog
167
204
  open={showConfirm}
168
205
  title="Submit Exam?"
169
- message={`You have answered ${navigatorItems.filter((q) => q.isAnswered).length} of ${questions.length} questions. Once submitted, you cannot change your answers.`}
206
+ message={`You have answered ${answeredCount} of ${questions.length} questions. Once submitted, you cannot change your answers.`}
170
207
  confirmLabel="Submit Exam"
171
208
  cancelLabel="Continue Exam"
172
209
  confirmColor="primary"
@@ -1,5 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
- import type { QuestionData, SessionAnswer } from "../../questions/types";
2
+ import type { QuestionData, QuestionMaterial, SessionAnswer } from "../../questions/types";
3
3
 
4
4
  /**
5
5
  * ExamSession section — a formal timed exam experience.
@@ -44,6 +44,11 @@ export interface ExamSessionProps {
44
44
  instructions?: ReactNode;
45
45
  /** Whether the submit action is in flight */
46
46
  isSubmitting?: boolean;
47
+ /**
48
+ * Related materials keyed by question UID. When provided, a "Materials"
49
+ * button appears in the question header, opening a drawer with content blocks.
50
+ */
51
+ questionMaterials?: QuestionMaterial[];
47
52
  /** When true, all inputs are disabled */
48
53
  readOnly?: boolean;
49
54
  /** CSS class name for the root element */
@@ -0,0 +1,284 @@
1
+ import { useState, useMemo } from "react";
2
+ import {
3
+ Plus,
4
+ MessageSquare,
5
+ Heart,
6
+ Pin,
7
+ CheckCircle2,
8
+ ArrowUpDown,
9
+ } from "lucide-react";
10
+ import { SearchInput } from "../../common/search-input";
11
+ import { UserAvatar } from "../../social/user-avatar";
12
+ import { EmptyState } from "../../common/empty-state";
13
+ import { Button } from "../../ui/button";
14
+ import { Card, CardContent } from "../../ui/card";
15
+ import { Badge } from "../../ui/badge";
16
+ import { Separator } from "../../ui/separator";
17
+ import { Input } from "../../ui/input";
18
+ import { RichTextEditor } from "../../ui/rich-text-editor";
19
+ import { isEmptyHtml } from "../../utils/is-empty-html";
20
+ import { cn } from "../../lib/utils";
21
+ import { formatTimestamp } from "../../utils/format-timestamp";
22
+ import type { ForumBoardProps, ForumTopic, ForumSortOrder } from "./types";
23
+
24
+ const SORT_LABELS: Record<ForumSortOrder, string> = {
25
+ newest: "Newest",
26
+ oldest: "Oldest",
27
+ most_replies: "Most Replies",
28
+ most_liked: "Most Liked",
29
+ };
30
+
31
+ /**
32
+ * ForumBoard — a topic listing view for a discussion forum.
33
+ *
34
+ * Displays a searchable, sortable list of discussion topics with author info,
35
+ * reply/like counts, timestamps, and new topic creation.
36
+ */
37
+ export function ForumBoard({
38
+ title,
39
+ topics,
40
+ onTopicClick,
41
+ onCreateTopic,
42
+ sortOrder = "newest",
43
+ onSortChange,
44
+ searchQuery = "",
45
+ onSearchChange,
46
+ readOnly,
47
+ className,
48
+ style,
49
+ }: ForumBoardProps) {
50
+ const [showCreateForm, setShowCreateForm] = useState(false);
51
+ const [newTitle, setNewTitle] = useState("");
52
+ const [newContent, setNewContent] = useState("");
53
+ const [localSearch, setLocalSearch] = useState(searchQuery);
54
+ const [localSort, setLocalSort] = useState(sortOrder);
55
+
56
+ const activeSearch = onSearchChange !== undefined ? searchQuery : localSearch;
57
+ const activeSort = onSortChange !== undefined ? sortOrder : localSort;
58
+
59
+ function handleSearchChange(query: string) {
60
+ if (onSearchChange) {
61
+ onSearchChange(query);
62
+ } else {
63
+ setLocalSearch(query);
64
+ }
65
+ }
66
+
67
+ function handleSortChange() {
68
+ const order: Array<"newest" | "oldest" | "most_replies" | "most_liked"> = [
69
+ "newest",
70
+ "oldest",
71
+ "most_replies",
72
+ "most_liked",
73
+ ];
74
+ const idx = order.indexOf(activeSort);
75
+ const next = order[(idx + 1) % order.length];
76
+ if (onSortChange) {
77
+ onSortChange(next);
78
+ } else {
79
+ setLocalSort(next);
80
+ }
81
+ }
82
+
83
+ function handleCreateTopic() {
84
+ if (!newTitle.trim() || isEmptyHtml(newContent)) return;
85
+ onCreateTopic?.(newTitle.trim(), newContent);
86
+ setNewTitle("");
87
+ setNewContent("");
88
+ setShowCreateForm(false);
89
+ }
90
+
91
+ // Filter and sort
92
+ const sorted = useMemo(() => {
93
+ const filtered = topics.filter((t) => {
94
+ if (!activeSearch) return true;
95
+ const q = activeSearch.toLowerCase();
96
+ return (
97
+ t.title.toLowerCase().includes(q) ||
98
+ t.preview?.toLowerCase().includes(q) ||
99
+ t.author.displayName.toLowerCase().includes(q)
100
+ );
101
+ });
102
+
103
+ return [...filtered].sort((a, b) => {
104
+ if (a.isPinned && !b.isPinned) return -1;
105
+ if (!a.isPinned && b.isPinned) return 1;
106
+ switch (activeSort) {
107
+ case "oldest":
108
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
109
+ case "most_replies":
110
+ return b.replyCount - a.replyCount;
111
+ case "most_liked":
112
+ return b.likeCount - a.likeCount;
113
+ case "newest":
114
+ default:
115
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
116
+ }
117
+ });
118
+ }, [topics, activeSearch, activeSort]);
119
+
120
+ return (
121
+ <div className={cn("flex flex-col gap-4", className)} style={style}>
122
+ {/* Header */}
123
+ <div className="flex items-center justify-between gap-4">
124
+ {title && (
125
+ <h2 className="text-xl font-bold text-foreground">{title}</h2>
126
+ )}
127
+ {!readOnly && onCreateTopic && (
128
+ <Button
129
+ size="sm"
130
+ onClick={() => setShowCreateForm(!showCreateForm)}
131
+ >
132
+ <Plus className="size-4 mr-1.5" />
133
+ New Topic
134
+ </Button>
135
+ )}
136
+ </div>
137
+
138
+ <Separator />
139
+
140
+ {/* Create topic form */}
141
+ {showCreateForm && (
142
+ <Card>
143
+ <CardContent className="pt-4 space-y-3">
144
+ <Input
145
+ placeholder="Topic title"
146
+ value={newTitle}
147
+ onChange={(e) => setNewTitle(e.target.value)}
148
+ />
149
+ <RichTextEditor
150
+ placeholder="What would you like to discuss?"
151
+ value={newContent}
152
+ onChange={(html) => setNewContent(html)}
153
+ variant="minimal"
154
+ />
155
+ <div className="flex justify-end gap-2">
156
+ <Button
157
+ variant="outline"
158
+ size="sm"
159
+ onClick={() => {
160
+ setShowCreateForm(false);
161
+ setNewTitle("");
162
+ setNewContent("");
163
+ }}
164
+ >
165
+ Cancel
166
+ </Button>
167
+ <Button
168
+ size="sm"
169
+ onClick={handleCreateTopic}
170
+ disabled={!newTitle.trim() || isEmptyHtml(newContent)}
171
+ >
172
+ Post Topic
173
+ </Button>
174
+ </div>
175
+ </CardContent>
176
+ </Card>
177
+ )}
178
+
179
+ {/* Search & sort bar */}
180
+ <div className="flex items-center gap-2">
181
+ <div className="flex-1">
182
+ <SearchInput
183
+ value={activeSearch}
184
+ onChange={handleSearchChange}
185
+ placeholder="Search topics..."
186
+ />
187
+ </div>
188
+ <Button variant="outline" size="sm" onClick={handleSortChange}>
189
+ <ArrowUpDown className="size-3.5 mr-1.5" />
190
+ {SORT_LABELS[activeSort]}
191
+ </Button>
192
+ </div>
193
+
194
+ {/* Topic list */}
195
+ {sorted.length === 0 ? (
196
+ <EmptyState
197
+ icon={<MessageSquare className="size-10 text-muted-foreground" />}
198
+ title="No topics found"
199
+ description={
200
+ activeSearch
201
+ ? "Try a different search term."
202
+ : "Be the first to start a discussion!"
203
+ }
204
+ />
205
+ ) : (
206
+ <div className="flex flex-col gap-2">
207
+ {sorted.map((topic) => (
208
+ <TopicRow
209
+ key={topic.uid}
210
+ topic={topic}
211
+ onClick={() => onTopicClick(topic.uid)}
212
+ />
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ );
218
+ }
219
+
220
+ function TopicRow({
221
+ topic,
222
+ onClick,
223
+ }: {
224
+ topic: ForumTopic;
225
+ onClick: () => void;
226
+ }) {
227
+ return (
228
+ <button
229
+ type="button"
230
+ className="w-full text-left"
231
+ onClick={onClick}
232
+ >
233
+ <Card
234
+ className={cn(
235
+ "transition-colors hover:bg-muted/30",
236
+ topic.isPinned && "border-l-2 border-l-warning"
237
+ )}
238
+ >
239
+ <CardContent className="py-3 px-4">
240
+ <div className="flex items-start gap-3">
241
+ <UserAvatar
242
+ displayName={topic.author.displayName}
243
+ avatarUrl={topic.author.avatarUrl}
244
+ size="small"
245
+ />
246
+ <div className="flex-1 min-w-0">
247
+ <div className="flex items-center gap-2 mb-0.5">
248
+ <span className="font-medium text-foreground text-sm truncate">
249
+ {topic.title}
250
+ </span>
251
+ {topic.isPinned && (
252
+ <Pin className="size-3 text-warning shrink-0" />
253
+ )}
254
+ {topic.isAnswered && (
255
+ <Badge variant="success" className="text-xs shrink-0">
256
+ <CheckCircle2 className="size-3 mr-0.5" />
257
+ Answered
258
+ </Badge>
259
+ )}
260
+ </div>
261
+ {topic.preview && (
262
+ <p className="text-xs text-muted-foreground line-clamp-1">
263
+ {topic.preview}
264
+ </p>
265
+ )}
266
+ <div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
267
+ <span>{topic.author.displayName}</span>
268
+ <span>{formatTimestamp(topic.createdAt)}</span>
269
+ <Badge variant="muted" className="gap-0.5 text-xs px-1.5 py-0">
270
+ <MessageSquare className="size-3" />
271
+ {topic.replyCount}
272
+ </Badge>
273
+ <Badge variant="muted" className="gap-0.5 text-xs px-1.5 py-0">
274
+ <Heart className="size-3" />
275
+ {topic.likeCount}
276
+ </Badge>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </CardContent>
281
+ </Card>
282
+ </button>
283
+ );
284
+ }
@@ -0,0 +1,67 @@
1
+ import type { DiscussionUser } from "../DiscussionThread/types";
2
+
3
+ export type ForumSortOrder = "newest" | "oldest" | "most_replies" | "most_liked";
4
+
5
+ /**
6
+ * ForumBoard section — a topic listing view for a discussion forum.
7
+ *
8
+ * Displays a searchable, sortable list of discussion topics with author info,
9
+ * reply/like counts, and timestamps. Supports creating new topics inline.
10
+ *
11
+ * @example
12
+ * <ForumBoard
13
+ * title="Class Discussion"
14
+ * topics={topics}
15
+ * onTopicClick={(uid) => openThread(uid)}
16
+ * onCreateTopic={(title, content) => createTopic(title, content)}
17
+ * />
18
+ */
19
+ export interface ForumBoardProps {
20
+ /** Forum title */
21
+ title?: string;
22
+ /** List of discussion topics */
23
+ topics: ForumTopic[];
24
+ /** The currently authenticated user (reserved for future use) */
25
+ currentUser?: DiscussionUser;
26
+ /** Called when a topic is clicked */
27
+ onTopicClick: (topicUid: string) => void;
28
+ /** Called when a new topic is created */
29
+ onCreateTopic?: (title: string, content: string) => void;
30
+ /** Current sort order */
31
+ sortOrder?: ForumSortOrder;
32
+ /** Called when sort order changes */
33
+ onSortChange?: (sort: ForumSortOrder) => void;
34
+ /** Current search query (controlled) */
35
+ searchQuery?: string;
36
+ /** Called when search query changes */
37
+ onSearchChange?: (query: string) => void;
38
+ /** When true, disables create and interactions */
39
+ readOnly?: boolean;
40
+ /** CSS class name for the root element */
41
+ className?: string;
42
+ /** Inline styles for the root element */
43
+ style?: React.CSSProperties;
44
+ }
45
+
46
+ export interface ForumTopic {
47
+ /** Unique identifier */
48
+ uid: string;
49
+ /** Topic title */
50
+ title: string;
51
+ /** Topic author */
52
+ author: DiscussionUser;
53
+ /** Creation timestamp */
54
+ createdAt: string;
55
+ /** Number of replies */
56
+ replyCount: number;
57
+ /** Number of likes */
58
+ likeCount: number;
59
+ /** Timestamp of the most recent reply */
60
+ lastReplyAt?: string;
61
+ /** Whether this topic is pinned to the top */
62
+ isPinned?: boolean;
63
+ /** Whether this topic has a marked answer */
64
+ isAnswered?: boolean;
65
+ /** Preview text (first ~100 chars of content) */
66
+ preview?: string;
67
+ }
@@ -169,7 +169,7 @@ export function GradebookTable({
169
169
  </TableHeader>
170
170
  <TableBody>
171
171
  {grouped.map((group, gi) => (
172
- <Fragment key={gi}>
172
+ <Fragment key={group.category?.uid ?? `ungrouped-${gi}`}>
173
173
  {group.category && showCategoryTotals && (
174
174
  <TableRow className="bg-muted hover:bg-muted">
175
175
  <TableCell colSpan={colCount}>
@@ -25,7 +25,7 @@ export function LecturePlayer({
25
25
  return (
26
26
  <div
27
27
  className={cn(
28
- "flex overflow-hidden",
28
+ "flex overflow-hidden gap-4",
29
29
  isHorizontal ? "flex-row" : "flex-col",
30
30
  className,
31
31
  )}
@@ -1,8 +1,8 @@
1
1
  import { useState } from "react";
2
2
  import { Check, ChevronRight, Clock } from "lucide-react";
3
3
  import { ContentBlock } from "../../content";
4
- import type { SessionAnswer } from "../../questions/types";
5
4
  import { Button } from "../../ui/button";
5
+ import { Card, CardContent } from "../../ui/card";
6
6
  import { Separator } from "../../ui/separator";
7
7
  import { formatDuration } from "../../utils/format-duration";
8
8
  import type { LessonPageProps } from "./types";
@@ -22,11 +22,6 @@ export function LessonPage({
22
22
  style,
23
23
  }: LessonPageProps) {
24
24
  const [completed, setCompleted] = useState(isCompleted);
25
- const [, setQuestionAnswers] = useState<Map<string, SessionAnswer[]>>(new Map());
26
-
27
- function handleQuestionAnswer(questionUid: string, answers: SessionAnswer[]) {
28
- setQuestionAnswers((prev) => new Map(prev).set(questionUid, answers));
29
- }
30
25
 
31
26
  function handleMarkComplete() {
32
27
  setCompleted(true);
@@ -54,14 +49,14 @@ export function LessonPage({
54
49
  <ContentBlock
55
50
  key={i}
56
51
  block={block}
57
- onQuestionAnswer={handleQuestionAnswer}
58
52
  readOnly={readOnly}
59
53
  />
60
54
  ))}
61
55
  </div>
62
56
 
63
57
  {/* Completion bar */}
64
- <div className="border border-border rounded-md px-4 py-3 mt-4 sticky bottom-0 bg-background z-10">
58
+ <Card className="mt-4 sticky bottom-0 z-10">
59
+ <CardContent className="px-4 py-3">
65
60
  <div className="flex justify-between items-center">
66
61
  {completed ? (
67
62
  <div className="flex items-center gap-1 text-success">
@@ -85,7 +80,8 @@ export function LessonPage({
85
80
  </Button>
86
81
  )}
87
82
  </div>
88
- </div>
83
+ </CardContent>
84
+ </Card>
89
85
  </div>
90
86
  );
91
87
  }
@@ -1,20 +1,12 @@
1
1
  import { useMemo, useState } from "react";
2
2
  import { CheckCircle } from "lucide-react";
3
- import { QuestionRenderer } from "../../questions";
3
+ import { QuestionRenderer, scoreQuestion } from "../../questions";
4
4
  import { FeedbackBanner } from "../../feedback";
5
5
  import { Button } from "../../ui/button";
6
6
  import { Card, CardContent } from "../../ui/card";
7
7
  import { Progress } from "../../ui/progress";
8
8
  import type { PracticeQuizProps, PracticeQuizStats } from "./types";
9
-
10
- function shuffleArray<T>(arr: T[]): T[] {
11
- const copy = [...arr];
12
- for (let i = copy.length - 1; i > 0; i--) {
13
- const j = Math.floor(Math.random() * (i + 1));
14
- [copy[i], copy[j]] = [copy[j], copy[i]];
15
- }
16
- return copy;
17
- }
9
+ import { shuffle } from "../../utils/shuffle";
18
10
 
19
11
  export function PracticeQuiz({
20
12
  questions: questionsProp,
@@ -27,7 +19,7 @@ export function PracticeQuiz({
27
19
  style,
28
20
  }: PracticeQuizProps) {
29
21
  const questions = useMemo(
30
- () => (shuffled ? shuffleArray(questionsProp) : questionsProp),
22
+ () => (shuffled ? shuffle(questionsProp) : questionsProp),
31
23
  // eslint-disable-next-line react-hooks/exhaustive-deps
32
24
  [questionsProp, shuffled],
33
25
  );
@@ -49,13 +41,12 @@ export function PracticeQuiz({
49
41
  setAttemptCounts((prev) => new Map(prev).set(currentQuestion.uid, attempts));
50
42
  setRevealedUids((prev) => new Set(prev).add(currentQuestion.uid));
51
43
 
52
- const correctUids = new Set(
53
- currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
54
- );
55
- const selectedUids = new Set(currentAnswer.map((a) => a.uid));
56
- const isCorrect =
57
- correctUids.size === selectedUids.size &&
58
- [...correctUids].every((uid) => selectedUids.has(uid));
44
+ const sessionAnswers = currentAnswer.map((a) => ({
45
+ uid: currentQuestion.uid,
46
+ answerUid: a.uid,
47
+ content: a.content,
48
+ }));
49
+ const isCorrect = scoreQuestion(currentQuestion, sessionAnswers) === true;
59
50
 
60
51
  if (isCorrect && attempts === 1) {
61
52
  setFirstAttemptCorrect((prev) => new Set(prev).add(currentQuestion.uid));
@@ -89,14 +80,12 @@ export function PracticeQuiz({
89
80
 
90
81
  const isCurrentCorrect = useMemo(() => {
91
82
  if (!currentQuestion || !currentAnswer) return false;
92
- const correctUids = new Set(
93
- currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
94
- );
95
- const selectedUids = new Set(currentAnswer.map((a) => a.uid));
96
- return (
97
- correctUids.size === selectedUids.size &&
98
- [...correctUids].every((uid) => selectedUids.has(uid))
99
- );
83
+ const sessionAnswers = currentAnswer.map((a) => ({
84
+ uid: currentQuestion.uid,
85
+ answerUid: a.uid,
86
+ content: a.content,
87
+ }));
88
+ return scoreQuestion(currentQuestion, sessionAnswers) === true;
100
89
  }, [currentQuestion, currentAnswer]);
101
90
 
102
91
  if (isComplete) {