@hydralms/components 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -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,199 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { CheckCircle } from "lucide-react";
|
|
3
|
+
import { QuestionRenderer } from "../../questions";
|
|
4
|
+
import { FeedbackBanner } from "../../feedback";
|
|
5
|
+
import { Button } from "../../ui/button";
|
|
6
|
+
import { Card, CardContent } from "../../ui/card";
|
|
7
|
+
import { Progress } from "../../ui/progress";
|
|
8
|
+
import type { PracticeQuizProps, PracticeQuizStats } from "./types";
|
|
9
|
+
|
|
10
|
+
function shuffleArray<T>(arr: T[]): T[] {
|
|
11
|
+
const copy = [...arr];
|
|
12
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
13
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
14
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
15
|
+
}
|
|
16
|
+
return copy;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PracticeQuiz({
|
|
20
|
+
questions: questionsProp,
|
|
21
|
+
instantFeedback = true,
|
|
22
|
+
allowRetry = true,
|
|
23
|
+
onComplete,
|
|
24
|
+
shuffled = false,
|
|
25
|
+
readOnly = false,
|
|
26
|
+
className,
|
|
27
|
+
style,
|
|
28
|
+
}: PracticeQuizProps) {
|
|
29
|
+
const questions = useMemo(
|
|
30
|
+
() => (shuffled ? shuffleArray(questionsProp) : questionsProp),
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
[questionsProp, shuffled],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
36
|
+
const [revealedUids, setRevealedUids] = useState<Set<string>>(new Set());
|
|
37
|
+
const [attemptCounts, setAttemptCounts] = useState<Map<string, number>>(new Map());
|
|
38
|
+
const [firstAttemptCorrect, setFirstAttemptCorrect] = useState<Set<string>>(new Set());
|
|
39
|
+
const [currentAnswer, setCurrentAnswer] = useState<{ uid: string; content?: string }[] | null>(null);
|
|
40
|
+
const [isComplete, setIsComplete] = useState(false);
|
|
41
|
+
|
|
42
|
+
const currentQuestion = questions[currentIndex];
|
|
43
|
+
const isRevealed = currentQuestion ? revealedUids.has(currentQuestion.uid) : false;
|
|
44
|
+
|
|
45
|
+
function checkAnswer() {
|
|
46
|
+
if (!currentQuestion || !currentAnswer) return;
|
|
47
|
+
|
|
48
|
+
const attempts = (attemptCounts.get(currentQuestion.uid) ?? 0) + 1;
|
|
49
|
+
setAttemptCounts((prev) => new Map(prev).set(currentQuestion.uid, attempts));
|
|
50
|
+
setRevealedUids((prev) => new Set(prev).add(currentQuestion.uid));
|
|
51
|
+
|
|
52
|
+
const correctUids = new Set(
|
|
53
|
+
currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
|
|
54
|
+
);
|
|
55
|
+
const selectedUids = new Set(currentAnswer.map((a) => a.uid));
|
|
56
|
+
const isCorrect =
|
|
57
|
+
correctUids.size === selectedUids.size &&
|
|
58
|
+
[...correctUids].every((uid) => selectedUids.has(uid));
|
|
59
|
+
|
|
60
|
+
if (isCorrect && attempts === 1) {
|
|
61
|
+
setFirstAttemptCorrect((prev) => new Set(prev).add(currentQuestion.uid));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function handleRetry() {
|
|
66
|
+
if (!currentQuestion) return;
|
|
67
|
+
setRevealedUids((prev) => {
|
|
68
|
+
const next = new Set(prev);
|
|
69
|
+
next.delete(currentQuestion.uid);
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
setCurrentAnswer(null);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function handleNext() {
|
|
76
|
+
if (currentIndex < questions.length - 1) {
|
|
77
|
+
setCurrentIndex((i) => i + 1);
|
|
78
|
+
setCurrentAnswer(null);
|
|
79
|
+
} else {
|
|
80
|
+
const stats: PracticeQuizStats = {
|
|
81
|
+
totalQuestions: questions.length,
|
|
82
|
+
correctOnFirstAttempt: firstAttemptCorrect.size,
|
|
83
|
+
totalAttempts: Array.from(attemptCounts.values()).reduce((a, b) => a + b, 0),
|
|
84
|
+
};
|
|
85
|
+
setIsComplete(true);
|
|
86
|
+
onComplete?.(stats);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isCurrentCorrect = useMemo(() => {
|
|
91
|
+
if (!currentQuestion || !currentAnswer) return false;
|
|
92
|
+
const correctUids = new Set(
|
|
93
|
+
currentQuestion.answers?.filter((a) => a.isCorrect).map((a) => a.uid) ?? [],
|
|
94
|
+
);
|
|
95
|
+
const selectedUids = new Set(currentAnswer.map((a) => a.uid));
|
|
96
|
+
return (
|
|
97
|
+
correctUids.size === selectedUids.size &&
|
|
98
|
+
[...correctUids].every((uid) => selectedUids.has(uid))
|
|
99
|
+
);
|
|
100
|
+
}, [currentQuestion, currentAnswer]);
|
|
101
|
+
|
|
102
|
+
if (isComplete) {
|
|
103
|
+
const percentage = questions.length > 0
|
|
104
|
+
? Math.round((firstAttemptCorrect.size / questions.length) * 100)
|
|
105
|
+
: 0;
|
|
106
|
+
return (
|
|
107
|
+
<Card className={className} style={style}>
|
|
108
|
+
<CardContent className="pt-6 text-center">
|
|
109
|
+
<CheckCircle size={48} className="text-success mx-auto mb-4" />
|
|
110
|
+
<p className="text-xl font-bold mb-1 text-foreground">Practice Complete!</p>
|
|
111
|
+
<p className="text-muted-foreground mb-2">
|
|
112
|
+
{firstAttemptCorrect.size} of {questions.length} correct on first attempt ({percentage}%)
|
|
113
|
+
</p>
|
|
114
|
+
<div className="flex justify-center">
|
|
115
|
+
<Button
|
|
116
|
+
variant="outline"
|
|
117
|
+
onClick={() => {
|
|
118
|
+
setCurrentIndex(0);
|
|
119
|
+
setRevealedUids(new Set());
|
|
120
|
+
setAttemptCounts(new Map());
|
|
121
|
+
setFirstAttemptCorrect(new Set());
|
|
122
|
+
setCurrentAnswer(null);
|
|
123
|
+
setIsComplete(false);
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
Practice Again
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={className} style={style}>
|
|
136
|
+
<div className="flex justify-between items-center mb-2">
|
|
137
|
+
<span className="font-semibold text-sm text-foreground">
|
|
138
|
+
Question {currentIndex + 1} of {questions.length}
|
|
139
|
+
</span>
|
|
140
|
+
<span className="text-xs text-muted-foreground">
|
|
141
|
+
{firstAttemptCorrect.size} correct on first try
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
<Progress
|
|
145
|
+
value={currentIndex + (isRevealed ? 1 : 0)}
|
|
146
|
+
max={questions.length}
|
|
147
|
+
size="sm"
|
|
148
|
+
className="mb-3"
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
{currentQuestion && (
|
|
152
|
+
<Card>
|
|
153
|
+
<CardContent className="pt-6">
|
|
154
|
+
<QuestionRenderer
|
|
155
|
+
question={currentQuestion}
|
|
156
|
+
sessionAnswers={
|
|
157
|
+
currentAnswer?.map((a) => ({
|
|
158
|
+
uid: currentQuestion.uid,
|
|
159
|
+
answerUid: a.uid,
|
|
160
|
+
content: a.content,
|
|
161
|
+
})) ?? []
|
|
162
|
+
}
|
|
163
|
+
onAnswer={(answers) => setCurrentAnswer(answers)}
|
|
164
|
+
readOnly={readOnly || isRevealed}
|
|
165
|
+
showCorrectAnswers={isRevealed}
|
|
166
|
+
/>
|
|
167
|
+
|
|
168
|
+
{instantFeedback && isRevealed && (
|
|
169
|
+
<FeedbackBanner
|
|
170
|
+
isCorrect={isCurrentCorrect}
|
|
171
|
+
explanation={currentQuestion.explanation}
|
|
172
|
+
onRetry={allowRetry && !isCurrentCorrect ? handleRetry : undefined}
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
<div className="flex justify-end gap-2 mt-2">
|
|
177
|
+
{!isRevealed && instantFeedback && (
|
|
178
|
+
<Button
|
|
179
|
+
onClick={checkAnswer}
|
|
180
|
+
disabled={!currentAnswer || currentAnswer.length === 0 || readOnly}
|
|
181
|
+
>
|
|
182
|
+
Check Answer
|
|
183
|
+
</Button>
|
|
184
|
+
)}
|
|
185
|
+
{(!instantFeedback || isRevealed) && (
|
|
186
|
+
<Button
|
|
187
|
+
onClick={handleNext}
|
|
188
|
+
disabled={readOnly}
|
|
189
|
+
>
|
|
190
|
+
{currentIndex < questions.length - 1 ? "Next Question" : "Finish"}
|
|
191
|
+
</Button>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</CardContent>
|
|
195
|
+
</Card>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { QuestionData } from "../../questions/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PracticeQuiz section — a low-stakes practice quiz with instant feedback.
|
|
5
|
+
*
|
|
6
|
+
* After each answer the correct answer is revealed with an explanation.
|
|
7
|
+
* Users can retry incorrect questions. A completion screen shows stats
|
|
8
|
+
* when all questions have been attempted.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <PracticeQuiz
|
|
12
|
+
* questions={questions}
|
|
13
|
+
* onComplete={(stats) => trackPractice(stats)}
|
|
14
|
+
* instantFeedback
|
|
15
|
+
* allowRetry
|
|
16
|
+
* />
|
|
17
|
+
*/
|
|
18
|
+
export interface PracticeQuizProps {
|
|
19
|
+
/** Ordered list of questions to practice */
|
|
20
|
+
questions: QuestionData[];
|
|
21
|
+
/** Whether to reveal the correct answer immediately after answering */
|
|
22
|
+
instantFeedback?: boolean;
|
|
23
|
+
/** Whether to allow retrying incorrect answers */
|
|
24
|
+
allowRetry?: boolean;
|
|
25
|
+
/** Called when the user completes all questions */
|
|
26
|
+
onComplete?: (stats: PracticeQuizStats) => void;
|
|
27
|
+
/** Whether to shuffle the question order */
|
|
28
|
+
shuffled?: boolean;
|
|
29
|
+
/** When true, all inputs are disabled */
|
|
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 PracticeQuizStats {
|
|
38
|
+
/** Total number of questions */
|
|
39
|
+
totalQuestions: number;
|
|
40
|
+
/** Questions answered correctly on the first attempt */
|
|
41
|
+
correctOnFirstAttempt: number;
|
|
42
|
+
/** Total answer attempts across all questions */
|
|
43
|
+
totalAttempts: number;
|
|
44
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Award,
|
|
3
|
+
BookOpen,
|
|
4
|
+
CheckCircle,
|
|
5
|
+
Clock,
|
|
6
|
+
Flame,
|
|
7
|
+
Send,
|
|
8
|
+
Trophy,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { ProgressRing, StatCard } from "../../progress";
|
|
11
|
+
import { Progress } from "../../ui/progress";
|
|
12
|
+
import { Card } from "../../ui/card";
|
|
13
|
+
import { formatDuration } from "../../utils/format-duration";
|
|
14
|
+
import type { ProgressDashboardProps } from "./types";
|
|
15
|
+
import { cn } from "../../lib/utils";
|
|
16
|
+
|
|
17
|
+
const ACTIVITY_ICONS = {
|
|
18
|
+
lesson_completed: BookOpen,
|
|
19
|
+
quiz_passed: CheckCircle,
|
|
20
|
+
assignment_submitted: Send,
|
|
21
|
+
badge_earned: Award,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function ProgressDashboard({
|
|
25
|
+
overallProgress,
|
|
26
|
+
totalTimeSpent,
|
|
27
|
+
modules,
|
|
28
|
+
recentActivity,
|
|
29
|
+
streak,
|
|
30
|
+
achievements,
|
|
31
|
+
recentActivityLimit = 5,
|
|
32
|
+
onModuleClick,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
}: ProgressDashboardProps) {
|
|
36
|
+
return (
|
|
37
|
+
<div className={className} style={style}>
|
|
38
|
+
{/* Stats row */}
|
|
39
|
+
<div className="grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-2 mb-3">
|
|
40
|
+
<Card className="p-2 flex justify-center">
|
|
41
|
+
<ProgressRing value={overallProgress} size={100} />
|
|
42
|
+
</Card>
|
|
43
|
+
<StatCard
|
|
44
|
+
icon={<Clock size={24} />}
|
|
45
|
+
label="Time Spent"
|
|
46
|
+
value={formatDuration(totalTimeSpent)}
|
|
47
|
+
/>
|
|
48
|
+
{streak && (
|
|
49
|
+
<StatCard
|
|
50
|
+
icon={<Flame size={24} />}
|
|
51
|
+
label="Current Streak"
|
|
52
|
+
value={`${streak.currentDays} days`}
|
|
53
|
+
subtitle={`Longest: ${streak.longestDays} days`}
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
<StatCard
|
|
57
|
+
icon={<BookOpen size={24} />}
|
|
58
|
+
label="Modules"
|
|
59
|
+
value={`${modules.filter((m) => m.completedItems === m.totalItems).length} / ${modules.length}`}
|
|
60
|
+
subtitle="completed"
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
{/* Module progress */}
|
|
65
|
+
<p className="text-lg font-semibold mb-2 text-foreground">Module Progress</p>
|
|
66
|
+
<div className="flex flex-col gap-2 mb-3">
|
|
67
|
+
{modules.map((mod) => {
|
|
68
|
+
const pct = mod.totalItems > 0 ? (mod.completedItems / mod.totalItems) * 100 : 0;
|
|
69
|
+
return (
|
|
70
|
+
<Card
|
|
71
|
+
key={mod.uid}
|
|
72
|
+
className={cn(
|
|
73
|
+
"p-2 transition-colors",
|
|
74
|
+
onModuleClick && "cursor-pointer hover:border-primary",
|
|
75
|
+
)}
|
|
76
|
+
onClick={() => onModuleClick?.(mod.uid)}
|
|
77
|
+
>
|
|
78
|
+
<div className="flex justify-between items-center mb-0.5">
|
|
79
|
+
<span className="font-semibold text-sm text-foreground">{mod.name}</span>
|
|
80
|
+
<span className="text-xs text-muted-foreground">
|
|
81
|
+
{mod.completedItems} / {mod.totalItems}
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
<Progress value={pct} size="sm" />
|
|
85
|
+
</Card>
|
|
86
|
+
);
|
|
87
|
+
})}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Recent activity */}
|
|
91
|
+
{recentActivity && recentActivity.length > 0 && (
|
|
92
|
+
<>
|
|
93
|
+
<p className="text-lg font-semibold mb-2 text-foreground">Recent Activity</p>
|
|
94
|
+
<div className="flex flex-col gap-1.5 mb-3">
|
|
95
|
+
{recentActivity.slice(0, recentActivityLimit).map((activity) => {
|
|
96
|
+
const Icon = ACTIVITY_ICONS[activity.type] ?? CheckCircle;
|
|
97
|
+
return (
|
|
98
|
+
<div key={activity.uid} className="flex gap-1.5 items-center">
|
|
99
|
+
<Icon size={16} />
|
|
100
|
+
<span className="flex-1 text-sm text-foreground">
|
|
101
|
+
{activity.description}
|
|
102
|
+
</span>
|
|
103
|
+
<span className="text-xs text-muted-foreground">
|
|
104
|
+
{new Date(activity.timestamp).toLocaleDateString()}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
</>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{/* Achievements */}
|
|
114
|
+
{achievements && achievements.length > 0 && (
|
|
115
|
+
<>
|
|
116
|
+
<p className="text-lg font-semibold mb-2 text-foreground">Achievements</p>
|
|
117
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-2">
|
|
118
|
+
{achievements.map((badge) => (
|
|
119
|
+
<Card key={badge.uid} className="p-2 text-center">
|
|
120
|
+
{badge.iconUrl ? (
|
|
121
|
+
<img
|
|
122
|
+
src={badge.iconUrl}
|
|
123
|
+
alt={badge.name}
|
|
124
|
+
className="w-12 h-12 mb-1 mx-auto"
|
|
125
|
+
/>
|
|
126
|
+
) : (
|
|
127
|
+
<Trophy size={32} className="mx-auto mb-2 text-warning" />
|
|
128
|
+
)}
|
|
129
|
+
<p className="font-semibold text-sm text-foreground">{badge.name}</p>
|
|
130
|
+
<p className="text-xs text-muted-foreground">
|
|
131
|
+
{badge.description}
|
|
132
|
+
</p>
|
|
133
|
+
</Card>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* ProgressDashboard section — a visual course progress overview.
|
|
4
|
+
*
|
|
5
|
+
* Shows overall completion, time spent, streak data, per-module progress,
|
|
6
|
+
* recent activity log, and earned achievements.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <ProgressDashboard
|
|
10
|
+
* overallProgress={72}
|
|
11
|
+
* totalTimeSpent={14400}
|
|
12
|
+
* modules={modules}
|
|
13
|
+
* streak={{ currentDays: 5, longestDays: 12 }}
|
|
14
|
+
* />
|
|
15
|
+
*/
|
|
16
|
+
export interface ProgressDashboardProps {
|
|
17
|
+
/** Overall course progress percentage (0-100) */
|
|
18
|
+
overallProgress: number;
|
|
19
|
+
/** Total time spent in seconds */
|
|
20
|
+
totalTimeSpent: number;
|
|
21
|
+
/** Per-module progress */
|
|
22
|
+
modules: ModuleProgress[];
|
|
23
|
+
/** Recent activity items */
|
|
24
|
+
recentActivity?: ActivityItem[];
|
|
25
|
+
/** Streak data */
|
|
26
|
+
streak?: { currentDays: number; longestDays: number };
|
|
27
|
+
/** Earned achievements/badges */
|
|
28
|
+
achievements?: Achievement[];
|
|
29
|
+
/** Number of recent activity items to show */
|
|
30
|
+
recentActivityLimit?: number;
|
|
31
|
+
/** Called when the user clicks a module */
|
|
32
|
+
onModuleClick?: (moduleUid: string) => void;
|
|
33
|
+
/** CSS class name for the root element */
|
|
34
|
+
className?: string;
|
|
35
|
+
/** Inline styles for the root element */
|
|
36
|
+
style?: React.CSSProperties;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ModuleProgress {
|
|
40
|
+
/** Unique identifier */
|
|
41
|
+
uid: string;
|
|
42
|
+
/** Module name */
|
|
43
|
+
name: string;
|
|
44
|
+
/** Completed items count */
|
|
45
|
+
completedItems: number;
|
|
46
|
+
/** Total items in the module */
|
|
47
|
+
totalItems: number;
|
|
48
|
+
/** Time spent in seconds */
|
|
49
|
+
timeSpent: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ActivityItem {
|
|
53
|
+
/** Unique identifier */
|
|
54
|
+
uid: string;
|
|
55
|
+
/** Activity description */
|
|
56
|
+
description: string;
|
|
57
|
+
/** Activity timestamp */
|
|
58
|
+
timestamp: string;
|
|
59
|
+
/** Activity type */
|
|
60
|
+
type: "lesson_completed" | "quiz_passed" | "assignment_submitted" | "badge_earned";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface Achievement {
|
|
64
|
+
/** Unique identifier */
|
|
65
|
+
uid: string;
|
|
66
|
+
/** Achievement name */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Achievement description */
|
|
69
|
+
description: string;
|
|
70
|
+
/** Icon URL */
|
|
71
|
+
iconUrl?: string;
|
|
72
|
+
/** Date earned */
|
|
73
|
+
earnedAt: string;
|
|
74
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { AssessmentToolbar } from "../../assessment-toolbar";
|
|
3
|
+
import type { QuestionNavigatorItem } from "../../assessment-toolbar/types";
|
|
4
|
+
import { QuestionRenderer } from "../../questions";
|
|
5
|
+
import type { SessionAnswer } from "../../questions/types";
|
|
6
|
+
import { Card, CardContent } from "../../ui/card";
|
|
7
|
+
import type { QuizSessionProps } from "./types";
|
|
8
|
+
import { cn } from "../../lib/utils";
|
|
9
|
+
|
|
10
|
+
export function QuizSession({
|
|
11
|
+
questions,
|
|
12
|
+
initialAnswers = [],
|
|
13
|
+
onSubmit,
|
|
14
|
+
onAnswerChange,
|
|
15
|
+
timeElapsedSeconds,
|
|
16
|
+
timeLimitSeconds,
|
|
17
|
+
isSubmitting = false,
|
|
18
|
+
readOnly = false,
|
|
19
|
+
className,
|
|
20
|
+
style,
|
|
21
|
+
}: QuizSessionProps) {
|
|
22
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
23
|
+
const [sessionAnswers, setSessionAnswers] =
|
|
24
|
+
useState<SessionAnswer[]>(initialAnswers);
|
|
25
|
+
const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
|
|
26
|
+
|
|
27
|
+
const currentQuestion = questions[currentIndex];
|
|
28
|
+
|
|
29
|
+
const navigatorItems = useMemo<QuestionNavigatorItem[]>(
|
|
30
|
+
() =>
|
|
31
|
+
questions.map((q, idx) => ({
|
|
32
|
+
uid: q.uid,
|
|
33
|
+
sequence: idx + 1,
|
|
34
|
+
isFlagged: flaggedUids.has(q.uid),
|
|
35
|
+
isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
|
|
36
|
+
isSkipped: false,
|
|
37
|
+
})),
|
|
38
|
+
[questions, sessionAnswers, flaggedUids],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
|
|
42
|
+
if (!currentQuestion) return;
|
|
43
|
+
const questionUid = currentQuestion.uid;
|
|
44
|
+
const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
|
|
45
|
+
uid: questionUid,
|
|
46
|
+
answerUid: a.uid,
|
|
47
|
+
content: a.content,
|
|
48
|
+
}));
|
|
49
|
+
setSessionAnswers((prev) => {
|
|
50
|
+
const filtered = prev.filter((a) => a.uid !== questionUid);
|
|
51
|
+
const merged = [...filtered, ...newAnswers];
|
|
52
|
+
onAnswerChange?.(merged);
|
|
53
|
+
return merged;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleNavigate(uid: string) {
|
|
58
|
+
const idx = questions.findIndex((q) => q.uid === uid);
|
|
59
|
+
if (idx !== -1) setCurrentIndex(idx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function handleToggleFlag(uid: string) {
|
|
63
|
+
setFlaggedUids((prev) => {
|
|
64
|
+
const next = new Set(prev);
|
|
65
|
+
if (next.has(uid)) {
|
|
66
|
+
next.delete(uid);
|
|
67
|
+
} else {
|
|
68
|
+
next.add(uid);
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleSubmit() {
|
|
75
|
+
onSubmit(sessionAnswers);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className={cn(className)} style={style}>
|
|
80
|
+
<AssessmentToolbar
|
|
81
|
+
currentQuestionIndex={currentIndex}
|
|
82
|
+
totalQuestions={questions.length}
|
|
83
|
+
hasNext={currentIndex < questions.length - 1}
|
|
84
|
+
hasPrevious={currentIndex > 0}
|
|
85
|
+
onNext={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
|
|
86
|
+
onPrevious={() => setCurrentIndex((i) => Math.max(i - 1, 0))}
|
|
87
|
+
onSubmit={handleSubmit}
|
|
88
|
+
timeElapsedSeconds={timeElapsedSeconds}
|
|
89
|
+
timeLimitSeconds={timeLimitSeconds}
|
|
90
|
+
questions={navigatorItems}
|
|
91
|
+
onNavigateToQuestion={handleNavigate}
|
|
92
|
+
onToggleFlag={handleToggleFlag}
|
|
93
|
+
currentQuestionUid={currentQuestion?.uid}
|
|
94
|
+
isSubmitting={isSubmitting}
|
|
95
|
+
readOnly={readOnly}
|
|
96
|
+
/>
|
|
97
|
+
{currentQuestion && (
|
|
98
|
+
<Card className="mt-3">
|
|
99
|
+
<CardContent className="pt-6">
|
|
100
|
+
<QuestionRenderer
|
|
101
|
+
question={currentQuestion}
|
|
102
|
+
sessionAnswers={sessionAnswers.filter(
|
|
103
|
+
(a) => a.uid === currentQuestion.uid,
|
|
104
|
+
)}
|
|
105
|
+
onAnswer={handleAnswer}
|
|
106
|
+
readOnly={readOnly}
|
|
107
|
+
/>
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QuizSession section — a complete assessment session experience.
|
|
5
|
+
*
|
|
6
|
+
* Manages question navigation, per-question answer accumulation, and
|
|
7
|
+
* flag toggling. Combines AssessmentToolbar with QuestionRenderer.
|
|
8
|
+
* Pass your questions array and an onSubmit callback — everything
|
|
9
|
+
* else is handled internally.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <QuizSession
|
|
13
|
+
* questions={questions}
|
|
14
|
+
* onSubmit={(answers) => submitAssessment(answers)}
|
|
15
|
+
* timeLimitSeconds={1800}
|
|
16
|
+
* />
|
|
17
|
+
*/
|
|
18
|
+
export interface QuizSessionProps {
|
|
19
|
+
/** Ordered list of questions to present */
|
|
20
|
+
questions: QuestionData[];
|
|
21
|
+
/**
|
|
22
|
+
* Pre-populated answers — use to resume a session already in progress.
|
|
23
|
+
*/
|
|
24
|
+
initialAnswers?: SessionAnswer[];
|
|
25
|
+
/**
|
|
26
|
+
* Called when the user clicks Submit.
|
|
27
|
+
* Receives the final accumulated answers array.
|
|
28
|
+
*/
|
|
29
|
+
onSubmit: (answers: SessionAnswer[]) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Called whenever the user changes an answer (useful for auto-save).
|
|
32
|
+
* Fires with the full answers array after each mutation.
|
|
33
|
+
*/
|
|
34
|
+
onAnswerChange?: (answers: SessionAnswer[]) => void;
|
|
35
|
+
/** Elapsed time in seconds — renders the timer in the toolbar when provided */
|
|
36
|
+
timeElapsedSeconds?: number;
|
|
37
|
+
/** Time limit in seconds — enables countdown mode in the timer */
|
|
38
|
+
timeLimitSeconds?: number;
|
|
39
|
+
/** Whether the submit action is currently in flight */
|
|
40
|
+
isSubmitting?: boolean;
|
|
41
|
+
/** When true, all inputs are disabled (e.g. after submission) */
|
|
42
|
+
readOnly?: boolean;
|
|
43
|
+
/** CSS class name for the root element */
|
|
44
|
+
className?: string;
|
|
45
|
+
/** Inline styles for the root element */
|
|
46
|
+
style?: React.CSSProperties;
|
|
47
|
+
}
|