@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
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[] };
@@ -0,0 +1,275 @@
1
+ import { useState, useRef } from "react";
2
+ import {
3
+ BookOpen,
4
+ Shuffle,
5
+ RotateCcw,
6
+ CheckCircle2,
7
+ Layers,
8
+ Clock,
9
+ ArrowLeft,
10
+ } from "lucide-react";
11
+ import { FlashcardStudySession } from "../../sections/FlashcardStudySession/FlashcardStudySession";
12
+ import { ProgressRing } from "../../progress/progress-ring";
13
+ import { StatCard } from "../../progress/stat-card";
14
+ import { Button } from "../../ui/button";
15
+ import { Badge } from "../../ui/badge";
16
+ import { Card, CardContent } from "../../ui/card";
17
+ import { formatDuration } from "../../utils/format-duration";
18
+ import { cn } from "../../lib/utils";
19
+ import type { FlashcardData } from "../../flashcards/types";
20
+ import type {
21
+ FlashcardLabProps,
22
+ FlashcardLabResult,
23
+ } from "./types";
24
+
25
+ type InternalStep =
26
+ | { tag: "setup" }
27
+ | { tag: "study"; cards: FlashcardData[]; deckUids: string[]; shuffled: boolean }
28
+ | { tag: "completion"; result: FlashcardLabResult };
29
+
30
+ export function FlashcardLab({
31
+ decks,
32
+ showShuffleToggle = true,
33
+ defaultShuffled = false,
34
+ allowMultiSelect = true,
35
+ onComplete,
36
+ className,
37
+ style,
38
+ }: FlashcardLabProps) {
39
+ const [step, setStep] = useState<InternalStep>({ tag: "setup" });
40
+ const [selectedUids, setSelectedUids] = useState<Set<string>>(new Set());
41
+ const [shuffled, setShuffled] = useState(defaultShuffled);
42
+ const startTimeRef = useRef<number | null>(null);
43
+
44
+ function toggleDeck(uid: string) {
45
+ setSelectedUids((prev) => {
46
+ const next = new Set(prev);
47
+ if (next.has(uid)) {
48
+ next.delete(uid);
49
+ } else {
50
+ if (!allowMultiSelect) next.clear();
51
+ next.add(uid);
52
+ }
53
+ return next;
54
+ });
55
+ }
56
+
57
+ function startStudying() {
58
+ const selected = decks.filter((d) => selectedUids.has(d.uid));
59
+ const cards = selected.flatMap((d) => d.cards);
60
+ startTimeRef.current = Date.now();
61
+ setStep({
62
+ tag: "study",
63
+ cards,
64
+ deckUids: selected.map((d) => d.uid),
65
+ shuffled,
66
+ });
67
+ }
68
+
69
+ function handleStudyComplete() {
70
+ if (step.tag !== "study") return;
71
+ const elapsed = startTimeRef.current
72
+ ? Math.floor((Date.now() - startTimeRef.current) / 1000)
73
+ : 0;
74
+
75
+ const result: FlashcardLabResult = {
76
+ totalCards: step.cards.length,
77
+ decksStudied: step.deckUids.length,
78
+ deckUids: step.deckUids,
79
+ wasShuffled: step.shuffled,
80
+ timeElapsedSeconds: elapsed,
81
+ };
82
+ setStep({ tag: "completion", result });
83
+ onComplete?.(result);
84
+ }
85
+
86
+ function handleStudyAgain() {
87
+ if (step.tag !== "completion") return;
88
+ const selected = decks.filter((d) => step.result.deckUids.includes(d.uid));
89
+ const cards = selected.flatMap((d) => d.cards);
90
+ startTimeRef.current = Date.now();
91
+ setStep({
92
+ tag: "study",
93
+ cards,
94
+ deckUids: step.result.deckUids,
95
+ shuffled: step.result.wasShuffled,
96
+ });
97
+ }
98
+
99
+ function handlePickNewDeck() {
100
+ setSelectedUids(new Set());
101
+ setShuffled(defaultShuffled);
102
+ setStep({ tag: "setup" });
103
+ }
104
+
105
+ // ─── Setup Screen ───
106
+ if (step.tag === "setup") {
107
+ return (
108
+ <div className={cn(className)} style={style}>
109
+ <div className="text-center mb-6">
110
+ <div className="mx-auto mb-3 w-14 h-14 rounded-full bg-primary/10 flex items-center justify-center">
111
+ <BookOpen className="size-7 text-primary" />
112
+ </div>
113
+ <h2 className="text-2xl font-bold text-foreground mb-1">
114
+ Choose Your Decks
115
+ </h2>
116
+ <p className="text-muted-foreground text-sm">
117
+ {allowMultiSelect
118
+ ? "Select one or more decks to study"
119
+ : "Select a deck to study"}
120
+ </p>
121
+ </div>
122
+
123
+ {/* Deck grid */}
124
+ <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 mb-6">
125
+ {decks.map((deck) => {
126
+ const isSelected = selectedUids.has(deck.uid);
127
+ return (
128
+ <Card
129
+ key={deck.uid}
130
+ className={cn(
131
+ "cursor-pointer transition-all py-0",
132
+ isSelected
133
+ ? "border-primary ring-1 ring-primary"
134
+ : "hover:border-muted-foreground/30",
135
+ )}
136
+ onClick={() => toggleDeck(deck.uid)}
137
+ >
138
+ <CardContent className="py-4">
139
+ <div className="flex items-start justify-between mb-2">
140
+ <h3 className="font-semibold text-foreground text-sm">
141
+ {deck.title}
142
+ </h3>
143
+ <Badge variant="secondary" className="text-xs shrink-0">
144
+ {deck.cards.length} cards
145
+ </Badge>
146
+ </div>
147
+ {deck.description && (
148
+ <p className="text-xs text-muted-foreground line-clamp-2">
149
+ {deck.description}
150
+ </p>
151
+ )}
152
+ </CardContent>
153
+ </Card>
154
+ );
155
+ })}
156
+ </div>
157
+
158
+ {/* Options + start */}
159
+ <div className="flex items-center justify-between">
160
+ <div className="flex items-center gap-3">
161
+ {showShuffleToggle && (
162
+ <button
163
+ type="button"
164
+ className={cn(
165
+ "flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors",
166
+ shuffled
167
+ ? "border-primary bg-primary/10 text-primary"
168
+ : "border-border text-muted-foreground hover:text-foreground",
169
+ )}
170
+ onClick={() => setShuffled((s) => !s)}
171
+ >
172
+ <Shuffle className="size-3.5" />
173
+ Shuffle
174
+ </button>
175
+ )}
176
+ {selectedUids.size > 0 && (
177
+ <span className="text-xs text-muted-foreground">
178
+ {decks
179
+ .filter((d) => selectedUids.has(d.uid))
180
+ .reduce((sum, d) => sum + d.cards.length, 0)}{" "}
181
+ cards selected
182
+ </span>
183
+ )}
184
+ </div>
185
+ <Button
186
+ onClick={startStudying}
187
+ disabled={selectedUids.size === 0}
188
+ >
189
+ Start Studying
190
+ </Button>
191
+ </div>
192
+ </div>
193
+ );
194
+ }
195
+
196
+ // ─── Study Screen ───
197
+ if (step.tag === "study") {
198
+ const deckNames = decks
199
+ .filter((d) => step.deckUids.includes(d.uid))
200
+ .map((d) => d.title)
201
+ .join(", ");
202
+
203
+ return (
204
+ <div className={cn(className)} style={style}>
205
+ <FlashcardStudySession
206
+ cards={step.cards}
207
+ title={deckNames}
208
+ shuffled={step.shuffled}
209
+ onComplete={handleStudyComplete}
210
+ />
211
+ </div>
212
+ );
213
+ }
214
+
215
+ // ─── Completion Screen ───
216
+ const { result } = step;
217
+
218
+ return (
219
+ <div className={cn(className)} style={style}>
220
+ <div className="text-center mb-8">
221
+ <div className="relative mx-auto mb-4">
222
+ <ProgressRing
223
+ value={100}
224
+ size={120}
225
+ strokeWidth={10}
226
+ color="var(--success)"
227
+ className="text-foreground"
228
+ label=""
229
+ />
230
+ <CheckCircle2 className="size-8 text-success absolute inset-0 m-auto" />
231
+ </div>
232
+ <h2 className="text-xl font-bold text-foreground mb-1">
233
+ Study Session Complete
234
+ </h2>
235
+ <p className="text-muted-foreground text-sm">
236
+ Great work! Here's your session summary.
237
+ </p>
238
+ </div>
239
+
240
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-8">
241
+ <StatCard
242
+ icon={<Layers />}
243
+ label="Cards Studied"
244
+ value={String(result.totalCards)}
245
+ />
246
+ <StatCard
247
+ icon={<BookOpen />}
248
+ label="Decks"
249
+ value={String(result.decksStudied)}
250
+ />
251
+ <StatCard
252
+ icon={<Clock />}
253
+ label="Time Spent"
254
+ value={formatDuration(result.timeElapsedSeconds)}
255
+ />
256
+ <StatCard
257
+ icon={<Shuffle />}
258
+ label="Shuffled"
259
+ value={result.wasShuffled ? "Yes" : "No"}
260
+ />
261
+ </div>
262
+
263
+ <div className="flex justify-center gap-3">
264
+ <Button variant="outline" onClick={handlePickNewDeck}>
265
+ <ArrowLeft className="size-4 mr-2" />
266
+ Pick New Deck
267
+ </Button>
268
+ <Button onClick={handleStudyAgain}>
269
+ <RotateCcw className="size-4 mr-2" />
270
+ Study Again
271
+ </Button>
272
+ </div>
273
+ </div>
274
+ );
275
+ }