@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.
- package/dist/components.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +442 -110
- package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
- package/dist/modules/CoursePlayer/types.d.ts +59 -0
- package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
- package/dist/modules/FlashcardLab/types.d.ts +55 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
- package/dist/modules/QuizModule/types.d.ts +54 -0
- package/dist/modules/index.d.ts +6 -0
- package/dist/provider/HydraProvider.d.ts +1 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +261 -291
- package/dist/table-BrS5cDQu.js +2510 -0
- package/dist/table-D6AkBBEo.cjs +1 -0
- package/dist/ui/alert-dialog.d.ts +14 -8
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/tabs.d.ts +15 -5
- package/dist/ui/tooltip.d.ts +12 -5
- package/dist/video/index.d.ts +6 -1
- package/dist/video/types.d.ts +167 -0
- package/dist/video/video-bookmark.d.ts +2 -0
- package/dist/video/video-chapter-list.d.ts +2 -0
- package/dist/video/video-playlist-item.d.ts +2 -0
- package/dist/video/video-thumbnail-card.d.ts +2 -0
- package/dist/video/video-transcript.d.ts +2 -0
- package/package.json +135 -24
- 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
- package/dist/table-CW4_BYny.js +0 -9869
- 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
|
+
}
|