@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.
- package/dist/components.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +442 -110
- package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
- package/dist/modules/CoursePlayer/types.d.ts +59 -0
- package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
- package/dist/modules/FlashcardLab/types.d.ts +55 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
- package/dist/modules/QuizModule/types.d.ts +54 -0
- package/dist/modules/index.d.ts +6 -0
- package/dist/provider/HydraProvider.d.ts +1 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +261 -291
- package/dist/table-BrS5cDQu.js +2510 -0
- package/dist/table-D6AkBBEo.cjs +1 -0
- package/dist/ui/alert-dialog.d.ts +14 -8
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/tabs.d.ts +15 -5
- package/dist/ui/tooltip.d.ts +12 -5
- package/dist/video/index.d.ts +6 -1
- package/dist/video/types.d.ts +167 -0
- package/dist/video/video-bookmark.d.ts +2 -0
- package/dist/video/video-chapter-list.d.ts +2 -0
- package/dist/video/video-playlist-item.d.ts +2 -0
- package/dist/video/video-thumbnail-card.d.ts +2 -0
- package/dist/video/video-transcript.d.ts +2 -0
- package/package.json +135 -24
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
- package/dist/table-CW4_BYny.js +0 -9869
- 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";
|
package/src/lib/utils.ts
ADDED
|
@@ -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
|
+
}
|