@hydralms/components 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/dist/components.css +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +442 -110
  4. package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
  5. package/dist/modules/CoursePlayer/types.d.ts +59 -0
  6. package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
  7. package/dist/modules/FlashcardLab/types.d.ts +55 -0
  8. package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
  9. package/dist/modules/QuizModule/types.d.ts +54 -0
  10. package/dist/modules/index.d.ts +6 -0
  11. package/dist/provider/HydraProvider.d.ts +1 -1
  12. package/dist/sections.cjs +1 -1
  13. package/dist/sections.js +261 -291
  14. package/dist/table-BrS5cDQu.js +2510 -0
  15. package/dist/table-D6AkBBEo.cjs +1 -0
  16. package/dist/ui/alert-dialog.d.ts +14 -8
  17. package/dist/ui/button.d.ts +1 -1
  18. package/dist/ui/tabs.d.ts +15 -5
  19. package/dist/ui/tooltip.d.ts +12 -5
  20. package/dist/video/index.d.ts +6 -1
  21. package/dist/video/types.d.ts +167 -0
  22. package/dist/video/video-bookmark.d.ts +2 -0
  23. package/dist/video/video-chapter-list.d.ts +2 -0
  24. package/dist/video/video-playlist-item.d.ts +2 -0
  25. package/dist/video/video-thumbnail-card.d.ts +2 -0
  26. package/dist/video/video-transcript.d.ts +2 -0
  27. package/package.json +135 -24
  28. package/src/__tests__/setup.ts +1 -0
  29. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  30. package/src/assessment-toolbar/index.ts +10 -0
  31. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  32. package/src/assessment-toolbar/timer-display.tsx +73 -0
  33. package/src/assessment-toolbar/types.ts +92 -0
  34. package/src/assets/hydra-icon.png +0 -0
  35. package/src/assets/hydra-icon.svg +18 -0
  36. package/src/assets/hydra-lms-icon.png +0 -0
  37. package/src/assets/hydra-lms-icon.svg +9 -0
  38. package/src/common/confirm-dialog.tsx +60 -0
  39. package/src/common/due-date-display.tsx +64 -0
  40. package/src/common/empty-state.tsx +24 -0
  41. package/src/common/index.ts +12 -0
  42. package/src/common/search-input.tsx +68 -0
  43. package/src/common/status-badge.test.tsx +43 -0
  44. package/src/common/status-badge.tsx +81 -0
  45. package/src/common/types.ts +129 -0
  46. package/src/content/content-block.tsx +116 -0
  47. package/src/content/file-upload-zone.tsx +109 -0
  48. package/src/content/index.ts +7 -0
  49. package/src/content/types.ts +76 -0
  50. package/src/curriculum/curriculum-item.tsx +81 -0
  51. package/src/curriculum/curriculum-tree.tsx +69 -0
  52. package/src/curriculum/index.ts +11 -0
  53. package/src/curriculum/learning-object-icon.tsx +44 -0
  54. package/src/curriculum/types.ts +83 -0
  55. package/src/feedback/feedback-banner.tsx +46 -0
  56. package/src/feedback/index.ts +8 -0
  57. package/src/feedback/likert-scale.tsx +58 -0
  58. package/src/feedback/star-rating.tsx +65 -0
  59. package/src/feedback/types.ts +86 -0
  60. package/src/flashcards/flashcard-deck.tsx +130 -0
  61. package/src/flashcards/flashcard.tsx +108 -0
  62. package/src/flashcards/index.ts +3 -0
  63. package/src/flashcards/types.ts +60 -0
  64. package/src/index.ts +38 -0
  65. package/src/lib/utils.ts +6 -0
  66. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  67. package/src/modules/CoursePlayer/types.ts +48 -0
  68. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  69. package/src/modules/FlashcardLab/types.ts +58 -0
  70. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  71. package/src/modules/QuizModule/types.ts +56 -0
  72. package/src/modules/index.ts +12 -0
  73. package/src/progress/grade-indicator.tsx +65 -0
  74. package/src/progress/index.ts +8 -0
  75. package/src/progress/progress-ring.tsx +56 -0
  76. package/src/progress/stat-card.tsx +42 -0
  77. package/src/progress/types.ts +73 -0
  78. package/src/provider/HydraProvider.tsx +26 -0
  79. package/src/provider/index.ts +2 -0
  80. package/src/questions/choice.tsx +90 -0
  81. package/src/questions/essay.tsx +59 -0
  82. package/src/questions/fill-in-the-blank.tsx +69 -0
  83. package/src/questions/index.ts +14 -0
  84. package/src/questions/multiple-choice.test.tsx +104 -0
  85. package/src/questions/multiple-choice.tsx +97 -0
  86. package/src/questions/question-renderer.tsx +37 -0
  87. package/src/questions/true-false.test.tsx +89 -0
  88. package/src/questions/true-false.tsx +90 -0
  89. package/src/questions/types.ts +53 -0
  90. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  91. package/src/sections/AnnouncementFeed/types.ts +50 -0
  92. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  93. package/src/sections/AssessmentReview/types.ts +61 -0
  94. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  95. package/src/sections/AssignmentSubmission/types.ts +60 -0
  96. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  97. package/src/sections/CertificateViewer/types.ts +45 -0
  98. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  99. package/src/sections/CourseOutline/types.ts +53 -0
  100. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  101. package/src/sections/DiscussionThread/types.ts +77 -0
  102. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  103. package/src/sections/ExamSession/types.ts +64 -0
  104. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  105. package/src/sections/FlashcardStudySession/types.ts +42 -0
  106. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  107. package/src/sections/GradebookTable/types.ts +75 -0
  108. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  109. package/src/sections/LecturePlayer/types.ts +48 -0
  110. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  111. package/src/sections/LessonPage/types.ts +41 -0
  112. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  113. package/src/sections/PracticeQuiz/types.ts +44 -0
  114. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  115. package/src/sections/ProgressDashboard/types.ts +74 -0
  116. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  117. package/src/sections/QuizSession/types.ts +47 -0
  118. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  119. package/src/sections/ResourceLibrary/types.ts +57 -0
  120. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  121. package/src/sections/ScrollableQuiz/types.ts +40 -0
  122. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  123. package/src/sections/SurveyForm/types.ts +69 -0
  124. package/src/sections/index.ts +90 -0
  125. package/src/social/index.ts +3 -0
  126. package/src/social/post-card.tsx +91 -0
  127. package/src/social/types.ts +57 -0
  128. package/src/social/user-avatar.tsx +76 -0
  129. package/src/styles/globals.css +125 -0
  130. package/src/ui/alert-dialog.tsx +343 -0
  131. package/src/ui/alert.tsx +65 -0
  132. package/src/ui/avatar.tsx +52 -0
  133. package/src/ui/badge.tsx +53 -0
  134. package/src/ui/button.tsx +62 -0
  135. package/src/ui/card.tsx +92 -0
  136. package/src/ui/index.ts +44 -0
  137. package/src/ui/input.tsx +21 -0
  138. package/src/ui/progress.tsx +73 -0
  139. package/src/ui/separator.tsx +29 -0
  140. package/src/ui/skeleton.tsx +15 -0
  141. package/src/ui/slot.tsx +48 -0
  142. package/src/ui/table.tsx +108 -0
  143. package/src/ui/tabs.tsx +147 -0
  144. package/src/ui/textarea.tsx +20 -0
  145. package/src/ui/tooltip.tsx +177 -0
  146. package/src/utils/debounce.test.ts +59 -0
  147. package/src/utils/debounce.ts +10 -0
  148. package/src/utils/format-duration.test.ts +55 -0
  149. package/src/utils/format-duration.ts +30 -0
  150. package/src/video/index.ts +17 -0
  151. package/src/video/types.ts +216 -0
  152. package/src/video/video-bookmark.tsx +76 -0
  153. package/src/video/video-chapter-list.tsx +93 -0
  154. package/src/video/video-player.tsx +103 -0
  155. package/src/video/video-playlist-item.tsx +90 -0
  156. package/src/video/video-thumbnail-card.tsx +74 -0
  157. package/src/video/video-transcript.tsx +102 -0
  158. package/dist/table-CW4_BYny.js +0 -9869
  159. package/dist/table-DSBBqb9X.cjs +0 -56
@@ -0,0 +1,60 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * AssignmentSubmission section — an assignment detail and submission view.
5
+ *
6
+ * Displays assignment instructions, due date, and submission status.
7
+ * Supports file upload, text, and URL submission types with draft saving
8
+ * and grade/feedback display.
9
+ *
10
+ * @example
11
+ * <AssignmentSubmission
12
+ * title="Week 3 Essay"
13
+ * instructions={<p>Write a 500-word essay on React.</p>}
14
+ * dueDate="2025-03-15T23:59:00Z"
15
+ * status="not_started"
16
+ * submissionTypes={["text", "file"]}
17
+ * onSubmit={(data) => submitAssignment(data)}
18
+ * />
19
+ */
20
+ export interface AssignmentSubmissionProps {
21
+ /** Assignment title */
22
+ title: string;
23
+ /** Assignment instructions (rich content) */
24
+ instructions: ReactNode;
25
+ /** Due date as ISO string */
26
+ dueDate?: string;
27
+ /** Maximum score points */
28
+ maxScore?: number;
29
+ /** Current submission status */
30
+ status: "not_started" | "draft" | "submitted" | "late" | "graded" | "resubmit";
31
+ /** Allowed submission types */
32
+ submissionTypes: ("text" | "file" | "url")[];
33
+ /** Existing submission data for editing/viewing */
34
+ existingSubmission?: SubmissionData;
35
+ /** File upload constraints */
36
+ fileConstraints?: { maxFiles?: number; maxSizeMB?: number; acceptedTypes?: string };
37
+ /** Called on final submission */
38
+ onSubmit: (submission: SubmissionData) => void;
39
+ /** Called on draft save */
40
+ onSaveDraft?: (submission: SubmissionData) => void;
41
+ /** Grade data when already graded */
42
+ grade?: { score: number; feedback?: ReactNode };
43
+ /** Whether the submit action is in flight */
44
+ isSubmitting?: boolean;
45
+ /** When true, disables all interactions */
46
+ readOnly?: boolean;
47
+ /** CSS class name for the root element */
48
+ className?: string;
49
+ /** Inline styles for the root element */
50
+ style?: React.CSSProperties;
51
+ }
52
+
53
+ export interface SubmissionData {
54
+ /** Text content */
55
+ textContent?: string;
56
+ /** Uploaded files */
57
+ files?: File[];
58
+ /** URL submission */
59
+ url?: string;
60
+ }
@@ -0,0 +1,117 @@
1
+ import { Award, Download, Printer } from "lucide-react";
2
+ import { Button } from "../../ui/button";
3
+ import { Separator } from "../../ui/separator";
4
+ import type { CertificateViewerProps } from "./types";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const VARIANT_CLASSES: Record<string, string> = {
8
+ classic: "border-[3px] border-double border-warning bg-linear-to-br from-[#fffbe6] to-[#fff8e1]",
9
+ modern: "border border-primary bg-linear-to-br from-[#fff5f5] to-[#fee2e2]",
10
+ minimal: "border border-border",
11
+ };
12
+
13
+ export function CertificateViewer({
14
+ recipientName,
15
+ courseTitle,
16
+ completionDate,
17
+ organizationName,
18
+ organizationLogo,
19
+ signatory,
20
+ certificateId,
21
+ variant = "classic",
22
+ showActions = true,
23
+ onPrint,
24
+ onDownload,
25
+ className,
26
+ style,
27
+ }: CertificateViewerProps) {
28
+ const formattedDate = (() => {
29
+ try {
30
+ return new Date(completionDate).toLocaleDateString("en-US", {
31
+ year: "numeric",
32
+ month: "long",
33
+ day: "numeric",
34
+ });
35
+ } catch {
36
+ return completionDate;
37
+ }
38
+ })();
39
+
40
+ function handlePrint() {
41
+ if (onPrint) {
42
+ onPrint();
43
+ } else {
44
+ window.print();
45
+ }
46
+ }
47
+
48
+ return (
49
+ <div className={className} style={style}>
50
+ <div
51
+ className={cn(
52
+ "p-3 sm:p-5 md:p-6 text-center max-w-200 mx-auto rounded-md",
53
+ VARIANT_CLASSES[variant],
54
+ )}
55
+ >
56
+ {/* Logo or icon */}
57
+ {organizationLogo ? (
58
+ <img
59
+ src={organizationLogo}
60
+ alt={organizationName}
61
+ className="h-15 mb-2 mx-auto block"
62
+ />
63
+ ) : (
64
+ <Award
65
+ size={48}
66
+ className={cn("mx-auto mb-4", variant === "classic" && "text-warning")}
67
+ />
68
+ )}
69
+
70
+ <p className="uppercase tracking-[3px] text-sm text-foreground/70">
71
+ Certificate of Completion
72
+ </p>
73
+
74
+ <Separator className="my-2 mx-auto max-w-50" />
75
+
76
+ <p className="text-sm text-foreground mb-1">This is to certify that</p>
77
+ <p className={cn("text-2xl font-bold mb-2 text-foreground", variant === "classic" && "font-serif")}>
78
+ {recipientName}
79
+ </p>
80
+ <p className="text-sm text-foreground mb-1">has successfully completed</p>
81
+ <p className="text-xl font-bold mb-2 text-primary">{courseTitle}</p>
82
+
83
+ <p className="text-sm text-foreground mb-3">
84
+ Issued by {organizationName} on {formattedDate}
85
+ </p>
86
+
87
+ {signatory && (
88
+ <div className="mt-4 mb-2">
89
+ <Separator className="my-2 mx-auto max-w-50" />
90
+ <p className="font-semibold text-sm text-foreground">{signatory.name}</p>
91
+ <p className="text-xs text-muted-foreground">{signatory.title}</p>
92
+ </div>
93
+ )}
94
+
95
+ {certificateId && (
96
+ <span className="block text-xs text-muted-foreground mt-2">
97
+ Certificate ID: {certificateId}
98
+ </span>
99
+ )}
100
+ </div>
101
+
102
+ {/* Actions */}
103
+ {showActions && (
104
+ <div className="flex justify-center gap-2 mt-3">
105
+ <Button variant="outline" onClick={handlePrint}>
106
+ <Printer size={16} /> Print
107
+ </Button>
108
+ {onDownload && (
109
+ <Button variant="outline" onClick={onDownload}>
110
+ <Download size={16} /> Download
111
+ </Button>
112
+ )}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,45 @@
1
+
2
+ /**
3
+ * CertificateViewer section — a printable completion certificate.
4
+ *
5
+ * Displays a certificate with recipient details, course information,
6
+ * signatory, and verification ID. Supports three visual variants:
7
+ * classic, modern, and minimal.
8
+ *
9
+ * @example
10
+ * <CertificateViewer
11
+ * recipientName="Jane Smith"
12
+ * courseTitle="Advanced React"
13
+ * completionDate="2025-03-01"
14
+ * organizationName="HydraLMS Academy"
15
+ * variant="modern"
16
+ * />
17
+ */
18
+ export interface CertificateViewerProps {
19
+ /** Recipient's full name */
20
+ recipientName: string;
21
+ /** Course or program title */
22
+ courseTitle: string;
23
+ /** Completion date as ISO string or human-readable string */
24
+ completionDate: string;
25
+ /** Issuing organization name */
26
+ organizationName: string;
27
+ /** Organization logo URL */
28
+ organizationLogo?: string;
29
+ /** Signatory information */
30
+ signatory?: { name: string; title: string };
31
+ /** Unique certificate ID */
32
+ certificateId?: string;
33
+ /** Certificate template variant */
34
+ variant?: "classic" | "modern" | "minimal";
35
+ /** Whether to show print/download actions */
36
+ showActions?: boolean;
37
+ /** Called when print is triggered */
38
+ onPrint?: () => void;
39
+ /** Called when download is triggered */
40
+ onDownload?: () => void;
41
+ /** CSS class name for the root element */
42
+ className?: string;
43
+ /** Inline styles for the root element */
44
+ style?: React.CSSProperties;
45
+ }
@@ -0,0 +1,79 @@
1
+ import { useMemo } from "react";
2
+ import { CurriculumTree } from "../../curriculum";
3
+ import type { CurriculumItem } from "../../curriculum/types";
4
+ import { Progress } from "../../ui/progress";
5
+ import type { CourseOutlineProps } from "./types";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ function flattenLeaves(items: CurriculumItem[]): string[] {
9
+ const leaves: string[] = [];
10
+ for (const item of items) {
11
+ if (!item.children || item.children.length === 0) {
12
+ leaves.push(item.uid);
13
+ } else {
14
+ leaves.push(...flattenLeaves(item.children));
15
+ }
16
+ }
17
+ return leaves;
18
+ }
19
+
20
+ export function CourseOutline({
21
+ items,
22
+ progress,
23
+ courseTitle,
24
+ activeItemUid,
25
+ onItemClick,
26
+ showOverallProgress = true,
27
+ showDuration = true,
28
+ showIcons = true,
29
+ readOnly = false,
30
+ className,
31
+ style,
32
+ }: CourseOutlineProps) {
33
+ const { completedCount, totalCount, percentage } = useMemo(() => {
34
+ const leafUids = flattenLeaves(items);
35
+ const total = leafUids.length;
36
+ const completed = progress
37
+ ? leafUids.filter((uid) =>
38
+ progress.some((p) => p.resourceUid === uid && p.isCompleted),
39
+ ).length
40
+ : 0;
41
+ return {
42
+ completedCount: completed,
43
+ totalCount: total,
44
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
45
+ };
46
+ }, [items, progress]);
47
+
48
+ return (
49
+ <div className={cn(className)} style={style}>
50
+ {(courseTitle || showOverallProgress) && (
51
+ <div className="px-2 pt-2 pb-1">
52
+ {courseTitle && (
53
+ <p className={cn("font-semibold text-sm text-foreground", showOverallProgress && "mb-1")}>
54
+ {courseTitle}
55
+ </p>
56
+ )}
57
+ {showOverallProgress && (
58
+ <div>
59
+ <Progress value={percentage} size="sm" />
60
+ <span className="text-xs text-muted-foreground mt-0.5 block">
61
+ {completedCount} of {totalCount} completed
62
+ </span>
63
+ </div>
64
+ )}
65
+ </div>
66
+ )}
67
+ <CurriculumTree
68
+ items={items}
69
+ progress={progress}
70
+ activeItemUid={activeItemUid}
71
+ onItemClick={onItemClick}
72
+ readOnly={readOnly}
73
+ showDuration={showDuration}
74
+ showIcons={showIcons}
75
+ showProgress
76
+ />
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,53 @@
1
+ import type {
2
+ CurriculumItem,
3
+ CurriculumItemProgress,
4
+ } from "../../curriculum/types";
5
+
6
+ /**
7
+ * CourseOutline section — a sidebar-ready course navigation panel.
8
+ *
9
+ * Wraps CurriculumTree with a course title header and an optional overall
10
+ * progress bar. Ready to drop into a sidebar layout without extra markup.
11
+ *
12
+ * @example
13
+ * <CourseOutline
14
+ * items={curriculum}
15
+ * progress={userProgress}
16
+ * courseTitle="Introduction to React"
17
+ * activeItemUid={currentLessonUid}
18
+ * onItemClick={(item) => navigate(item.uid)}
19
+ * />
20
+ */
21
+ export interface CourseOutlineProps {
22
+ /** The course curriculum items */
23
+ items: CurriculumItem[];
24
+ /** User progress data */
25
+ progress?: CurriculumItemProgress[];
26
+ /** Course title displayed in the header */
27
+ courseTitle?: string;
28
+ /** UID of the currently active/playing item */
29
+ activeItemUid?: string;
30
+ /** Called when the user clicks a leaf curriculum item */
31
+ onItemClick?: (item: CurriculumItem) => void;
32
+ /**
33
+ * Whether to show a linear progress bar summarising overall completion.
34
+ * @default true
35
+ */
36
+ showOverallProgress?: boolean;
37
+ /**
38
+ * Whether to show per-item duration labels in the tree.
39
+ * @default true
40
+ */
41
+ showDuration?: boolean;
42
+ /**
43
+ * Whether to show type icons per item.
44
+ * @default true
45
+ */
46
+ showIcons?: boolean;
47
+ /** When true, disables all click interactions */
48
+ readOnly?: boolean;
49
+ /** CSS class name for the root element */
50
+ className?: string;
51
+ /** Inline styles for the root element */
52
+ style?: React.CSSProperties;
53
+ }
@@ -0,0 +1,186 @@
1
+ import { useMemo, useState } from "react";
2
+ import { CheckCircle, Heart, MessageSquare, Reply } from "lucide-react";
3
+ import { PostCard } from "../../social";
4
+ import { Button } from "../../ui/button";
5
+ import { Textarea } from "../../ui/textarea";
6
+ import { Separator } from "../../ui/separator";
7
+ import { Card, CardContent } from "../../ui/card";
8
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
9
+ import type { DiscussionThreadProps, DiscussionPost } from "./types";
10
+ import { cn } from "../../lib/utils";
11
+
12
+ export function DiscussionThread({
13
+ title,
14
+ rootPost,
15
+ replies,
16
+ currentUser,
17
+ onReply,
18
+ onToggleLike,
19
+ onMarkAnswer,
20
+ maxDepth = 3,
21
+ allowReplies = true,
22
+ sortOrder = "oldest",
23
+ readOnly = false,
24
+ className,
25
+ style,
26
+ }: DiscussionThreadProps) {
27
+ const [replyingToUid, setReplyingToUid] = useState<string | null>(null);
28
+ const [replyContent, setReplyContent] = useState("");
29
+
30
+ const replyTree = useMemo(() => {
31
+ const childrenMap = new Map<string, DiscussionPost[]>();
32
+ for (const reply of replies) {
33
+ const parentUid = reply.parentUid ?? rootPost.uid;
34
+ const siblings = childrenMap.get(parentUid) ?? [];
35
+ siblings.push(reply);
36
+ childrenMap.set(parentUid, siblings);
37
+ }
38
+
39
+ for (const [, children] of childrenMap) {
40
+ children.sort((a, b) => {
41
+ if (sortOrder === "newest") return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
42
+ if (sortOrder === "most_liked") return b.likeCount - a.likeCount;
43
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
44
+ });
45
+ }
46
+
47
+ return childrenMap;
48
+ }, [replies, rootPost.uid, sortOrder]);
49
+
50
+ function handleSubmitReply(parentUid: string) {
51
+ if (!replyContent.trim()) return;
52
+ onReply(parentUid, replyContent.trim());
53
+ setReplyContent("");
54
+ setReplyingToUid(null);
55
+ }
56
+
57
+ function renderPost(post: DiscussionPost, depth: number) {
58
+ const children = replyTree.get(post.uid) ?? [];
59
+ const effectiveDepth = Math.min(depth, maxDepth);
60
+
61
+ const actions = (
62
+ <div className="flex items-center gap-1">
63
+ {onToggleLike && !readOnly && (
64
+ <Tooltip>
65
+ <TooltipTrigger>
66
+ <Button
67
+ variant="ghost"
68
+ size="sm"
69
+ aria-label={post.isLikedByCurrentUser ? "Unlike" : "Like"}
70
+ className={cn(post.isLikedByCurrentUser && "text-destructive")}
71
+ onClick={() => onToggleLike(post.uid)}
72
+ >
73
+ <Heart size={14} fill={post.isLikedByCurrentUser ? "currentColor" : "none"} />
74
+ {post.likeCount > 0 ? post.likeCount : "Like"}
75
+ </Button>
76
+ </TooltipTrigger>
77
+ <TooltipContent>{post.isLikedByCurrentUser ? "Unlike" : "Like"}</TooltipContent>
78
+ </Tooltip>
79
+ )}
80
+ {allowReplies && !readOnly && (
81
+ <Tooltip>
82
+ <TooltipTrigger>
83
+ <Button
84
+ variant="ghost"
85
+ size="sm"
86
+ aria-label="Reply"
87
+ onClick={() => setReplyingToUid(post.uid)}
88
+ >
89
+ <Reply size={14} />
90
+ Reply
91
+ </Button>
92
+ </TooltipTrigger>
93
+ <TooltipContent>Reply</TooltipContent>
94
+ </Tooltip>
95
+ )}
96
+ {onMarkAnswer && !readOnly && currentUser.role !== "student" && !post.isAnswer && (
97
+ <Tooltip>
98
+ <TooltipTrigger>
99
+ <Button
100
+ variant="ghost"
101
+ size="sm"
102
+ aria-label="Mark as answer"
103
+ className="text-success"
104
+ onClick={() => onMarkAnswer(post.uid)}
105
+ >
106
+ <CheckCircle size={14} />
107
+ Mark Answer
108
+ </Button>
109
+ </TooltipTrigger>
110
+ <TooltipContent>Mark as answer</TooltipContent>
111
+ </Tooltip>
112
+ )}
113
+ </div>
114
+ );
115
+
116
+ return (
117
+ <div key={post.uid}>
118
+ <PostCard
119
+ author={post.author}
120
+ content={post.content}
121
+ createdAt={post.createdAt}
122
+ updatedAt={post.updatedAt}
123
+ actions={actions}
124
+ highlight={post.isAnswer ? "answer" : "none"}
125
+ indentLevel={effectiveDepth}
126
+ className="mb-2"
127
+ />
128
+
129
+ {/* Reply editor */}
130
+ {replyingToUid === post.uid && (
131
+ <Card
132
+ className="mb-2"
133
+ style={{ marginLeft: `${(effectiveDepth + 1) * 16}px` }}
134
+ >
135
+ <CardContent className="pt-4 pb-4">
136
+ <Textarea
137
+ className="min-h-15 mb-2"
138
+ placeholder="Write a reply..."
139
+ value={replyContent}
140
+ onChange={(e) => setReplyContent(e.target.value)}
141
+ />
142
+ <div className="flex items-center gap-2">
143
+ <Button
144
+ size="sm"
145
+ onClick={() => handleSubmitReply(post.uid)}
146
+ disabled={!replyContent.trim()}
147
+ >
148
+ Post Reply
149
+ </Button>
150
+ <Button
151
+ variant="ghost"
152
+ size="sm"
153
+ onClick={() => {
154
+ setReplyingToUid(null);
155
+ setReplyContent("");
156
+ }}
157
+ >
158
+ Cancel
159
+ </Button>
160
+ </div>
161
+ </CardContent>
162
+ </Card>
163
+ )}
164
+
165
+ {/* Nested children */}
166
+ {children.map((child) => renderPost(child, depth + 1))}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <div className={className} style={style}>
173
+ <div className="flex items-center gap-1 mb-2">
174
+ <MessageSquare size={20} className="text-foreground shrink-0" />
175
+ <span className="text-lg font-semibold text-foreground">{title}</span>
176
+ <span className="text-sm text-muted-foreground">
177
+ {replies.length} {replies.length === 1 ? "reply" : "replies"}
178
+ </span>
179
+ </div>
180
+
181
+ <Separator className="mb-2" />
182
+
183
+ {renderPost(rootPost, 0)}
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,77 @@
1
+
2
+ /**
3
+ * DiscussionThread section — a threaded discussion forum.
4
+ *
5
+ * Supports nested replies, author avatars, timestamps, like/unlike,
6
+ * and mark-as-answer for Q&A threads. Replies nest up to a
7
+ * configurable maximum depth.
8
+ *
9
+ * @example
10
+ * <DiscussionThread
11
+ * title="How do React hooks work?"
12
+ * rootPost={rootPost}
13
+ * replies={replies}
14
+ * currentUser={user}
15
+ * onReply={(parentUid, content) => postReply(parentUid, content)}
16
+ * />
17
+ */
18
+ export interface DiscussionThreadProps {
19
+ /** Thread title */
20
+ title: string;
21
+ /** The initial/root post */
22
+ rootPost: DiscussionPost;
23
+ /** All replies (flat array — parentUid determines nesting) */
24
+ replies: DiscussionPost[];
25
+ /** The currently authenticated user */
26
+ currentUser: DiscussionUser;
27
+ /** Called when the user submits a reply */
28
+ onReply: (parentUid: string, content: string) => void;
29
+ /** Called when the user toggles a like */
30
+ onToggleLike?: (postUid: string) => void;
31
+ /** Called when an instructor marks a post as the answer */
32
+ onMarkAnswer?: (postUid: string) => void;
33
+ /** Maximum nesting depth before replies flatten */
34
+ maxDepth?: number;
35
+ /** Whether new replies are allowed */
36
+ allowReplies?: boolean;
37
+ /** Sort order for top-level replies */
38
+ sortOrder?: "newest" | "oldest" | "most_liked";
39
+ /** When true, disables interactions */
40
+ readOnly?: boolean;
41
+ /** CSS class name for the root element */
42
+ className?: string;
43
+ /** Inline styles for the root element */
44
+ style?: React.CSSProperties;
45
+ }
46
+
47
+ export interface DiscussionPost {
48
+ /** Unique identifier */
49
+ uid: string;
50
+ /** Parent post UID — null for root post */
51
+ parentUid: string | null;
52
+ /** Post author */
53
+ author: DiscussionUser;
54
+ /** Post body content */
55
+ content: string;
56
+ /** Creation timestamp */
57
+ createdAt: string;
58
+ /** Last update timestamp */
59
+ updatedAt?: string;
60
+ /** Number of likes */
61
+ likeCount: number;
62
+ /** Whether the current user has liked this post */
63
+ isLikedByCurrentUser: boolean;
64
+ /** Whether this post is marked as the answer */
65
+ isAnswer?: boolean;
66
+ }
67
+
68
+ export interface DiscussionUser {
69
+ /** Unique identifier */
70
+ uid: string;
71
+ /** Display name */
72
+ displayName: string;
73
+ /** Avatar image URL */
74
+ avatarUrl?: string;
75
+ /** User role */
76
+ role?: "student" | "instructor" | "ta" | "admin";
77
+ }