@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 @@
1
+ import "@testing-library/jest-dom/vitest";
@@ -0,0 +1,96 @@
1
+ import { ChevronLeft, ChevronRight, Send } from "lucide-react";
2
+ import { TimerDisplay } from "./timer-display";
3
+ import { QuestionNavigator } from "./question-navigator";
4
+ import { Button } from "../ui/button";
5
+ import type { AssessmentToolbarProps } from "./types";
6
+
7
+ export const AssessmentToolbar = ({
8
+ currentQuestionIndex,
9
+ totalQuestions,
10
+ hasNext,
11
+ hasPrevious,
12
+ onNext,
13
+ onPrevious,
14
+ onSubmit,
15
+ timeElapsedSeconds,
16
+ timeLimitSeconds,
17
+ questions,
18
+ onNavigateToQuestion,
19
+ onToggleFlag,
20
+ currentQuestionUid,
21
+ isCompleted = false,
22
+ isSubmitting = false,
23
+ readOnly = false,
24
+ }: AssessmentToolbarProps) => {
25
+ const showTimer =
26
+ timeElapsedSeconds != null && timeElapsedSeconds >= 0;
27
+ const showNavigator = questions && questions.length > 0;
28
+
29
+ return (
30
+ <div className="rounded-lg border border-border overflow-hidden shadow-sm">
31
+ {/* Header */}
32
+ <div className="flex items-center justify-between px-4 py-2.5 bg-muted border-b border-border">
33
+ <span className="text-sm font-medium text-foreground">
34
+ Question{" "}
35
+ <span className="font-bold">{currentQuestionIndex + 1}</span>
36
+ {" "}of {totalQuestions}
37
+ </span>
38
+
39
+ {showTimer && (
40
+ <TimerDisplay
41
+ timeElapsedSeconds={timeElapsedSeconds}
42
+ timeLimitSeconds={timeLimitSeconds}
43
+ />
44
+ )}
45
+ </div>
46
+
47
+ {/* Navigator */}
48
+ {showNavigator && (
49
+ <div className="px-4 py-3 border-b border-border">
50
+ <QuestionNavigator
51
+ questions={questions}
52
+ currentQuestionUid={currentQuestionUid}
53
+ onNavigate={onNavigateToQuestion}
54
+ onToggleFlag={onToggleFlag}
55
+ readOnly={readOnly}
56
+ />
57
+ </div>
58
+ )}
59
+
60
+ {/* Navigation buttons */}
61
+ <div className="flex items-center justify-between gap-2 px-4 py-3">
62
+ <Button
63
+ variant="outline"
64
+ size="sm"
65
+ className="rounded-lg"
66
+ disabled={!hasPrevious || readOnly}
67
+ onClick={onPrevious}
68
+ >
69
+ <ChevronLeft size={16} /> Previous
70
+ </Button>
71
+
72
+ {!hasNext || isCompleted ? (
73
+ <Button
74
+ variant={isCompleted ? "secondary" : "default"}
75
+ size="sm"
76
+ className="rounded-lg"
77
+ onClick={onSubmit}
78
+ disabled={readOnly || isSubmitting}
79
+ >
80
+ {isSubmitting ? "Submitting..." : isCompleted ? "Review" : "Submit"}
81
+ {!isSubmitting && <Send size={14} />}
82
+ </Button>
83
+ ) : (
84
+ <Button
85
+ size="sm"
86
+ className="rounded-lg"
87
+ disabled={!hasNext || readOnly}
88
+ onClick={onNext}
89
+ >
90
+ Next <ChevronRight size={16} />
91
+ </Button>
92
+ )}
93
+ </div>
94
+ </div>
95
+ );
96
+ };
@@ -0,0 +1,10 @@
1
+ export { AssessmentToolbar } from "./assessment-toolbar";
2
+ export { TimerDisplay } from "./timer-display";
3
+ export { QuestionNavigator } from "./question-navigator";
4
+
5
+ export type {
6
+ AssessmentToolbarProps,
7
+ TimerDisplayProps,
8
+ QuestionNavigatorProps,
9
+ QuestionNavigatorItem,
10
+ } from "./types";
@@ -0,0 +1,86 @@
1
+ import { Flag } from "lucide-react";
2
+ import type { QuestionNavigatorProps } from "./types";
3
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
4
+ import { cn } from "../lib/utils";
5
+
6
+ const CHIP_CLASSES = {
7
+ current: "bg-primary text-primary-foreground border-primary",
8
+ flagged: "bg-warning/10 text-warning border-warning/30",
9
+ answered: "bg-success/10 text-success border-success/30",
10
+ default: "bg-background text-muted-foreground border-border",
11
+ } as const;
12
+
13
+ const getChipVariant = (
14
+ q: { uid: string; isFlagged: boolean; isAnswered: boolean },
15
+ currentUid?: string,
16
+ ): keyof typeof CHIP_CLASSES => {
17
+ if (q.uid === currentUid) return "current";
18
+ if (q.isFlagged) return "flagged";
19
+ if (q.isAnswered) return "answered";
20
+ return "default";
21
+ };
22
+
23
+ export const QuestionNavigator = ({
24
+ questions,
25
+ currentQuestionUid,
26
+ onNavigate,
27
+ onToggleFlag,
28
+ readOnly = false,
29
+ }: QuestionNavigatorProps) => {
30
+ const getTooltipContent = (q: (typeof questions)[0]): string => {
31
+ const parts: string[] = [`Question ${q.sequence + 1}`];
32
+ if (q.isFlagged) parts.push("Flagged");
33
+ if (q.isAnswered) parts.push("Answered");
34
+ if (q.isSkipped) parts.push("Skipped");
35
+ return parts.join(" \u00B7 ");
36
+ };
37
+
38
+ return (
39
+ <div className="flex flex-wrap gap-1.5">
40
+ {questions.map((q) => {
41
+ const variant = getChipVariant(q, currentQuestionUid);
42
+ return (
43
+ <div
44
+ key={q.uid}
45
+ title={getTooltipContent(q)}
46
+ className={cn(
47
+ "inline-flex items-center gap-1 px-2 py-1 rounded-md border transition-all duration-150",
48
+ CHIP_CLASSES[variant],
49
+ readOnly ? "cursor-default" : "cursor-pointer hover:shadow-sm hover:-translate-y-px",
50
+ )}
51
+ onClick={readOnly ? undefined : () => onNavigate?.(q.uid)}
52
+ >
53
+ <span className="text-xs font-semibold min-w-4 text-center">
54
+ {q.sequence + 1}
55
+ </span>
56
+ {!readOnly && onToggleFlag && (
57
+ <Tooltip>
58
+ <TooltipTrigger>
59
+ <span
60
+ role="button"
61
+ aria-label={q.isFlagged ? "Unflag question" : "Flag question"}
62
+ className={cn(
63
+ "inline-flex cursor-pointer transition-colors duration-150",
64
+ q.isFlagged
65
+ ? "text-warning"
66
+ : variant === "current"
67
+ ? "text-primary-foreground/60 hover:text-primary-foreground"
68
+ : "text-muted-foreground hover:text-warning",
69
+ )}
70
+ onClick={(e: React.MouseEvent) => {
71
+ e.stopPropagation();
72
+ onToggleFlag(q.uid);
73
+ }}
74
+ >
75
+ <Flag size={11} fill={q.isFlagged ? "currentColor" : "none"} />
76
+ </span>
77
+ </TooltipTrigger>
78
+ <TooltipContent>{q.isFlagged ? "Unflag question" : "Flag question"}</TooltipContent>
79
+ </Tooltip>
80
+ )}
81
+ </div>
82
+ );
83
+ })}
84
+ </div>
85
+ );
86
+ };
@@ -0,0 +1,73 @@
1
+ import { Clock } from "lucide-react";
2
+ import { cn } from "../lib/utils";
3
+ import { formatTimer } from "../utils/format-duration";
4
+ import type { TimerDisplayProps } from "./types";
5
+
6
+ function getTimerClasses(isDanger: boolean, isWarning: boolean) {
7
+ if (isDanger) {
8
+ return {
9
+ wrapper: "border-destructive/30 bg-destructive/10 text-destructive",
10
+ bold: true,
11
+ };
12
+ }
13
+ if (isWarning) {
14
+ return {
15
+ wrapper: "border-warning/30 bg-warning/10 text-warning",
16
+ bold: true,
17
+ };
18
+ }
19
+ return {
20
+ wrapper: "border-border bg-muted text-muted-foreground",
21
+ bold: false,
22
+ };
23
+ }
24
+
25
+ export const TimerDisplay = ({
26
+ timeElapsedSeconds,
27
+ timeLimitSeconds,
28
+ variant = "compact",
29
+ }: TimerDisplayProps) => {
30
+ const hasTimeLimit = timeLimitSeconds != null && timeLimitSeconds > 0;
31
+ const remainingSeconds = hasTimeLimit
32
+ ? Math.max(0, timeLimitSeconds - timeElapsedSeconds)
33
+ : 0;
34
+
35
+ const isWarning = hasTimeLimit && remainingSeconds < timeLimitSeconds * 0.1;
36
+ const isDanger = hasTimeLimit && remainingSeconds <= 60;
37
+
38
+ const displayTime = hasTimeLimit
39
+ ? formatTimer(remainingSeconds)
40
+ : formatTimer(timeElapsedSeconds);
41
+
42
+ const classes = getTimerClasses(isDanger, isWarning);
43
+
44
+ if (variant === "compact") {
45
+ return (
46
+ <div
47
+ className={cn(
48
+ "flex items-center gap-1.5 px-2.5 py-1 rounded-full border",
49
+ classes.wrapper,
50
+ )}
51
+ >
52
+ <span className="inline-flex leading-none">
53
+ <Clock size={13} />
54
+ </span>
55
+ <span className="text-xs font-semibold font-mono tracking-wide">
56
+ {displayTime}
57
+ </span>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className={cn("flex items-center gap-1.5", classes.wrapper)}>
64
+ <span className="inline-flex leading-none">
65
+ <Clock size={16} />
66
+ </span>
67
+ <span className={cn("text-sm", classes.bold && "font-semibold")}>
68
+ {hasTimeLimit ? "Time remaining: " : "Time elapsed: "}
69
+ <span className="font-mono font-semibold">{displayTime}</span>
70
+ </span>
71
+ </div>
72
+ );
73
+ };
@@ -0,0 +1,92 @@
1
+ /**
2
+ * AssessmentToolbar provides navigation controls, timer, and question overview for quiz/assessment sessions.
3
+ *
4
+ * @example
5
+ * <AssessmentToolbar
6
+ * currentQuestionIndex={0}
7
+ * totalQuestions={10}
8
+ * hasNext
9
+ * hasPrevious={false}
10
+ * onNext={() => setIndex(i => i + 1)}
11
+ * onPrevious={() => setIndex(i => i - 1)}
12
+ * onSubmit={handleSubmit}
13
+ * timeElapsedSeconds={120}
14
+ * timeLimitSeconds={1800}
15
+ * />
16
+ */
17
+ export interface AssessmentToolbarProps {
18
+ /** Zero-based index of the current question */
19
+ currentQuestionIndex: number;
20
+ /** Total number of questions in the assessment */
21
+ totalQuestions: number;
22
+ /** Whether a next question is available */
23
+ hasNext: boolean;
24
+ /** Whether a previous question is available */
25
+ hasPrevious: boolean;
26
+ /** Called when the user clicks Next */
27
+ onNext: () => void;
28
+ /** Called when the user clicks Previous */
29
+ onPrevious: () => void;
30
+ /** Called when the user submits the assessment */
31
+ onSubmit: () => void;
32
+ /** Elapsed time in seconds for the timer display */
33
+ timeElapsedSeconds?: number;
34
+ /** Time limit in seconds — when set, shows countdown instead of elapsed */
35
+ timeLimitSeconds?: number;
36
+ /** List of questions for the navigator chips */
37
+ questions?: QuestionNavigatorItem[];
38
+ /** Called when the user navigates to a specific question via the navigator */
39
+ onNavigateToQuestion?: (questionUid: string) => void;
40
+ /** Called when the user flags or unflags a question */
41
+ onToggleFlag?: (questionUid: string) => void;
42
+ /** UID of the currently active question */
43
+ currentQuestionUid?: string;
44
+ /** Whether the assessment has been completed/submitted */
45
+ isCompleted?: boolean;
46
+ /** Whether the submit action is in progress */
47
+ isSubmitting?: boolean;
48
+ /** When true, disables all interactive controls */
49
+ readOnly?: boolean;
50
+ }
51
+
52
+ /**
53
+ * TimerDisplay shows elapsed or remaining time for an assessment.
54
+ *
55
+ * @example
56
+ * <TimerDisplay timeElapsedSeconds={90} timeLimitSeconds={600} variant="compact" />
57
+ */
58
+ export interface TimerDisplayProps {
59
+ /** Total seconds elapsed since the assessment started */
60
+ timeElapsedSeconds: number;
61
+ /** Optional time limit in seconds — enables countdown mode */
62
+ timeLimitSeconds?: number;
63
+ /** Display variant: compact chip or full text */
64
+ variant?: "compact" | "full";
65
+ }
66
+
67
+ /**
68
+ * QuestionNavigator renders chip-based navigation for quickly jumping between questions.
69
+ *
70
+ * @example
71
+ * <QuestionNavigator
72
+ * questions={questions}
73
+ * currentQuestionUid={currentUid}
74
+ * onNavigate={handleNavigate}
75
+ * onToggleFlag={handleFlag}
76
+ * />
77
+ */
78
+ export interface QuestionNavigatorProps {
79
+ questions: QuestionNavigatorItem[];
80
+ currentQuestionUid?: string;
81
+ onNavigate?: (questionUid: string) => void;
82
+ onToggleFlag?: (questionUid: string) => void;
83
+ readOnly?: boolean;
84
+ }
85
+
86
+ export interface QuestionNavigatorItem {
87
+ uid: string;
88
+ sequence: number;
89
+ isFlagged: boolean;
90
+ isAnswered: boolean;
91
+ isSkipped: boolean;
92
+ }
Binary file
@@ -0,0 +1,18 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
2
+ <!-- Center cobra hood -->
3
+ <path d="M9 12 C9 7 10 3 12 3 C14 3 15 7 15 12" />
4
+ <!-- Center hood inner curls -->
5
+ <path d="M10 10 C10.5 7 12 9 12 9 C12 9 13.5 7 14 10" />
6
+ <!-- Center jaw -->
7
+ <path d="M10 12.5 C10.5 14 13.5 14 14 12.5" />
8
+ <!-- Left cobra — large coil -->
9
+ <path d="M6.5 13 C3.5 12 1 9 1 6 C1 3 3 1.5 5.5 3.5 C7.5 5.5 7.5 9 6.5 13" />
10
+ <!-- Right cobra — large coil -->
11
+ <path d="M17.5 13 C20.5 12 23 9 23 6 C23 3 21 1.5 18.5 3.5 C16.5 5.5 16.5 9 17.5 13" />
12
+ <!-- Left neck -->
13
+ <path d="M6.5 13 C7.5 16 9 19 10.5 22" />
14
+ <!-- Center neck -->
15
+ <path d="M12 14 L12 22" />
16
+ <!-- Right neck -->
17
+ <path d="M17.5 13 C16.5 16 15 19 13.5 22" />
18
+ </svg>
Binary file
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
3
+ <path d="M10.5 9.5C9 5 5 1.5 2 2.5"/>
4
+ <path d="M12 9C14 4.5 18 3 22 2"/>
5
+ <path d="M15 10.5C19 8 22 8.5 23 6"/>
6
+ <path d="M9 14C5 15 2.5 18.5 1.5 22"/>
7
+ <path d="M12 15C11 19 12 21.5 11 23"/>
8
+ <path d="M14.5 14.5C18 17 20.5 20.5 22 23"/>
9
+ </svg>
@@ -0,0 +1,60 @@
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogContent,
4
+ AlertDialogHeader,
5
+ AlertDialogFooter,
6
+ AlertDialogTitle,
7
+ AlertDialogDescription,
8
+ AlertDialogCancel,
9
+ AlertDialogAction,
10
+ } from "../ui/alert-dialog";
11
+ import { cn } from "../lib/utils";
12
+ import type { ConfirmDialogProps } from "./types";
13
+
14
+ const CONFIRM_VARIANT: Record<string, string> = {
15
+ primary: "bg-primary text-primary-foreground hover:bg-primary/90",
16
+ error: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
17
+ warning: "bg-warning text-warning-foreground hover:bg-warning/90",
18
+ };
19
+
20
+ export function ConfirmDialog({
21
+ open,
22
+ title,
23
+ message,
24
+ confirmLabel = "Confirm",
25
+ cancelLabel = "Cancel",
26
+ confirmColor = "primary",
27
+ onConfirm,
28
+ onCancel,
29
+ isLoading = false,
30
+ }: ConfirmDialogProps) {
31
+ return (
32
+ <AlertDialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
33
+ <AlertDialogContent>
34
+ <AlertDialogHeader>
35
+ <AlertDialogTitle>{title}</AlertDialogTitle>
36
+ {typeof message === "string" ? (
37
+ <AlertDialogDescription>{message}</AlertDialogDescription>
38
+ ) : (
39
+ message
40
+ )}
41
+ </AlertDialogHeader>
42
+ <AlertDialogFooter>
43
+ <AlertDialogCancel onClick={onCancel} disabled={isLoading}>
44
+ {cancelLabel}
45
+ </AlertDialogCancel>
46
+ <AlertDialogAction
47
+ className={cn(CONFIRM_VARIANT[confirmColor] ?? CONFIRM_VARIANT.primary)}
48
+ onClick={onConfirm}
49
+ disabled={isLoading}
50
+ >
51
+ {isLoading && (
52
+ <span className="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
53
+ )}
54
+ {confirmLabel}
55
+ </AlertDialogAction>
56
+ </AlertDialogFooter>
57
+ </AlertDialogContent>
58
+ </AlertDialog>
59
+ );
60
+ }
@@ -0,0 +1,64 @@
1
+ import { Clock, CheckCircle } from "lucide-react";
2
+ import type { DueDateDisplayProps } from "./types";
3
+ import { cn } from "../lib/utils";
4
+
5
+ const URGENCY_CLASS: Record<string, string> = {
6
+ success: "text-success",
7
+ destructive: "text-destructive",
8
+ warning: "text-warning",
9
+ muted: "text-muted-foreground",
10
+ };
11
+
12
+ function getRelativeTime(date: Date): string {
13
+ const now = new Date();
14
+ const diffMs = date.getTime() - now.getTime();
15
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
16
+
17
+ if (diffDays === 0) return "Due today";
18
+ if (diffDays === 1) return "Due tomorrow";
19
+ if (diffDays === -1) return "Due yesterday";
20
+ if (diffDays > 1) return `Due in ${diffDays} days`;
21
+ return `${Math.abs(diffDays)} days overdue`;
22
+ }
23
+
24
+ function getUrgencyKey(date: Date, submitted: boolean): string {
25
+ if (submitted) return "success";
26
+ const now = new Date();
27
+ const diffMs = date.getTime() - now.getTime();
28
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
29
+ if (diffDays < 0) return "destructive";
30
+ if (diffDays < 2) return "warning";
31
+ return "muted";
32
+ }
33
+
34
+ export function DueDateDisplay({
35
+ dueDate,
36
+ submittedDate,
37
+ showRelative = true,
38
+ size = "medium",
39
+ className,
40
+ style,
41
+ }: DueDateDisplayProps) {
42
+ const date = new Date(dueDate);
43
+ const isSubmitted = !!submittedDate;
44
+ const colorClass = URGENCY_CLASS[getUrgencyKey(date, isSubmitted)] ?? "text-muted-foreground";
45
+ const iconSize = size === "small" ? 14 : 16;
46
+
47
+ return (
48
+ <div
49
+ className={cn("flex items-center gap-1", colorClass, className)}
50
+ style={style}
51
+ >
52
+ <span className="shrink-0 inline-flex">
53
+ {isSubmitted ? <CheckCircle size={iconSize} /> : <Clock size={iconSize} />}
54
+ </span>
55
+ <span className={size === "small" ? "text-xs" : "text-sm"}>
56
+ {isSubmitted
57
+ ? `Submitted ${new Date(submittedDate).toLocaleDateString()}`
58
+ : showRelative
59
+ ? getRelativeTime(date)
60
+ : date.toLocaleDateString()}
61
+ </span>
62
+ </div>
63
+ );
64
+ }
@@ -0,0 +1,24 @@
1
+ import type { EmptyStateProps } from "./types";
2
+ import { cn } from "../lib/utils";
3
+
4
+ export function EmptyState({ icon, title, description, action, className, style }: EmptyStateProps) {
5
+ return (
6
+ <div
7
+ className={cn("flex flex-col items-center justify-center text-center px-3 py-6", className)}
8
+ style={style}
9
+ >
10
+ {icon && (
11
+ <div className="mb-2 text-muted-foreground [&>svg]:size-12">
12
+ {icon}
13
+ </div>
14
+ )}
15
+ <p className="text-lg font-semibold text-foreground mb-1">{title}</p>
16
+ {description && (
17
+ <p className={cn("text-sm text-muted-foreground max-w-[360px]", action && "mb-2")}>
18
+ {description}
19
+ </p>
20
+ )}
21
+ {action}
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,12 @@
1
+ export { EmptyState } from "./empty-state";
2
+ export { ConfirmDialog } from "./confirm-dialog";
3
+ export { SearchInput } from "./search-input";
4
+ export { StatusBadge } from "./status-badge";
5
+ export { DueDateDisplay } from "./due-date-display";
6
+ export type {
7
+ EmptyStateProps,
8
+ ConfirmDialogProps,
9
+ SearchInputProps,
10
+ StatusBadgeProps,
11
+ DueDateDisplayProps,
12
+ } from "./types";
@@ -0,0 +1,68 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Search, X } from "lucide-react";
3
+ import { debounce } from "../utils/debounce";
4
+ import type { SearchInputProps } from "./types";
5
+ import { cn } from "../lib/utils";
6
+
7
+ export function SearchInput({
8
+ value,
9
+ onChange,
10
+ placeholder = "Search...",
11
+ debounceMs = 300,
12
+ fullWidth = false,
13
+ size = "small",
14
+ className,
15
+ style,
16
+ }: SearchInputProps) {
17
+ const [localValue, setLocalValue] = useState(value);
18
+
19
+ useEffect(() => {
20
+ setLocalValue(value);
21
+ }, [value]);
22
+
23
+ // eslint-disable-next-line react-hooks/exhaustive-deps
24
+ const debouncedOnChange = useCallback(debounce(onChange, debounceMs), [onChange, debounceMs]);
25
+
26
+ function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
27
+ const next = e.target.value;
28
+ setLocalValue(next);
29
+ debouncedOnChange(next);
30
+ }
31
+
32
+ function handleClear() {
33
+ setLocalValue("");
34
+ onChange("");
35
+ }
36
+
37
+ return (
38
+ <div
39
+ className={cn("w-full", className)}
40
+ style={{ ...style, width: fullWidth ? "100%" : undefined }}
41
+ >
42
+ <div className="flex items-center w-full border border-input rounded-md bg-background transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]">
43
+ <span className="flex items-center justify-center px-2 text-muted-foreground shrink-0">
44
+ <Search size={18} />
45
+ </span>
46
+ <input
47
+ className={cn(
48
+ "flex-1 border-none outline-none bg-transparent text-foreground text-sm min-w-0 placeholder:text-muted-foreground",
49
+ size === "medium" ? "py-2" : "py-1.5",
50
+ )}
51
+ value={localValue}
52
+ onChange={handleChange}
53
+ placeholder={placeholder}
54
+ />
55
+ {localValue && (
56
+ <button
57
+ type="button"
58
+ className="inline-flex items-center justify-center p-0.5 mr-1 rounded-md text-muted-foreground cursor-pointer transition-colors hover:bg-muted hover:text-foreground"
59
+ aria-label="Clear search"
60
+ onClick={handleClear}
61
+ >
62
+ <X size={16} />
63
+ </button>
64
+ )}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
@@ -0,0 +1,43 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { StatusBadge } from "./status-badge";
3
+
4
+ describe("StatusBadge", () => {
5
+ it("renders the status text with title case", () => {
6
+ render(<StatusBadge status="submitted" />);
7
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
8
+ });
9
+
10
+ it("converts underscores to spaces in label", () => {
11
+ render(<StatusBadge status="not_started" />);
12
+ expect(screen.getByText("Not Started")).toBeInTheDocument();
13
+ });
14
+
15
+ it("renders with default color mapping for graded", () => {
16
+ const { container } = render(<StatusBadge status="graded" />);
17
+ expect(container.firstChild).toBeInTheDocument();
18
+ });
19
+
20
+ it("renders with custom color map", () => {
21
+ render(
22
+ <StatusBadge
23
+ status="pending"
24
+ colorMap={{ pending: "warning" }}
25
+ />,
26
+ );
27
+ expect(screen.getByText("Pending")).toBeInTheDocument();
28
+ });
29
+
30
+ it("falls back to gray for unknown statuses", () => {
31
+ render(<StatusBadge status="unknown_status" />);
32
+ expect(screen.getByText("Unknown Status")).toBeInTheDocument();
33
+ });
34
+
35
+ it("renders all default status types", () => {
36
+ const statuses = ["graded", "submitted", "pending", "missing", "late", "excused", "draft", "resubmit"];
37
+ for (const status of statuses) {
38
+ const { unmount } = render(<StatusBadge status={status} />);
39
+ expect(screen.getByText(status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()))).toBeInTheDocument();
40
+ unmount();
41
+ }
42
+ });
43
+ });