@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,44 @@
1
+ import {
2
+ Video,
3
+ FileText,
4
+ HelpCircle,
5
+ ClipboardList,
6
+ MessageSquare,
7
+ ExternalLink,
8
+ Music,
9
+ Image,
10
+ Code,
11
+ BookOpen,
12
+ PlayCircle,
13
+ type LucideIcon,
14
+ } from "lucide-react";
15
+ import type { LearningObjectIconProps } from "./types";
16
+
17
+ const ICON_MAP: Record<string, LucideIcon> = {
18
+ video: Video,
19
+ video_lesson: Video,
20
+ stream: PlayCircle,
21
+ document: FileText,
22
+ pdf: FileText,
23
+ page: FileText,
24
+ quiz: HelpCircle,
25
+ assessment: HelpCircle,
26
+ assignment: ClipboardList,
27
+ discussion: MessageSquare,
28
+ link: ExternalLink,
29
+ url: ExternalLink,
30
+ audio: Music,
31
+ image: Image,
32
+ scorm: Code,
33
+ iframe: Code,
34
+ lesson: BookOpen,
35
+ module: BookOpen,
36
+ };
37
+
38
+ export const LearningObjectIcon = ({
39
+ type,
40
+ size = 18,
41
+ }: LearningObjectIconProps) => {
42
+ const IconComponent = ICON_MAP[type] ?? FileText;
43
+ return <IconComponent size={size} />;
44
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * CurriculumTree component for rendering a hierarchical course curriculum with expand/collapse support.
3
+ *
4
+ * @example
5
+ * <CurriculumTree
6
+ * items={curriculum}
7
+ * progress={userProgress}
8
+ * activeItemUid={currentLessonUid}
9
+ * onItemClick={(item) => navigate(item.uid)}
10
+ * showProgress
11
+ * />
12
+ */
13
+ export interface CurriculumTreeProps {
14
+ /** Hierarchical curriculum items to display */
15
+ items: CurriculumItem[];
16
+ /** User progress data for completion indicators */
17
+ progress?: CurriculumItemProgress[];
18
+ /** UID of the currently active item */
19
+ activeItemUid?: string;
20
+ /** Called when the user clicks a curriculum item */
21
+ onItemClick?: (item: CurriculumItem) => void;
22
+ /** When true, disables item click interactions */
23
+ readOnly?: boolean;
24
+ /** Whether to show estimated duration for each item */
25
+ showDuration?: boolean;
26
+ /** Whether to show learning object type icons */
27
+ showIcons?: boolean;
28
+ /** Whether to show completion progress indicators */
29
+ showProgress?: boolean;
30
+ }
31
+
32
+ /**
33
+ * CurriculumItemRow renders a single row in the curriculum tree.
34
+ *
35
+ * @example
36
+ * <CurriculumItemRow
37
+ * item={item}
38
+ * level={0}
39
+ * isActive
40
+ * hasChildren={false}
41
+ * />
42
+ */
43
+ export interface CurriculumItemRowProps {
44
+ item: CurriculumItem;
45
+ level: number;
46
+ isActive?: boolean;
47
+ isCompleted?: boolean;
48
+ isExpanded?: boolean;
49
+ hasChildren: boolean;
50
+ onToggleExpand?: () => void;
51
+ onClick?: () => void;
52
+ showDuration?: boolean;
53
+ showIcon?: boolean;
54
+ showProgress?: boolean;
55
+ }
56
+
57
+ /**
58
+ * LearningObjectIcon renders the appropriate icon for a given learning object type.
59
+ *
60
+ * @example
61
+ * <LearningObjectIcon type="video" size={18} />
62
+ */
63
+ export interface LearningObjectIconProps {
64
+ /** Learning object type string (e.g. "video", "quiz", "document") */
65
+ type: string;
66
+ /** Icon size in pixels */
67
+ size?: number;
68
+ }
69
+
70
+ export interface CurriculumItem {
71
+ uid: string;
72
+ name: string;
73
+ type: string;
74
+ duration: number;
75
+ sequence: number;
76
+ children?: CurriculumItem[];
77
+ }
78
+
79
+ export interface CurriculumItemProgress {
80
+ resourceUid: string;
81
+ isCompleted: boolean;
82
+ timeSpent?: number;
83
+ }
@@ -0,0 +1,46 @@
1
+ import { CheckCircle, XCircle } from "lucide-react";
2
+ import type { FeedbackBannerProps } from "./types";
3
+ import { cn } from "../lib/utils";
4
+
5
+ export function FeedbackBanner({
6
+ isCorrect,
7
+ explanation,
8
+ onRetry,
9
+ retryLabel = "Try Again",
10
+ className,
11
+ style,
12
+ }: FeedbackBannerProps) {
13
+ return (
14
+ <div
15
+ role="alert"
16
+ className={cn(
17
+ "flex items-start gap-2 rounded-md border p-3 text-sm",
18
+ isCorrect
19
+ ? "border-success/50 bg-success/5"
20
+ : "border-destructive/50 bg-destructive/5",
21
+ className,
22
+ )}
23
+ style={style}
24
+ >
25
+ <span className={cn("shrink-0", isCorrect ? "text-success" : "text-destructive")}>
26
+ {isCorrect ? <CheckCircle size={20} /> : <XCircle size={20} />}
27
+ </span>
28
+ <div className="flex-1">
29
+ <div className="flex items-start justify-between gap-2">
30
+ <div>
31
+ <span className="font-semibold">{isCorrect ? "Correct!" : "Incorrect"}</span>
32
+ {explanation && <p className="mt-1 text-foreground">{explanation}</p>}
33
+ </div>
34
+ {!isCorrect && onRetry && (
35
+ <button
36
+ className="shrink-0 rounded-md border border-border bg-background px-3 py-1 text-sm font-medium transition-colors hover:bg-muted"
37
+ onClick={onRetry}
38
+ >
39
+ {retryLabel}
40
+ </button>
41
+ )}
42
+ </div>
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,8 @@
1
+ export { FeedbackBanner } from "./feedback-banner";
2
+ export { LikertScale } from "./likert-scale";
3
+ export { StarRating } from "./star-rating";
4
+ export type {
5
+ FeedbackBannerProps,
6
+ LikertScaleProps,
7
+ StarRatingProps,
8
+ } from "./types";
@@ -0,0 +1,58 @@
1
+ import type { LikertScaleProps } from "./types";
2
+ import { cn } from "../lib/utils";
3
+
4
+ export function LikertScale({
5
+ value,
6
+ onChange,
7
+ points = 5,
8
+ lowLabel = "Strongly Disagree",
9
+ highLabel = "Strongly Agree",
10
+ disabled = false,
11
+ readOnly = false,
12
+ className,
13
+ style,
14
+ }: LikertScaleProps) {
15
+ const options = Array.from({ length: points }, (_, i) => i + 1);
16
+ const isInteractive = !disabled && !readOnly;
17
+
18
+ return (
19
+ <div className={cn("flex flex-col gap-1", className)} style={style}>
20
+ <div className="flex flex-row" role="radiogroup">
21
+ {options.map((n) => {
22
+ const isSelected = value === n;
23
+
24
+ return (
25
+ <label
26
+ key={n}
27
+ className={cn(
28
+ "flex items-center justify-center border border-border px-1.5 py-2 text-sm transition-colors first:rounded-l-md last:rounded-r-md -ml-px first:ml-0",
29
+ isSelected
30
+ ? "bg-primary text-primary-foreground border-primary z-10"
31
+ : "bg-background text-foreground",
32
+ !isInteractive && "opacity-60 cursor-not-allowed",
33
+ isInteractive && !isSelected && "cursor-pointer hover:bg-muted",
34
+ )}
35
+ >
36
+ <input
37
+ type="radio"
38
+ name="likert-scale"
39
+ className="sr-only"
40
+ value={n}
41
+ checked={isSelected}
42
+ disabled={disabled}
43
+ onChange={() => {
44
+ if (isInteractive) onChange(n);
45
+ }}
46
+ />
47
+ <span className="text-sm font-medium">{n}</span>
48
+ </label>
49
+ );
50
+ })}
51
+ </div>
52
+ <div className="flex justify-between">
53
+ <span className="text-xs text-muted-foreground">{lowLabel}</span>
54
+ <span className="text-xs text-muted-foreground">{highLabel}</span>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,65 @@
1
+ import { useState } from "react";
2
+ import { Star } from "lucide-react";
3
+ import type { StarRatingProps } from "./types";
4
+ import { cn } from "../lib/utils";
5
+
6
+ const SIZES = { small: 18, medium: 24, large: 32 };
7
+
8
+ export function StarRating({
9
+ value,
10
+ onChange,
11
+ maxStars = 5,
12
+ allowHalf = false,
13
+ size = "medium",
14
+ disabled = false,
15
+ readOnly = false,
16
+ className,
17
+ style,
18
+ }: StarRatingProps) {
19
+ const [hoverValue, setHoverValue] = useState<number | null>(null);
20
+ const iconSize = SIZES[size];
21
+ const displayValue = hoverValue ?? value;
22
+ const isInteractive = !disabled && !readOnly;
23
+
24
+ function handleClick(starIndex: number) {
25
+ if (!isInteractive) return;
26
+ onChange(starIndex);
27
+ }
28
+
29
+ return (
30
+ <div
31
+ className={cn("inline-flex gap-0.5", className)}
32
+ style={style}
33
+ onMouseLeave={() => isInteractive && setHoverValue(null)}
34
+ >
35
+ {Array.from({ length: maxStars }, (_, i) => {
36
+ const starValue = i + 1;
37
+ const isFilled = displayValue >= starValue;
38
+ const isHalf = allowHalf && !isFilled && displayValue >= starValue - 0.5;
39
+
40
+ return (
41
+ <button
42
+ key={i}
43
+ type="button"
44
+ className={cn(
45
+ "inline-flex items-center justify-center rounded-md border-none bg-transparent p-0",
46
+ isInteractive ? "cursor-pointer" : "cursor-default",
47
+ isFilled || isHalf ? "text-warning" : "text-muted-foreground",
48
+ )}
49
+ disabled={disabled}
50
+ onClick={() => handleClick(starValue)}
51
+ onMouseEnter={() => isInteractive && setHoverValue(starValue)}
52
+ aria-label={`Rate ${starValue} star${starValue !== 1 ? "s" : ""}`}
53
+ >
54
+ <Star
55
+ size={iconSize}
56
+ fill={isFilled ? "currentColor" : isHalf ? "currentColor" : "none"}
57
+ strokeWidth={1.5}
58
+ style={isHalf ? { clipPath: "inset(0 50% 0 0)" } : undefined}
59
+ />
60
+ </button>
61
+ );
62
+ })}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,86 @@
1
+
2
+ /**
3
+ * FeedbackBanner shows a correct/incorrect status banner with an optional
4
+ * explanation and retry button after answering a question.
5
+ *
6
+ * @example
7
+ * <FeedbackBanner
8
+ * isCorrect={false}
9
+ * explanation="The correct answer is React because it uses a virtual DOM."
10
+ * onRetry={() => resetAnswer()}
11
+ * />
12
+ */
13
+ export interface FeedbackBannerProps {
14
+ /** Whether the answer was correct */
15
+ isCorrect: boolean;
16
+ /** Explanation text shown below the status */
17
+ explanation?: string;
18
+ /** Called when the user clicks the retry button */
19
+ onRetry?: () => void;
20
+ /** Label for the retry button */
21
+ retryLabel?: string;
22
+ /** CSS class name for the root element */
23
+ className?: string;
24
+ /** Inline styles for the root element */
25
+ style?: React.CSSProperties;
26
+ }
27
+
28
+ /**
29
+ * LikertScale renders a horizontal 5 or 7 point agreement scale
30
+ * commonly used in surveys and feedback forms.
31
+ *
32
+ * @example
33
+ * <LikertScale
34
+ * value={3}
35
+ * onChange={(v) => setRating(v)}
36
+ * lowLabel="Strongly Disagree"
37
+ * highLabel="Strongly Agree"
38
+ * />
39
+ */
40
+ export interface LikertScaleProps {
41
+ /** Currently selected value (1-based), or null if none selected */
42
+ value: number | null;
43
+ /** Called when the user selects a point */
44
+ onChange: (value: number) => void;
45
+ /** Number of scale points */
46
+ points?: 5 | 7;
47
+ /** Label for the low end of the scale */
48
+ lowLabel?: string;
49
+ /** Label for the high end of the scale */
50
+ highLabel?: string;
51
+ /** Whether the input is disabled */
52
+ disabled?: boolean;
53
+ /** When true, disables interaction */
54
+ readOnly?: boolean;
55
+ /** CSS class name for the root element */
56
+ className?: string;
57
+ /** Inline styles for the root element */
58
+ style?: React.CSSProperties;
59
+ }
60
+
61
+ /**
62
+ * StarRating renders a 1–5 star rating input with hover preview.
63
+ *
64
+ * @example
65
+ * <StarRating value={4} onChange={(v) => setRating(v)} />
66
+ */
67
+ export interface StarRatingProps {
68
+ /** Current rating value */
69
+ value: number;
70
+ /** Called when the user selects a rating */
71
+ onChange: (value: number) => void;
72
+ /** Maximum number of stars */
73
+ maxStars?: number;
74
+ /** Whether to allow half-star increments */
75
+ allowHalf?: boolean;
76
+ /** Star size */
77
+ size?: "small" | "medium" | "large";
78
+ /** Whether the input is disabled */
79
+ disabled?: boolean;
80
+ /** When true, disables interaction */
81
+ readOnly?: boolean;
82
+ /** CSS class name for the root element */
83
+ className?: string;
84
+ /** Inline styles for the root element */
85
+ style?: React.CSSProperties;
86
+ }
@@ -0,0 +1,130 @@
1
+ import { useState, useMemo } from "react";
2
+ import { ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { Flashcard } from "./flashcard";
4
+ import { Button } from "../ui/button";
5
+ import type { FlashcardDeckProps } from "./types";
6
+
7
+ function shuffle<T>(array: T[]): T[] {
8
+ const shuffled = [...array];
9
+ for (let i = shuffled.length - 1; i > 0; i--) {
10
+ const j = Math.floor(Math.random() * (i + 1));
11
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
12
+ }
13
+ return shuffled;
14
+ }
15
+
16
+ export const FlashcardDeck = ({
17
+ cards,
18
+ deckName,
19
+ deckDescription,
20
+ currentIndex: controlledIndex,
21
+ onNavigate,
22
+ onComplete,
23
+ readOnly = false,
24
+ showProgress = true,
25
+ shuffled = false,
26
+ }: FlashcardDeckProps) => {
27
+ const orderedCards = useMemo(
28
+ () => (shuffled ? shuffle(cards) : cards),
29
+ [cards, shuffled],
30
+ );
31
+
32
+ const [internalIndex, setInternalIndex] = useState(0);
33
+ const [isFlipped, setIsFlipped] = useState(false);
34
+
35
+ const isControlled = controlledIndex !== undefined;
36
+ const currentIndex = isControlled ? controlledIndex : internalIndex;
37
+ const currentCard = orderedCards[currentIndex];
38
+
39
+ const hasPrevious = currentIndex > 0;
40
+ const hasNext = currentIndex < orderedCards.length - 1;
41
+
42
+ const handleNavigate = (index: number) => {
43
+ setIsFlipped(false);
44
+ if (isControlled) {
45
+ onNavigate?.(index);
46
+ } else {
47
+ setInternalIndex(index);
48
+ }
49
+ };
50
+
51
+ const handlePrevious = () => {
52
+ if (hasPrevious) handleNavigate(currentIndex - 1);
53
+ };
54
+
55
+ const handleNext = () => {
56
+ if (hasNext) {
57
+ handleNavigate(currentIndex + 1);
58
+ } else {
59
+ onComplete?.();
60
+ }
61
+ };
62
+
63
+ if (!orderedCards.length) {
64
+ return (
65
+ <span className="text-sm text-muted-foreground">
66
+ No flashcards in this deck.
67
+ </span>
68
+ );
69
+ }
70
+
71
+ const progress = ((currentIndex + 1) / orderedCards.length) * 100;
72
+
73
+ return (
74
+ <div className="flex flex-col items-center gap-4">
75
+ {(deckName || deckDescription) && (
76
+ <div className="flex flex-col gap-0.5 text-center">
77
+ {deckName && (
78
+ <span className="text-lg font-semibold">{deckName}</span>
79
+ )}
80
+ {deckDescription && (
81
+ <span className="text-sm text-muted-foreground">{deckDescription}</span>
82
+ )}
83
+ </div>
84
+ )}
85
+
86
+ {currentCard && (
87
+ <Flashcard
88
+ card={currentCard}
89
+ isFlipped={isFlipped}
90
+ onFlip={() => setIsFlipped((prev) => !prev)}
91
+ readOnly={readOnly}
92
+ />
93
+ )}
94
+
95
+ {showProgress && (
96
+ <div className="flex w-full items-center gap-2">
97
+ <div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
98
+ <div
99
+ className="h-full rounded-full bg-primary transition-[width] duration-300"
100
+ style={{ width: `${progress}%` }}
101
+ />
102
+ </div>
103
+ <span className="text-xs text-muted-foreground whitespace-nowrap">
104
+ {currentIndex + 1} of {orderedCards.length}
105
+ </span>
106
+ </div>
107
+ )}
108
+
109
+ <div className="flex w-full items-center justify-between gap-3">
110
+ <Button
111
+ variant="outline"
112
+ size="sm"
113
+ className="rounded-lg"
114
+ disabled={!hasPrevious}
115
+ onClick={handlePrevious}
116
+ >
117
+ <ChevronLeft size={16} /> Previous
118
+ </Button>
119
+
120
+ <Button
121
+ size="sm"
122
+ className="rounded-lg"
123
+ onClick={handleNext}
124
+ >
125
+ {hasNext ? "Next" : "Finish"} <ChevronRight size={16} />
126
+ </Button>
127
+ </div>
128
+ </div>
129
+ );
130
+ };
@@ -0,0 +1,108 @@
1
+ import { useState } from "react";
2
+ import { RotateCcw } from "lucide-react";
3
+ import type { FlashcardProps } from "./types";
4
+ import { cn } from "../lib/utils";
5
+
6
+ const COLOR_MAP: Record<string, { bg: string; border: string; accent: string }> = {
7
+ color1: { bg: "bg-blue-50 dark:bg-blue-950/40", border: "border-blue-200 dark:border-blue-800", accent: "bg-blue-200 dark:bg-blue-800" },
8
+ color2: { bg: "bg-violet-50 dark:bg-violet-950/40", border: "border-violet-200 dark:border-violet-800", accent: "bg-violet-200 dark:bg-violet-800" },
9
+ color3: { bg: "bg-emerald-50 dark:bg-emerald-950/40", border: "border-emerald-200 dark:border-emerald-800", accent: "bg-emerald-200 dark:bg-emerald-800" },
10
+ color4: { bg: "bg-amber-50 dark:bg-amber-950/40", border: "border-amber-200 dark:border-amber-800", accent: "bg-amber-200 dark:bg-amber-800" },
11
+ color5: { bg: "bg-rose-50 dark:bg-rose-950/40", border: "border-rose-200 dark:border-rose-800", accent: "bg-rose-200 dark:bg-rose-800" },
12
+ color6: { bg: "bg-green-50 dark:bg-green-950/40", border: "border-green-200 dark:border-green-800", accent: "bg-green-200 dark:bg-green-800" },
13
+ };
14
+
15
+ const SIZE_MAP: Record<string, { width: number; height: number; fontSize: string }> = {
16
+ small: { width: 280, height: 180, fontSize: "0.875rem" },
17
+ medium: { width: 400, height: 260, fontSize: "1rem" },
18
+ large: { width: 520, height: 340, fontSize: "1.125rem" },
19
+ };
20
+
21
+ export const Flashcard = ({
22
+ card,
23
+ isFlipped: controlledFlipped,
24
+ onFlip,
25
+ readOnly = false,
26
+ size = "medium",
27
+ }: FlashcardProps) => {
28
+ const [internalFlipped, setInternalFlipped] = useState(false);
29
+
30
+ const isControlled = controlledFlipped !== undefined;
31
+ const isFlipped = isControlled ? controlledFlipped : internalFlipped;
32
+
33
+ const handleClick = () => {
34
+ if (readOnly) return;
35
+ if (isControlled) {
36
+ onFlip?.();
37
+ } else {
38
+ setInternalFlipped((prev) => !prev);
39
+ onFlip?.();
40
+ }
41
+ };
42
+
43
+ const { width, height, fontSize } = SIZE_MAP[size];
44
+ const colors = COLOR_MAP[card.color] || COLOR_MAP.color1;
45
+
46
+ return (
47
+ <div
48
+ className="perspective-[1000px]"
49
+ style={{ width: `${width}px`, height: `${height}px` }}
50
+ >
51
+ <div
52
+ className={cn(
53
+ "relative size-full transition-transform duration-500 transform-3d",
54
+ isFlipped && "transform-[rotateY(180deg)]",
55
+ )}
56
+ >
57
+ {/* Front */}
58
+ <div
59
+ className={cn(
60
+ "absolute inset-0 flex flex-col rounded-lg border backface-hidden",
61
+ colors.bg,
62
+ colors.border,
63
+ !readOnly && "cursor-pointer",
64
+ )}
65
+ onClick={handleClick}
66
+ >
67
+ <div className="flex flex-1 items-center justify-center p-5 overflow-auto">
68
+ <div
69
+ className="text-center"
70
+ style={{ fontSize }}
71
+ dangerouslySetInnerHTML={{ __html: card.front }}
72
+ />
73
+ </div>
74
+ {!readOnly && (
75
+ <div className="flex items-center justify-center gap-1 pb-3 text-muted-foreground">
76
+ <RotateCcw size={12} />
77
+ <span className="text-xs">Tap to flip</span>
78
+ </div>
79
+ )}
80
+ </div>
81
+
82
+ {/* Back */}
83
+ <div
84
+ className={cn(
85
+ "absolute inset-0 flex flex-col rounded-lg border border-border bg-background backface-hidden transform-[rotateY(180deg)]",
86
+ !readOnly && "cursor-pointer",
87
+ )}
88
+ onClick={handleClick}
89
+ >
90
+ <div className={cn("h-1.5 rounded-t-lg", colors.accent)} />
91
+ <div className="flex flex-1 items-start justify-center p-5 overflow-auto">
92
+ <div
93
+ className="text-left w-full flashcard-back-content"
94
+ style={{ fontSize }}
95
+ dangerouslySetInnerHTML={{ __html: card.back }}
96
+ />
97
+ </div>
98
+ {!readOnly && (
99
+ <div className="flex items-center justify-center gap-1 pb-3 text-muted-foreground">
100
+ <RotateCcw size={12} />
101
+ <span className="text-xs">Tap to flip back</span>
102
+ </div>
103
+ )}
104
+ </div>
105
+ </div>
106
+ </div>
107
+ );
108
+ };
@@ -0,0 +1,3 @@
1
+ export { Flashcard } from "./flashcard";
2
+ export { FlashcardDeck } from "./flashcard-deck";
3
+ export type { FlashcardData, FlashcardProps, FlashcardDeckProps } from "./types";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Flashcard component with 3D flip animation. Supports controlled and uncontrolled flip state.
3
+ *
4
+ * @example
5
+ * <Flashcard
6
+ * card={{ uid: "1", front: "What is React?", back: "A JavaScript library for building UIs", color: "color1" }}
7
+ * size="medium"
8
+ * />
9
+ */
10
+ export interface FlashcardProps {
11
+ /** The flashcard data to display */
12
+ card: FlashcardData;
13
+ /** Controlled flip state — if provided, flip state is managed externally */
14
+ isFlipped?: boolean;
15
+ /** Called when the card is flipped */
16
+ onFlip?: () => void;
17
+ /** When true, disables the flip interaction */
18
+ readOnly?: boolean;
19
+ /** Card size variant */
20
+ size?: "small" | "medium" | "large";
21
+ }
22
+
23
+ /**
24
+ * FlashcardDeck component for navigating through a collection of flashcards.
25
+ *
26
+ * @example
27
+ * <FlashcardDeck
28
+ * cards={cards}
29
+ * deckName="React Fundamentals"
30
+ * showProgress
31
+ * onComplete={() => alert('Deck complete!')}
32
+ * />
33
+ */
34
+ export interface FlashcardDeckProps {
35
+ /** Array of flashcard data to display */
36
+ cards: FlashcardData[];
37
+ /** Title shown above the deck */
38
+ deckName?: string;
39
+ /** Description shown below the deck name */
40
+ deckDescription?: string;
41
+ /** Controlled current card index */
42
+ currentIndex?: number;
43
+ /** Called when the user navigates to a different card */
44
+ onNavigate?: (index: number) => void;
45
+ /** Called when the user finishes the last card */
46
+ onComplete?: () => void;
47
+ /** When true, disables card flipping */
48
+ readOnly?: boolean;
49
+ /** Whether to show the progress bar and counter */
50
+ showProgress?: boolean;
51
+ /** Whether to shuffle the cards randomly */
52
+ shuffled?: boolean;
53
+ }
54
+
55
+ export interface FlashcardData {
56
+ uid: string;
57
+ front: string;
58
+ back: string;
59
+ color: string;
60
+ }