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