@hydralms/components 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/package.json +52 -1
  2. package/src/__tests__/setup.ts +1 -0
  3. package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
  4. package/src/assessment-toolbar/index.ts +10 -0
  5. package/src/assessment-toolbar/question-navigator.tsx +86 -0
  6. package/src/assessment-toolbar/timer-display.tsx +73 -0
  7. package/src/assessment-toolbar/types.ts +92 -0
  8. package/src/assets/hydra-icon.png +0 -0
  9. package/src/assets/hydra-icon.svg +18 -0
  10. package/src/assets/hydra-lms-icon.png +0 -0
  11. package/src/assets/hydra-lms-icon.svg +9 -0
  12. package/src/common/confirm-dialog.tsx +60 -0
  13. package/src/common/due-date-display.tsx +64 -0
  14. package/src/common/empty-state.tsx +24 -0
  15. package/src/common/index.ts +12 -0
  16. package/src/common/search-input.tsx +68 -0
  17. package/src/common/status-badge.test.tsx +43 -0
  18. package/src/common/status-badge.tsx +81 -0
  19. package/src/common/types.ts +129 -0
  20. package/src/content/content-block.tsx +116 -0
  21. package/src/content/file-upload-zone.tsx +109 -0
  22. package/src/content/index.ts +7 -0
  23. package/src/content/types.ts +76 -0
  24. package/src/curriculum/curriculum-item.tsx +81 -0
  25. package/src/curriculum/curriculum-tree.tsx +69 -0
  26. package/src/curriculum/index.ts +11 -0
  27. package/src/curriculum/learning-object-icon.tsx +44 -0
  28. package/src/curriculum/types.ts +83 -0
  29. package/src/feedback/feedback-banner.tsx +46 -0
  30. package/src/feedback/index.ts +8 -0
  31. package/src/feedback/likert-scale.tsx +58 -0
  32. package/src/feedback/star-rating.tsx +65 -0
  33. package/src/feedback/types.ts +86 -0
  34. package/src/flashcards/flashcard-deck.tsx +130 -0
  35. package/src/flashcards/flashcard.tsx +108 -0
  36. package/src/flashcards/index.ts +3 -0
  37. package/src/flashcards/types.ts +60 -0
  38. package/src/index.ts +38 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
  41. package/src/modules/CoursePlayer/types.ts +48 -0
  42. package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
  43. package/src/modules/FlashcardLab/types.ts +58 -0
  44. package/src/modules/QuizModule/QuizModule.tsx +241 -0
  45. package/src/modules/QuizModule/types.ts +56 -0
  46. package/src/modules/index.ts +12 -0
  47. package/src/progress/grade-indicator.tsx +65 -0
  48. package/src/progress/index.ts +8 -0
  49. package/src/progress/progress-ring.tsx +56 -0
  50. package/src/progress/stat-card.tsx +42 -0
  51. package/src/progress/types.ts +73 -0
  52. package/src/provider/HydraProvider.tsx +26 -0
  53. package/src/provider/index.ts +2 -0
  54. package/src/questions/choice.tsx +90 -0
  55. package/src/questions/essay.tsx +59 -0
  56. package/src/questions/fill-in-the-blank.tsx +69 -0
  57. package/src/questions/index.ts +14 -0
  58. package/src/questions/multiple-choice.test.tsx +104 -0
  59. package/src/questions/multiple-choice.tsx +97 -0
  60. package/src/questions/question-renderer.tsx +37 -0
  61. package/src/questions/true-false.test.tsx +89 -0
  62. package/src/questions/true-false.tsx +90 -0
  63. package/src/questions/types.ts +53 -0
  64. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
  65. package/src/sections/AnnouncementFeed/types.ts +50 -0
  66. package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
  67. package/src/sections/AssessmentReview/types.ts +61 -0
  68. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
  69. package/src/sections/AssignmentSubmission/types.ts +60 -0
  70. package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
  71. package/src/sections/CertificateViewer/types.ts +45 -0
  72. package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
  73. package/src/sections/CourseOutline/types.ts +53 -0
  74. package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
  75. package/src/sections/DiscussionThread/types.ts +77 -0
  76. package/src/sections/ExamSession/ExamSession.tsx +182 -0
  77. package/src/sections/ExamSession/types.ts +64 -0
  78. package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
  79. package/src/sections/FlashcardStudySession/types.ts +42 -0
  80. package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
  81. package/src/sections/GradebookTable/types.ts +75 -0
  82. package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
  83. package/src/sections/LecturePlayer/types.ts +48 -0
  84. package/src/sections/LessonPage/LessonPage.tsx +91 -0
  85. package/src/sections/LessonPage/types.ts +41 -0
  86. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
  87. package/src/sections/PracticeQuiz/types.ts +44 -0
  88. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
  89. package/src/sections/ProgressDashboard/types.ts +74 -0
  90. package/src/sections/QuizSession/QuizSession.tsx +113 -0
  91. package/src/sections/QuizSession/types.ts +47 -0
  92. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
  93. package/src/sections/ResourceLibrary/types.ts +57 -0
  94. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
  95. package/src/sections/ScrollableQuiz/types.ts +40 -0
  96. package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
  97. package/src/sections/SurveyForm/types.ts +69 -0
  98. package/src/sections/index.ts +90 -0
  99. package/src/social/index.ts +3 -0
  100. package/src/social/post-card.tsx +91 -0
  101. package/src/social/types.ts +57 -0
  102. package/src/social/user-avatar.tsx +76 -0
  103. package/src/styles/globals.css +125 -0
  104. package/src/ui/alert-dialog.tsx +343 -0
  105. package/src/ui/alert.tsx +65 -0
  106. package/src/ui/avatar.tsx +52 -0
  107. package/src/ui/badge.tsx +53 -0
  108. package/src/ui/button.tsx +62 -0
  109. package/src/ui/card.tsx +92 -0
  110. package/src/ui/index.ts +44 -0
  111. package/src/ui/input.tsx +21 -0
  112. package/src/ui/progress.tsx +73 -0
  113. package/src/ui/separator.tsx +29 -0
  114. package/src/ui/skeleton.tsx +15 -0
  115. package/src/ui/slot.tsx +48 -0
  116. package/src/ui/table.tsx +108 -0
  117. package/src/ui/tabs.tsx +147 -0
  118. package/src/ui/textarea.tsx +20 -0
  119. package/src/ui/tooltip.tsx +177 -0
  120. package/src/utils/debounce.test.ts +59 -0
  121. package/src/utils/debounce.ts +10 -0
  122. package/src/utils/format-duration.test.ts +55 -0
  123. package/src/utils/format-duration.ts +30 -0
  124. package/src/video/index.ts +17 -0
  125. package/src/video/types.ts +216 -0
  126. package/src/video/video-bookmark.tsx +76 -0
  127. package/src/video/video-chapter-list.tsx +93 -0
  128. package/src/video/video-player.tsx +103 -0
  129. package/src/video/video-playlist-item.tsx +90 -0
  130. package/src/video/video-thumbnail-card.tsx +74 -0
  131. package/src/video/video-transcript.tsx +102 -0
@@ -0,0 +1,79 @@
1
+ import { useMemo } from "react";
2
+ import { CurriculumTree } from "../../curriculum";
3
+ import type { CurriculumItem } from "../../curriculum/types";
4
+ import { Progress } from "../../ui/progress";
5
+ import type { CourseOutlineProps } from "./types";
6
+ import { cn } from "../../lib/utils";
7
+
8
+ function flattenLeaves(items: CurriculumItem[]): string[] {
9
+ const leaves: string[] = [];
10
+ for (const item of items) {
11
+ if (!item.children || item.children.length === 0) {
12
+ leaves.push(item.uid);
13
+ } else {
14
+ leaves.push(...flattenLeaves(item.children));
15
+ }
16
+ }
17
+ return leaves;
18
+ }
19
+
20
+ export function CourseOutline({
21
+ items,
22
+ progress,
23
+ courseTitle,
24
+ activeItemUid,
25
+ onItemClick,
26
+ showOverallProgress = true,
27
+ showDuration = true,
28
+ showIcons = true,
29
+ readOnly = false,
30
+ className,
31
+ style,
32
+ }: CourseOutlineProps) {
33
+ const { completedCount, totalCount, percentage } = useMemo(() => {
34
+ const leafUids = flattenLeaves(items);
35
+ const total = leafUids.length;
36
+ const completed = progress
37
+ ? leafUids.filter((uid) =>
38
+ progress.some((p) => p.resourceUid === uid && p.isCompleted),
39
+ ).length
40
+ : 0;
41
+ return {
42
+ completedCount: completed,
43
+ totalCount: total,
44
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0,
45
+ };
46
+ }, [items, progress]);
47
+
48
+ return (
49
+ <div className={cn(className)} style={style}>
50
+ {(courseTitle || showOverallProgress) && (
51
+ <div className="px-2 pt-2 pb-1">
52
+ {courseTitle && (
53
+ <p className={cn("font-semibold text-sm text-foreground", showOverallProgress && "mb-1")}>
54
+ {courseTitle}
55
+ </p>
56
+ )}
57
+ {showOverallProgress && (
58
+ <div>
59
+ <Progress value={percentage} size="sm" />
60
+ <span className="text-xs text-muted-foreground mt-0.5 block">
61
+ {completedCount} of {totalCount} completed
62
+ </span>
63
+ </div>
64
+ )}
65
+ </div>
66
+ )}
67
+ <CurriculumTree
68
+ items={items}
69
+ progress={progress}
70
+ activeItemUid={activeItemUid}
71
+ onItemClick={onItemClick}
72
+ readOnly={readOnly}
73
+ showDuration={showDuration}
74
+ showIcons={showIcons}
75
+ showProgress
76
+ />
77
+ </div>
78
+ );
79
+ }
@@ -0,0 +1,53 @@
1
+ import type {
2
+ CurriculumItem,
3
+ CurriculumItemProgress,
4
+ } from "../../curriculum/types";
5
+
6
+ /**
7
+ * CourseOutline section — a sidebar-ready course navigation panel.
8
+ *
9
+ * Wraps CurriculumTree with a course title header and an optional overall
10
+ * progress bar. Ready to drop into a sidebar layout without extra markup.
11
+ *
12
+ * @example
13
+ * <CourseOutline
14
+ * items={curriculum}
15
+ * progress={userProgress}
16
+ * courseTitle="Introduction to React"
17
+ * activeItemUid={currentLessonUid}
18
+ * onItemClick={(item) => navigate(item.uid)}
19
+ * />
20
+ */
21
+ export interface CourseOutlineProps {
22
+ /** The course curriculum items */
23
+ items: CurriculumItem[];
24
+ /** User progress data */
25
+ progress?: CurriculumItemProgress[];
26
+ /** Course title displayed in the header */
27
+ courseTitle?: string;
28
+ /** UID of the currently active/playing item */
29
+ activeItemUid?: string;
30
+ /** Called when the user clicks a leaf curriculum item */
31
+ onItemClick?: (item: CurriculumItem) => void;
32
+ /**
33
+ * Whether to show a linear progress bar summarising overall completion.
34
+ * @default true
35
+ */
36
+ showOverallProgress?: boolean;
37
+ /**
38
+ * Whether to show per-item duration labels in the tree.
39
+ * @default true
40
+ */
41
+ showDuration?: boolean;
42
+ /**
43
+ * Whether to show type icons per item.
44
+ * @default true
45
+ */
46
+ showIcons?: boolean;
47
+ /** When true, disables all click interactions */
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
+ }
@@ -0,0 +1,186 @@
1
+ import { useMemo, useState } from "react";
2
+ import { CheckCircle, Heart, MessageSquare, Reply } from "lucide-react";
3
+ import { PostCard } from "../../social";
4
+ import { Button } from "../../ui/button";
5
+ import { Textarea } from "../../ui/textarea";
6
+ import { Separator } from "../../ui/separator";
7
+ import { Card, CardContent } from "../../ui/card";
8
+ import { Tooltip, TooltipTrigger, TooltipContent } from "../../ui/tooltip";
9
+ import type { DiscussionThreadProps, DiscussionPost } from "./types";
10
+ import { cn } from "../../lib/utils";
11
+
12
+ export function DiscussionThread({
13
+ title,
14
+ rootPost,
15
+ replies,
16
+ currentUser,
17
+ onReply,
18
+ onToggleLike,
19
+ onMarkAnswer,
20
+ maxDepth = 3,
21
+ allowReplies = true,
22
+ sortOrder = "oldest",
23
+ readOnly = false,
24
+ className,
25
+ style,
26
+ }: DiscussionThreadProps) {
27
+ const [replyingToUid, setReplyingToUid] = useState<string | null>(null);
28
+ const [replyContent, setReplyContent] = useState("");
29
+
30
+ const replyTree = useMemo(() => {
31
+ const childrenMap = new Map<string, DiscussionPost[]>();
32
+ for (const reply of replies) {
33
+ const parentUid = reply.parentUid ?? rootPost.uid;
34
+ const siblings = childrenMap.get(parentUid) ?? [];
35
+ siblings.push(reply);
36
+ childrenMap.set(parentUid, siblings);
37
+ }
38
+
39
+ for (const [, children] of childrenMap) {
40
+ children.sort((a, b) => {
41
+ if (sortOrder === "newest") return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
42
+ if (sortOrder === "most_liked") return b.likeCount - a.likeCount;
43
+ return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
44
+ });
45
+ }
46
+
47
+ return childrenMap;
48
+ }, [replies, rootPost.uid, sortOrder]);
49
+
50
+ function handleSubmitReply(parentUid: string) {
51
+ if (!replyContent.trim()) return;
52
+ onReply(parentUid, replyContent.trim());
53
+ setReplyContent("");
54
+ setReplyingToUid(null);
55
+ }
56
+
57
+ function renderPost(post: DiscussionPost, depth: number) {
58
+ const children = replyTree.get(post.uid) ?? [];
59
+ const effectiveDepth = Math.min(depth, maxDepth);
60
+
61
+ const actions = (
62
+ <div className="flex items-center gap-1">
63
+ {onToggleLike && !readOnly && (
64
+ <Tooltip>
65
+ <TooltipTrigger>
66
+ <Button
67
+ variant="ghost"
68
+ size="sm"
69
+ aria-label={post.isLikedByCurrentUser ? "Unlike" : "Like"}
70
+ className={cn(post.isLikedByCurrentUser && "text-destructive")}
71
+ onClick={() => onToggleLike(post.uid)}
72
+ >
73
+ <Heart size={14} fill={post.isLikedByCurrentUser ? "currentColor" : "none"} />
74
+ {post.likeCount > 0 ? post.likeCount : "Like"}
75
+ </Button>
76
+ </TooltipTrigger>
77
+ <TooltipContent>{post.isLikedByCurrentUser ? "Unlike" : "Like"}</TooltipContent>
78
+ </Tooltip>
79
+ )}
80
+ {allowReplies && !readOnly && (
81
+ <Tooltip>
82
+ <TooltipTrigger>
83
+ <Button
84
+ variant="ghost"
85
+ size="sm"
86
+ aria-label="Reply"
87
+ onClick={() => setReplyingToUid(post.uid)}
88
+ >
89
+ <Reply size={14} />
90
+ Reply
91
+ </Button>
92
+ </TooltipTrigger>
93
+ <TooltipContent>Reply</TooltipContent>
94
+ </Tooltip>
95
+ )}
96
+ {onMarkAnswer && !readOnly && currentUser.role !== "student" && !post.isAnswer && (
97
+ <Tooltip>
98
+ <TooltipTrigger>
99
+ <Button
100
+ variant="ghost"
101
+ size="sm"
102
+ aria-label="Mark as answer"
103
+ className="text-success"
104
+ onClick={() => onMarkAnswer(post.uid)}
105
+ >
106
+ <CheckCircle size={14} />
107
+ Mark Answer
108
+ </Button>
109
+ </TooltipTrigger>
110
+ <TooltipContent>Mark as answer</TooltipContent>
111
+ </Tooltip>
112
+ )}
113
+ </div>
114
+ );
115
+
116
+ return (
117
+ <div key={post.uid}>
118
+ <PostCard
119
+ author={post.author}
120
+ content={post.content}
121
+ createdAt={post.createdAt}
122
+ updatedAt={post.updatedAt}
123
+ actions={actions}
124
+ highlight={post.isAnswer ? "answer" : "none"}
125
+ indentLevel={effectiveDepth}
126
+ className="mb-2"
127
+ />
128
+
129
+ {/* Reply editor */}
130
+ {replyingToUid === post.uid && (
131
+ <Card
132
+ className="mb-2"
133
+ style={{ marginLeft: `${(effectiveDepth + 1) * 16}px` }}
134
+ >
135
+ <CardContent className="pt-4 pb-4">
136
+ <Textarea
137
+ className="min-h-15 mb-2"
138
+ placeholder="Write a reply..."
139
+ value={replyContent}
140
+ onChange={(e) => setReplyContent(e.target.value)}
141
+ />
142
+ <div className="flex items-center gap-2">
143
+ <Button
144
+ size="sm"
145
+ onClick={() => handleSubmitReply(post.uid)}
146
+ disabled={!replyContent.trim()}
147
+ >
148
+ Post Reply
149
+ </Button>
150
+ <Button
151
+ variant="ghost"
152
+ size="sm"
153
+ onClick={() => {
154
+ setReplyingToUid(null);
155
+ setReplyContent("");
156
+ }}
157
+ >
158
+ Cancel
159
+ </Button>
160
+ </div>
161
+ </CardContent>
162
+ </Card>
163
+ )}
164
+
165
+ {/* Nested children */}
166
+ {children.map((child) => renderPost(child, depth + 1))}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ return (
172
+ <div className={className} style={style}>
173
+ <div className="flex items-center gap-1 mb-2">
174
+ <MessageSquare size={20} className="text-foreground shrink-0" />
175
+ <span className="text-lg font-semibold text-foreground">{title}</span>
176
+ <span className="text-sm text-muted-foreground">
177
+ {replies.length} {replies.length === 1 ? "reply" : "replies"}
178
+ </span>
179
+ </div>
180
+
181
+ <Separator className="mb-2" />
182
+
183
+ {renderPost(rootPost, 0)}
184
+ </div>
185
+ );
186
+ }
@@ -0,0 +1,77 @@
1
+
2
+ /**
3
+ * DiscussionThread section — a threaded discussion forum.
4
+ *
5
+ * Supports nested replies, author avatars, timestamps, like/unlike,
6
+ * and mark-as-answer for Q&A threads. Replies nest up to a
7
+ * configurable maximum depth.
8
+ *
9
+ * @example
10
+ * <DiscussionThread
11
+ * title="How do React hooks work?"
12
+ * rootPost={rootPost}
13
+ * replies={replies}
14
+ * currentUser={user}
15
+ * onReply={(parentUid, content) => postReply(parentUid, content)}
16
+ * />
17
+ */
18
+ export interface DiscussionThreadProps {
19
+ /** Thread title */
20
+ title: string;
21
+ /** The initial/root post */
22
+ rootPost: DiscussionPost;
23
+ /** All replies (flat array — parentUid determines nesting) */
24
+ replies: DiscussionPost[];
25
+ /** The currently authenticated user */
26
+ currentUser: DiscussionUser;
27
+ /** Called when the user submits a reply */
28
+ onReply: (parentUid: string, content: string) => void;
29
+ /** Called when the user toggles a like */
30
+ onToggleLike?: (postUid: string) => void;
31
+ /** Called when an instructor marks a post as the answer */
32
+ onMarkAnswer?: (postUid: string) => void;
33
+ /** Maximum nesting depth before replies flatten */
34
+ maxDepth?: number;
35
+ /** Whether new replies are allowed */
36
+ allowReplies?: boolean;
37
+ /** Sort order for top-level replies */
38
+ sortOrder?: "newest" | "oldest" | "most_liked";
39
+ /** When true, disables interactions */
40
+ readOnly?: boolean;
41
+ /** CSS class name for the root element */
42
+ className?: string;
43
+ /** Inline styles for the root element */
44
+ style?: React.CSSProperties;
45
+ }
46
+
47
+ export interface DiscussionPost {
48
+ /** Unique identifier */
49
+ uid: string;
50
+ /** Parent post UID — null for root post */
51
+ parentUid: string | null;
52
+ /** Post author */
53
+ author: DiscussionUser;
54
+ /** Post body content */
55
+ content: string;
56
+ /** Creation timestamp */
57
+ createdAt: string;
58
+ /** Last update timestamp */
59
+ updatedAt?: string;
60
+ /** Number of likes */
61
+ likeCount: number;
62
+ /** Whether the current user has liked this post */
63
+ isLikedByCurrentUser: boolean;
64
+ /** Whether this post is marked as the answer */
65
+ isAnswer?: boolean;
66
+ }
67
+
68
+ export interface DiscussionUser {
69
+ /** Unique identifier */
70
+ uid: string;
71
+ /** Display name */
72
+ displayName: string;
73
+ /** Avatar image URL */
74
+ avatarUrl?: string;
75
+ /** User role */
76
+ role?: "student" | "instructor" | "ta" | "admin";
77
+ }
@@ -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
+ }