@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,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
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Download, Grid, List as ListIcon } from "lucide-react";
|
|
3
|
+
import { LearningObjectIcon } from "../../curriculum";
|
|
4
|
+
import { SearchInput, EmptyState } from "../../common";
|
|
5
|
+
import { Button } from "../../ui/button";
|
|
6
|
+
import { Card, CardContent } from "../../ui/card";
|
|
7
|
+
import { Tabs, TabsList, TabsTrigger } from "../../ui/tabs";
|
|
8
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
|
|
9
|
+
import type { ResourceLibraryProps, Resource } from "./types";
|
|
10
|
+
import { cn } from "../../lib/utils";
|
|
11
|
+
|
|
12
|
+
function formatBytes(bytes: number): string {
|
|
13
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
14
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
15
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TYPE_TO_ICON: Record<string, string> = {
|
|
19
|
+
pdf: "document",
|
|
20
|
+
document: "document",
|
|
21
|
+
video: "video",
|
|
22
|
+
link: "link",
|
|
23
|
+
image: "document",
|
|
24
|
+
archive: "document",
|
|
25
|
+
other: "document",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ResourceLibrary({
|
|
29
|
+
resources,
|
|
30
|
+
categories,
|
|
31
|
+
onResourceClick,
|
|
32
|
+
onDownload,
|
|
33
|
+
viewMode: initialViewMode = "list",
|
|
34
|
+
allowViewToggle = true,
|
|
35
|
+
showSearch = true,
|
|
36
|
+
emptyMessage = "No resources found",
|
|
37
|
+
readOnly = false,
|
|
38
|
+
className,
|
|
39
|
+
style,
|
|
40
|
+
}: ResourceLibraryProps) {
|
|
41
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
42
|
+
const [activeCategoryUid, setActiveCategoryUid] = useState<string | null>(null);
|
|
43
|
+
const [viewMode, setViewMode] = useState(initialViewMode);
|
|
44
|
+
|
|
45
|
+
const filtered = useMemo(() => {
|
|
46
|
+
let result = resources;
|
|
47
|
+
if (activeCategoryUid) {
|
|
48
|
+
result = result.filter((r) => r.categoryUid === activeCategoryUid);
|
|
49
|
+
}
|
|
50
|
+
if (searchQuery.trim()) {
|
|
51
|
+
const q = searchQuery.toLowerCase();
|
|
52
|
+
result = result.filter(
|
|
53
|
+
(r) =>
|
|
54
|
+
r.name.toLowerCase().includes(q) ||
|
|
55
|
+
r.description?.toLowerCase().includes(q),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}, [resources, activeCategoryUid, searchQuery]);
|
|
60
|
+
|
|
61
|
+
function renderResource(resource: Resource) {
|
|
62
|
+
const iconType = TYPE_TO_ICON[resource.type] ?? "document";
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
key={resource.uid}
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex items-center gap-3 px-3 py-2",
|
|
69
|
+
!readOnly && "cursor-pointer hover:bg-muted",
|
|
70
|
+
readOnly && "opacity-70",
|
|
71
|
+
)}
|
|
72
|
+
onClick={() => !readOnly && onResourceClick(resource)}
|
|
73
|
+
>
|
|
74
|
+
<div className="min-w-10">
|
|
75
|
+
<LearningObjectIcon type={iconType} size={20} />
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex-1 min-w-0">
|
|
78
|
+
<span className="text-sm text-foreground">{resource.name}</span>
|
|
79
|
+
{(resource.description || resource.fileSize != null) && (
|
|
80
|
+
<span className="text-sm text-muted-foreground">
|
|
81
|
+
{[
|
|
82
|
+
resource.description,
|
|
83
|
+
resource.fileSize != null && formatBytes(resource.fileSize),
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(" \u00b7 ")}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
{onDownload && (
|
|
91
|
+
<Tooltip>
|
|
92
|
+
<TooltipTrigger>
|
|
93
|
+
<Button
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="icon-xs"
|
|
96
|
+
aria-label="Download"
|
|
97
|
+
onClick={(e) => {
|
|
98
|
+
e.stopPropagation();
|
|
99
|
+
onDownload(resource);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<Download size={16} />
|
|
103
|
+
</Button>
|
|
104
|
+
</TooltipTrigger>
|
|
105
|
+
<TooltipContent>Download</TooltipContent>
|
|
106
|
+
</Tooltip>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className={className} style={style}>
|
|
114
|
+
{/* Toolbar */}
|
|
115
|
+
<div className="flex gap-2 items-center mb-2">
|
|
116
|
+
{showSearch && (
|
|
117
|
+
<div className="flex-1 max-w-80">
|
|
118
|
+
<SearchInput
|
|
119
|
+
value={searchQuery}
|
|
120
|
+
onChange={setSearchQuery}
|
|
121
|
+
placeholder="Search resources..."
|
|
122
|
+
size="small"
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
{allowViewToggle && (
|
|
127
|
+
<div className="flex gap-0.5">
|
|
128
|
+
<Tooltip>
|
|
129
|
+
<TooltipTrigger>
|
|
130
|
+
<Button
|
|
131
|
+
variant="ghost"
|
|
132
|
+
size="icon-xs"
|
|
133
|
+
aria-label="List view"
|
|
134
|
+
className={cn(viewMode === "list" && "text-primary")}
|
|
135
|
+
onClick={() => setViewMode("list")}
|
|
136
|
+
>
|
|
137
|
+
<ListIcon size={18} />
|
|
138
|
+
</Button>
|
|
139
|
+
</TooltipTrigger>
|
|
140
|
+
<TooltipContent>List view</TooltipContent>
|
|
141
|
+
</Tooltip>
|
|
142
|
+
<Tooltip>
|
|
143
|
+
<TooltipTrigger>
|
|
144
|
+
<Button
|
|
145
|
+
variant="ghost"
|
|
146
|
+
size="icon-xs"
|
|
147
|
+
aria-label="Grid view"
|
|
148
|
+
className={cn(viewMode === "grid" && "text-primary")}
|
|
149
|
+
onClick={() => setViewMode("grid")}
|
|
150
|
+
>
|
|
151
|
+
<Grid size={18} />
|
|
152
|
+
</Button>
|
|
153
|
+
</TooltipTrigger>
|
|
154
|
+
<TooltipContent>Grid view</TooltipContent>
|
|
155
|
+
</Tooltip>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Category tabs */}
|
|
161
|
+
{categories && categories.length > 0 && (
|
|
162
|
+
<Tabs
|
|
163
|
+
value={activeCategoryUid ?? "all"}
|
|
164
|
+
onValueChange={(v) => setActiveCategoryUid(v === "all" ? null : v)}
|
|
165
|
+
className="mb-2"
|
|
166
|
+
>
|
|
167
|
+
<TabsList>
|
|
168
|
+
<TabsTrigger value="all">All</TabsTrigger>
|
|
169
|
+
{categories.map((cat) => (
|
|
170
|
+
<TabsTrigger key={cat.uid} value={cat.uid}>{cat.label}</TabsTrigger>
|
|
171
|
+
))}
|
|
172
|
+
</TabsList>
|
|
173
|
+
</Tabs>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Resource list */}
|
|
177
|
+
{filtered.length === 0 ? (
|
|
178
|
+
<EmptyState title={emptyMessage} description="Try adjusting your search or filter." />
|
|
179
|
+
) : viewMode === "list" ? (
|
|
180
|
+
<Card>
|
|
181
|
+
{filtered.map(renderResource)}
|
|
182
|
+
</Card>
|
|
183
|
+
) : (
|
|
184
|
+
<div className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-2">
|
|
185
|
+
{filtered.map((resource) => (
|
|
186
|
+
<Card
|
|
187
|
+
key={resource.uid}
|
|
188
|
+
className={cn(
|
|
189
|
+
"transition-colors",
|
|
190
|
+
!readOnly && "cursor-pointer hover:border-primary",
|
|
191
|
+
)}
|
|
192
|
+
onClick={() => !readOnly && onResourceClick(resource)}
|
|
193
|
+
>
|
|
194
|
+
<CardContent className="pt-4 pb-4">
|
|
195
|
+
<div className="flex gap-1 items-center mb-1">
|
|
196
|
+
<LearningObjectIcon type={TYPE_TO_ICON[resource.type] ?? "document"} size={20} />
|
|
197
|
+
<span className="font-semibold text-sm text-foreground truncate">
|
|
198
|
+
{resource.name}
|
|
199
|
+
</span>
|
|
200
|
+
</div>
|
|
201
|
+
{resource.description && (
|
|
202
|
+
<p className="text-sm text-muted-foreground mb-1 truncate">
|
|
203
|
+
{resource.description}
|
|
204
|
+
</p>
|
|
205
|
+
)}
|
|
206
|
+
{resource.fileSize != null && (
|
|
207
|
+
<span className="text-xs text-muted-foreground">
|
|
208
|
+
{formatBytes(resource.fileSize)}
|
|
209
|
+
</span>
|
|
210
|
+
)}
|
|
211
|
+
</CardContent>
|
|
212
|
+
</Card>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* ResourceLibrary section — a searchable resource catalog.
|
|
4
|
+
*
|
|
5
|
+
* Displays downloadable course resources in a grid or list view with
|
|
6
|
+
* search, category tabs, and view mode toggling.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <ResourceLibrary
|
|
10
|
+
* resources={resources}
|
|
11
|
+
* categories={categories}
|
|
12
|
+
* onResourceClick={(r) => download(r)}
|
|
13
|
+
* />
|
|
14
|
+
*/
|
|
15
|
+
export interface ResourceLibraryProps {
|
|
16
|
+
/** Resources to display */
|
|
17
|
+
resources: Resource[];
|
|
18
|
+
/** Optional categories for tab filtering */
|
|
19
|
+
categories?: { uid: string; label: string }[];
|
|
20
|
+
/** Called when the user clicks a resource */
|
|
21
|
+
onResourceClick: (resource: Resource) => void;
|
|
22
|
+
/** Called when the user downloads a resource */
|
|
23
|
+
onDownload?: (resource: Resource) => void;
|
|
24
|
+
/** Layout view mode */
|
|
25
|
+
viewMode?: "grid" | "list";
|
|
26
|
+
/** Whether the user can toggle between grid and list */
|
|
27
|
+
allowViewToggle?: boolean;
|
|
28
|
+
/** Whether to show search */
|
|
29
|
+
showSearch?: boolean;
|
|
30
|
+
/** Empty state message */
|
|
31
|
+
emptyMessage?: string;
|
|
32
|
+
/** When true, disables interactions */
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
/** CSS class name for the root element */
|
|
35
|
+
className?: string;
|
|
36
|
+
/** Inline styles for the root element */
|
|
37
|
+
style?: React.CSSProperties;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Resource {
|
|
41
|
+
/** Unique identifier */
|
|
42
|
+
uid: string;
|
|
43
|
+
/** Resource name */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Optional description */
|
|
46
|
+
description?: string;
|
|
47
|
+
/** Resource type */
|
|
48
|
+
type: "pdf" | "document" | "video" | "link" | "image" | "archive" | "other";
|
|
49
|
+
/** Resource URL */
|
|
50
|
+
url: string;
|
|
51
|
+
/** File size in bytes */
|
|
52
|
+
fileSize?: number;
|
|
53
|
+
/** Category UID for filtering */
|
|
54
|
+
categoryUid?: string;
|
|
55
|
+
/** Date added as ISO string */
|
|
56
|
+
addedAt?: string;
|
|
57
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { QuestionRenderer } from "../../questions";
|
|
3
|
+
import type { SessionAnswer } from "../../questions/types";
|
|
4
|
+
import { Button } from "../../ui/button";
|
|
5
|
+
import { Card, CardContent } from "../../ui/card";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import type { ScrollableQuizProps } from "./types";
|
|
8
|
+
|
|
9
|
+
export function ScrollableQuiz({
|
|
10
|
+
questions,
|
|
11
|
+
initialAnswers = [],
|
|
12
|
+
onSubmit,
|
|
13
|
+
onAnswerChange,
|
|
14
|
+
showNavigator = true,
|
|
15
|
+
showQuestionNumbers = true,
|
|
16
|
+
questionGroups,
|
|
17
|
+
isSubmitting = false,
|
|
18
|
+
readOnly = false,
|
|
19
|
+
className,
|
|
20
|
+
style,
|
|
21
|
+
}: ScrollableQuizProps) {
|
|
22
|
+
const [sessionAnswers, setSessionAnswers] = useState<SessionAnswer[]>(initialAnswers);
|
|
23
|
+
const [activeUid, setActiveUid] = useState<string | null>(questions[0]?.uid ?? null);
|
|
24
|
+
const questionRefs = useRef<Map<string, HTMLElement>>(new Map());
|
|
25
|
+
|
|
26
|
+
const answeredCount = useMemo(() => {
|
|
27
|
+
const answered = new Set(sessionAnswers.map((a) => a.uid));
|
|
28
|
+
return questions.filter((q) => answered.has(q.uid)).length;
|
|
29
|
+
}, [questions, sessionAnswers]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const observer = new IntersectionObserver(
|
|
33
|
+
(entries) => {
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (entry.isIntersecting) {
|
|
36
|
+
setActiveUid(entry.target.getAttribute("data-question-uid"));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ rootMargin: "-20% 0px -60% 0px" },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
questionRefs.current.forEach((el) => observer.observe(el));
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
}, [questions]);
|
|
46
|
+
|
|
47
|
+
const setRef = useCallback((uid: string, el: HTMLElement | null) => {
|
|
48
|
+
if (el) questionRefs.current.set(uid, el);
|
|
49
|
+
else questionRefs.current.delete(uid);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
function handleAnswer(questionUid: string, rawAnswers: { uid: string; content?: string }[]) {
|
|
53
|
+
const newAnswers: SessionAnswer[] = rawAnswers.map((a) => ({
|
|
54
|
+
uid: questionUid,
|
|
55
|
+
answerUid: a.uid,
|
|
56
|
+
content: a.content,
|
|
57
|
+
}));
|
|
58
|
+
setSessionAnswers((prev) => {
|
|
59
|
+
const filtered = prev.filter((a) => a.uid !== questionUid);
|
|
60
|
+
const merged = [...filtered, ...newAnswers];
|
|
61
|
+
onAnswerChange?.(merged);
|
|
62
|
+
return merged;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function scrollToQuestion(uid: string) {
|
|
67
|
+
questionRefs.current.get(uid)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const orderedQuestions = useMemo(() => {
|
|
71
|
+
if (!questionGroups) return [{ label: null, questions }];
|
|
72
|
+
const grouped: { label: string | null; questions: typeof questions }[] = questionGroups.map(
|
|
73
|
+
(g) => ({
|
|
74
|
+
label: g.label,
|
|
75
|
+
questions: g.questionUids
|
|
76
|
+
.map((uid) => questions.find((q) => q.uid === uid))
|
|
77
|
+
.filter(Boolean) as typeof questions,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const groupedUids = new Set(questionGroups.flatMap((g) => g.questionUids));
|
|
81
|
+
const ungrouped = questions.filter((q) => !groupedUids.has(q.uid));
|
|
82
|
+
if (ungrouped.length > 0) grouped.push({ label: null, questions: ungrouped });
|
|
83
|
+
return grouped;
|
|
84
|
+
}, [questions, questionGroups]);
|
|
85
|
+
|
|
86
|
+
let globalIndex = 0;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className={cn("flex gap-3", className)} style={style}>
|
|
90
|
+
{/* Main content */}
|
|
91
|
+
<div className="flex-1 min-w-0">
|
|
92
|
+
{orderedQuestions.map((group, gi) => (
|
|
93
|
+
<div key={gi}>
|
|
94
|
+
{group.label && (
|
|
95
|
+
<p className={cn("text-lg font-semibold mb-2 text-foreground", gi > 0 && "mt-4")}>
|
|
96
|
+
{group.label}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
{group.questions.map((q) => {
|
|
100
|
+
const qIndex = globalIndex++;
|
|
101
|
+
return (
|
|
102
|
+
<Card
|
|
103
|
+
key={q.uid}
|
|
104
|
+
ref={(el: HTMLElement | null) => setRef(q.uid, el)}
|
|
105
|
+
data-question-uid={q.uid}
|
|
106
|
+
className="mb-2"
|
|
107
|
+
>
|
|
108
|
+
<CardContent className="pt-6">
|
|
109
|
+
{showQuestionNumbers && (
|
|
110
|
+
<p className="font-semibold text-sm text-muted-foreground mb-1">
|
|
111
|
+
Question {qIndex + 1}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
<QuestionRenderer
|
|
115
|
+
question={q}
|
|
116
|
+
sessionAnswers={sessionAnswers.filter((a) => a.uid === q.uid)}
|
|
117
|
+
onAnswer={(answers) => handleAnswer(q.uid, answers)}
|
|
118
|
+
readOnly={readOnly}
|
|
119
|
+
/>
|
|
120
|
+
</CardContent>
|
|
121
|
+
</Card>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
<div className="mt-3 flex justify-between items-center">
|
|
127
|
+
<span className="text-sm text-muted-foreground">
|
|
128
|
+
{answeredCount} of {questions.length} answered
|
|
129
|
+
</span>
|
|
130
|
+
<Button
|
|
131
|
+
onClick={() => onSubmit(sessionAnswers)}
|
|
132
|
+
disabled={isSubmitting || readOnly}
|
|
133
|
+
>
|
|
134
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Sidebar navigator */}
|
|
140
|
+
{showNavigator && (
|
|
141
|
+
<Card className="hidden md:block w-50 shrink-0 sticky top-4 self-start p-3">
|
|
142
|
+
<p className="font-semibold text-sm mb-1 text-foreground">Questions</p>
|
|
143
|
+
<div className="flex flex-wrap gap-1">
|
|
144
|
+
{questions.map((q, i) => {
|
|
145
|
+
const isAnswered = sessionAnswers.some((a) => a.uid === q.uid);
|
|
146
|
+
const isActive = activeUid === q.uid;
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
key={q.uid}
|
|
151
|
+
className={cn(
|
|
152
|
+
"inline-flex items-center justify-center text-xs font-medium min-w-9 px-1 py-0.5 rounded-full cursor-pointer border transition-colors",
|
|
153
|
+
isActive
|
|
154
|
+
? "bg-primary border-primary text-primary-foreground"
|
|
155
|
+
: isAnswered
|
|
156
|
+
? "border-success text-success bg-transparent"
|
|
157
|
+
: "border-border text-foreground bg-transparent hover:bg-muted",
|
|
158
|
+
)}
|
|
159
|
+
onClick={() => scrollToQuestion(q.uid)}
|
|
160
|
+
>
|
|
161
|
+
{i + 1}
|
|
162
|
+
</button>
|
|
163
|
+
);
|
|
164
|
+
})}
|
|
165
|
+
</div>
|
|
166
|
+
</Card>
|
|
167
|
+
)}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
}
|