@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,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
|
+
}
|