@hydralms/components 0.1.1 → 0.1.3

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 +52 -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,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
+ }
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Questions
2
+ export * from "./questions";
3
+
4
+ // Assessment Toolbar
5
+ export * from "./assessment-toolbar";
6
+
7
+ // Flashcards
8
+ export * from "./flashcards";
9
+
10
+ // Curriculum
11
+ export * from "./curriculum";
12
+
13
+ // Video
14
+ export * from "./video";
15
+
16
+ // Common
17
+ export * from "./common";
18
+
19
+ // Feedback
20
+ export * from "./feedback";
21
+
22
+ // Progress
23
+ export * from "./progress";
24
+
25
+ // Social
26
+ export * from "./social";
27
+
28
+ // Content
29
+ export * from "./content";
30
+
31
+ // Provider
32
+ export * from "./provider";
33
+
34
+ // UI primitives (shadcn/ui + Base UI)
35
+ export * from "./ui";
36
+
37
+ // Utilities
38
+ export { cn } from "./lib/utils";
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,281 @@
1
+ import { useState, useMemo } from "react";
2
+ import {
3
+ ChevronLeft,
4
+ ChevronRight,
5
+ Check,
6
+ PanelLeftClose,
7
+ PanelLeft,
8
+ } from "lucide-react";
9
+ import { CourseOutline } from "../../sections/CourseOutline/CourseOutline";
10
+ import { LessonPage } from "../../sections/LessonPage/LessonPage";
11
+ import { LecturePlayer } from "../../sections/LecturePlayer/LecturePlayer";
12
+ import { PracticeQuiz } from "../../sections/PracticeQuiz/PracticeQuiz";
13
+ import { EmptyState } from "../../common";
14
+ import { Progress } from "../../ui/progress";
15
+ import { Button } from "../../ui/button";
16
+ import { cn } from "../../lib/utils";
17
+ import type { CurriculumItem } from "../../curriculum/types";
18
+ import type { CoursePlayerProps, CoursePlayerItem } from "./types";
19
+
20
+ function flattenLeaves(items: CurriculumItem[]): string[] {
21
+ const leaves: string[] = [];
22
+ for (const item of items) {
23
+ if (!item.children || item.children.length === 0) {
24
+ leaves.push(item.uid);
25
+ } else {
26
+ leaves.push(...flattenLeaves(item.children));
27
+ }
28
+ }
29
+ return leaves;
30
+ }
31
+
32
+ export function CoursePlayer({
33
+ courseTitle,
34
+ curriculum,
35
+ progress,
36
+ items,
37
+ initialItemUid,
38
+ onItemComplete,
39
+ onItemChange,
40
+ sidebarCollapsed = false,
41
+ readOnly = false,
42
+ className,
43
+ style,
44
+ }: CoursePlayerProps) {
45
+ const leafUids = useMemo(() => flattenLeaves(curriculum), [curriculum]);
46
+ const itemMap = useMemo(
47
+ () => new Map(items.map((item) => [item.uid, item])),
48
+ [items],
49
+ );
50
+
51
+ const [activeUid, setActiveUid] = useState(
52
+ initialItemUid ?? leafUids[0] ?? "",
53
+ );
54
+ const [sidebarOpen, setSidebarOpen] = useState(!sidebarCollapsed);
55
+ const [completedUids, setCompletedUids] = useState<Set<string>>(() => {
56
+ if (!progress) return new Set();
57
+ return new Set(
58
+ progress.filter((p) => p.isCompleted).map((p) => p.resourceUid),
59
+ );
60
+ });
61
+
62
+ const currentIndex = leafUids.indexOf(activeUid);
63
+ const hasPrevious = currentIndex > 0;
64
+ const hasNext = currentIndex < leafUids.length - 1;
65
+ const isCurrentCompleted = completedUids.has(activeUid);
66
+ const overallPercentage =
67
+ leafUids.length > 0
68
+ ? Math.round(
69
+ (leafUids.filter((uid) => completedUids.has(uid)).length /
70
+ leafUids.length) *
71
+ 100,
72
+ )
73
+ : 0;
74
+
75
+ const activeItem = itemMap.get(activeUid);
76
+
77
+ function navigateTo(uid: string) {
78
+ setActiveUid(uid);
79
+ onItemChange?.(uid);
80
+ }
81
+
82
+ function handleItemClick(item: CurriculumItem) {
83
+ if (!item.children || item.children.length === 0) {
84
+ navigateTo(item.uid);
85
+ }
86
+ }
87
+
88
+ function handleMarkComplete() {
89
+ setCompletedUids((prev) => new Set(prev).add(activeUid));
90
+ onItemComplete?.(activeUid);
91
+ }
92
+
93
+ function handleNext() {
94
+ if (hasNext) navigateTo(leafUids[currentIndex + 1]);
95
+ }
96
+
97
+ function handlePrevious() {
98
+ if (hasPrevious) navigateTo(leafUids[currentIndex - 1]);
99
+ }
100
+
101
+ // Build combined progress for CourseOutline
102
+ const combinedProgress = useMemo(() => {
103
+ const progressMap = new Map(
104
+ (progress ?? []).map((p) => [p.resourceUid, p]),
105
+ );
106
+ for (const uid of completedUids) {
107
+ if (!progressMap.has(uid)) {
108
+ progressMap.set(uid, { resourceUid: uid, isCompleted: true });
109
+ } else {
110
+ progressMap.set(uid, { ...progressMap.get(uid)!, isCompleted: true });
111
+ }
112
+ }
113
+ return Array.from(progressMap.values());
114
+ }, [progress, completedUids]);
115
+
116
+ // Find next item title for the nav buttons
117
+ const nextItem = hasNext ? itemMap.get(leafUids[currentIndex + 1]) : null;
118
+
119
+ return (
120
+ <div
121
+ className={cn("flex h-full overflow-hidden", className)}
122
+ style={style}
123
+ >
124
+ {/* Sidebar */}
125
+ {sidebarOpen && (
126
+ <aside className="w-80 shrink-0 border-r border-border overflow-y-auto bg-background">
127
+ <div className="flex items-center justify-between px-3 pt-3 pb-1">
128
+ <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
129
+ Course
130
+ </span>
131
+ <button
132
+ type="button"
133
+ className="p-1 rounded hover:bg-muted text-muted-foreground"
134
+ onClick={() => setSidebarOpen(false)}
135
+ >
136
+ <PanelLeftClose className="size-4" />
137
+ </button>
138
+ </div>
139
+ <CourseOutline
140
+ items={curriculum}
141
+ progress={combinedProgress}
142
+ courseTitle={courseTitle}
143
+ activeItemUid={activeUid}
144
+ onItemClick={handleItemClick}
145
+ readOnly={readOnly}
146
+ />
147
+ </aside>
148
+ )}
149
+
150
+ {/* Main content area */}
151
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
152
+ {/* Toolbar */}
153
+ <div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background shrink-0">
154
+ {!sidebarOpen && (
155
+ <button
156
+ type="button"
157
+ className="p-1 rounded hover:bg-muted text-muted-foreground mr-1"
158
+ onClick={() => setSidebarOpen(true)}
159
+ >
160
+ <PanelLeft className="size-4" />
161
+ </button>
162
+ )}
163
+ <div className="flex-1 min-w-0">
164
+ <span className="text-sm font-semibold text-foreground truncate block">
165
+ {activeItem?.title ?? "Select an item"}
166
+ </span>
167
+ </div>
168
+ <span className="text-xs text-muted-foreground shrink-0">
169
+ {currentIndex + 1} / {leafUids.length}
170
+ </span>
171
+ </div>
172
+
173
+ {/* Content */}
174
+ <div className="flex-1 overflow-y-auto p-6">
175
+ {activeItem ? (
176
+ renderContent(activeItem, readOnly, handleMarkComplete, isCurrentCompleted, handleNext, hasNext, nextItem)
177
+ ) : (
178
+ <EmptyState
179
+ title="No content selected"
180
+ description="Select an item from the course outline to get started."
181
+ />
182
+ )}
183
+ </div>
184
+
185
+ {/* Bottom bar */}
186
+ <div className="shrink-0 border-t border-border bg-background px-4 py-3">
187
+ <div className="flex items-center gap-4">
188
+ <div className="flex-1 min-w-0">
189
+ <div className="flex items-center gap-2 mb-1">
190
+ <span className="text-xs text-muted-foreground">
191
+ {overallPercentage}% complete
192
+ </span>
193
+ </div>
194
+ <Progress value={overallPercentage} size="sm" />
195
+ </div>
196
+ <div className="flex items-center gap-2 shrink-0">
197
+ {!isCurrentCompleted && activeItem && (
198
+ <Button
199
+ size="sm"
200
+ variant="outline"
201
+ onClick={handleMarkComplete}
202
+ disabled={readOnly}
203
+ >
204
+ <Check className="size-3.5 mr-1" />
205
+ Complete
206
+ </Button>
207
+ )}
208
+ <Button
209
+ size="sm"
210
+ variant="ghost"
211
+ onClick={handlePrevious}
212
+ disabled={!hasPrevious}
213
+ >
214
+ <ChevronLeft className="size-4" />
215
+ </Button>
216
+ <Button
217
+ size="sm"
218
+ variant="ghost"
219
+ onClick={handleNext}
220
+ disabled={!hasNext}
221
+ >
222
+ <ChevronRight className="size-4" />
223
+ </Button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ function renderContent(
233
+ item: CoursePlayerItem,
234
+ readOnly: boolean,
235
+ onMarkComplete: () => void,
236
+ isCompleted: boolean,
237
+ onNext: () => void,
238
+ hasNext: boolean,
239
+ nextItem: CoursePlayerItem | null | undefined,
240
+ ) {
241
+ switch (item.type) {
242
+ case "lesson":
243
+ return (
244
+ <LessonPage
245
+ title={item.title}
246
+ blocks={item.blocks}
247
+ isCompleted={isCompleted}
248
+ onMarkComplete={onMarkComplete}
249
+ onNextLesson={hasNext ? onNext : undefined}
250
+ nextLessonTitle={nextItem?.title}
251
+ readOnly={readOnly}
252
+ />
253
+ );
254
+ case "video":
255
+ return (
256
+ <LecturePlayer
257
+ video={{
258
+ src: item.src,
259
+ poster: item.poster,
260
+ title: item.title,
261
+ }}
262
+ />
263
+ );
264
+ case "quiz":
265
+ return (
266
+ <PracticeQuiz
267
+ questions={item.questions}
268
+ instantFeedback
269
+ allowRetry
270
+ readOnly={readOnly}
271
+ />
272
+ );
273
+ default:
274
+ return (
275
+ <EmptyState
276
+ title="Unknown content type"
277
+ description="This content type is not supported."
278
+ />
279
+ );
280
+ }
281
+ }
@@ -0,0 +1,48 @@
1
+ import type { CurriculumItem, CurriculumItemProgress } from "../../curriculum/types";
2
+ import type { LessonBlock } from "../../content/types";
3
+ import type { QuestionData } from "../../questions/types";
4
+
5
+ /**
6
+ * CoursePlayer — a complete course consumption shell with sidebar and content panel.
7
+ *
8
+ * Renders a collapsible CourseOutline sidebar alongside a content panel that
9
+ * switches between LessonPage, LecturePlayer, or PracticeQuiz based on the
10
+ * active item type. Includes a bottom bar with progress and navigation.
11
+ *
12
+ * @example
13
+ * <CoursePlayer
14
+ * courseTitle="Introduction to React"
15
+ * curriculum={curriculum}
16
+ * items={courseItems}
17
+ * onItemComplete={(uid) => markComplete(uid)}
18
+ * />
19
+ */
20
+ export interface CoursePlayerProps {
21
+ /** Course title displayed in the sidebar header */
22
+ courseTitle: string;
23
+ /** Hierarchical curriculum structure for the sidebar */
24
+ curriculum: CurriculumItem[];
25
+ /** User's progress data for completion indicators */
26
+ progress?: CurriculumItemProgress[];
27
+ /** Content data for each leaf curriculum item */
28
+ items: CoursePlayerItem[];
29
+ /** UID of the initially active item */
30
+ initialItemUid?: string;
31
+ /** Called when the user marks a content item as complete */
32
+ onItemComplete?: (itemUid: string) => void;
33
+ /** Called when the active item changes (user navigates) */
34
+ onItemChange?: (itemUid: string) => void;
35
+ /** Whether the sidebar starts collapsed. @default false */
36
+ sidebarCollapsed?: boolean;
37
+ /** When true, disables all interactive elements */
38
+ readOnly?: boolean;
39
+ /** CSS class name for the root element */
40
+ className?: string;
41
+ /** Inline styles for the root element */
42
+ style?: React.CSSProperties;
43
+ }
44
+
45
+ export type CoursePlayerItem =
46
+ | { uid: string; type: "lesson"; title: string; blocks: LessonBlock[] }
47
+ | { uid: string; type: "video"; title: string; src: string; poster?: string }
48
+ | { uid: string; type: "quiz"; title: string; questions: QuestionData[] };