@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.
Files changed (131) hide show
  1. package/package.json +52 -1
  2. package/src/__tests__/setup.ts +1 -0
  3. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  4. package/src/assessment-toolbar/index.ts +10 -0
  5. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  6. package/src/assessment-toolbar/timer-display.tsx +73 -0
  7. package/src/assessment-toolbar/types.ts +92 -0
  8. package/src/assets/hydra-icon.png +0 -0
  9. package/src/assets/hydra-icon.svg +18 -0
  10. package/src/assets/hydra-lms-icon.png +0 -0
  11. package/src/assets/hydra-lms-icon.svg +9 -0
  12. package/src/common/confirm-dialog.tsx +60 -0
  13. package/src/common/due-date-display.tsx +64 -0
  14. package/src/common/empty-state.tsx +24 -0
  15. package/src/common/index.ts +12 -0
  16. package/src/common/search-input.tsx +68 -0
  17. package/src/common/status-badge.test.tsx +43 -0
  18. package/src/common/status-badge.tsx +81 -0
  19. package/src/common/types.ts +129 -0
  20. package/src/content/content-block.tsx +116 -0
  21. package/src/content/file-upload-zone.tsx +109 -0
  22. package/src/content/index.ts +7 -0
  23. package/src/content/types.ts +76 -0
  24. package/src/curriculum/curriculum-item.tsx +81 -0
  25. package/src/curriculum/curriculum-tree.tsx +69 -0
  26. package/src/curriculum/index.ts +11 -0
  27. package/src/curriculum/learning-object-icon.tsx +44 -0
  28. package/src/curriculum/types.ts +83 -0
  29. package/src/feedback/feedback-banner.tsx +46 -0
  30. package/src/feedback/index.ts +8 -0
  31. package/src/feedback/likert-scale.tsx +58 -0
  32. package/src/feedback/star-rating.tsx +65 -0
  33. package/src/feedback/types.ts +86 -0
  34. package/src/flashcards/flashcard-deck.tsx +130 -0
  35. package/src/flashcards/flashcard.tsx +108 -0
  36. package/src/flashcards/index.ts +3 -0
  37. package/src/flashcards/types.ts +60 -0
  38. package/src/index.ts +38 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  41. package/src/modules/CoursePlayer/types.ts +48 -0
  42. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  43. package/src/modules/FlashcardLab/types.ts +58 -0
  44. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  45. package/src/modules/QuizModule/types.ts +56 -0
  46. package/src/modules/index.ts +12 -0
  47. package/src/progress/grade-indicator.tsx +65 -0
  48. package/src/progress/index.ts +8 -0
  49. package/src/progress/progress-ring.tsx +56 -0
  50. package/src/progress/stat-card.tsx +42 -0
  51. package/src/progress/types.ts +73 -0
  52. package/src/provider/HydraProvider.tsx +26 -0
  53. package/src/provider/index.ts +2 -0
  54. package/src/questions/choice.tsx +90 -0
  55. package/src/questions/essay.tsx +59 -0
  56. package/src/questions/fill-in-the-blank.tsx +69 -0
  57. package/src/questions/index.ts +14 -0
  58. package/src/questions/multiple-choice.test.tsx +104 -0
  59. package/src/questions/multiple-choice.tsx +97 -0
  60. package/src/questions/question-renderer.tsx +37 -0
  61. package/src/questions/true-false.test.tsx +89 -0
  62. package/src/questions/true-false.tsx +90 -0
  63. package/src/questions/types.ts +53 -0
  64. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  65. package/src/sections/AnnouncementFeed/types.ts +50 -0
  66. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  67. package/src/sections/AssessmentReview/types.ts +61 -0
  68. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  69. package/src/sections/AssignmentSubmission/types.ts +60 -0
  70. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  71. package/src/sections/CertificateViewer/types.ts +45 -0
  72. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  73. package/src/sections/CourseOutline/types.ts +53 -0
  74. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  75. package/src/sections/DiscussionThread/types.ts +77 -0
  76. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  77. package/src/sections/ExamSession/types.ts +64 -0
  78. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  79. package/src/sections/FlashcardStudySession/types.ts +42 -0
  80. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  81. package/src/sections/GradebookTable/types.ts +75 -0
  82. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  83. package/src/sections/LecturePlayer/types.ts +48 -0
  84. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  85. package/src/sections/LessonPage/types.ts +41 -0
  86. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  87. package/src/sections/PracticeQuiz/types.ts +44 -0
  88. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  89. package/src/sections/ProgressDashboard/types.ts +74 -0
  90. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  91. package/src/sections/QuizSession/types.ts +47 -0
  92. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  93. package/src/sections/ResourceLibrary/types.ts +57 -0
  94. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  95. package/src/sections/ScrollableQuiz/types.ts +40 -0
  96. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  97. package/src/sections/SurveyForm/types.ts +69 -0
  98. package/src/sections/index.ts +90 -0
  99. package/src/social/index.ts +3 -0
  100. package/src/social/post-card.tsx +91 -0
  101. package/src/social/types.ts +57 -0
  102. package/src/social/user-avatar.tsx +76 -0
  103. package/src/styles/globals.css +125 -0
  104. package/src/ui/alert-dialog.tsx +343 -0
  105. package/src/ui/alert.tsx +65 -0
  106. package/src/ui/avatar.tsx +52 -0
  107. package/src/ui/badge.tsx +53 -0
  108. package/src/ui/button.tsx +62 -0
  109. package/src/ui/card.tsx +92 -0
  110. package/src/ui/index.ts +44 -0
  111. package/src/ui/input.tsx +21 -0
  112. package/src/ui/progress.tsx +73 -0
  113. package/src/ui/separator.tsx +29 -0
  114. package/src/ui/skeleton.tsx +15 -0
  115. package/src/ui/slot.tsx +48 -0
  116. package/src/ui/table.tsx +108 -0
  117. package/src/ui/tabs.tsx +147 -0
  118. package/src/ui/textarea.tsx +20 -0
  119. package/src/ui/tooltip.tsx +177 -0
  120. package/src/utils/debounce.test.ts +59 -0
  121. package/src/utils/debounce.ts +10 -0
  122. package/src/utils/format-duration.test.ts +55 -0
  123. package/src/utils/format-duration.ts +30 -0
  124. package/src/video/index.ts +17 -0
  125. package/src/video/types.ts +216 -0
  126. package/src/video/video-bookmark.tsx +76 -0
  127. package/src/video/video-chapter-list.tsx +93 -0
  128. package/src/video/video-player.tsx +103 -0
  129. package/src/video/video-playlist-item.tsx +90 -0
  130. package/src/video/video-thumbnail-card.tsx +74 -0
  131. 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 &ldquo;{props.question.type}&rdquo; 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
+ }