@hydralms/components 0.1.1 → 0.1.3
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/package.json +52 -1
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { QuestionProps } from "./types";
|
|
3
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MultipleChoice renders a question with multiple correct answers using checkboxes.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <MultipleChoice
|
|
11
|
+
* question={question}
|
|
12
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
export const MultipleChoice = ({
|
|
16
|
+
question,
|
|
17
|
+
sessionAnswers,
|
|
18
|
+
onAnswer,
|
|
19
|
+
readOnly = false,
|
|
20
|
+
showCorrectAnswers = false,
|
|
21
|
+
disabled = false,
|
|
22
|
+
}: QuestionProps) => {
|
|
23
|
+
const [selectedAnswers, setSelectedAnswers] = useState<string[]>([]);
|
|
24
|
+
|
|
25
|
+
const sortedAnswers = [...(question.answers || [])].sort(
|
|
26
|
+
(a, b) => a.sequence - b.sequence,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const handleChange = (uid: string) => {
|
|
30
|
+
if (readOnly || disabled) return;
|
|
31
|
+
|
|
32
|
+
setSelectedAnswers((prev) => {
|
|
33
|
+
const newSelected = prev.includes(uid)
|
|
34
|
+
? prev.filter((id) => id !== uid)
|
|
35
|
+
: [...prev, uid];
|
|
36
|
+
|
|
37
|
+
onAnswer?.(newSelected.map((id) => ({ uid: id })));
|
|
38
|
+
return newSelected;
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const current = sessionAnswers?.map((sa) => sa.answerUid) || [];
|
|
44
|
+
setSelectedAnswers(current);
|
|
45
|
+
}, [sessionAnswers]);
|
|
46
|
+
|
|
47
|
+
const getAnswerClasses = (answerUid: string) => {
|
|
48
|
+
if (!showCorrectAnswers) return "";
|
|
49
|
+
|
|
50
|
+
const answer = question.answers?.find((a) => a.uid === answerUid);
|
|
51
|
+
if (answer?.isCorrect) {
|
|
52
|
+
return "bg-success/10 border border-success/30 px-2";
|
|
53
|
+
}
|
|
54
|
+
if (selectedAnswers.includes(answerUid) && !answer?.isCorrect) {
|
|
55
|
+
return "bg-destructive/10 border border-destructive/30 px-2";
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex flex-col gap-4">
|
|
62
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
63
|
+
|
|
64
|
+
<div className="flex flex-col gap-2">
|
|
65
|
+
{sortedAnswers.map((answer) => (
|
|
66
|
+
<div
|
|
67
|
+
key={answer.uid}
|
|
68
|
+
className={cn("rounded-md transition-colors", getAnswerClasses(answer.uid))}
|
|
69
|
+
>
|
|
70
|
+
<label className="flex items-center gap-2 cursor-pointer py-1 has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
|
|
71
|
+
<input
|
|
72
|
+
type="checkbox"
|
|
73
|
+
checked={selectedAnswers.includes(answer.uid)}
|
|
74
|
+
onChange={() => handleChange(answer.uid)}
|
|
75
|
+
disabled={readOnly || disabled}
|
|
76
|
+
className="accent-primary m-0 shrink-0"
|
|
77
|
+
/>
|
|
78
|
+
<span
|
|
79
|
+
className="text-sm"
|
|
80
|
+
dangerouslySetInnerHTML={{ __html: answer.content }}
|
|
81
|
+
/>
|
|
82
|
+
</label>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{showCorrectAnswers && question.explanation && (
|
|
88
|
+
<Alert className="mt-2">
|
|
89
|
+
<AlertDescription>
|
|
90
|
+
<strong>Explanation:</strong>{" "}
|
|
91
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
92
|
+
</AlertDescription>
|
|
93
|
+
</Alert>
|
|
94
|
+
)}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { QuestionProps } from "./types";
|
|
2
|
+
import { MultipleChoice } from "./multiple-choice";
|
|
3
|
+
import { Choice } from "./choice";
|
|
4
|
+
import { TrueFalse } from "./true-false";
|
|
5
|
+
import { FillInTheBlank } from "./fill-in-the-blank";
|
|
6
|
+
import { Essay } from "./essay";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* QuestionRenderer dispatches to the appropriate question component based on question type.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <QuestionRenderer
|
|
13
|
+
* question={question}
|
|
14
|
+
* sessionAnswers={answers}
|
|
15
|
+
* onAnswer={handleAnswer}
|
|
16
|
+
* />
|
|
17
|
+
*/
|
|
18
|
+
export const QuestionRenderer = (props: QuestionProps) => {
|
|
19
|
+
switch (props.question.type) {
|
|
20
|
+
case "multiple_choice":
|
|
21
|
+
return <MultipleChoice {...props} />;
|
|
22
|
+
case "choice":
|
|
23
|
+
return <Choice {...props} />;
|
|
24
|
+
case "true_false":
|
|
25
|
+
return <TrueFalse {...props} />;
|
|
26
|
+
case "fill_in_the_blank":
|
|
27
|
+
return <FillInTheBlank {...props} />;
|
|
28
|
+
case "essay":
|
|
29
|
+
return <Essay {...props} />;
|
|
30
|
+
default:
|
|
31
|
+
return (
|
|
32
|
+
<p className="text-muted-foreground">
|
|
33
|
+
Question type “{props.question.type}” is not supported yet.
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { TrueFalse } from "./true-false";
|
|
4
|
+
import type { QuestionData } from "./types";
|
|
5
|
+
|
|
6
|
+
const mockQuestion: QuestionData = {
|
|
7
|
+
uid: "q1",
|
|
8
|
+
type: "true_false",
|
|
9
|
+
content: "<p>The sky is blue.</p>",
|
|
10
|
+
explanation: "<p>Due to Rayleigh scattering.</p>",
|
|
11
|
+
answers: [
|
|
12
|
+
{ uid: "a1", content: "True", isCorrect: true, sequence: 1 },
|
|
13
|
+
{ uid: "a2", content: "False", isCorrect: false, sequence: 2 },
|
|
14
|
+
],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("TrueFalse", () => {
|
|
18
|
+
it("renders question content and both options", () => {
|
|
19
|
+
render(<TrueFalse question={mockQuestion} />);
|
|
20
|
+
expect(screen.getByText("The sky is blue.")).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText("True")).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText("False")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("renders radio inputs", () => {
|
|
26
|
+
render(<TrueFalse question={mockQuestion} />);
|
|
27
|
+
const radios = screen.getAllByRole("radio");
|
|
28
|
+
expect(radios).toHaveLength(2);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("calls onAnswer when an option is selected", async () => {
|
|
32
|
+
const onAnswer = vi.fn();
|
|
33
|
+
const user = userEvent.setup();
|
|
34
|
+
|
|
35
|
+
render(<TrueFalse question={mockQuestion} onAnswer={onAnswer} />);
|
|
36
|
+
await user.click(screen.getByText("True"));
|
|
37
|
+
|
|
38
|
+
expect(onAnswer).toHaveBeenCalledWith([{ uid: "a1" }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("disables inputs when readOnly is true", () => {
|
|
42
|
+
render(<TrueFalse question={mockQuestion} readOnly />);
|
|
43
|
+
const radios = screen.getAllByRole("radio");
|
|
44
|
+
radios.forEach((radio) => expect(radio).toBeDisabled());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("disables inputs when disabled is true", () => {
|
|
48
|
+
render(<TrueFalse question={mockQuestion} disabled />);
|
|
49
|
+
const radios = screen.getAllByRole("radio");
|
|
50
|
+
radios.forEach((radio) => expect(radio).toBeDisabled());
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows explanation when showCorrectAnswers is true", () => {
|
|
54
|
+
render(<TrueFalse question={mockQuestion} showCorrectAnswers />);
|
|
55
|
+
expect(screen.getByText("Explanation:")).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByText("Due to Rayleigh scattering.")).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does not show explanation when showCorrectAnswers is false", () => {
|
|
60
|
+
render(<TrueFalse question={mockQuestion} showCorrectAnswers={false} />);
|
|
61
|
+
expect(screen.queryByText("Explanation:")).not.toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("pre-selects answer from sessionAnswers", () => {
|
|
65
|
+
render(
|
|
66
|
+
<TrueFalse
|
|
67
|
+
question={mockQuestion}
|
|
68
|
+
sessionAnswers={[{ uid: "sa1", answerUid: "a1" }]}
|
|
69
|
+
/>,
|
|
70
|
+
);
|
|
71
|
+
const radios = screen.getAllByRole("radio");
|
|
72
|
+
expect(radios[0]).toBeChecked();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("sorts answers by sequence", () => {
|
|
76
|
+
const reversed: QuestionData = {
|
|
77
|
+
...mockQuestion,
|
|
78
|
+
answers: [
|
|
79
|
+
{ uid: "a2", content: "False", isCorrect: false, sequence: 2 },
|
|
80
|
+
{ uid: "a1", content: "True", isCorrect: true, sequence: 1 },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
render(<TrueFalse question={reversed} />);
|
|
85
|
+
const radios = screen.getAllByRole("radio");
|
|
86
|
+
const labels = radios.map((r) => r.closest("label")?.textContent);
|
|
87
|
+
expect(labels).toEqual(["True", "False"]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { QuestionProps } from "./types";
|
|
3
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TrueFalse renders a true/false question with two radio button options.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <TrueFalse
|
|
11
|
+
* question={question}
|
|
12
|
+
* onAnswer={(answers) => handleAnswer(answers)}
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
export const TrueFalse = ({
|
|
16
|
+
question,
|
|
17
|
+
sessionAnswers,
|
|
18
|
+
onAnswer,
|
|
19
|
+
readOnly = false,
|
|
20
|
+
showCorrectAnswers = false,
|
|
21
|
+
disabled = false,
|
|
22
|
+
}: QuestionProps) => {
|
|
23
|
+
const [selectedAnswer, setSelectedAnswer] = useState<string>("");
|
|
24
|
+
|
|
25
|
+
const sortedAnswers = [...(question.answers || [])].sort(
|
|
26
|
+
(a, b) => a.sequence - b.sequence,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const handleChange = (uid: string) => {
|
|
30
|
+
if (readOnly || disabled) return;
|
|
31
|
+
|
|
32
|
+
setSelectedAnswer(uid);
|
|
33
|
+
onAnswer?.([{ uid }]);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const current = sessionAnswers?.[0]?.answerUid || "";
|
|
38
|
+
setSelectedAnswer(current);
|
|
39
|
+
}, [sessionAnswers]);
|
|
40
|
+
|
|
41
|
+
const getAnswerClasses = (answerUid: string) => {
|
|
42
|
+
if (!showCorrectAnswers) return "px-2";
|
|
43
|
+
|
|
44
|
+
const answer = question.answers?.find((a) => a.uid === answerUid);
|
|
45
|
+
if (answer?.isCorrect) {
|
|
46
|
+
return "bg-success/10 border border-success/30 px-2";
|
|
47
|
+
}
|
|
48
|
+
if (selectedAnswer === answerUid && !answer?.isCorrect) {
|
|
49
|
+
return "bg-destructive/10 border border-destructive/30 px-2";
|
|
50
|
+
}
|
|
51
|
+
return "px-2";
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex flex-col gap-4">
|
|
56
|
+
<div dangerouslySetInnerHTML={{ __html: question.content }} />
|
|
57
|
+
|
|
58
|
+
<div className="flex flex-col gap-2">
|
|
59
|
+
{sortedAnswers.map((answer) => (
|
|
60
|
+
<div
|
|
61
|
+
key={answer.uid}
|
|
62
|
+
className={cn("rounded-md transition-colors", getAnswerClasses(answer.uid))}
|
|
63
|
+
>
|
|
64
|
+
<label className="flex items-center gap-2 cursor-pointer py-1 has-[input:disabled]:cursor-default has-[input:disabled]:opacity-60">
|
|
65
|
+
<input
|
|
66
|
+
type="radio"
|
|
67
|
+
name={question.uid}
|
|
68
|
+
value={answer.uid}
|
|
69
|
+
checked={selectedAnswer === answer.uid}
|
|
70
|
+
onChange={() => handleChange(answer.uid)}
|
|
71
|
+
disabled={readOnly || disabled}
|
|
72
|
+
className="accent-primary m-0 shrink-0"
|
|
73
|
+
/>
|
|
74
|
+
<span dangerouslySetInnerHTML={{ __html: answer.content }} />
|
|
75
|
+
</label>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{showCorrectAnswers && question.explanation && (
|
|
81
|
+
<Alert className="mt-2">
|
|
82
|
+
<AlertDescription>
|
|
83
|
+
<strong>Explanation:</strong>{" "}
|
|
84
|
+
<span dangerouslySetInnerHTML={{ __html: question.explanation }} />
|
|
85
|
+
</AlertDescription>
|
|
86
|
+
</Alert>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type QuestionTypeEnum =
|
|
2
|
+
| "multiple_choice"
|
|
3
|
+
| "choice"
|
|
4
|
+
| "true_false"
|
|
5
|
+
| "fill_in_the_blank"
|
|
6
|
+
| "essay";
|
|
7
|
+
|
|
8
|
+
export interface AnswerOption {
|
|
9
|
+
uid: string;
|
|
10
|
+
content: string;
|
|
11
|
+
explanation?: string;
|
|
12
|
+
isCorrect?: boolean;
|
|
13
|
+
sequence: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionAnswer {
|
|
17
|
+
uid: string;
|
|
18
|
+
answerUid: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
confidence?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QuestionData {
|
|
24
|
+
uid: string;
|
|
25
|
+
type: QuestionTypeEnum;
|
|
26
|
+
content: string;
|
|
27
|
+
explanation?: string;
|
|
28
|
+
answers?: AnswerOption[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared props interface for all question type components.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <QuestionRenderer
|
|
36
|
+
* question={{ uid: "q1", type: "choice", content: "What is JSX?", answers: [...] }}
|
|
37
|
+
* onAnswer={(answers) => saveAnswer(answers)}
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
export interface QuestionProps {
|
|
41
|
+
/** The question data to render */
|
|
42
|
+
question: QuestionData;
|
|
43
|
+
/** Current user answers for this question */
|
|
44
|
+
sessionAnswers?: SessionAnswer[];
|
|
45
|
+
/** Called when the user selects or changes an answer */
|
|
46
|
+
onAnswer?: (answers: { uid: string; content?: string }[]) => void;
|
|
47
|
+
/** When true, disables all input interactions */
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
/** When true, highlights correct/incorrect answers */
|
|
50
|
+
showCorrectAnswers?: boolean;
|
|
51
|
+
/** When true, disables inputs without showing review state */
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Pin } from "lucide-react";
|
|
3
|
+
import { UserAvatar } from "../../social";
|
|
4
|
+
import { EmptyState } from "../../common";
|
|
5
|
+
import { Badge } from "../../ui/badge";
|
|
6
|
+
import { Button } from "../../ui/button";
|
|
7
|
+
import { Card, CardContent } from "../../ui/card";
|
|
8
|
+
import type { AnnouncementFeedProps } from "./types";
|
|
9
|
+
import { cn } from "../../lib/utils";
|
|
10
|
+
|
|
11
|
+
function formatTimestamp(iso: string): string {
|
|
12
|
+
const date = new Date(iso);
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const diffMs = now.getTime() - date.getTime();
|
|
15
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
16
|
+
if (diffMins < 1) return "Just now";
|
|
17
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
18
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
19
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
20
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
21
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
22
|
+
return date.toLocaleDateString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AnnouncementFeed({
|
|
26
|
+
announcements,
|
|
27
|
+
onMarkRead,
|
|
28
|
+
onSelect,
|
|
29
|
+
showAvatars = true,
|
|
30
|
+
previewLines = 3,
|
|
31
|
+
emptyMessage = "No announcements yet",
|
|
32
|
+
readOnly = false,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
}: AnnouncementFeedProps) {
|
|
36
|
+
const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
|
|
37
|
+
|
|
38
|
+
const sorted = useMemo(() => {
|
|
39
|
+
const pinned = announcements.filter((a) => a.isPinned);
|
|
40
|
+
const rest = announcements.filter((a) => !a.isPinned);
|
|
41
|
+
return [...pinned, ...rest];
|
|
42
|
+
}, [announcements]);
|
|
43
|
+
|
|
44
|
+
function toggleExpand(uid: string) {
|
|
45
|
+
setExpandedUids((prev) => {
|
|
46
|
+
const next = new Set(prev);
|
|
47
|
+
if (next.has(uid)) next.delete(uid);
|
|
48
|
+
else next.add(uid);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
const announcement = announcements.find((a) => a.uid === uid);
|
|
52
|
+
if (announcement && !announcement.isRead) {
|
|
53
|
+
onMarkRead?.(uid);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (sorted.length === 0) {
|
|
58
|
+
return (
|
|
59
|
+
<div className={className} style={style}>
|
|
60
|
+
<EmptyState title={emptyMessage} />
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn("flex flex-col gap-2", className)} style={style}>
|
|
67
|
+
{sorted.map((a) => {
|
|
68
|
+
const isExpanded = expandedUids.has(a.uid);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Card
|
|
72
|
+
key={a.uid}
|
|
73
|
+
className={cn(
|
|
74
|
+
a.isRead && "opacity-85",
|
|
75
|
+
onSelect && "cursor-pointer",
|
|
76
|
+
a.isPinned && "border-l-4 border-l-warning",
|
|
77
|
+
)}
|
|
78
|
+
onClick={() => onSelect && !readOnly ? onSelect(a) : toggleExpand(a.uid)}
|
|
79
|
+
>
|
|
80
|
+
<CardContent className="pt-4 pb-4">
|
|
81
|
+
<div className="flex gap-1.5 items-start">
|
|
82
|
+
{showAvatars && (
|
|
83
|
+
<UserAvatar
|
|
84
|
+
displayName={a.author.displayName}
|
|
85
|
+
avatarUrl={a.author.avatarUrl}
|
|
86
|
+
role={a.author.role as "student" | "instructor" | "ta" | "admin" | undefined}
|
|
87
|
+
size="medium"
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
<div className="flex-1 min-w-0">
|
|
91
|
+
<div className="flex items-center gap-1 mb-0.5">
|
|
92
|
+
{a.isPinned && <Pin size={14} />}
|
|
93
|
+
<span
|
|
94
|
+
className={cn("text-foreground", !a.isRead ? "font-semibold" : "font-normal")}
|
|
95
|
+
>
|
|
96
|
+
{a.title}
|
|
97
|
+
</span>
|
|
98
|
+
{!a.isRead && (
|
|
99
|
+
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
|
100
|
+
New
|
|
101
|
+
</Badge>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<span className="block text-xs text-muted-foreground mb-1">
|
|
105
|
+
{a.author.displayName} · {formatTimestamp(a.createdAt)}
|
|
106
|
+
</span>
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
"text-sm text-foreground",
|
|
110
|
+
!isExpanded && "line-clamp-(--preview-lines) overflow-hidden",
|
|
111
|
+
)}
|
|
112
|
+
style={
|
|
113
|
+
!isExpanded
|
|
114
|
+
? { "--preview-lines": previewLines } as React.CSSProperties
|
|
115
|
+
: undefined
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{a.content}
|
|
119
|
+
</span>
|
|
120
|
+
{!isExpanded && a.content.length > 200 && (
|
|
121
|
+
<Button
|
|
122
|
+
variant="link"
|
|
123
|
+
size="xs"
|
|
124
|
+
className="px-0 mt-0.5 h-auto"
|
|
125
|
+
onClick={(e) => {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
toggleExpand(a.uid);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
Read more
|
|
131
|
+
</Button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</CardContent>
|
|
136
|
+
</Card>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* AnnouncementFeed section — a chronological announcement feed.
|
|
4
|
+
*
|
|
5
|
+
* Displays course announcements with pinned posts, read/unread
|
|
6
|
+
* tracking, expandable content, and author avatars.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <AnnouncementFeed
|
|
10
|
+
* announcements={announcements}
|
|
11
|
+
* onMarkRead={(uid) => markRead(uid)}
|
|
12
|
+
* />
|
|
13
|
+
*/
|
|
14
|
+
export interface AnnouncementFeedProps {
|
|
15
|
+
/** Announcements sorted by recency (newest first) */
|
|
16
|
+
announcements: Announcement[];
|
|
17
|
+
/** Called when the user marks an announcement as read */
|
|
18
|
+
onMarkRead?: (announcementUid: string) => void;
|
|
19
|
+
/** Called when the user clicks an announcement */
|
|
20
|
+
onSelect?: (announcement: Announcement) => void;
|
|
21
|
+
/** Whether to show author avatars */
|
|
22
|
+
showAvatars?: boolean;
|
|
23
|
+
/** Max lines before truncating with "Read more" */
|
|
24
|
+
previewLines?: number;
|
|
25
|
+
/** Empty state message */
|
|
26
|
+
emptyMessage?: string;
|
|
27
|
+
/** When true, disables interactions */
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
/** CSS class name for the root element */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Inline styles for the root element */
|
|
32
|
+
style?: React.CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Announcement {
|
|
36
|
+
/** Unique identifier */
|
|
37
|
+
uid: string;
|
|
38
|
+
/** Announcement title */
|
|
39
|
+
title: string;
|
|
40
|
+
/** Announcement body content */
|
|
41
|
+
content: string;
|
|
42
|
+
/** Author information */
|
|
43
|
+
author: { displayName: string; avatarUrl?: string; role?: string };
|
|
44
|
+
/** Creation timestamp as ISO string */
|
|
45
|
+
createdAt: string;
|
|
46
|
+
/** Whether this announcement is pinned */
|
|
47
|
+
isPinned?: boolean;
|
|
48
|
+
/** Whether the current user has read this */
|
|
49
|
+
isRead?: boolean;
|
|
50
|
+
}
|