@hydralms/components 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/package.json +3 -1
  2. package/src/__tests__/setup.ts +1 -0
  3. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  4. package/src/assessment-toolbar/index.ts +10 -0
  5. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  6. package/src/assessment-toolbar/timer-display.tsx +73 -0
  7. package/src/assessment-toolbar/types.ts +92 -0
  8. package/src/assets/hydra-icon.png +0 -0
  9. package/src/assets/hydra-icon.svg +18 -0
  10. package/src/assets/hydra-lms-icon.png +0 -0
  11. package/src/assets/hydra-lms-icon.svg +9 -0
  12. package/src/common/confirm-dialog.tsx +60 -0
  13. package/src/common/due-date-display.tsx +64 -0
  14. package/src/common/empty-state.tsx +24 -0
  15. package/src/common/index.ts +12 -0
  16. package/src/common/search-input.tsx +68 -0
  17. package/src/common/status-badge.test.tsx +43 -0
  18. package/src/common/status-badge.tsx +81 -0
  19. package/src/common/types.ts +129 -0
  20. package/src/content/content-block.tsx +116 -0
  21. package/src/content/file-upload-zone.tsx +109 -0
  22. package/src/content/index.ts +7 -0
  23. package/src/content/types.ts +76 -0
  24. package/src/curriculum/curriculum-item.tsx +81 -0
  25. package/src/curriculum/curriculum-tree.tsx +69 -0
  26. package/src/curriculum/index.ts +11 -0
  27. package/src/curriculum/learning-object-icon.tsx +44 -0
  28. package/src/curriculum/types.ts +83 -0
  29. package/src/feedback/feedback-banner.tsx +46 -0
  30. package/src/feedback/index.ts +8 -0
  31. package/src/feedback/likert-scale.tsx +58 -0
  32. package/src/feedback/star-rating.tsx +65 -0
  33. package/src/feedback/types.ts +86 -0
  34. package/src/flashcards/flashcard-deck.tsx +130 -0
  35. package/src/flashcards/flashcard.tsx +108 -0
  36. package/src/flashcards/index.ts +3 -0
  37. package/src/flashcards/types.ts +60 -0
  38. package/src/index.ts +38 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  41. package/src/modules/CoursePlayer/types.ts +48 -0
  42. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  43. package/src/modules/FlashcardLab/types.ts +58 -0
  44. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  45. package/src/modules/QuizModule/types.ts +56 -0
  46. package/src/modules/index.ts +12 -0
  47. package/src/progress/grade-indicator.tsx +65 -0
  48. package/src/progress/index.ts +8 -0
  49. package/src/progress/progress-ring.tsx +56 -0
  50. package/src/progress/stat-card.tsx +42 -0
  51. package/src/progress/types.ts +73 -0
  52. package/src/provider/HydraProvider.tsx +26 -0
  53. package/src/provider/index.ts +2 -0
  54. package/src/questions/choice.tsx +90 -0
  55. package/src/questions/essay.tsx +59 -0
  56. package/src/questions/fill-in-the-blank.tsx +69 -0
  57. package/src/questions/index.ts +14 -0
  58. package/src/questions/multiple-choice.test.tsx +104 -0
  59. package/src/questions/multiple-choice.tsx +97 -0
  60. package/src/questions/question-renderer.tsx +37 -0
  61. package/src/questions/true-false.test.tsx +89 -0
  62. package/src/questions/true-false.tsx +90 -0
  63. package/src/questions/types.ts +53 -0
  64. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  65. package/src/sections/AnnouncementFeed/types.ts +50 -0
  66. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  67. package/src/sections/AssessmentReview/types.ts +61 -0
  68. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  69. package/src/sections/AssignmentSubmission/types.ts +60 -0
  70. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  71. package/src/sections/CertificateViewer/types.ts +45 -0
  72. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  73. package/src/sections/CourseOutline/types.ts +53 -0
  74. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  75. package/src/sections/DiscussionThread/types.ts +77 -0
  76. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  77. package/src/sections/ExamSession/types.ts +64 -0
  78. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  79. package/src/sections/FlashcardStudySession/types.ts +42 -0
  80. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  81. package/src/sections/GradebookTable/types.ts +75 -0
  82. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  83. package/src/sections/LecturePlayer/types.ts +48 -0
  84. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  85. package/src/sections/LessonPage/types.ts +41 -0
  86. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  87. package/src/sections/PracticeQuiz/types.ts +44 -0
  88. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  89. package/src/sections/ProgressDashboard/types.ts +74 -0
  90. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  91. package/src/sections/QuizSession/types.ts +47 -0
  92. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  93. package/src/sections/ResourceLibrary/types.ts +57 -0
  94. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  95. package/src/sections/ScrollableQuiz/types.ts +40 -0
  96. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  97. package/src/sections/SurveyForm/types.ts +69 -0
  98. package/src/sections/index.ts +90 -0
  99. package/src/social/index.ts +3 -0
  100. package/src/social/post-card.tsx +91 -0
  101. package/src/social/types.ts +57 -0
  102. package/src/social/user-avatar.tsx +76 -0
  103. package/src/styles/globals.css +125 -0
  104. package/src/ui/alert-dialog.tsx +343 -0
  105. package/src/ui/alert.tsx +65 -0
  106. package/src/ui/avatar.tsx +52 -0
  107. package/src/ui/badge.tsx +53 -0
  108. package/src/ui/button.tsx +62 -0
  109. package/src/ui/card.tsx +92 -0
  110. package/src/ui/index.ts +44 -0
  111. package/src/ui/input.tsx +21 -0
  112. package/src/ui/progress.tsx +73 -0
  113. package/src/ui/separator.tsx +29 -0
  114. package/src/ui/skeleton.tsx +15 -0
  115. package/src/ui/slot.tsx +48 -0
  116. package/src/ui/table.tsx +108 -0
  117. package/src/ui/tabs.tsx +147 -0
  118. package/src/ui/textarea.tsx +20 -0
  119. package/src/ui/tooltip.tsx +177 -0
  120. package/src/utils/debounce.test.ts +59 -0
  121. package/src/utils/debounce.ts +10 -0
  122. package/src/utils/format-duration.test.ts +55 -0
  123. package/src/utils/format-duration.ts +30 -0
  124. package/src/video/index.ts +17 -0
  125. package/src/video/types.ts +216 -0
  126. package/src/video/video-bookmark.tsx +76 -0
  127. package/src/video/video-chapter-list.tsx +93 -0
  128. package/src/video/video-player.tsx +103 -0
  129. package/src/video/video-playlist-item.tsx +90 -0
  130. package/src/video/video-thumbnail-card.tsx +74 -0
  131. package/src/video/video-transcript.tsx +102 -0
@@ -0,0 +1,76 @@
1
+ import { useState } from "react";
2
+ import { CheckCircle } from "lucide-react";
3
+ import { FlashcardDeck } from "../../flashcards";
4
+ import { Button } from "../../ui/button";
5
+ import { Card, CardContent } from "../../ui/card";
6
+ import type {
7
+ FlashcardStudySessionProps,
8
+ FlashcardSessionStats,
9
+ } from "./types";
10
+ import { cn } from "../../lib/utils";
11
+
12
+ export function FlashcardStudySession({
13
+ cards,
14
+ title,
15
+ description,
16
+ shuffled = false,
17
+ onComplete,
18
+ readOnly = false,
19
+ className,
20
+ style,
21
+ }: FlashcardStudySessionProps) {
22
+ const [isComplete, setIsComplete] = useState(false);
23
+
24
+ const stats: FlashcardSessionStats = {
25
+ totalCards: cards.length,
26
+ wasShuffled: shuffled,
27
+ };
28
+
29
+ function handleComplete() {
30
+ setIsComplete(true);
31
+ onComplete?.(stats);
32
+ }
33
+
34
+ function handleStudyAgain() {
35
+ setIsComplete(false);
36
+ }
37
+
38
+ if (isComplete) {
39
+ return (
40
+ <div className={cn("flex flex-col items-center", className)} style={style}>
41
+ <Card>
42
+ <CardContent className="pt-6 text-center flex flex-col items-center gap-2">
43
+ <CheckCircle size={48} className="text-success" />
44
+ <span className="text-xl font-bold text-foreground">Deck complete!</span>
45
+ {title && (
46
+ <span className="text-muted-foreground">
47
+ You finished <strong>{title}</strong>
48
+ </span>
49
+ )}
50
+ <span className="text-sm text-muted-foreground">
51
+ {stats.totalCards} card{stats.totalCards !== 1 ? "s" : ""} studied
52
+ {stats.wasShuffled ? " (shuffled)" : ""}
53
+ </span>
54
+ <Button className="mt-2" onClick={handleStudyAgain}>
55
+ Study Again
56
+ </Button>
57
+ </CardContent>
58
+ </Card>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div className={cn("flex flex-col items-center", className)} style={style}>
65
+ <FlashcardDeck
66
+ cards={cards}
67
+ deckName={title}
68
+ deckDescription={description}
69
+ shuffled={shuffled}
70
+ showProgress
71
+ onComplete={handleComplete}
72
+ readOnly={readOnly}
73
+ />
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,42 @@
1
+ import type { FlashcardData } from "../../flashcards/types";
2
+
3
+ /**
4
+ * FlashcardStudySession section — a complete flashcard study experience.
5
+ *
6
+ * Wraps FlashcardDeck with a session header and a completion screen
7
+ * (checkmark, stats, "Study Again" button). Drop in your cards and an
8
+ * optional onComplete callback to get a ready-to-use study session.
9
+ *
10
+ * @example
11
+ * <FlashcardStudySession
12
+ * cards={cards}
13
+ * title="React Fundamentals"
14
+ * shuffled
15
+ * onComplete={(stats) => trackStudySession(stats)}
16
+ * />
17
+ */
18
+ export interface FlashcardStudySessionProps {
19
+ /** The cards to study */
20
+ cards: FlashcardData[];
21
+ /** Session title displayed in the header */
22
+ title?: string;
23
+ /** Optional subtitle / description */
24
+ description?: string;
25
+ /** Whether to shuffle cards at session start — passed through to FlashcardDeck */
26
+ shuffled?: boolean;
27
+ /** Called when the user completes the deck */
28
+ onComplete?: (stats: FlashcardSessionStats) => void;
29
+ /** When true, disables card flipping */
30
+ readOnly?: boolean;
31
+ /** CSS class name for the root element */
32
+ className?: string;
33
+ /** Inline styles for the root element */
34
+ style?: React.CSSProperties;
35
+ }
36
+
37
+ export interface FlashcardSessionStats {
38
+ /** Total number of cards in the deck */
39
+ totalCards: number;
40
+ /** Whether the deck was shuffled */
41
+ wasShuffled: boolean;
42
+ }
@@ -0,0 +1,229 @@
1
+ import { Fragment, useMemo, useState } from "react";
2
+ import { ArrowUp, ArrowDown } from "lucide-react";
3
+ import { GradeIndicator } from "../../progress";
4
+ import { StatusBadge, DueDateDisplay } from "../../common";
5
+ import { Card, CardContent } from "../../ui/card";
6
+ import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "../../ui/table";
7
+ import type { GradebookTableProps, GradeCategory } from "./types";
8
+ import { cn } from "../../lib/utils";
9
+
10
+ type SortField = "name" | "score" | "dueDate" | "status";
11
+
12
+ function SortableHeader({
13
+ label,
14
+ field,
15
+ sortField,
16
+ sortDir,
17
+ onSort,
18
+ textAlign,
19
+ }: {
20
+ label: string;
21
+ field: SortField;
22
+ sortField: SortField;
23
+ sortDir: "asc" | "desc";
24
+ onSort: (field: SortField) => void;
25
+ textAlign?: "left" | "right";
26
+ }) {
27
+ const isActive = sortField === field;
28
+ return (
29
+ <TableHead
30
+ className={cn(
31
+ "cursor-pointer select-none hover:bg-muted",
32
+ textAlign === "right" && "text-right",
33
+ )}
34
+ onClick={() => onSort(field)}
35
+ >
36
+ <div className={cn("flex items-center gap-1", textAlign === "right" && "justify-end")}>
37
+ <span className={cn(isActive ? "text-foreground" : "text-muted-foreground")}>
38
+ {label}
39
+ </span>
40
+ {isActive && (
41
+ sortDir === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />
42
+ )}
43
+ </div>
44
+ </TableHead>
45
+ );
46
+ }
47
+
48
+ export function GradebookTable({
49
+ items,
50
+ categories,
51
+ overallGrade,
52
+ showWeights = true,
53
+ showCategoryTotals = true,
54
+ onItemClick,
55
+ readOnly = false,
56
+ className,
57
+ style,
58
+ }: GradebookTableProps) {
59
+ const [sortField, setSortField] = useState<SortField>("dueDate");
60
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
61
+
62
+ function handleSort(field: SortField) {
63
+ if (sortField === field) {
64
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
65
+ } else {
66
+ setSortField(field);
67
+ setSortDir("asc");
68
+ }
69
+ }
70
+
71
+ const sortedItems = useMemo(() => {
72
+ const copy = [...items];
73
+ copy.sort((a, b) => {
74
+ let cmp = 0;
75
+ switch (sortField) {
76
+ case "name":
77
+ cmp = a.name.localeCompare(b.name);
78
+ break;
79
+ case "score":
80
+ cmp = (a.score ?? -1) - (b.score ?? -1);
81
+ break;
82
+ case "dueDate":
83
+ cmp = (a.dueDate ?? "").localeCompare(b.dueDate ?? "");
84
+ break;
85
+ case "status":
86
+ cmp = a.status.localeCompare(b.status);
87
+ break;
88
+ }
89
+ return sortDir === "asc" ? cmp : -cmp;
90
+ });
91
+ return copy;
92
+ }, [items, sortField, sortDir]);
93
+
94
+ const grouped = useMemo<{ category: GradeCategory | null; items: typeof sortedItems }[]>(() => {
95
+ if (!categories || categories.length === 0) return [{ category: null, items: sortedItems }];
96
+ const groups: { category: GradeCategory | null; items: typeof sortedItems }[] = categories.map((cat) => ({
97
+ category: cat,
98
+ items: sortedItems.filter((item) => item.categoryUid === cat.uid),
99
+ }));
100
+ const ungrouped = sortedItems.filter((item) => !item.categoryUid);
101
+ if (ungrouped.length > 0) groups.push({ category: null, items: ungrouped });
102
+ return groups;
103
+ }, [sortedItems, categories]);
104
+
105
+ const colCount = showWeights ? 5 : 4;
106
+
107
+ return (
108
+ <div className={className} style={style}>
109
+ {/* Overall grade */}
110
+ {overallGrade && (
111
+ <Card className="mb-3">
112
+ <CardContent className="pt-6">
113
+ <div className="flex items-center gap-3">
114
+ <GradeIndicator
115
+ percentage={overallGrade.percentage}
116
+ letterGrade={overallGrade.letterGrade}
117
+ size="large"
118
+ passingThreshold={60}
119
+ />
120
+ <div>
121
+ <div className="text-lg font-semibold text-foreground">Overall Grade</div>
122
+ <span className="text-sm text-muted-foreground">
123
+ {overallGrade.pointsEarned} / {overallGrade.pointsPossible} points
124
+ </span>
125
+ </div>
126
+ </div>
127
+ </CardContent>
128
+ </Card>
129
+ )}
130
+
131
+ {/* Table */}
132
+ <Card>
133
+ <Table>
134
+ <TableHeader>
135
+ <TableRow>
136
+ <SortableHeader
137
+ label="Assignment"
138
+ field="name"
139
+ sortField={sortField}
140
+ sortDir={sortDir}
141
+ onSort={handleSort}
142
+ />
143
+ <SortableHeader
144
+ label="Status"
145
+ field="status"
146
+ sortField={sortField}
147
+ sortDir={sortDir}
148
+ onSort={handleSort}
149
+ />
150
+ <SortableHeader
151
+ label="Due Date"
152
+ field="dueDate"
153
+ sortField={sortField}
154
+ sortDir={sortDir}
155
+ onSort={handleSort}
156
+ />
157
+ <SortableHeader
158
+ label="Score"
159
+ field="score"
160
+ sortField={sortField}
161
+ sortDir={sortDir}
162
+ onSort={handleSort}
163
+ textAlign="right"
164
+ />
165
+ {showWeights && (
166
+ <TableHead className="text-right text-muted-foreground">Weight</TableHead>
167
+ )}
168
+ </TableRow>
169
+ </TableHeader>
170
+ <TableBody>
171
+ {grouped.map((group, gi) => (
172
+ <Fragment key={gi}>
173
+ {group.category && showCategoryTotals && (
174
+ <TableRow className="bg-muted hover:bg-muted">
175
+ <TableCell colSpan={colCount}>
176
+ <span className="font-semibold text-sm text-foreground">
177
+ {group.category.name}
178
+ {group.category.weight != null && ` (${group.category.weight}%)`}
179
+ </span>
180
+ </TableCell>
181
+ </TableRow>
182
+ )}
183
+ {group.items.map((item) => (
184
+ <TableRow
185
+ key={item.uid}
186
+ className={cn(onItemClick && !readOnly && "cursor-pointer")}
187
+ onClick={() => onItemClick && !readOnly ? onItemClick(item) : undefined}
188
+ >
189
+ <TableCell>{item.name}</TableCell>
190
+ <TableCell>
191
+ <StatusBadge status={item.status} size="small" />
192
+ </TableCell>
193
+ <TableCell>
194
+ {item.dueDate ? (
195
+ <DueDateDisplay
196
+ dueDate={item.dueDate}
197
+ submittedDate={item.submittedDate}
198
+ size="small"
199
+ />
200
+ ) : (
201
+ "\u2014"
202
+ )}
203
+ </TableCell>
204
+ <TableCell className="text-right">
205
+ {item.score != null ? (
206
+ <span className="text-sm font-semibold text-foreground">
207
+ {item.score} / {item.maxScore}
208
+ </span>
209
+ ) : item.status === "excused" ? (
210
+ <span className="text-sm text-muted-foreground">Excused</span>
211
+ ) : (
212
+ <span className="text-sm text-muted-foreground">{"\u2014"}</span>
213
+ )}
214
+ </TableCell>
215
+ {showWeights && (
216
+ <TableCell className="text-right">
217
+ {item.weight != null ? `${item.weight}%` : "\u2014"}
218
+ </TableCell>
219
+ )}
220
+ </TableRow>
221
+ ))}
222
+ </Fragment>
223
+ ))}
224
+ </TableBody>
225
+ </Table>
226
+ </Card>
227
+ </div>
228
+ );
229
+ }
@@ -0,0 +1,75 @@
1
+
2
+ /**
3
+ * GradebookTable section — a tabular student gradebook.
4
+ *
5
+ * Displays assignment scores, weights, and an overall computed grade
6
+ * with sortable columns and optional category grouping.
7
+ *
8
+ * @example
9
+ * <GradebookTable
10
+ * items={gradeItems}
11
+ * categories={gradeCategories}
12
+ * overallGrade={{ percentage: 87, letterGrade: "B+", pointsEarned: 435, pointsPossible: 500 }}
13
+ * />
14
+ */
15
+ export interface GradebookTableProps {
16
+ /** Grade items (assignments, quizzes, etc.) */
17
+ items: GradeItem[];
18
+ /** Optional category grouping */
19
+ categories?: GradeCategory[];
20
+ /** Overall course grade summary */
21
+ overallGrade?: OverallGrade;
22
+ /** Whether to show the weight column */
23
+ showWeights?: boolean;
24
+ /** Whether to show category subtotals */
25
+ showCategoryTotals?: boolean;
26
+ /** Called when the user clicks a grade item row */
27
+ onItemClick?: (item: GradeItem) => void;
28
+ /** When true, disables interactions */
29
+ readOnly?: boolean;
30
+ /** CSS class name for the root element */
31
+ className?: string;
32
+ /** Inline styles for the root element */
33
+ style?: React.CSSProperties;
34
+ }
35
+
36
+ export interface GradeItem {
37
+ /** Unique identifier */
38
+ uid: string;
39
+ /** Assignment name */
40
+ name: string;
41
+ /** Category UID for grouping */
42
+ categoryUid?: string;
43
+ /** Score earned — null if not yet graded */
44
+ score: number | null;
45
+ /** Maximum possible score */
46
+ maxScore: number;
47
+ /** Weight as a percentage */
48
+ weight?: number;
49
+ /** Due date as ISO string */
50
+ dueDate?: string;
51
+ /** Submission date */
52
+ submittedDate?: string;
53
+ /** Item status */
54
+ status: "graded" | "submitted" | "pending" | "missing" | "excused";
55
+ }
56
+
57
+ export interface GradeCategory {
58
+ /** Unique identifier */
59
+ uid: string;
60
+ /** Category name (e.g. "Assignments", "Quizzes") */
61
+ name: string;
62
+ /** Category weight percentage */
63
+ weight?: number;
64
+ }
65
+
66
+ export interface OverallGrade {
67
+ /** Overall percentage */
68
+ percentage: number;
69
+ /** Letter grade */
70
+ letterGrade?: string;
71
+ /** Points earned */
72
+ pointsEarned: number;
73
+ /** Points possible */
74
+ pointsPossible: number;
75
+ }
@@ -0,0 +1,60 @@
1
+ import { VideoPlayer } from "../../video";
2
+ import { Card, CardHeader, CardTitle, CardContent } from "../../ui/card";
3
+ import type { LecturePlayerProps } from "./types";
4
+ import { cn } from "../../lib/utils";
5
+
6
+ export function LecturePlayer({
7
+ video,
8
+ notes,
9
+ layout = "horizontal",
10
+ notesPanelWidth = "340px",
11
+ notesPanelHeight = "240px",
12
+ className,
13
+ style,
14
+ }: LecturePlayerProps) {
15
+ const isHorizontal = layout === "horizontal";
16
+
17
+ if (!notes) {
18
+ return (
19
+ <div className={className} style={style}>
20
+ <VideoPlayer {...video} />
21
+ </div>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <div
27
+ className={cn(
28
+ "flex overflow-hidden",
29
+ isHorizontal ? "flex-row" : "flex-col",
30
+ className,
31
+ )}
32
+ style={style}
33
+ >
34
+ <div className="flex-1 min-w-0 min-h-0">
35
+ <VideoPlayer {...video} />
36
+ </div>
37
+ <Card
38
+ className={cn(
39
+ "overflow-auto shrink-0 rounded-none border-0",
40
+ isHorizontal ? "border-l border-border" : "border-t border-border w-full",
41
+ )}
42
+ style={{
43
+ width: isHorizontal ? notesPanelWidth : undefined,
44
+ height: isHorizontal ? undefined : notesPanelHeight,
45
+ }}
46
+ >
47
+ <CardHeader>
48
+ <CardTitle>Notes</CardTitle>
49
+ </CardHeader>
50
+ <CardContent>
51
+ {typeof notes === "string" ? (
52
+ <span className="text-sm text-foreground">{notes}</span>
53
+ ) : (
54
+ notes
55
+ )}
56
+ </CardContent>
57
+ </Card>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from "react";
2
+ import type { VideoPlayerProps } from "../../video/types";
3
+
4
+ /**
5
+ * LecturePlayer section — video player paired with an optional notes/transcript panel.
6
+ *
7
+ * Supports horizontal (side-by-side) and vertical (stacked) layouts with
8
+ * configurable panel dimensions. When notes are omitted, the video fills
9
+ * the full container.
10
+ *
11
+ * @example
12
+ * <LecturePlayer
13
+ * video={{ src: "https://example.com/lecture.mp4", title: "React Hooks" }}
14
+ * notes={<TranscriptPanel />}
15
+ * layout="horizontal"
16
+ * />
17
+ */
18
+ export interface LecturePlayerProps {
19
+ /** Props passed directly to the underlying VideoPlayer */
20
+ video: VideoPlayerProps;
21
+ /**
22
+ * Content rendered in the companion panel.
23
+ * Can be any ReactNode — transcript, MDX, note editor, etc.
24
+ * If omitted, the player fills the full container with no panel.
25
+ */
26
+ notes?: ReactNode;
27
+ /**
28
+ * Layout direction of the two-pane split.
29
+ * - "horizontal": video left, notes right
30
+ * - "vertical": video top, notes bottom
31
+ * @default "horizontal"
32
+ */
33
+ layout?: "horizontal" | "vertical";
34
+ /**
35
+ * Width of the notes panel when layout is "horizontal".
36
+ * @default "340px"
37
+ */
38
+ notesPanelWidth?: string | number;
39
+ /**
40
+ * Height of the notes panel when layout is "vertical".
41
+ * @default "240px"
42
+ */
43
+ notesPanelHeight?: string | number;
44
+ /** CSS class name for the root element */
45
+ className?: string;
46
+ /** Inline styles for the root element */
47
+ style?: React.CSSProperties;
48
+ }
@@ -0,0 +1,91 @@
1
+ import { useState } from "react";
2
+ import { Check, ChevronRight, Clock } from "lucide-react";
3
+ import { ContentBlock } from "../../content";
4
+ import type { SessionAnswer } from "../../questions/types";
5
+ import { Button } from "../../ui/button";
6
+ import { Separator } from "../../ui/separator";
7
+ import { formatDuration } from "../../utils/format-duration";
8
+ import type { LessonPageProps } from "./types";
9
+ import { cn } from "../../lib/utils";
10
+
11
+ export function LessonPage({
12
+ title,
13
+ blocks,
14
+ isCompleted = false,
15
+ onMarkComplete,
16
+ onNextLesson,
17
+ nextLessonTitle,
18
+ estimatedDuration,
19
+ showDuration = true,
20
+ readOnly = false,
21
+ className,
22
+ style,
23
+ }: LessonPageProps) {
24
+ const [completed, setCompleted] = useState(isCompleted);
25
+ const [, setQuestionAnswers] = useState<Map<string, SessionAnswer[]>>(new Map());
26
+
27
+ function handleQuestionAnswer(questionUid: string, answers: SessionAnswer[]) {
28
+ setQuestionAnswers((prev) => new Map(prev).set(questionUid, answers));
29
+ }
30
+
31
+ function handleMarkComplete() {
32
+ setCompleted(true);
33
+ onMarkComplete?.();
34
+ }
35
+
36
+ return (
37
+ <div className={cn(className)} style={style}>
38
+ {/* Header */}
39
+ <div className="mb-3">
40
+ <p className="text-2xl font-bold mb-0.5 text-foreground">{title}</p>
41
+ {showDuration && estimatedDuration != null && (
42
+ <div className="flex items-center gap-0.5 text-muted-foreground">
43
+ <Clock size={16} />
44
+ <span className="text-sm">{formatDuration(estimatedDuration)}</span>
45
+ </div>
46
+ )}
47
+ </div>
48
+
49
+ <Separator className="mb-3" />
50
+
51
+ {/* Content blocks */}
52
+ <div className="flex flex-col gap-3">
53
+ {blocks.map((block, i) => (
54
+ <ContentBlock
55
+ key={i}
56
+ block={block}
57
+ onQuestionAnswer={handleQuestionAnswer}
58
+ readOnly={readOnly}
59
+ />
60
+ ))}
61
+ </div>
62
+
63
+ {/* Completion bar */}
64
+ <div className="border border-border rounded-md px-4 py-3 mt-4 sticky bottom-0 bg-background z-10">
65
+ <div className="flex justify-between items-center">
66
+ {completed ? (
67
+ <div className="flex items-center gap-1 text-success">
68
+ <Check size={20} />
69
+ <span className="text-sm font-semibold">Lesson Complete</span>
70
+ </div>
71
+ ) : (
72
+ <Button
73
+ onClick={handleMarkComplete}
74
+ disabled={readOnly}
75
+ >
76
+ <Check size={18} /> Mark Complete
77
+ </Button>
78
+ )}
79
+ {onNextLesson && (
80
+ <Button
81
+ variant={completed ? "default" : "outline"}
82
+ onClick={onNextLesson}
83
+ >
84
+ {nextLessonTitle ? `Next: ${nextLessonTitle}` : "Next Lesson"} <ChevronRight size={18} />
85
+ </Button>
86
+ )}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,41 @@
1
+ import type { LessonBlock } from "../../content/types";
2
+
3
+ /**
4
+ * LessonPage section — a multi-content lesson experience.
5
+ *
6
+ * Sequences video, rich text, images, embedded quizzes, flashcards,
7
+ * and callouts into a single scrollable page with a sticky completion
8
+ * bar at the bottom.
9
+ *
10
+ * @example
11
+ * <LessonPage
12
+ * title="React Hooks Deep Dive"
13
+ * blocks={lessonBlocks}
14
+ * onMarkComplete={() => completeLesson()}
15
+ * onNextLesson={() => navigate(nextLesson)}
16
+ * />
17
+ */
18
+ export interface LessonPageProps {
19
+ /** Lesson title */
20
+ title: string;
21
+ /** Ordered content blocks */
22
+ blocks: LessonBlock[];
23
+ /** Whether the lesson is already marked complete */
24
+ isCompleted?: boolean;
25
+ /** Called when the user marks the lesson complete */
26
+ onMarkComplete?: () => void;
27
+ /** Called when the user clicks "Next Lesson" */
28
+ onNextLesson?: () => void;
29
+ /** Next lesson title shown on the completion bar */
30
+ nextLessonTitle?: string;
31
+ /** Estimated duration in seconds */
32
+ estimatedDuration?: number;
33
+ /** Whether to show estimated duration */
34
+ showDuration?: boolean;
35
+ /** When true, disables interactive elements */
36
+ readOnly?: boolean;
37
+ /** CSS class name for the root element */
38
+ className?: string;
39
+ /** Inline styles for the root element */
40
+ style?: React.CSSProperties;
41
+ }