@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,53 @@
|
|
|
1
|
+
export type QuestionTypeEnum =
|
|
2
|
+
| "multiple_choice"
|
|
3
|
+
| "choice"
|
|
4
|
+
| "true_false"
|
|
5
|
+
| "fill_in_the_blank"
|
|
6
|
+
| "essay";
|
|
7
|
+
|
|
8
|
+
export interface AnswerOption {
|
|
9
|
+
uid: string;
|
|
10
|
+
content: string;
|
|
11
|
+
explanation?: string;
|
|
12
|
+
isCorrect?: boolean;
|
|
13
|
+
sequence: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionAnswer {
|
|
17
|
+
uid: string;
|
|
18
|
+
answerUid: string;
|
|
19
|
+
content?: string;
|
|
20
|
+
confidence?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QuestionData {
|
|
24
|
+
uid: string;
|
|
25
|
+
type: QuestionTypeEnum;
|
|
26
|
+
content: string;
|
|
27
|
+
explanation?: string;
|
|
28
|
+
answers?: AnswerOption[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared props interface for all question type components.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* <QuestionRenderer
|
|
36
|
+
* question={{ uid: "q1", type: "choice", content: "What is JSX?", answers: [...] }}
|
|
37
|
+
* onAnswer={(answers) => saveAnswer(answers)}
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
export interface QuestionProps {
|
|
41
|
+
/** The question data to render */
|
|
42
|
+
question: QuestionData;
|
|
43
|
+
/** Current user answers for this question */
|
|
44
|
+
sessionAnswers?: SessionAnswer[];
|
|
45
|
+
/** Called when the user selects or changes an answer */
|
|
46
|
+
onAnswer?: (answers: { uid: string; content?: string }[]) => void;
|
|
47
|
+
/** When true, disables all input interactions */
|
|
48
|
+
readOnly?: boolean;
|
|
49
|
+
/** When true, highlights correct/incorrect answers */
|
|
50
|
+
showCorrectAnswers?: boolean;
|
|
51
|
+
/** When true, disables inputs without showing review state */
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Pin } from "lucide-react";
|
|
3
|
+
import { UserAvatar } from "../../social";
|
|
4
|
+
import { EmptyState } from "../../common";
|
|
5
|
+
import { Badge } from "../../ui/badge";
|
|
6
|
+
import { Button } from "../../ui/button";
|
|
7
|
+
import { Card, CardContent } from "../../ui/card";
|
|
8
|
+
import type { AnnouncementFeedProps } from "./types";
|
|
9
|
+
import { cn } from "../../lib/utils";
|
|
10
|
+
|
|
11
|
+
function formatTimestamp(iso: string): string {
|
|
12
|
+
const date = new Date(iso);
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const diffMs = now.getTime() - date.getTime();
|
|
15
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
16
|
+
if (diffMins < 1) return "Just now";
|
|
17
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
18
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
19
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
20
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
21
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
22
|
+
return date.toLocaleDateString();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function AnnouncementFeed({
|
|
26
|
+
announcements,
|
|
27
|
+
onMarkRead,
|
|
28
|
+
onSelect,
|
|
29
|
+
showAvatars = true,
|
|
30
|
+
previewLines = 3,
|
|
31
|
+
emptyMessage = "No announcements yet",
|
|
32
|
+
readOnly = false,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
}: AnnouncementFeedProps) {
|
|
36
|
+
const [expandedUids, setExpandedUids] = useState<Set<string>>(new Set());
|
|
37
|
+
|
|
38
|
+
const sorted = useMemo(() => {
|
|
39
|
+
const pinned = announcements.filter((a) => a.isPinned);
|
|
40
|
+
const rest = announcements.filter((a) => !a.isPinned);
|
|
41
|
+
return [...pinned, ...rest];
|
|
42
|
+
}, [announcements]);
|
|
43
|
+
|
|
44
|
+
function toggleExpand(uid: string) {
|
|
45
|
+
setExpandedUids((prev) => {
|
|
46
|
+
const next = new Set(prev);
|
|
47
|
+
if (next.has(uid)) next.delete(uid);
|
|
48
|
+
else next.add(uid);
|
|
49
|
+
return next;
|
|
50
|
+
});
|
|
51
|
+
const announcement = announcements.find((a) => a.uid === uid);
|
|
52
|
+
if (announcement && !announcement.isRead) {
|
|
53
|
+
onMarkRead?.(uid);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (sorted.length === 0) {
|
|
58
|
+
return (
|
|
59
|
+
<div className={className} style={style}>
|
|
60
|
+
<EmptyState title={emptyMessage} />
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={cn("flex flex-col gap-2", className)} style={style}>
|
|
67
|
+
{sorted.map((a) => {
|
|
68
|
+
const isExpanded = expandedUids.has(a.uid);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Card
|
|
72
|
+
key={a.uid}
|
|
73
|
+
className={cn(
|
|
74
|
+
a.isRead && "opacity-85",
|
|
75
|
+
onSelect && "cursor-pointer",
|
|
76
|
+
a.isPinned && "border-l-4 border-l-warning",
|
|
77
|
+
)}
|
|
78
|
+
onClick={() => onSelect && !readOnly ? onSelect(a) : toggleExpand(a.uid)}
|
|
79
|
+
>
|
|
80
|
+
<CardContent className="pt-4 pb-4">
|
|
81
|
+
<div className="flex gap-1.5 items-start">
|
|
82
|
+
{showAvatars && (
|
|
83
|
+
<UserAvatar
|
|
84
|
+
displayName={a.author.displayName}
|
|
85
|
+
avatarUrl={a.author.avatarUrl}
|
|
86
|
+
role={a.author.role as "student" | "instructor" | "ta" | "admin" | undefined}
|
|
87
|
+
size="medium"
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
<div className="flex-1 min-w-0">
|
|
91
|
+
<div className="flex items-center gap-1 mb-0.5">
|
|
92
|
+
{a.isPinned && <Pin size={14} />}
|
|
93
|
+
<span
|
|
94
|
+
className={cn("text-foreground", !a.isRead ? "font-semibold" : "font-normal")}
|
|
95
|
+
>
|
|
96
|
+
{a.title}
|
|
97
|
+
</span>
|
|
98
|
+
{!a.isRead && (
|
|
99
|
+
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
|
|
100
|
+
New
|
|
101
|
+
</Badge>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<span className="block text-xs text-muted-foreground mb-1">
|
|
105
|
+
{a.author.displayName} · {formatTimestamp(a.createdAt)}
|
|
106
|
+
</span>
|
|
107
|
+
<span
|
|
108
|
+
className={cn(
|
|
109
|
+
"text-sm text-foreground",
|
|
110
|
+
!isExpanded && "line-clamp-(--preview-lines) overflow-hidden",
|
|
111
|
+
)}
|
|
112
|
+
style={
|
|
113
|
+
!isExpanded
|
|
114
|
+
? { "--preview-lines": previewLines } as React.CSSProperties
|
|
115
|
+
: undefined
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
{a.content}
|
|
119
|
+
</span>
|
|
120
|
+
{!isExpanded && a.content.length > 200 && (
|
|
121
|
+
<Button
|
|
122
|
+
variant="link"
|
|
123
|
+
size="xs"
|
|
124
|
+
className="px-0 mt-0.5 h-auto"
|
|
125
|
+
onClick={(e) => {
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
toggleExpand(a.uid);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
Read more
|
|
131
|
+
</Button>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</CardContent>
|
|
136
|
+
</Card>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* AnnouncementFeed section — a chronological announcement feed.
|
|
4
|
+
*
|
|
5
|
+
* Displays course announcements with pinned posts, read/unread
|
|
6
|
+
* tracking, expandable content, and author avatars.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* <AnnouncementFeed
|
|
10
|
+
* announcements={announcements}
|
|
11
|
+
* onMarkRead={(uid) => markRead(uid)}
|
|
12
|
+
* />
|
|
13
|
+
*/
|
|
14
|
+
export interface AnnouncementFeedProps {
|
|
15
|
+
/** Announcements sorted by recency (newest first) */
|
|
16
|
+
announcements: Announcement[];
|
|
17
|
+
/** Called when the user marks an announcement as read */
|
|
18
|
+
onMarkRead?: (announcementUid: string) => void;
|
|
19
|
+
/** Called when the user clicks an announcement */
|
|
20
|
+
onSelect?: (announcement: Announcement) => void;
|
|
21
|
+
/** Whether to show author avatars */
|
|
22
|
+
showAvatars?: boolean;
|
|
23
|
+
/** Max lines before truncating with "Read more" */
|
|
24
|
+
previewLines?: number;
|
|
25
|
+
/** Empty state message */
|
|
26
|
+
emptyMessage?: string;
|
|
27
|
+
/** When true, disables interactions */
|
|
28
|
+
readOnly?: boolean;
|
|
29
|
+
/** CSS class name for the root element */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Inline styles for the root element */
|
|
32
|
+
style?: React.CSSProperties;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Announcement {
|
|
36
|
+
/** Unique identifier */
|
|
37
|
+
uid: string;
|
|
38
|
+
/** Announcement title */
|
|
39
|
+
title: string;
|
|
40
|
+
/** Announcement body content */
|
|
41
|
+
content: string;
|
|
42
|
+
/** Author information */
|
|
43
|
+
author: { displayName: string; avatarUrl?: string; role?: string };
|
|
44
|
+
/** Creation timestamp as ISO string */
|
|
45
|
+
createdAt: string;
|
|
46
|
+
/** Whether this announcement is pinned */
|
|
47
|
+
isPinned?: boolean;
|
|
48
|
+
/** Whether the current user has read this */
|
|
49
|
+
isRead?: boolean;
|
|
50
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { QuestionRenderer } from "../../questions";
|
|
2
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
3
|
+
import { Badge } from "../../ui/badge";
|
|
4
|
+
import { Card, CardContent } from "../../ui/card";
|
|
5
|
+
import { Separator } from "../../ui/separator";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
import type {
|
|
8
|
+
AssessmentReviewProps,
|
|
9
|
+
AssessmentReviewGroup,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
function ScoreHeader({
|
|
13
|
+
score,
|
|
14
|
+
}: {
|
|
15
|
+
score: NonNullable<AssessmentReviewProps["score"]>;
|
|
16
|
+
}) {
|
|
17
|
+
const pct =
|
|
18
|
+
score.percentage !== undefined
|
|
19
|
+
? score.percentage
|
|
20
|
+
: score.total > 0
|
|
21
|
+
? Math.round((score.correct / score.total) * 100)
|
|
22
|
+
: 0;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Card className="mb-3">
|
|
26
|
+
<CardContent className="pt-6">
|
|
27
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
28
|
+
<div>
|
|
29
|
+
<span className="text-2xl font-bold leading-none text-foreground">{pct}%</span>
|
|
30
|
+
<span className="text-sm text-muted-foreground">
|
|
31
|
+
{score.correct} of {score.total} correct
|
|
32
|
+
</span>
|
|
33
|
+
</div>
|
|
34
|
+
{score.passed !== undefined && (
|
|
35
|
+
<Badge variant={score.passed ? "success" : "destructive"}>
|
|
36
|
+
{score.passed ? "Passed" : "Failed"}
|
|
37
|
+
</Badge>
|
|
38
|
+
)}
|
|
39
|
+
{score.passingScore !== undefined && (
|
|
40
|
+
<span className="text-sm text-muted-foreground">
|
|
41
|
+
Passing score: {score.passingScore}%
|
|
42
|
+
</span>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</CardContent>
|
|
46
|
+
</Card>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function QuestionList({
|
|
51
|
+
questions,
|
|
52
|
+
sessionAnswers,
|
|
53
|
+
showCorrectAnswers,
|
|
54
|
+
}: {
|
|
55
|
+
questions: QuestionData[];
|
|
56
|
+
sessionAnswers: SessionAnswer[];
|
|
57
|
+
showCorrectAnswers: boolean;
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex flex-col gap-3">
|
|
61
|
+
{questions.map((question, idx) => (
|
|
62
|
+
<Card key={question.uid} className="overflow-hidden">
|
|
63
|
+
<div className="px-2 py-1 bg-muted">
|
|
64
|
+
<span className="text-xs text-muted-foreground font-semibold">
|
|
65
|
+
Question {idx + 1}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
<Separator />
|
|
69
|
+
<CardContent className="pt-4 pb-4">
|
|
70
|
+
<QuestionRenderer
|
|
71
|
+
question={question}
|
|
72
|
+
sessionAnswers={sessionAnswers.filter((a) => a.uid === question.uid)}
|
|
73
|
+
readOnly
|
|
74
|
+
showCorrectAnswers={showCorrectAnswers}
|
|
75
|
+
/>
|
|
76
|
+
</CardContent>
|
|
77
|
+
</Card>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderGroups(
|
|
84
|
+
questions: QuestionData[],
|
|
85
|
+
sessionAnswers: SessionAnswer[],
|
|
86
|
+
questionGroups: AssessmentReviewGroup[],
|
|
87
|
+
showCorrectAnswers: boolean,
|
|
88
|
+
) {
|
|
89
|
+
const questionMap = new Map(questions.map((q) => [q.uid, q]));
|
|
90
|
+
const groupedUids = new Set(questionGroups.flatMap((g) => g.questionUids));
|
|
91
|
+
const ungrouped = questions.filter((q) => !groupedUids.has(q.uid));
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex flex-col gap-4">
|
|
95
|
+
{questionGroups.map((group) => {
|
|
96
|
+
const groupQuestions = group.questionUids
|
|
97
|
+
.map((uid) => questionMap.get(uid))
|
|
98
|
+
.filter(Boolean) as QuestionData[];
|
|
99
|
+
return (
|
|
100
|
+
<div key={group.label}>
|
|
101
|
+
<span className="uppercase text-xs tracking-wide text-muted-foreground font-semibold">
|
|
102
|
+
{group.label}
|
|
103
|
+
</span>
|
|
104
|
+
<Separator className="mb-2" />
|
|
105
|
+
<QuestionList
|
|
106
|
+
questions={groupQuestions}
|
|
107
|
+
sessionAnswers={sessionAnswers}
|
|
108
|
+
showCorrectAnswers={showCorrectAnswers}
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
{ungrouped.length > 0 && (
|
|
114
|
+
<div>
|
|
115
|
+
<QuestionList
|
|
116
|
+
questions={ungrouped}
|
|
117
|
+
sessionAnswers={sessionAnswers}
|
|
118
|
+
showCorrectAnswers={showCorrectAnswers}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function AssessmentReview({
|
|
127
|
+
questions,
|
|
128
|
+
sessionAnswers,
|
|
129
|
+
score,
|
|
130
|
+
questionGroups,
|
|
131
|
+
showCorrectAnswers = true,
|
|
132
|
+
className,
|
|
133
|
+
style,
|
|
134
|
+
}: AssessmentReviewProps) {
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn(className)} style={style}>
|
|
137
|
+
{score && <ScoreHeader score={score} />}
|
|
138
|
+
{questionGroups && questionGroups.length > 0
|
|
139
|
+
? renderGroups(questions, sessionAnswers, questionGroups, showCorrectAnswers)
|
|
140
|
+
: <QuestionList
|
|
141
|
+
questions={questions}
|
|
142
|
+
sessionAnswers={sessionAnswers}
|
|
143
|
+
showCorrectAnswers={showCorrectAnswers}
|
|
144
|
+
/>
|
|
145
|
+
}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { QuestionData, SessionAnswer } from "../../questions/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AssessmentReview section — read-only review of a completed assessment.
|
|
5
|
+
*
|
|
6
|
+
* Renders all questions in review mode with correct/incorrect highlighting
|
|
7
|
+
* and an optional score summary header. Supports optional grouping of
|
|
8
|
+
* questions by section label.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* <AssessmentReview
|
|
12
|
+
* questions={questions}
|
|
13
|
+
* sessionAnswers={submittedAnswers}
|
|
14
|
+
* score={{ correct: 8, total: 10, passed: true, passingScore: 70 }}
|
|
15
|
+
* />
|
|
16
|
+
*/
|
|
17
|
+
export interface AssessmentReviewProps {
|
|
18
|
+
/** All questions that were in the assessment */
|
|
19
|
+
questions: QuestionData[];
|
|
20
|
+
/** The user's submitted answers */
|
|
21
|
+
sessionAnswers: SessionAnswer[];
|
|
22
|
+
/**
|
|
23
|
+
* Score metadata to display in the summary header.
|
|
24
|
+
* If omitted, the score header is not rendered.
|
|
25
|
+
*/
|
|
26
|
+
score?: AssessmentScore;
|
|
27
|
+
/**
|
|
28
|
+
* Optional grouping: renders questions under section headings.
|
|
29
|
+
* Questions not referenced in any group are rendered last without a heading.
|
|
30
|
+
*/
|
|
31
|
+
questionGroups?: AssessmentReviewGroup[];
|
|
32
|
+
/**
|
|
33
|
+
* Whether to show correct/incorrect answer highlighting on each question.
|
|
34
|
+
* @default true
|
|
35
|
+
*/
|
|
36
|
+
showCorrectAnswers?: boolean;
|
|
37
|
+
/** CSS class name for the root element */
|
|
38
|
+
className?: string;
|
|
39
|
+
/** Inline styles for the root element */
|
|
40
|
+
style?: React.CSSProperties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AssessmentScore {
|
|
44
|
+
/** Number of correct answers */
|
|
45
|
+
correct: number;
|
|
46
|
+
/** Total number of questions */
|
|
47
|
+
total: number;
|
|
48
|
+
/** Optional pre-computed percentage (falls back to correct/total * 100) */
|
|
49
|
+
percentage?: number;
|
|
50
|
+
/** Whether the user passed */
|
|
51
|
+
passed?: boolean;
|
|
52
|
+
/** Passing threshold as a percentage (e.g. 70 means 70%) */
|
|
53
|
+
passingScore?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AssessmentReviewGroup {
|
|
57
|
+
/** Group label displayed as a section heading */
|
|
58
|
+
label: string;
|
|
59
|
+
/** UIDs of questions belonging to this group, in display order */
|
|
60
|
+
questionUids: string[];
|
|
61
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Send, Save } from "lucide-react";
|
|
3
|
+
import { StatusBadge, DueDateDisplay } from "../../common";
|
|
4
|
+
import { FileUploadZone } from "../../content";
|
|
5
|
+
import { Button } from "../../ui/button";
|
|
6
|
+
import { Textarea } from "../../ui/textarea";
|
|
7
|
+
import { Input } from "../../ui/input";
|
|
8
|
+
import { Separator } from "../../ui/separator";
|
|
9
|
+
import { Card, CardContent } from "../../ui/card";
|
|
10
|
+
import { Alert, AlertDescription } from "../../ui/alert";
|
|
11
|
+
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../../ui/tabs";
|
|
12
|
+
import type { AssignmentSubmissionProps, SubmissionData } from "./types";
|
|
13
|
+
|
|
14
|
+
export function AssignmentSubmission({
|
|
15
|
+
title,
|
|
16
|
+
instructions,
|
|
17
|
+
dueDate,
|
|
18
|
+
maxScore,
|
|
19
|
+
status,
|
|
20
|
+
submissionTypes,
|
|
21
|
+
existingSubmission,
|
|
22
|
+
fileConstraints,
|
|
23
|
+
onSubmit,
|
|
24
|
+
onSaveDraft,
|
|
25
|
+
grade,
|
|
26
|
+
isSubmitting = false,
|
|
27
|
+
readOnly = false,
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
}: AssignmentSubmissionProps) {
|
|
31
|
+
const [textContent, setTextContent] = useState(existingSubmission?.textContent ?? "");
|
|
32
|
+
const [files, setFiles] = useState<File[]>(existingSubmission?.files ?? []);
|
|
33
|
+
const [url, setUrl] = useState(existingSubmission?.url ?? "");
|
|
34
|
+
const [activeTab, setActiveTab] = useState<"text" | "file" | "url">(submissionTypes[0]);
|
|
35
|
+
|
|
36
|
+
const isEditable = !readOnly && !["submitted", "graded"].includes(status);
|
|
37
|
+
|
|
38
|
+
function getSubmissionData(): SubmissionData {
|
|
39
|
+
return {
|
|
40
|
+
textContent: submissionTypes.includes("text") ? textContent : undefined,
|
|
41
|
+
files: submissionTypes.includes("file") ? files : undefined,
|
|
42
|
+
url: submissionTypes.includes("url") ? url : undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className={className} style={style}>
|
|
48
|
+
{/* Header */}
|
|
49
|
+
<div className="flex justify-between items-start mb-2">
|
|
50
|
+
<div>
|
|
51
|
+
<div className="text-xl font-bold text-foreground mb-0.5">{title}</div>
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<StatusBadge status={status} />
|
|
54
|
+
{dueDate && <DueDateDisplay dueDate={dueDate} size="small" />}
|
|
55
|
+
{maxScore != null && (
|
|
56
|
+
<span className="text-sm text-muted-foreground">{maxScore} points</span>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Instructions */}
|
|
63
|
+
<Card className="mb-3">
|
|
64
|
+
<CardContent className="pt-6">
|
|
65
|
+
<div className="font-semibold text-sm text-foreground mb-1">Instructions</div>
|
|
66
|
+
<div className="text-muted-foreground text-sm">
|
|
67
|
+
{typeof instructions === "string" ? (
|
|
68
|
+
<span>{instructions}</span>
|
|
69
|
+
) : (
|
|
70
|
+
instructions
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
|
|
76
|
+
{/* Grade display */}
|
|
77
|
+
{grade && (
|
|
78
|
+
<Alert variant="success" className="mb-3">
|
|
79
|
+
<AlertDescription>
|
|
80
|
+
<div className="font-semibold text-sm mb-1">
|
|
81
|
+
Grade: {grade.score}{maxScore != null ? ` / ${maxScore}` : ""}
|
|
82
|
+
</div>
|
|
83
|
+
{grade.feedback && (
|
|
84
|
+
<div>{grade.feedback}</div>
|
|
85
|
+
)}
|
|
86
|
+
</AlertDescription>
|
|
87
|
+
</Alert>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Submission area */}
|
|
91
|
+
{isEditable && (
|
|
92
|
+
<>
|
|
93
|
+
{submissionTypes.length > 1 ? (
|
|
94
|
+
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as "text" | "file" | "url")}>
|
|
95
|
+
<TabsList>
|
|
96
|
+
{submissionTypes.includes("text") && <TabsTrigger value="text">Text</TabsTrigger>}
|
|
97
|
+
{submissionTypes.includes("file") && <TabsTrigger value="file">File Upload</TabsTrigger>}
|
|
98
|
+
{submissionTypes.includes("url") && <TabsTrigger value="url">URL</TabsTrigger>}
|
|
99
|
+
</TabsList>
|
|
100
|
+
{submissionTypes.includes("text") && (
|
|
101
|
+
<TabsContent value="text">
|
|
102
|
+
<Textarea
|
|
103
|
+
className="min-h-45"
|
|
104
|
+
placeholder="Type your submission..."
|
|
105
|
+
value={textContent}
|
|
106
|
+
onChange={(e) => setTextContent(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</TabsContent>
|
|
109
|
+
)}
|
|
110
|
+
{submissionTypes.includes("file") && (
|
|
111
|
+
<TabsContent value="file">
|
|
112
|
+
<FileUploadZone
|
|
113
|
+
files={files}
|
|
114
|
+
onFilesAdded={(newFiles) => setFiles((prev) => [...prev, ...newFiles])}
|
|
115
|
+
onFileRemove={(index) => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
|
116
|
+
accept={fileConstraints?.acceptedTypes}
|
|
117
|
+
maxFiles={fileConstraints?.maxFiles}
|
|
118
|
+
maxSizeMB={fileConstraints?.maxSizeMB}
|
|
119
|
+
/>
|
|
120
|
+
</TabsContent>
|
|
121
|
+
)}
|
|
122
|
+
{submissionTypes.includes("url") && (
|
|
123
|
+
<TabsContent value="url">
|
|
124
|
+
<Input
|
|
125
|
+
placeholder="https://..."
|
|
126
|
+
value={url}
|
|
127
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
128
|
+
/>
|
|
129
|
+
</TabsContent>
|
|
130
|
+
)}
|
|
131
|
+
</Tabs>
|
|
132
|
+
) : (
|
|
133
|
+
<>
|
|
134
|
+
{submissionTypes.includes("text") && (
|
|
135
|
+
<Textarea
|
|
136
|
+
className="min-h-45 mb-2"
|
|
137
|
+
placeholder="Type your submission..."
|
|
138
|
+
value={textContent}
|
|
139
|
+
onChange={(e) => setTextContent(e.target.value)}
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
{submissionTypes.includes("file") && (
|
|
143
|
+
<div className="mb-2">
|
|
144
|
+
<FileUploadZone
|
|
145
|
+
files={files}
|
|
146
|
+
onFilesAdded={(newFiles) => setFiles((prev) => [...prev, ...newFiles])}
|
|
147
|
+
onFileRemove={(index) => setFiles((prev) => prev.filter((_, i) => i !== index))}
|
|
148
|
+
accept={fileConstraints?.acceptedTypes}
|
|
149
|
+
maxFiles={fileConstraints?.maxFiles}
|
|
150
|
+
maxSizeMB={fileConstraints?.maxSizeMB}
|
|
151
|
+
/>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
{submissionTypes.includes("url") && (
|
|
155
|
+
<Input
|
|
156
|
+
className="mb-2"
|
|
157
|
+
placeholder="https://..."
|
|
158
|
+
value={url}
|
|
159
|
+
onChange={(e) => setUrl(e.target.value)}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
</>
|
|
163
|
+
)}
|
|
164
|
+
|
|
165
|
+
<Separator className="my-2" />
|
|
166
|
+
|
|
167
|
+
<div className="flex gap-2 justify-end">
|
|
168
|
+
{onSaveDraft && (
|
|
169
|
+
<Button
|
|
170
|
+
variant="outline"
|
|
171
|
+
onClick={() => onSaveDraft(getSubmissionData())}
|
|
172
|
+
disabled={isSubmitting}
|
|
173
|
+
>
|
|
174
|
+
<Save size={16} />
|
|
175
|
+
Save Draft
|
|
176
|
+
</Button>
|
|
177
|
+
)}
|
|
178
|
+
<Button
|
|
179
|
+
onClick={() => onSubmit(getSubmissionData())}
|
|
180
|
+
disabled={isSubmitting}
|
|
181
|
+
>
|
|
182
|
+
<Send size={16} />
|
|
183
|
+
{isSubmitting ? "Submitting..." : "Submit"}
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|