@hydralms/components 0.1.1 → 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/package.json +3 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydralms/components",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "React component library for LMS platforms",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "HydraLMS",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"module": "./src/index.ts",
|
|
31
31
|
"types": "./src/index.ts",
|
|
32
32
|
"files": [
|
|
33
|
+
"src",
|
|
33
34
|
"dist",
|
|
34
35
|
"LICENSE",
|
|
35
36
|
"README.md"
|
|
@@ -134,6 +135,7 @@
|
|
|
134
135
|
"types": "./src/provider/index.ts",
|
|
135
136
|
"default": "./src/provider/index.ts"
|
|
136
137
|
},
|
|
138
|
+
"./*": "./src/*",
|
|
137
139
|
"./styles.css": "./src/styles/globals.css",
|
|
138
140
|
"./package.json": "./package.json"
|
|
139
141
|
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "@testing-library/jest-dom/vitest";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ChevronLeft, ChevronRight, Send } from "lucide-react";
|
|
2
|
+
import { TimerDisplay } from "./timer-display";
|
|
3
|
+
import { QuestionNavigator } from "./question-navigator";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import type { AssessmentToolbarProps } from "./types";
|
|
6
|
+
|
|
7
|
+
export const AssessmentToolbar = ({
|
|
8
|
+
currentQuestionIndex,
|
|
9
|
+
totalQuestions,
|
|
10
|
+
hasNext,
|
|
11
|
+
hasPrevious,
|
|
12
|
+
onNext,
|
|
13
|
+
onPrevious,
|
|
14
|
+
onSubmit,
|
|
15
|
+
timeElapsedSeconds,
|
|
16
|
+
timeLimitSeconds,
|
|
17
|
+
questions,
|
|
18
|
+
onNavigateToQuestion,
|
|
19
|
+
onToggleFlag,
|
|
20
|
+
currentQuestionUid,
|
|
21
|
+
isCompleted = false,
|
|
22
|
+
isSubmitting = false,
|
|
23
|
+
readOnly = false,
|
|
24
|
+
}: AssessmentToolbarProps) => {
|
|
25
|
+
const showTimer =
|
|
26
|
+
timeElapsedSeconds != null && timeElapsedSeconds >= 0;
|
|
27
|
+
const showNavigator = questions && questions.length > 0;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="rounded-lg border border-border overflow-hidden shadow-sm">
|
|
31
|
+
{/* Header */}
|
|
32
|
+
<div className="flex items-center justify-between px-4 py-2.5 bg-muted border-b border-border">
|
|
33
|
+
<span className="text-sm font-medium text-foreground">
|
|
34
|
+
Question{" "}
|
|
35
|
+
<span className="font-bold">{currentQuestionIndex + 1}</span>
|
|
36
|
+
{" "}of {totalQuestions}
|
|
37
|
+
</span>
|
|
38
|
+
|
|
39
|
+
{showTimer && (
|
|
40
|
+
<TimerDisplay
|
|
41
|
+
timeElapsedSeconds={timeElapsedSeconds}
|
|
42
|
+
timeLimitSeconds={timeLimitSeconds}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{/* Navigator */}
|
|
48
|
+
{showNavigator && (
|
|
49
|
+
<div className="px-4 py-3 border-b border-border">
|
|
50
|
+
<QuestionNavigator
|
|
51
|
+
questions={questions}
|
|
52
|
+
currentQuestionUid={currentQuestionUid}
|
|
53
|
+
onNavigate={onNavigateToQuestion}
|
|
54
|
+
onToggleFlag={onToggleFlag}
|
|
55
|
+
readOnly={readOnly}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{/* Navigation buttons */}
|
|
61
|
+
<div className="flex items-center justify-between gap-2 px-4 py-3">
|
|
62
|
+
<Button
|
|
63
|
+
variant="outline"
|
|
64
|
+
size="sm"
|
|
65
|
+
className="rounded-lg"
|
|
66
|
+
disabled={!hasPrevious || readOnly}
|
|
67
|
+
onClick={onPrevious}
|
|
68
|
+
>
|
|
69
|
+
<ChevronLeft size={16} /> Previous
|
|
70
|
+
</Button>
|
|
71
|
+
|
|
72
|
+
{!hasNext || isCompleted ? (
|
|
73
|
+
<Button
|
|
74
|
+
variant={isCompleted ? "secondary" : "default"}
|
|
75
|
+
size="sm"
|
|
76
|
+
className="rounded-lg"
|
|
77
|
+
onClick={onSubmit}
|
|
78
|
+
disabled={readOnly || isSubmitting}
|
|
79
|
+
>
|
|
80
|
+
{isSubmitting ? "Submitting..." : isCompleted ? "Review" : "Submit"}
|
|
81
|
+
{!isSubmitting && <Send size={14} />}
|
|
82
|
+
</Button>
|
|
83
|
+
) : (
|
|
84
|
+
<Button
|
|
85
|
+
size="sm"
|
|
86
|
+
className="rounded-lg"
|
|
87
|
+
disabled={!hasNext || readOnly}
|
|
88
|
+
onClick={onNext}
|
|
89
|
+
>
|
|
90
|
+
Next <ChevronRight size={16} />
|
|
91
|
+
</Button>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { AssessmentToolbar } from "./assessment-toolbar";
|
|
2
|
+
export { TimerDisplay } from "./timer-display";
|
|
3
|
+
export { QuestionNavigator } from "./question-navigator";
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
AssessmentToolbarProps,
|
|
7
|
+
TimerDisplayProps,
|
|
8
|
+
QuestionNavigatorProps,
|
|
9
|
+
QuestionNavigatorItem,
|
|
10
|
+
} from "./types";
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { Flag } from "lucide-react";
|
|
2
|
+
import type { QuestionNavigatorProps } from "./types";
|
|
3
|
+
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
const CHIP_CLASSES = {
|
|
7
|
+
current: "bg-primary text-primary-foreground border-primary",
|
|
8
|
+
flagged: "bg-warning/10 text-warning border-warning/30",
|
|
9
|
+
answered: "bg-success/10 text-success border-success/30",
|
|
10
|
+
default: "bg-background text-muted-foreground border-border",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
const getChipVariant = (
|
|
14
|
+
q: { uid: string; isFlagged: boolean; isAnswered: boolean },
|
|
15
|
+
currentUid?: string,
|
|
16
|
+
): keyof typeof CHIP_CLASSES => {
|
|
17
|
+
if (q.uid === currentUid) return "current";
|
|
18
|
+
if (q.isFlagged) return "flagged";
|
|
19
|
+
if (q.isAnswered) return "answered";
|
|
20
|
+
return "default";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const QuestionNavigator = ({
|
|
24
|
+
questions,
|
|
25
|
+
currentQuestionUid,
|
|
26
|
+
onNavigate,
|
|
27
|
+
onToggleFlag,
|
|
28
|
+
readOnly = false,
|
|
29
|
+
}: QuestionNavigatorProps) => {
|
|
30
|
+
const getTooltipContent = (q: (typeof questions)[0]): string => {
|
|
31
|
+
const parts: string[] = [`Question ${q.sequence + 1}`];
|
|
32
|
+
if (q.isFlagged) parts.push("Flagged");
|
|
33
|
+
if (q.isAnswered) parts.push("Answered");
|
|
34
|
+
if (q.isSkipped) parts.push("Skipped");
|
|
35
|
+
return parts.join(" \u00B7 ");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex flex-wrap gap-1.5">
|
|
40
|
+
{questions.map((q) => {
|
|
41
|
+
const variant = getChipVariant(q, currentQuestionUid);
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
key={q.uid}
|
|
45
|
+
title={getTooltipContent(q)}
|
|
46
|
+
className={cn(
|
|
47
|
+
"inline-flex items-center gap-1 px-2 py-1 rounded-md border transition-all duration-150",
|
|
48
|
+
CHIP_CLASSES[variant],
|
|
49
|
+
readOnly ? "cursor-default" : "cursor-pointer hover:shadow-sm hover:-translate-y-px",
|
|
50
|
+
)}
|
|
51
|
+
onClick={readOnly ? undefined : () => onNavigate?.(q.uid)}
|
|
52
|
+
>
|
|
53
|
+
<span className="text-xs font-semibold min-w-4 text-center">
|
|
54
|
+
{q.sequence + 1}
|
|
55
|
+
</span>
|
|
56
|
+
{!readOnly && onToggleFlag && (
|
|
57
|
+
<Tooltip>
|
|
58
|
+
<TooltipTrigger>
|
|
59
|
+
<span
|
|
60
|
+
role="button"
|
|
61
|
+
aria-label={q.isFlagged ? "Unflag question" : "Flag question"}
|
|
62
|
+
className={cn(
|
|
63
|
+
"inline-flex cursor-pointer transition-colors duration-150",
|
|
64
|
+
q.isFlagged
|
|
65
|
+
? "text-warning"
|
|
66
|
+
: variant === "current"
|
|
67
|
+
? "text-primary-foreground/60 hover:text-primary-foreground"
|
|
68
|
+
: "text-muted-foreground hover:text-warning",
|
|
69
|
+
)}
|
|
70
|
+
onClick={(e: React.MouseEvent) => {
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
onToggleFlag(q.uid);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Flag size={11} fill={q.isFlagged ? "currentColor" : "none"} />
|
|
76
|
+
</span>
|
|
77
|
+
</TooltipTrigger>
|
|
78
|
+
<TooltipContent>{q.isFlagged ? "Unflag question" : "Flag question"}</TooltipContent>
|
|
79
|
+
</Tooltip>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Clock } from "lucide-react";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
import { formatTimer } from "../utils/format-duration";
|
|
4
|
+
import type { TimerDisplayProps } from "./types";
|
|
5
|
+
|
|
6
|
+
function getTimerClasses(isDanger: boolean, isWarning: boolean) {
|
|
7
|
+
if (isDanger) {
|
|
8
|
+
return {
|
|
9
|
+
wrapper: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
10
|
+
bold: true,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
if (isWarning) {
|
|
14
|
+
return {
|
|
15
|
+
wrapper: "border-warning/30 bg-warning/10 text-warning",
|
|
16
|
+
bold: true,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
wrapper: "border-border bg-muted text-muted-foreground",
|
|
21
|
+
bold: false,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TimerDisplay = ({
|
|
26
|
+
timeElapsedSeconds,
|
|
27
|
+
timeLimitSeconds,
|
|
28
|
+
variant = "compact",
|
|
29
|
+
}: TimerDisplayProps) => {
|
|
30
|
+
const hasTimeLimit = timeLimitSeconds != null && timeLimitSeconds > 0;
|
|
31
|
+
const remainingSeconds = hasTimeLimit
|
|
32
|
+
? Math.max(0, timeLimitSeconds - timeElapsedSeconds)
|
|
33
|
+
: 0;
|
|
34
|
+
|
|
35
|
+
const isWarning = hasTimeLimit && remainingSeconds < timeLimitSeconds * 0.1;
|
|
36
|
+
const isDanger = hasTimeLimit && remainingSeconds <= 60;
|
|
37
|
+
|
|
38
|
+
const displayTime = hasTimeLimit
|
|
39
|
+
? formatTimer(remainingSeconds)
|
|
40
|
+
: formatTimer(timeElapsedSeconds);
|
|
41
|
+
|
|
42
|
+
const classes = getTimerClasses(isDanger, isWarning);
|
|
43
|
+
|
|
44
|
+
if (variant === "compact") {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
className={cn(
|
|
48
|
+
"flex items-center gap-1.5 px-2.5 py-1 rounded-full border",
|
|
49
|
+
classes.wrapper,
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<span className="inline-flex leading-none">
|
|
53
|
+
<Clock size={13} />
|
|
54
|
+
</span>
|
|
55
|
+
<span className="text-xs font-semibold font-mono tracking-wide">
|
|
56
|
+
{displayTime}
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className={cn("flex items-center gap-1.5", classes.wrapper)}>
|
|
64
|
+
<span className="inline-flex leading-none">
|
|
65
|
+
<Clock size={16} />
|
|
66
|
+
</span>
|
|
67
|
+
<span className={cn("text-sm", classes.bold && "font-semibold")}>
|
|
68
|
+
{hasTimeLimit ? "Time remaining: " : "Time elapsed: "}
|
|
69
|
+
<span className="font-mono font-semibold">{displayTime}</span>
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssessmentToolbar provides navigation controls, timer, and question overview for quiz/assessment sessions.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <AssessmentToolbar
|
|
6
|
+
* currentQuestionIndex={0}
|
|
7
|
+
* totalQuestions={10}
|
|
8
|
+
* hasNext
|
|
9
|
+
* hasPrevious={false}
|
|
10
|
+
* onNext={() => setIndex(i => i + 1)}
|
|
11
|
+
* onPrevious={() => setIndex(i => i - 1)}
|
|
12
|
+
* onSubmit={handleSubmit}
|
|
13
|
+
* timeElapsedSeconds={120}
|
|
14
|
+
* timeLimitSeconds={1800}
|
|
15
|
+
* />
|
|
16
|
+
*/
|
|
17
|
+
export interface AssessmentToolbarProps {
|
|
18
|
+
/** Zero-based index of the current question */
|
|
19
|
+
currentQuestionIndex: number;
|
|
20
|
+
/** Total number of questions in the assessment */
|
|
21
|
+
totalQuestions: number;
|
|
22
|
+
/** Whether a next question is available */
|
|
23
|
+
hasNext: boolean;
|
|
24
|
+
/** Whether a previous question is available */
|
|
25
|
+
hasPrevious: boolean;
|
|
26
|
+
/** Called when the user clicks Next */
|
|
27
|
+
onNext: () => void;
|
|
28
|
+
/** Called when the user clicks Previous */
|
|
29
|
+
onPrevious: () => void;
|
|
30
|
+
/** Called when the user submits the assessment */
|
|
31
|
+
onSubmit: () => void;
|
|
32
|
+
/** Elapsed time in seconds for the timer display */
|
|
33
|
+
timeElapsedSeconds?: number;
|
|
34
|
+
/** Time limit in seconds — when set, shows countdown instead of elapsed */
|
|
35
|
+
timeLimitSeconds?: number;
|
|
36
|
+
/** List of questions for the navigator chips */
|
|
37
|
+
questions?: QuestionNavigatorItem[];
|
|
38
|
+
/** Called when the user navigates to a specific question via the navigator */
|
|
39
|
+
onNavigateToQuestion?: (questionUid: string) => void;
|
|
40
|
+
/** Called when the user flags or unflags a question */
|
|
41
|
+
onToggleFlag?: (questionUid: string) => void;
|
|
42
|
+
/** UID of the currently active question */
|
|
43
|
+
currentQuestionUid?: string;
|
|
44
|
+
/** Whether the assessment has been completed/submitted */
|
|
45
|
+
isCompleted?: boolean;
|
|
46
|
+
/** Whether the submit action is in progress */
|
|
47
|
+
isSubmitting?: boolean;
|
|
48
|
+
/** When true, disables all interactive controls */
|
|
49
|
+
readOnly?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* TimerDisplay shows elapsed or remaining time for an assessment.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* <TimerDisplay timeElapsedSeconds={90} timeLimitSeconds={600} variant="compact" />
|
|
57
|
+
*/
|
|
58
|
+
export interface TimerDisplayProps {
|
|
59
|
+
/** Total seconds elapsed since the assessment started */
|
|
60
|
+
timeElapsedSeconds: number;
|
|
61
|
+
/** Optional time limit in seconds — enables countdown mode */
|
|
62
|
+
timeLimitSeconds?: number;
|
|
63
|
+
/** Display variant: compact chip or full text */
|
|
64
|
+
variant?: "compact" | "full";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* QuestionNavigator renders chip-based navigation for quickly jumping between questions.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* <QuestionNavigator
|
|
72
|
+
* questions={questions}
|
|
73
|
+
* currentQuestionUid={currentUid}
|
|
74
|
+
* onNavigate={handleNavigate}
|
|
75
|
+
* onToggleFlag={handleFlag}
|
|
76
|
+
* />
|
|
77
|
+
*/
|
|
78
|
+
export interface QuestionNavigatorProps {
|
|
79
|
+
questions: QuestionNavigatorItem[];
|
|
80
|
+
currentQuestionUid?: string;
|
|
81
|
+
onNavigate?: (questionUid: string) => void;
|
|
82
|
+
onToggleFlag?: (questionUid: string) => void;
|
|
83
|
+
readOnly?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface QuestionNavigatorItem {
|
|
87
|
+
uid: string;
|
|
88
|
+
sequence: number;
|
|
89
|
+
isFlagged: boolean;
|
|
90
|
+
isAnswered: boolean;
|
|
91
|
+
isSkipped: boolean;
|
|
92
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- Center cobra hood -->
|
|
3
|
+
<path d="M9 12 C9 7 10 3 12 3 C14 3 15 7 15 12" />
|
|
4
|
+
<!-- Center hood inner curls -->
|
|
5
|
+
<path d="M10 10 C10.5 7 12 9 12 9 C12 9 13.5 7 14 10" />
|
|
6
|
+
<!-- Center jaw -->
|
|
7
|
+
<path d="M10 12.5 C10.5 14 13.5 14 14 12.5" />
|
|
8
|
+
<!-- Left cobra — large coil -->
|
|
9
|
+
<path d="M6.5 13 C3.5 12 1 9 1 6 C1 3 3 1.5 5.5 3.5 C7.5 5.5 7.5 9 6.5 13" />
|
|
10
|
+
<!-- Right cobra — large coil -->
|
|
11
|
+
<path d="M17.5 13 C20.5 12 23 9 23 6 C23 3 21 1.5 18.5 3.5 C16.5 5.5 16.5 9 17.5 13" />
|
|
12
|
+
<!-- Left neck -->
|
|
13
|
+
<path d="M6.5 13 C7.5 16 9 19 10.5 22" />
|
|
14
|
+
<!-- Center neck -->
|
|
15
|
+
<path d="M12 14 L12 22" />
|
|
16
|
+
<!-- Right neck -->
|
|
17
|
+
<path d="M17.5 13 C16.5 16 15 19 13.5 22" />
|
|
18
|
+
</svg>
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
|
|
3
|
+
<path d="M10.5 9.5C9 5 5 1.5 2 2.5"/>
|
|
4
|
+
<path d="M12 9C14 4.5 18 3 22 2"/>
|
|
5
|
+
<path d="M15 10.5C19 8 22 8.5 23 6"/>
|
|
6
|
+
<path d="M9 14C5 15 2.5 18.5 1.5 22"/>
|
|
7
|
+
<path d="M12 15C11 19 12 21.5 11 23"/>
|
|
8
|
+
<path d="M14.5 14.5C18 17 20.5 20.5 22 23"/>
|
|
9
|
+
</svg>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AlertDialog,
|
|
3
|
+
AlertDialogContent,
|
|
4
|
+
AlertDialogHeader,
|
|
5
|
+
AlertDialogFooter,
|
|
6
|
+
AlertDialogTitle,
|
|
7
|
+
AlertDialogDescription,
|
|
8
|
+
AlertDialogCancel,
|
|
9
|
+
AlertDialogAction,
|
|
10
|
+
} from "../ui/alert-dialog";
|
|
11
|
+
import { cn } from "../lib/utils";
|
|
12
|
+
import type { ConfirmDialogProps } from "./types";
|
|
13
|
+
|
|
14
|
+
const CONFIRM_VARIANT: Record<string, string> = {
|
|
15
|
+
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
16
|
+
error: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
17
|
+
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function ConfirmDialog({
|
|
21
|
+
open,
|
|
22
|
+
title,
|
|
23
|
+
message,
|
|
24
|
+
confirmLabel = "Confirm",
|
|
25
|
+
cancelLabel = "Cancel",
|
|
26
|
+
confirmColor = "primary",
|
|
27
|
+
onConfirm,
|
|
28
|
+
onCancel,
|
|
29
|
+
isLoading = false,
|
|
30
|
+
}: ConfirmDialogProps) {
|
|
31
|
+
return (
|
|
32
|
+
<AlertDialog open={open} onOpenChange={(nextOpen) => !nextOpen && onCancel()}>
|
|
33
|
+
<AlertDialogContent>
|
|
34
|
+
<AlertDialogHeader>
|
|
35
|
+
<AlertDialogTitle>{title}</AlertDialogTitle>
|
|
36
|
+
{typeof message === "string" ? (
|
|
37
|
+
<AlertDialogDescription>{message}</AlertDialogDescription>
|
|
38
|
+
) : (
|
|
39
|
+
message
|
|
40
|
+
)}
|
|
41
|
+
</AlertDialogHeader>
|
|
42
|
+
<AlertDialogFooter>
|
|
43
|
+
<AlertDialogCancel onClick={onCancel} disabled={isLoading}>
|
|
44
|
+
{cancelLabel}
|
|
45
|
+
</AlertDialogCancel>
|
|
46
|
+
<AlertDialogAction
|
|
47
|
+
className={cn(CONFIRM_VARIANT[confirmColor] ?? CONFIRM_VARIANT.primary)}
|
|
48
|
+
onClick={onConfirm}
|
|
49
|
+
disabled={isLoading}
|
|
50
|
+
>
|
|
51
|
+
{isLoading && (
|
|
52
|
+
<span className="inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
53
|
+
)}
|
|
54
|
+
{confirmLabel}
|
|
55
|
+
</AlertDialogAction>
|
|
56
|
+
</AlertDialogFooter>
|
|
57
|
+
</AlertDialogContent>
|
|
58
|
+
</AlertDialog>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Clock, CheckCircle } from "lucide-react";
|
|
2
|
+
import type { DueDateDisplayProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const URGENCY_CLASS: Record<string, string> = {
|
|
6
|
+
success: "text-success",
|
|
7
|
+
destructive: "text-destructive",
|
|
8
|
+
warning: "text-warning",
|
|
9
|
+
muted: "text-muted-foreground",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function getRelativeTime(date: Date): string {
|
|
13
|
+
const now = new Date();
|
|
14
|
+
const diffMs = date.getTime() - now.getTime();
|
|
15
|
+
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
16
|
+
|
|
17
|
+
if (diffDays === 0) return "Due today";
|
|
18
|
+
if (diffDays === 1) return "Due tomorrow";
|
|
19
|
+
if (diffDays === -1) return "Due yesterday";
|
|
20
|
+
if (diffDays > 1) return `Due in ${diffDays} days`;
|
|
21
|
+
return `${Math.abs(diffDays)} days overdue`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getUrgencyKey(date: Date, submitted: boolean): string {
|
|
25
|
+
if (submitted) return "success";
|
|
26
|
+
const now = new Date();
|
|
27
|
+
const diffMs = date.getTime() - now.getTime();
|
|
28
|
+
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
|
29
|
+
if (diffDays < 0) return "destructive";
|
|
30
|
+
if (diffDays < 2) return "warning";
|
|
31
|
+
return "muted";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function DueDateDisplay({
|
|
35
|
+
dueDate,
|
|
36
|
+
submittedDate,
|
|
37
|
+
showRelative = true,
|
|
38
|
+
size = "medium",
|
|
39
|
+
className,
|
|
40
|
+
style,
|
|
41
|
+
}: DueDateDisplayProps) {
|
|
42
|
+
const date = new Date(dueDate);
|
|
43
|
+
const isSubmitted = !!submittedDate;
|
|
44
|
+
const colorClass = URGENCY_CLASS[getUrgencyKey(date, isSubmitted)] ?? "text-muted-foreground";
|
|
45
|
+
const iconSize = size === "small" ? 14 : 16;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className={cn("flex items-center gap-1", colorClass, className)}
|
|
50
|
+
style={style}
|
|
51
|
+
>
|
|
52
|
+
<span className="shrink-0 inline-flex">
|
|
53
|
+
{isSubmitted ? <CheckCircle size={iconSize} /> : <Clock size={iconSize} />}
|
|
54
|
+
</span>
|
|
55
|
+
<span className={size === "small" ? "text-xs" : "text-sm"}>
|
|
56
|
+
{isSubmitted
|
|
57
|
+
? `Submitted ${new Date(submittedDate).toLocaleDateString()}`
|
|
58
|
+
: showRelative
|
|
59
|
+
? getRelativeTime(date)
|
|
60
|
+
: date.toLocaleDateString()}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { EmptyStateProps } from "./types";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
export function EmptyState({ icon, title, description, action, className, style }: EmptyStateProps) {
|
|
5
|
+
return (
|
|
6
|
+
<div
|
|
7
|
+
className={cn("flex flex-col items-center justify-center text-center px-3 py-6", className)}
|
|
8
|
+
style={style}
|
|
9
|
+
>
|
|
10
|
+
{icon && (
|
|
11
|
+
<div className="mb-2 text-muted-foreground [&>svg]:size-12">
|
|
12
|
+
{icon}
|
|
13
|
+
</div>
|
|
14
|
+
)}
|
|
15
|
+
<p className="text-lg font-semibold text-foreground mb-1">{title}</p>
|
|
16
|
+
{description && (
|
|
17
|
+
<p className={cn("text-sm text-muted-foreground max-w-[360px]", action && "mb-2")}>
|
|
18
|
+
{description}
|
|
19
|
+
</p>
|
|
20
|
+
)}
|
|
21
|
+
{action}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { EmptyState } from "./empty-state";
|
|
2
|
+
export { ConfirmDialog } from "./confirm-dialog";
|
|
3
|
+
export { SearchInput } from "./search-input";
|
|
4
|
+
export { StatusBadge } from "./status-badge";
|
|
5
|
+
export { DueDateDisplay } from "./due-date-display";
|
|
6
|
+
export type {
|
|
7
|
+
EmptyStateProps,
|
|
8
|
+
ConfirmDialogProps,
|
|
9
|
+
SearchInputProps,
|
|
10
|
+
StatusBadgeProps,
|
|
11
|
+
DueDateDisplayProps,
|
|
12
|
+
} from "./types";
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { Search, X } from "lucide-react";
|
|
3
|
+
import { debounce } from "../utils/debounce";
|
|
4
|
+
import type { SearchInputProps } from "./types";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
export function SearchInput({
|
|
8
|
+
value,
|
|
9
|
+
onChange,
|
|
10
|
+
placeholder = "Search...",
|
|
11
|
+
debounceMs = 300,
|
|
12
|
+
fullWidth = false,
|
|
13
|
+
size = "small",
|
|
14
|
+
className,
|
|
15
|
+
style,
|
|
16
|
+
}: SearchInputProps) {
|
|
17
|
+
const [localValue, setLocalValue] = useState(value);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
setLocalValue(value);
|
|
21
|
+
}, [value]);
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
24
|
+
const debouncedOnChange = useCallback(debounce(onChange, debounceMs), [onChange, debounceMs]);
|
|
25
|
+
|
|
26
|
+
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
27
|
+
const next = e.target.value;
|
|
28
|
+
setLocalValue(next);
|
|
29
|
+
debouncedOnChange(next);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleClear() {
|
|
33
|
+
setLocalValue("");
|
|
34
|
+
onChange("");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div
|
|
39
|
+
className={cn("w-full", className)}
|
|
40
|
+
style={{ ...style, width: fullWidth ? "100%" : undefined }}
|
|
41
|
+
>
|
|
42
|
+
<div className="flex items-center w-full border border-input rounded-md bg-background transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]">
|
|
43
|
+
<span className="flex items-center justify-center px-2 text-muted-foreground shrink-0">
|
|
44
|
+
<Search size={18} />
|
|
45
|
+
</span>
|
|
46
|
+
<input
|
|
47
|
+
className={cn(
|
|
48
|
+
"flex-1 border-none outline-none bg-transparent text-foreground text-sm min-w-0 placeholder:text-muted-foreground",
|
|
49
|
+
size === "medium" ? "py-2" : "py-1.5",
|
|
50
|
+
)}
|
|
51
|
+
value={localValue}
|
|
52
|
+
onChange={handleChange}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
/>
|
|
55
|
+
{localValue && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
className="inline-flex items-center justify-center p-0.5 mr-1 rounded-md text-muted-foreground cursor-pointer transition-colors hover:bg-muted hover:text-foreground"
|
|
59
|
+
aria-label="Clear search"
|
|
60
|
+
onClick={handleClear}
|
|
61
|
+
>
|
|
62
|
+
<X size={16} />
|
|
63
|
+
</button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|