@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.
- package/package.json +52 -1
- package/src/__tests__/setup.ts +1 -0
- package/src/assessment-toolbar/assessment-toolbar.tsx +96 -0
- package/src/assessment-toolbar/index.ts +10 -0
- package/src/assessment-toolbar/question-navigator.tsx +86 -0
- package/src/assessment-toolbar/timer-display.tsx +73 -0
- package/src/assessment-toolbar/types.ts +92 -0
- package/src/assets/hydra-icon.png +0 -0
- package/src/assets/hydra-icon.svg +18 -0
- package/src/assets/hydra-lms-icon.png +0 -0
- package/src/assets/hydra-lms-icon.svg +9 -0
- package/src/common/confirm-dialog.tsx +60 -0
- package/src/common/due-date-display.tsx +64 -0
- package/src/common/empty-state.tsx +24 -0
- package/src/common/index.ts +12 -0
- package/src/common/search-input.tsx +68 -0
- package/src/common/status-badge.test.tsx +43 -0
- package/src/common/status-badge.tsx +81 -0
- package/src/common/types.ts +129 -0
- package/src/content/content-block.tsx +116 -0
- package/src/content/file-upload-zone.tsx +109 -0
- package/src/content/index.ts +7 -0
- package/src/content/types.ts +76 -0
- package/src/curriculum/curriculum-item.tsx +81 -0
- package/src/curriculum/curriculum-tree.tsx +69 -0
- package/src/curriculum/index.ts +11 -0
- package/src/curriculum/learning-object-icon.tsx +44 -0
- package/src/curriculum/types.ts +83 -0
- package/src/feedback/feedback-banner.tsx +46 -0
- package/src/feedback/index.ts +8 -0
- package/src/feedback/likert-scale.tsx +58 -0
- package/src/feedback/star-rating.tsx +65 -0
- package/src/feedback/types.ts +86 -0
- package/src/flashcards/flashcard-deck.tsx +130 -0
- package/src/flashcards/flashcard.tsx +108 -0
- package/src/flashcards/index.ts +3 -0
- package/src/flashcards/types.ts +60 -0
- package/src/index.ts +38 -0
- package/src/lib/utils.ts +6 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +281 -0
- package/src/modules/CoursePlayer/types.ts +48 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +275 -0
- package/src/modules/FlashcardLab/types.ts +58 -0
- package/src/modules/QuizModule/QuizModule.tsx +241 -0
- package/src/modules/QuizModule/types.ts +56 -0
- package/src/modules/index.ts +12 -0
- package/src/progress/grade-indicator.tsx +65 -0
- package/src/progress/index.ts +8 -0
- package/src/progress/progress-ring.tsx +56 -0
- package/src/progress/stat-card.tsx +42 -0
- package/src/progress/types.ts +73 -0
- package/src/provider/HydraProvider.tsx +26 -0
- package/src/provider/index.ts +2 -0
- package/src/questions/choice.tsx +90 -0
- package/src/questions/essay.tsx +59 -0
- package/src/questions/fill-in-the-blank.tsx +69 -0
- package/src/questions/index.ts +14 -0
- package/src/questions/multiple-choice.test.tsx +104 -0
- package/src/questions/multiple-choice.tsx +97 -0
- package/src/questions/question-renderer.tsx +37 -0
- package/src/questions/true-false.test.tsx +89 -0
- package/src/questions/true-false.tsx +90 -0
- package/src/questions/types.ts +53 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +141 -0
- package/src/sections/AnnouncementFeed/types.ts +50 -0
- package/src/sections/AssessmentReview/AssessmentReview.tsx +148 -0
- package/src/sections/AssessmentReview/types.ts +61 -0
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +190 -0
- package/src/sections/AssignmentSubmission/types.ts +60 -0
- package/src/sections/CertificateViewer/CertificateViewer.tsx +117 -0
- package/src/sections/CertificateViewer/types.ts +45 -0
- package/src/sections/CourseOutline/CourseOutline.tsx +79 -0
- package/src/sections/CourseOutline/types.ts +53 -0
- package/src/sections/DiscussionThread/DiscussionThread.tsx +186 -0
- package/src/sections/DiscussionThread/types.ts +77 -0
- package/src/sections/ExamSession/ExamSession.tsx +182 -0
- package/src/sections/ExamSession/types.ts +64 -0
- package/src/sections/FlashcardStudySession/FlashcardStudySession.tsx +76 -0
- package/src/sections/FlashcardStudySession/types.ts +42 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +229 -0
- package/src/sections/GradebookTable/types.ts +75 -0
- package/src/sections/LecturePlayer/LecturePlayer.tsx +60 -0
- package/src/sections/LecturePlayer/types.ts +48 -0
- package/src/sections/LessonPage/LessonPage.tsx +91 -0
- package/src/sections/LessonPage/types.ts +41 -0
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +199 -0
- package/src/sections/PracticeQuiz/types.ts +44 -0
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +140 -0
- package/src/sections/ProgressDashboard/types.ts +74 -0
- package/src/sections/QuizSession/QuizSession.tsx +113 -0
- package/src/sections/QuizSession/types.ts +47 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +218 -0
- package/src/sections/ResourceLibrary/types.ts +57 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +170 -0
- package/src/sections/ScrollableQuiz/types.ts +40 -0
- package/src/sections/SurveyForm/SurveyForm.tsx +180 -0
- package/src/sections/SurveyForm/types.ts +69 -0
- package/src/sections/index.ts +90 -0
- package/src/social/index.ts +3 -0
- package/src/social/post-card.tsx +91 -0
- package/src/social/types.ts +57 -0
- package/src/social/user-avatar.tsx +76 -0
- package/src/styles/globals.css +125 -0
- package/src/ui/alert-dialog.tsx +343 -0
- package/src/ui/alert.tsx +65 -0
- package/src/ui/avatar.tsx +52 -0
- package/src/ui/badge.tsx +53 -0
- package/src/ui/button.tsx +62 -0
- package/src/ui/card.tsx +92 -0
- package/src/ui/index.ts +44 -0
- package/src/ui/input.tsx +21 -0
- package/src/ui/progress.tsx +73 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/slot.tsx +48 -0
- package/src/ui/table.tsx +108 -0
- package/src/ui/tabs.tsx +147 -0
- package/src/ui/textarea.tsx +20 -0
- package/src/ui/tooltip.tsx +177 -0
- package/src/utils/debounce.test.ts +59 -0
- package/src/utils/debounce.ts +10 -0
- package/src/utils/format-duration.test.ts +55 -0
- package/src/utils/format-duration.ts +30 -0
- package/src/video/index.ts +17 -0
- package/src/video/types.ts +216 -0
- package/src/video/video-bookmark.tsx +76 -0
- package/src/video/video-chapter-list.tsx +93 -0
- package/src/video/video-player.tsx +103 -0
- package/src/video/video-playlist-item.tsx +90 -0
- package/src/video/video-thumbnail-card.tsx +74 -0
- package/src/video/video-transcript.tsx +102 -0
|
@@ -0,0 +1,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
|
+
}
|