@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.
- 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 +92 -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
|
@@ -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
|
|
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
|
-
<
|
|
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={
|
|
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 ${
|
|
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}>
|
|
@@ -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
|
-
<
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
53
|
-
currentQuestion.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
93
|
-
currentQuestion.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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) {
|