@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,182 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, 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 { ConfirmDialog } from "../../common";
|
|
7
|
+
import { Alert, AlertDescription } from "../../ui/alert";
|
|
8
|
+
import { Card, CardContent } from "../../ui/card";
|
|
9
|
+
import { cn } from "../../lib/utils";
|
|
10
|
+
import type { ExamSessionProps, ExamSubmitMetadata } from "./types";
|
|
11
|
+
|
|
12
|
+
export function ExamSession({
|
|
13
|
+
questions,
|
|
14
|
+
initialAnswers = [],
|
|
15
|
+
onSubmit,
|
|
16
|
+
onAnswerChange,
|
|
17
|
+
timeLimitSeconds,
|
|
18
|
+
timeElapsedSeconds,
|
|
19
|
+
autoSubmitOnTimeout = true,
|
|
20
|
+
timeWarningThreshold = 300,
|
|
21
|
+
allowBackNavigation = true,
|
|
22
|
+
confirmBeforeSubmit = true,
|
|
23
|
+
examTitle,
|
|
24
|
+
instructions,
|
|
25
|
+
isSubmitting = false,
|
|
26
|
+
readOnly = false,
|
|
27
|
+
className,
|
|
28
|
+
style,
|
|
29
|
+
}: ExamSessionProps) {
|
|
30
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
31
|
+
const [sessionAnswers, setSessionAnswers] = useState<SessionAnswer[]>(initialAnswers);
|
|
32
|
+
const [flaggedUids, setFlaggedUids] = useState<Set<string>>(new Set());
|
|
33
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
34
|
+
const [showTimeWarning, setShowTimeWarning] = useState(false);
|
|
35
|
+
const hasAutoSubmitted = useRef(false);
|
|
36
|
+
|
|
37
|
+
const currentQuestion = questions[currentIndex];
|
|
38
|
+
const remainingSeconds = timeLimitSeconds - timeElapsedSeconds;
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (remainingSeconds <= timeWarningThreshold && remainingSeconds > 0) {
|
|
42
|
+
setShowTimeWarning(true);
|
|
43
|
+
}
|
|
44
|
+
}, [remainingSeconds, timeWarningThreshold]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (autoSubmitOnTimeout && remainingSeconds <= 0 && !hasAutoSubmitted.current) {
|
|
48
|
+
hasAutoSubmitted.current = true;
|
|
49
|
+
doSubmit(true);
|
|
50
|
+
}
|
|
51
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
52
|
+
}, [remainingSeconds, autoSubmitOnTimeout]);
|
|
53
|
+
|
|
54
|
+
const navigatorItems = useMemo<QuestionNavigatorItem[]>(
|
|
55
|
+
() =>
|
|
56
|
+
questions.map((q, idx) => ({
|
|
57
|
+
uid: q.uid,
|
|
58
|
+
sequence: idx + 1,
|
|
59
|
+
isFlagged: flaggedUids.has(q.uid),
|
|
60
|
+
isAnswered: sessionAnswers.some((a) => a.uid === q.uid),
|
|
61
|
+
isSkipped: false,
|
|
62
|
+
})),
|
|
63
|
+
[questions, sessionAnswers, flaggedUids],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
function handleAnswer(rawAnswers: { uid: string; content?: string }[]) {
|
|
67
|
+
if (!currentQuestion) return;
|
|
68
|
+
const questionUid = currentQuestion.uid;
|
|
69
|
+
const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
|
|
70
|
+
uid: questionUid,
|
|
71
|
+
answerUid: a.uid,
|
|
72
|
+
content: a.content,
|
|
73
|
+
}));
|
|
74
|
+
setSessionAnswers((prev) => {
|
|
75
|
+
const filtered = prev.filter((a) => a.uid !== questionUid);
|
|
76
|
+
const merged = [...filtered, ...newAnswers];
|
|
77
|
+
onAnswerChange?.(merged);
|
|
78
|
+
return merged;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleNavigate(uid: string) {
|
|
83
|
+
const idx = questions.findIndex((q) => q.uid === uid);
|
|
84
|
+
if (idx !== -1) setCurrentIndex(idx);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleToggleFlag(uid: string) {
|
|
88
|
+
setFlaggedUids((prev) => {
|
|
89
|
+
const next = new Set(prev);
|
|
90
|
+
if (next.has(uid)) next.delete(uid);
|
|
91
|
+
else next.add(uid);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleSubmitClick() {
|
|
97
|
+
if (confirmBeforeSubmit) {
|
|
98
|
+
setShowConfirm(true);
|
|
99
|
+
} else {
|
|
100
|
+
doSubmit(false);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function doSubmit(wasAutoSubmitted: boolean) {
|
|
105
|
+
const answeredUids = new Set(sessionAnswers.map((a) => a.uid));
|
|
106
|
+
const metadata: ExamSubmitMetadata = {
|
|
107
|
+
timeElapsedSeconds,
|
|
108
|
+
wasAutoSubmitted,
|
|
109
|
+
answeredCount: questions.filter((q) => answeredUids.has(q.uid)).length,
|
|
110
|
+
totalQuestions: questions.length,
|
|
111
|
+
};
|
|
112
|
+
onSubmit(sessionAnswers, metadata);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div className={cn(className)} style={style}>
|
|
117
|
+
{examTitle && (
|
|
118
|
+
<p className="text-xl font-bold mb-2 text-foreground">{examTitle}</p>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{showTimeWarning && remainingSeconds > 0 && remainingSeconds <= timeWarningThreshold && (
|
|
122
|
+
<Alert variant="warning" className="mb-2">
|
|
123
|
+
<AlertDescription>
|
|
124
|
+
{Math.ceil(remainingSeconds / 60)} minute{Math.ceil(remainingSeconds / 60) !== 1 ? "s" : ""} remaining
|
|
125
|
+
</AlertDescription>
|
|
126
|
+
</Alert>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<AssessmentToolbar
|
|
130
|
+
currentQuestionIndex={currentIndex}
|
|
131
|
+
totalQuestions={questions.length}
|
|
132
|
+
hasNext={currentIndex < questions.length - 1}
|
|
133
|
+
hasPrevious={allowBackNavigation && currentIndex > 0}
|
|
134
|
+
onNext={() => setCurrentIndex((i) => Math.min(i + 1, questions.length - 1))}
|
|
135
|
+
onPrevious={() => allowBackNavigation && setCurrentIndex((i) => Math.max(i - 1, 0))}
|
|
136
|
+
onSubmit={handleSubmitClick}
|
|
137
|
+
timeElapsedSeconds={timeElapsedSeconds}
|
|
138
|
+
timeLimitSeconds={timeLimitSeconds}
|
|
139
|
+
questions={navigatorItems}
|
|
140
|
+
onNavigateToQuestion={handleNavigate}
|
|
141
|
+
onToggleFlag={handleToggleFlag}
|
|
142
|
+
currentQuestionUid={currentQuestion?.uid}
|
|
143
|
+
isSubmitting={isSubmitting}
|
|
144
|
+
readOnly={readOnly}
|
|
145
|
+
/>
|
|
146
|
+
|
|
147
|
+
{instructions && currentIndex === 0 && (
|
|
148
|
+
<Card className="mt-3">
|
|
149
|
+
<CardContent className="pt-6">{instructions}</CardContent>
|
|
150
|
+
</Card>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{currentQuestion && (
|
|
154
|
+
<Card className="mt-3">
|
|
155
|
+
<CardContent className="pt-6">
|
|
156
|
+
<QuestionRenderer
|
|
157
|
+
question={currentQuestion}
|
|
158
|
+
sessionAnswers={sessionAnswers.filter((a) => a.uid === currentQuestion.uid)}
|
|
159
|
+
onAnswer={handleAnswer}
|
|
160
|
+
readOnly={readOnly}
|
|
161
|
+
/>
|
|
162
|
+
</CardContent>
|
|
163
|
+
</Card>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<ConfirmDialog
|
|
167
|
+
open={showConfirm}
|
|
168
|
+
title="Submit Exam?"
|
|
169
|
+
message={`You have answered ${navigatorItems.filter((q) => q.isAnswered).length} of ${questions.length} questions. Once submitted, you cannot change your answers.`}
|
|
170
|
+
confirmLabel="Submit Exam"
|
|
171
|
+
cancelLabel="Continue Exam"
|
|
172
|
+
confirmColor="primary"
|
|
173
|
+
onConfirm={() => {
|
|
174
|
+
setShowConfirm(false);
|
|
175
|
+
doSubmit(false);
|
|
176
|
+
}}
|
|
177
|
+
onCancel={() => setShowConfirm(false)}
|
|
178
|
+
isLoading={isSubmitting}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ExamSession section — a formal timed exam experience.
|
|
6
|
+
*
|
|
7
|
+
* Provides enforced time limits with auto-submit on timeout, time warnings,
|
|
8
|
+
* optional back-navigation restriction, and a confirmation dialog before
|
|
9
|
+
* final submission.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* <ExamSession
|
|
13
|
+
* questions={questions}
|
|
14
|
+
* timeLimitSeconds={3600}
|
|
15
|
+
* timeElapsedSeconds={elapsed}
|
|
16
|
+
* onSubmit={(answers, meta) => submitExam(answers, meta)}
|
|
17
|
+
* examTitle="Final Exam"
|
|
18
|
+
* />
|
|
19
|
+
*/
|
|
20
|
+
export interface ExamSessionProps {
|
|
21
|
+
/** Ordered list of questions */
|
|
22
|
+
questions: QuestionData[];
|
|
23
|
+
/** Pre-populated answers for resuming */
|
|
24
|
+
initialAnswers?: SessionAnswer[];
|
|
25
|
+
/** Called on submission with answers and metadata */
|
|
26
|
+
onSubmit: (answers: SessionAnswer[], metadata: ExamSubmitMetadata) => void;
|
|
27
|
+
/** Called whenever the user changes an answer */
|
|
28
|
+
onAnswerChange?: (answers: SessionAnswer[]) => void;
|
|
29
|
+
/** Time limit in seconds (required for exam mode) */
|
|
30
|
+
timeLimitSeconds: number;
|
|
31
|
+
/** Current elapsed time in seconds (controlled externally) */
|
|
32
|
+
timeElapsedSeconds: number;
|
|
33
|
+
/** Auto-submit when time runs out */
|
|
34
|
+
autoSubmitOnTimeout?: boolean;
|
|
35
|
+
/** Seconds remaining at which to show a time warning */
|
|
36
|
+
timeWarningThreshold?: number;
|
|
37
|
+
/** Whether the user can go back to previous questions */
|
|
38
|
+
allowBackNavigation?: boolean;
|
|
39
|
+
/** Show a confirmation dialog before final submission */
|
|
40
|
+
confirmBeforeSubmit?: boolean;
|
|
41
|
+
/** Title shown in the exam header */
|
|
42
|
+
examTitle?: string;
|
|
43
|
+
/** Instructions displayed above the first question */
|
|
44
|
+
instructions?: ReactNode;
|
|
45
|
+
/** Whether the submit action is in flight */
|
|
46
|
+
isSubmitting?: boolean;
|
|
47
|
+
/** When true, all inputs are disabled */
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
/** CSS class name for the root element */
|
|
50
|
+
className?: string;
|
|
51
|
+
/** Inline styles for the root element */
|
|
52
|
+
style?: React.CSSProperties;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ExamSubmitMetadata {
|
|
56
|
+
/** Total time the user spent in seconds */
|
|
57
|
+
timeElapsedSeconds: number;
|
|
58
|
+
/** Whether the submission was triggered by timeout */
|
|
59
|
+
wasAutoSubmitted: boolean;
|
|
60
|
+
/** Number of questions the user answered */
|
|
61
|
+
answeredCount: number;
|
|
62
|
+
/** Total questions in the exam */
|
|
63
|
+
totalQuestions: number;
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|