@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.
- package/package.json +3 -1
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
|
@@ -0,0 +1,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
|
+
}
|