@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.
- package/package.json +52 -1
- 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
|
@@ -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,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";
|
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[] };
|