@hydralms/components 0.1.1 → 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 (131) hide show
  1. package/package.json +3 -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,148 @@
1
+ import { QuestionRenderer } from "../../questions";
2
+ import type { QuestionData, SessionAnswer } from "../../questions/types";
3
+ import { Badge } from "../../ui/badge";
4
+ import { Card, CardContent } from "../../ui/card";
5
+ import { Separator } from "../../ui/separator";
6
+ import { cn } from "../../lib/utils";
7
+ import type {
8
+ AssessmentReviewProps,
9
+ AssessmentReviewGroup,
10
+ } from "./types";
11
+
12
+ function ScoreHeader({
13
+ score,
14
+ }: {
15
+ score: NonNullable<AssessmentReviewProps["score"]>;
16
+ }) {
17
+ const pct =
18
+ score.percentage !== undefined
19
+ ? score.percentage
20
+ : score.total > 0
21
+ ? Math.round((score.correct / score.total) * 100)
22
+ : 0;
23
+
24
+ return (
25
+ <Card className="mb-3">
26
+ <CardContent className="pt-6">
27
+ <div className="flex flex-wrap items-center gap-2">
28
+ <div>
29
+ <span className="text-2xl font-bold leading-none text-foreground">{pct}%</span>
30
+ <span className="text-sm text-muted-foreground">
31
+ {score.correct} of {score.total} correct
32
+ </span>
33
+ </div>
34
+ {score.passed !== undefined && (
35
+ <Badge variant={score.passed ? "success" : "destructive"}>
36
+ {score.passed ? "Passed" : "Failed"}
37
+ </Badge>
38
+ )}
39
+ {score.passingScore !== undefined && (
40
+ <span className="text-sm text-muted-foreground">
41
+ Passing score: {score.passingScore}%
42
+ </span>
43
+ )}
44
+ </div>
45
+ </CardContent>
46
+ </Card>
47
+ );
48
+ }
49
+
50
+ function QuestionList({
51
+ questions,
52
+ sessionAnswers,
53
+ showCorrectAnswers,
54
+ }: {
55
+ questions: QuestionData[];
56
+ sessionAnswers: SessionAnswer[];
57
+ showCorrectAnswers: boolean;
58
+ }) {
59
+ return (
60
+ <div className="flex flex-col gap-3">
61
+ {questions.map((question, idx) => (
62
+ <Card key={question.uid} className="overflow-hidden">
63
+ <div className="px-2 py-1 bg-muted">
64
+ <span className="text-xs text-muted-foreground font-semibold">
65
+ Question {idx + 1}
66
+ </span>
67
+ </div>
68
+ <Separator />
69
+ <CardContent className="pt-4 pb-4">
70
+ <QuestionRenderer
71
+ question={question}
72
+ sessionAnswers={sessionAnswers.filter((a) => a.uid === question.uid)}
73
+ readOnly
74
+ showCorrectAnswers={showCorrectAnswers}
75
+ />
76
+ </CardContent>
77
+ </Card>
78
+ ))}
79
+ </div>
80
+ );
81
+ }
82
+
83
+ function renderGroups(
84
+ questions: QuestionData[],
85
+ sessionAnswers: SessionAnswer[],
86
+ questionGroups: AssessmentReviewGroup[],
87
+ showCorrectAnswers: boolean,
88
+ ) {
89
+ const questionMap = new Map(questions.map((q) => [q.uid, q]));
90
+ const groupedUids = new Set(questionGroups.flatMap((g) => g.questionUids));
91
+ const ungrouped = questions.filter((q) => !groupedUids.has(q.uid));
92
+
93
+ return (
94
+ <div className="flex flex-col gap-4">
95
+ {questionGroups.map((group) => {
96
+ const groupQuestions = group.questionUids
97
+ .map((uid) => questionMap.get(uid))
98
+ .filter(Boolean) as QuestionData[];
99
+ return (
100
+ <div key={group.label}>
101
+ <span className="uppercase text-xs tracking-wide text-muted-foreground font-semibold">
102
+ {group.label}
103
+ </span>
104
+ <Separator className="mb-2" />
105
+ <QuestionList
106
+ questions={groupQuestions}
107
+ sessionAnswers={sessionAnswers}
108
+ showCorrectAnswers={showCorrectAnswers}
109
+ />
110
+ </div>
111
+ );
112
+ })}
113
+ {ungrouped.length > 0 && (
114
+ <div>
115
+ <QuestionList
116
+ questions={ungrouped}
117
+ sessionAnswers={sessionAnswers}
118
+ showCorrectAnswers={showCorrectAnswers}
119
+ />
120
+ </div>
121
+ )}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ export function AssessmentReview({
127
+ questions,
128
+ sessionAnswers,
129
+ score,
130
+ questionGroups,
131
+ showCorrectAnswers = true,
132
+ className,
133
+ style,
134
+ }: AssessmentReviewProps) {
135
+ return (
136
+ <div className={cn(className)} style={style}>
137
+ {score && <ScoreHeader score={score} />}
138
+ {questionGroups && questionGroups.length > 0
139
+ ? renderGroups(questions, sessionAnswers, questionGroups, showCorrectAnswers)
140
+ : <QuestionList
141
+ questions={questions}
142
+ sessionAnswers={sessionAnswers}
143
+ showCorrectAnswers={showCorrectAnswers}
144
+ />
145
+ }
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,61 @@
1
+ import type { QuestionData, SessionAnswer } from "../../questions/types";
2
+
3
+ /**
4
+ * AssessmentReview section — read-only review of a completed assessment.
5
+ *
6
+ * Renders all questions in review mode with correct/incorrect highlighting
7
+ * and an optional score summary header. Supports optional grouping of
8
+ * questions by section label.
9
+ *
10
+ * @example
11
+ * <AssessmentReview
12
+ * questions={questions}
13
+ * sessionAnswers={submittedAnswers}
14
+ * score={{ correct: 8, total: 10, passed: true, passingScore: 70 }}
15
+ * />
16
+ */
17
+ export interface AssessmentReviewProps {
18
+ /** All questions that were in the assessment */
19
+ questions: QuestionData[];
20
+ /** The user's submitted answers */
21
+ sessionAnswers: SessionAnswer[];
22
+ /**
23
+ * Score metadata to display in the summary header.
24
+ * If omitted, the score header is not rendered.
25
+ */
26
+ score?: AssessmentScore;
27
+ /**
28
+ * Optional grouping: renders questions under section headings.
29
+ * Questions not referenced in any group are rendered last without a heading.
30
+ */
31
+ questionGroups?: AssessmentReviewGroup[];
32
+ /**
33
+ * Whether to show correct/incorrect answer highlighting on each question.
34
+ * @default true
35
+ */
36
+ showCorrectAnswers?: boolean;
37
+ /** CSS class name for the root element */
38
+ className?: string;
39
+ /** Inline styles for the root element */
40
+ style?: React.CSSProperties;
41
+ }
42
+
43
+ export interface AssessmentScore {
44
+ /** Number of correct answers */
45
+ correct: number;
46
+ /** Total number of questions */
47
+ total: number;
48
+ /** Optional pre-computed percentage (falls back to correct/total * 100) */
49
+ percentage?: number;
50
+ /** Whether the user passed */
51
+ passed?: boolean;
52
+ /** Passing threshold as a percentage (e.g. 70 means 70%) */
53
+ passingScore?: number;
54
+ }
55
+
56
+ export interface AssessmentReviewGroup {
57
+ /** Group label displayed as a section heading */
58
+ label: string;
59
+ /** UIDs of questions belonging to this group, in display order */
60
+ questionUids: string[];
61
+ }
@@ -0,0 +1,190 @@
1
+ import { useState } from "react";
2
+ import { Send, Save } from "lucide-react";
3
+ import { StatusBadge, DueDateDisplay } from "../../common";
4
+ import { FileUploadZone } from "../../content";
5
+ import { Button } from "../../ui/button";
6
+ import { Textarea } from "../../ui/textarea";
7
+ import { Input } from "../../ui/input";
8
+ import { Separator } from "../../ui/separator";
9
+ import { Card, CardContent } from "../../ui/card";
10
+ import { Alert, AlertDescription } from "../../ui/alert";
11
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../ui/tabs";
12
+ import type { AssignmentSubmissionProps, SubmissionData } from "./types";
13
+
14
+ export function AssignmentSubmission({
15
+ title,
16
+ instructions,
17
+ dueDate,
18
+ maxScore,
19
+ status,
20
+ submissionTypes,
21
+ existingSubmission,
22
+ fileConstraints,
23
+ onSubmit,
24
+ onSaveDraft,
25
+ grade,
26
+ isSubmitting = false,
27
+ readOnly = false,
28
+ className,
29
+ style,
30
+ }: AssignmentSubmissionProps) {
31
+ const [textContent, setTextContent] = useState(existingSubmission?.textContent ?? "");
32
+ const [files, setFiles] = useState<File[]>(existingSubmission?.files ?? []);
33
+ const [url, setUrl] = useState(existingSubmission?.url ?? "");
34
+ const [activeTab, setActiveTab] = useState<"text" | "file" | "url">(submissionTypes[0]);
35
+
36
+ const isEditable = !readOnly && !["submitted", "graded"].includes(status);
37
+
38
+ function getSubmissionData(): SubmissionData {
39
+ return {
40
+ textContent: submissionTypes.includes("text") ? textContent : undefined,
41
+ files: submissionTypes.includes("file") ? files : undefined,
42
+ url: submissionTypes.includes("url") ? url : undefined,
43
+ };
44
+ }
45
+
46
+ return (
47
+ <div className={className} style={style}>
48
+ {/* Header */}
49
+ <div className="flex justify-between items-start mb-2">
50
+ <div>
51
+ <div className="text-xl font-bold text-foreground mb-0.5">{title}</div>
52
+ <div className="flex items-center gap-2">
53
+ <StatusBadge status={status} />
54
+ {dueDate && <DueDateDisplay dueDate={dueDate} size="small" />}
55
+ {maxScore != null && (
56
+ <span className="text-sm text-muted-foreground">{maxScore} points</span>
57
+ )}
58
+ </div>
59
+ </div>
60
+ </div>
61
+
62
+ {/* Instructions */}
63
+ <Card className="mb-3">
64
+ <CardContent className="pt-6">
65
+ <div className="font-semibold text-sm text-foreground mb-1">Instructions</div>
66
+ <div className="text-muted-foreground text-sm">
67
+ {typeof instructions === "string" ? (
68
+ <span>{instructions}</span>
69
+ ) : (
70
+ instructions
71
+ )}
72
+ </div>
73
+ </CardContent>
74
+ </Card>
75
+
76
+ {/* Grade display */}
77
+ {grade && (
78
+ <Alert variant="success" className="mb-3">
79
+ <AlertDescription>
80
+ <div className="font-semibold text-sm mb-1">
81
+ Grade: {grade.score}{maxScore != null ? ` / ${maxScore}` : ""}
82
+ </div>
83
+ {grade.feedback && (
84
+ <div>{grade.feedback}</div>
85
+ )}
86
+ </AlertDescription>
87
+ </Alert>
88
+ )}
89
+
90
+ {/* Submission area */}
91
+ {isEditable && (
92
+ <>
93
+ {submissionTypes.length > 1 ? (
94
+ <Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "text" | "file" | "url")}>
95
+ <TabsList>
96
+ {submissionTypes.includes("text") && <TabsTrigger value="text">Text</TabsTrigger>}
97
+ {submissionTypes.includes("file") && <TabsTrigger value="file">File Upload</TabsTrigger>}
98
+ {submissionTypes.includes("url") && <TabsTrigger value="url">URL</TabsTrigger>}
99
+ </TabsList>
100
+ {submissionTypes.includes("text") && (
101
+ <TabsContent value="text">
102
+ <Textarea
103
+ className="min-h-45"
104
+ placeholder="Type your submission..."
105
+ value={textContent}
106
+ onChange={(e) => setTextContent(e.target.value)}
107
+ />
108
+ </TabsContent>
109
+ )}
110
+ {submissionTypes.includes("file") && (
111
+ <TabsContent value="file">
112
+ <FileUploadZone
113
+ files={files}
114
+ onFilesAdded={(newFiles) => setFiles((prev) => [...prev, ...newFiles])}
115
+ onFileRemove={(index) => setFiles((prev) => prev.filter((_, i) => i !== index))}
116
+ accept={fileConstraints?.acceptedTypes}
117
+ maxFiles={fileConstraints?.maxFiles}
118
+ maxSizeMB={fileConstraints?.maxSizeMB}
119
+ />
120
+ </TabsContent>
121
+ )}
122
+ {submissionTypes.includes("url") && (
123
+ <TabsContent value="url">
124
+ <Input
125
+ placeholder="https://..."
126
+ value={url}
127
+ onChange={(e) => setUrl(e.target.value)}
128
+ />
129
+ </TabsContent>
130
+ )}
131
+ </Tabs>
132
+ ) : (
133
+ <>
134
+ {submissionTypes.includes("text") && (
135
+ <Textarea
136
+ className="min-h-45 mb-2"
137
+ placeholder="Type your submission..."
138
+ value={textContent}
139
+ onChange={(e) => setTextContent(e.target.value)}
140
+ />
141
+ )}
142
+ {submissionTypes.includes("file") && (
143
+ <div className="mb-2">
144
+ <FileUploadZone
145
+ files={files}
146
+ onFilesAdded={(newFiles) => setFiles((prev) => [...prev, ...newFiles])}
147
+ onFileRemove={(index) => setFiles((prev) => prev.filter((_, i) => i !== index))}
148
+ accept={fileConstraints?.acceptedTypes}
149
+ maxFiles={fileConstraints?.maxFiles}
150
+ maxSizeMB={fileConstraints?.maxSizeMB}
151
+ />
152
+ </div>
153
+ )}
154
+ {submissionTypes.includes("url") && (
155
+ <Input
156
+ className="mb-2"
157
+ placeholder="https://..."
158
+ value={url}
159
+ onChange={(e) => setUrl(e.target.value)}
160
+ />
161
+ )}
162
+ </>
163
+ )}
164
+
165
+ <Separator className="my-2" />
166
+
167
+ <div className="flex gap-2 justify-end">
168
+ {onSaveDraft && (
169
+ <Button
170
+ variant="outline"
171
+ onClick={() => onSaveDraft(getSubmissionData())}
172
+ disabled={isSubmitting}
173
+ >
174
+ <Save size={16} />
175
+ Save Draft
176
+ </Button>
177
+ )}
178
+ <Button
179
+ onClick={() => onSubmit(getSubmissionData())}
180
+ disabled={isSubmitting}
181
+ >
182
+ <Send size={16} />
183
+ {isSubmitting ? "Submitting..." : "Submit"}
184
+ </Button>
185
+ </div>
186
+ </>
187
+ )}
188
+ </div>
189
+ );
190
+ }
@@ -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
+ }