@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,81 @@
|
|
|
1
|
+
import { ChevronRight, ChevronDown, CheckCircle2, Circle } from "lucide-react";
|
|
2
|
+
import { LearningObjectIcon } from "./learning-object-icon";
|
|
3
|
+
import { formatDuration } from "../utils/format-duration";
|
|
4
|
+
import type { CurriculumItemRowProps } from "./types";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
export const CurriculumItemRow = ({
|
|
8
|
+
item,
|
|
9
|
+
level,
|
|
10
|
+
isActive = false,
|
|
11
|
+
isCompleted = false,
|
|
12
|
+
isExpanded = false,
|
|
13
|
+
hasChildren,
|
|
14
|
+
onToggleExpand,
|
|
15
|
+
onClick,
|
|
16
|
+
showDuration = true,
|
|
17
|
+
showIcon = true,
|
|
18
|
+
showProgress = true,
|
|
19
|
+
}: CurriculumItemRowProps) => {
|
|
20
|
+
const isModule = hasChildren;
|
|
21
|
+
|
|
22
|
+
const fontWeightClass = isModule ? "font-semibold" : isActive ? "font-medium" : "font-normal";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
"flex items-center gap-2 py-2 pr-3 transition-colors",
|
|
28
|
+
onClick && "cursor-pointer hover:bg-muted",
|
|
29
|
+
isActive && "bg-secondary",
|
|
30
|
+
)}
|
|
31
|
+
style={{ paddingLeft: `${(level * 20) + 8}px` }}
|
|
32
|
+
onClick={onClick}
|
|
33
|
+
>
|
|
34
|
+
<div className="shrink-0 flex items-center justify-center size-5">
|
|
35
|
+
{showProgress && isCompleted ? (
|
|
36
|
+
<span className="text-success">
|
|
37
|
+
<CheckCircle2 size={18} />
|
|
38
|
+
</span>
|
|
39
|
+
) : showIcon ? (
|
|
40
|
+
<span className={isActive ? "text-primary" : "text-muted-foreground"}>
|
|
41
|
+
<LearningObjectIcon type={item.type} size={16} />
|
|
42
|
+
</span>
|
|
43
|
+
) : (
|
|
44
|
+
<span className="text-muted-foreground">
|
|
45
|
+
<Circle size={16} />
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div className="flex-1 min-w-0">
|
|
51
|
+
<div
|
|
52
|
+
className={cn("text-sm truncate", isActive ? "text-primary" : "text-foreground", fontWeightClass)}
|
|
53
|
+
>
|
|
54
|
+
{item.name}
|
|
55
|
+
</div>
|
|
56
|
+
{showDuration && item.duration > 0 && !isModule && (
|
|
57
|
+
<div className="text-xs text-muted-foreground mt-0.5">
|
|
58
|
+
{formatDuration(item.duration)}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{hasChildren && (
|
|
64
|
+
<button
|
|
65
|
+
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
66
|
+
aria-label={isExpanded ? "Collapse" : "Expand"}
|
|
67
|
+
onClick={(e) => {
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
onToggleExpand?.();
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{isExpanded ? (
|
|
73
|
+
<ChevronDown size={16} />
|
|
74
|
+
) : (
|
|
75
|
+
<ChevronRight size={16} />
|
|
76
|
+
)}
|
|
77
|
+
</button>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { CurriculumItemRow } from "./curriculum-item";
|
|
3
|
+
import type { CurriculumTreeProps, CurriculumItem } from "./types";
|
|
4
|
+
|
|
5
|
+
export const CurriculumTree = ({
|
|
6
|
+
items,
|
|
7
|
+
progress,
|
|
8
|
+
activeItemUid,
|
|
9
|
+
onItemClick,
|
|
10
|
+
readOnly = false,
|
|
11
|
+
showDuration = true,
|
|
12
|
+
showIcons = true,
|
|
13
|
+
showProgress = true,
|
|
14
|
+
}: CurriculumTreeProps) => {
|
|
15
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
16
|
+
|
|
17
|
+
if (!items || items.length === 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const handleToggleExpand = (uid: string) => {
|
|
22
|
+
setExpanded((prev) => ({ ...prev, [uid]: !prev[uid] }));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const isItemCompleted = (uid: string): boolean => {
|
|
26
|
+
if (!progress || !showProgress) return false;
|
|
27
|
+
return progress.some((p) => p.resourceUid === uid && p.isCompleted);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const renderItems = (
|
|
31
|
+
nodeItems: CurriculumItem[],
|
|
32
|
+
level: number,
|
|
33
|
+
) => {
|
|
34
|
+
return (
|
|
35
|
+
<div className="flex flex-col">
|
|
36
|
+
{nodeItems.map((item) => {
|
|
37
|
+
const hasChildren = !!item.children?.length;
|
|
38
|
+
const isExpanded = expanded[item.uid] || false;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div key={item.uid}>
|
|
42
|
+
<CurriculumItemRow
|
|
43
|
+
item={item}
|
|
44
|
+
level={level}
|
|
45
|
+
isActive={activeItemUid === item.uid}
|
|
46
|
+
isCompleted={isItemCompleted(item.uid)}
|
|
47
|
+
isExpanded={isExpanded}
|
|
48
|
+
hasChildren={hasChildren}
|
|
49
|
+
onToggleExpand={() => handleToggleExpand(item.uid)}
|
|
50
|
+
onClick={readOnly ? undefined : () => onItemClick?.(item)}
|
|
51
|
+
showDuration={showDuration}
|
|
52
|
+
showIcon={showIcons}
|
|
53
|
+
showProgress={showProgress}
|
|
54
|
+
/>
|
|
55
|
+
|
|
56
|
+
{hasChildren && isExpanded && renderItems(item.children!, level + 1)}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
})}
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<nav className="rounded-lg border border-border overflow-hidden">
|
|
66
|
+
{renderItems(items, 0)}
|
|
67
|
+
</nav>
|
|
68
|
+
);
|
|
69
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CurriculumTree } from "./curriculum-tree";
|
|
2
|
+
export { CurriculumItemRow } from "./curriculum-item";
|
|
3
|
+
export { LearningObjectIcon } from "./learning-object-icon";
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
CurriculumItem,
|
|
7
|
+
CurriculumItemProgress,
|
|
8
|
+
CurriculumTreeProps,
|
|
9
|
+
CurriculumItemRowProps,
|
|
10
|
+
LearningObjectIconProps,
|
|
11
|
+
} from "./types";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Video,
|
|
3
|
+
FileText,
|
|
4
|
+
HelpCircle,
|
|
5
|
+
ClipboardList,
|
|
6
|
+
MessageSquare,
|
|
7
|
+
ExternalLink,
|
|
8
|
+
Music,
|
|
9
|
+
Image,
|
|
10
|
+
Code,
|
|
11
|
+
BookOpen,
|
|
12
|
+
PlayCircle,
|
|
13
|
+
type LucideIcon,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import type { LearningObjectIconProps } from "./types";
|
|
16
|
+
|
|
17
|
+
const ICON_MAP: Record<string, LucideIcon> = {
|
|
18
|
+
video: Video,
|
|
19
|
+
video_lesson: Video,
|
|
20
|
+
stream: PlayCircle,
|
|
21
|
+
document: FileText,
|
|
22
|
+
pdf: FileText,
|
|
23
|
+
page: FileText,
|
|
24
|
+
quiz: HelpCircle,
|
|
25
|
+
assessment: HelpCircle,
|
|
26
|
+
assignment: ClipboardList,
|
|
27
|
+
discussion: MessageSquare,
|
|
28
|
+
link: ExternalLink,
|
|
29
|
+
url: ExternalLink,
|
|
30
|
+
audio: Music,
|
|
31
|
+
image: Image,
|
|
32
|
+
scorm: Code,
|
|
33
|
+
iframe: Code,
|
|
34
|
+
lesson: BookOpen,
|
|
35
|
+
module: BookOpen,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const LearningObjectIcon = ({
|
|
39
|
+
type,
|
|
40
|
+
size = 18,
|
|
41
|
+
}: LearningObjectIconProps) => {
|
|
42
|
+
const IconComponent = ICON_MAP[type] ?? FileText;
|
|
43
|
+
return <IconComponent size={size} />;
|
|
44
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CurriculumTree component for rendering a hierarchical course curriculum with expand/collapse support.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <CurriculumTree
|
|
6
|
+
* items={curriculum}
|
|
7
|
+
* progress={userProgress}
|
|
8
|
+
* activeItemUid={currentLessonUid}
|
|
9
|
+
* onItemClick={(item) => navigate(item.uid)}
|
|
10
|
+
* showProgress
|
|
11
|
+
* />
|
|
12
|
+
*/
|
|
13
|
+
export interface CurriculumTreeProps {
|
|
14
|
+
/** Hierarchical curriculum items to display */
|
|
15
|
+
items: CurriculumItem[];
|
|
16
|
+
/** User progress data for completion indicators */
|
|
17
|
+
progress?: CurriculumItemProgress[];
|
|
18
|
+
/** UID of the currently active item */
|
|
19
|
+
activeItemUid?: string;
|
|
20
|
+
/** Called when the user clicks a curriculum item */
|
|
21
|
+
onItemClick?: (item: CurriculumItem) => void;
|
|
22
|
+
/** When true, disables item click interactions */
|
|
23
|
+
readOnly?: boolean;
|
|
24
|
+
/** Whether to show estimated duration for each item */
|
|
25
|
+
showDuration?: boolean;
|
|
26
|
+
/** Whether to show learning object type icons */
|
|
27
|
+
showIcons?: boolean;
|
|
28
|
+
/** Whether to show completion progress indicators */
|
|
29
|
+
showProgress?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* CurriculumItemRow renders a single row in the curriculum tree.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* <CurriculumItemRow
|
|
37
|
+
* item={item}
|
|
38
|
+
* level={0}
|
|
39
|
+
* isActive
|
|
40
|
+
* hasChildren={false}
|
|
41
|
+
* />
|
|
42
|
+
*/
|
|
43
|
+
export interface CurriculumItemRowProps {
|
|
44
|
+
item: CurriculumItem;
|
|
45
|
+
level: number;
|
|
46
|
+
isActive?: boolean;
|
|
47
|
+
isCompleted?: boolean;
|
|
48
|
+
isExpanded?: boolean;
|
|
49
|
+
hasChildren: boolean;
|
|
50
|
+
onToggleExpand?: () => void;
|
|
51
|
+
onClick?: () => void;
|
|
52
|
+
showDuration?: boolean;
|
|
53
|
+
showIcon?: boolean;
|
|
54
|
+
showProgress?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* LearningObjectIcon renders the appropriate icon for a given learning object type.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* <LearningObjectIcon type="video" size={18} />
|
|
62
|
+
*/
|
|
63
|
+
export interface LearningObjectIconProps {
|
|
64
|
+
/** Learning object type string (e.g. "video", "quiz", "document") */
|
|
65
|
+
type: string;
|
|
66
|
+
/** Icon size in pixels */
|
|
67
|
+
size?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CurriculumItem {
|
|
71
|
+
uid: string;
|
|
72
|
+
name: string;
|
|
73
|
+
type: string;
|
|
74
|
+
duration: number;
|
|
75
|
+
sequence: number;
|
|
76
|
+
children?: CurriculumItem[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CurriculumItemProgress {
|
|
80
|
+
resourceUid: string;
|
|
81
|
+
isCompleted: boolean;
|
|
82
|
+
timeSpent?: number;
|
|
83
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CheckCircle, XCircle } from "lucide-react";
|
|
2
|
+
import type { FeedbackBannerProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
export function FeedbackBanner({
|
|
6
|
+
isCorrect,
|
|
7
|
+
explanation,
|
|
8
|
+
onRetry,
|
|
9
|
+
retryLabel = "Try Again",
|
|
10
|
+
className,
|
|
11
|
+
style,
|
|
12
|
+
}: FeedbackBannerProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
role="alert"
|
|
16
|
+
className={cn(
|
|
17
|
+
"flex items-start gap-2 rounded-md border p-3 text-sm",
|
|
18
|
+
isCorrect
|
|
19
|
+
? "border-success/50 bg-success/5"
|
|
20
|
+
: "border-destructive/50 bg-destructive/5",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
style={style}
|
|
24
|
+
>
|
|
25
|
+
<span className={cn("shrink-0", isCorrect ? "text-success" : "text-destructive")}>
|
|
26
|
+
{isCorrect ? <CheckCircle size={20} /> : <XCircle size={20} />}
|
|
27
|
+
</span>
|
|
28
|
+
<div className="flex-1">
|
|
29
|
+
<div className="flex items-start justify-between gap-2">
|
|
30
|
+
<div>
|
|
31
|
+
<span className="font-semibold">{isCorrect ? "Correct!" : "Incorrect"}</span>
|
|
32
|
+
{explanation && <p className="mt-1 text-foreground">{explanation}</p>}
|
|
33
|
+
</div>
|
|
34
|
+
{!isCorrect && onRetry && (
|
|
35
|
+
<button
|
|
36
|
+
className="shrink-0 rounded-md border border-border bg-background px-3 py-1 text-sm font-medium transition-colors hover:bg-muted"
|
|
37
|
+
onClick={onRetry}
|
|
38
|
+
>
|
|
39
|
+
{retryLabel}
|
|
40
|
+
</button>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { LikertScaleProps } from "./types";
|
|
2
|
+
import { cn } from "../lib/utils";
|
|
3
|
+
|
|
4
|
+
export function LikertScale({
|
|
5
|
+
value,
|
|
6
|
+
onChange,
|
|
7
|
+
points = 5,
|
|
8
|
+
lowLabel = "Strongly Disagree",
|
|
9
|
+
highLabel = "Strongly Agree",
|
|
10
|
+
disabled = false,
|
|
11
|
+
readOnly = false,
|
|
12
|
+
className,
|
|
13
|
+
style,
|
|
14
|
+
}: LikertScaleProps) {
|
|
15
|
+
const options = Array.from({ length: points }, (_, i) => i + 1);
|
|
16
|
+
const isInteractive = !disabled && !readOnly;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className={cn("flex flex-col gap-1", className)} style={style}>
|
|
20
|
+
<div className="flex flex-row" role="radiogroup">
|
|
21
|
+
{options.map((n) => {
|
|
22
|
+
const isSelected = value === n;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<label
|
|
26
|
+
key={n}
|
|
27
|
+
className={cn(
|
|
28
|
+
"flex items-center justify-center border border-border px-1.5 py-2 text-sm transition-colors first:rounded-l-md last:rounded-r-md -ml-px first:ml-0",
|
|
29
|
+
isSelected
|
|
30
|
+
? "bg-primary text-primary-foreground border-primary z-10"
|
|
31
|
+
: "bg-background text-foreground",
|
|
32
|
+
!isInteractive && "opacity-60 cursor-not-allowed",
|
|
33
|
+
isInteractive && !isSelected && "cursor-pointer hover:bg-muted",
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<input
|
|
37
|
+
type="radio"
|
|
38
|
+
name="likert-scale"
|
|
39
|
+
className="sr-only"
|
|
40
|
+
value={n}
|
|
41
|
+
checked={isSelected}
|
|
42
|
+
disabled={disabled}
|
|
43
|
+
onChange={() => {
|
|
44
|
+
if (isInteractive) onChange(n);
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
<span className="text-sm font-medium">{n}</span>
|
|
48
|
+
</label>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="flex justify-between">
|
|
53
|
+
<span className="text-xs text-muted-foreground">{lowLabel}</span>
|
|
54
|
+
<span className="text-xs text-muted-foreground">{highLabel}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Star } from "lucide-react";
|
|
3
|
+
import type { StarRatingProps } from "./types";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
const SIZES = { small: 18, medium: 24, large: 32 };
|
|
7
|
+
|
|
8
|
+
export function StarRating({
|
|
9
|
+
value,
|
|
10
|
+
onChange,
|
|
11
|
+
maxStars = 5,
|
|
12
|
+
allowHalf = false,
|
|
13
|
+
size = "medium",
|
|
14
|
+
disabled = false,
|
|
15
|
+
readOnly = false,
|
|
16
|
+
className,
|
|
17
|
+
style,
|
|
18
|
+
}: StarRatingProps) {
|
|
19
|
+
const [hoverValue, setHoverValue] = useState<number | null>(null);
|
|
20
|
+
const iconSize = SIZES[size];
|
|
21
|
+
const displayValue = hoverValue ?? value;
|
|
22
|
+
const isInteractive = !disabled && !readOnly;
|
|
23
|
+
|
|
24
|
+
function handleClick(starIndex: number) {
|
|
25
|
+
if (!isInteractive) return;
|
|
26
|
+
onChange(starIndex);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
className={cn("inline-flex gap-0.5", className)}
|
|
32
|
+
style={style}
|
|
33
|
+
onMouseLeave={() => isInteractive && setHoverValue(null)}
|
|
34
|
+
>
|
|
35
|
+
{Array.from({ length: maxStars }, (_, i) => {
|
|
36
|
+
const starValue = i + 1;
|
|
37
|
+
const isFilled = displayValue >= starValue;
|
|
38
|
+
const isHalf = allowHalf && !isFilled && displayValue >= starValue - 0.5;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
key={i}
|
|
43
|
+
type="button"
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex items-center justify-center rounded-md border-none bg-transparent p-0",
|
|
46
|
+
isInteractive ? "cursor-pointer" : "cursor-default",
|
|
47
|
+
isFilled || isHalf ? "text-warning" : "text-muted-foreground",
|
|
48
|
+
)}
|
|
49
|
+
disabled={disabled}
|
|
50
|
+
onClick={() => handleClick(starValue)}
|
|
51
|
+
onMouseEnter={() => isInteractive && setHoverValue(starValue)}
|
|
52
|
+
aria-label={`Rate ${starValue} star${starValue !== 1 ? "s" : ""}`}
|
|
53
|
+
>
|
|
54
|
+
<Star
|
|
55
|
+
size={iconSize}
|
|
56
|
+
fill={isFilled ? "currentColor" : isHalf ? "currentColor" : "none"}
|
|
57
|
+
strokeWidth={1.5}
|
|
58
|
+
style={isHalf ? { clipPath: "inset(0 50% 0 0)" } : undefined}
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* FeedbackBanner shows a correct/incorrect status banner with an optional
|
|
4
|
+
* explanation and retry button after answering a question.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* <FeedbackBanner
|
|
8
|
+
* isCorrect={false}
|
|
9
|
+
* explanation="The correct answer is React because it uses a virtual DOM."
|
|
10
|
+
* onRetry={() => resetAnswer()}
|
|
11
|
+
* />
|
|
12
|
+
*/
|
|
13
|
+
export interface FeedbackBannerProps {
|
|
14
|
+
/** Whether the answer was correct */
|
|
15
|
+
isCorrect: boolean;
|
|
16
|
+
/** Explanation text shown below the status */
|
|
17
|
+
explanation?: string;
|
|
18
|
+
/** Called when the user clicks the retry button */
|
|
19
|
+
onRetry?: () => void;
|
|
20
|
+
/** Label for the retry button */
|
|
21
|
+
retryLabel?: string;
|
|
22
|
+
/** CSS class name for the root element */
|
|
23
|
+
className?: string;
|
|
24
|
+
/** Inline styles for the root element */
|
|
25
|
+
style?: React.CSSProperties;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* LikertScale renders a horizontal 5 or 7 point agreement scale
|
|
30
|
+
* commonly used in surveys and feedback forms.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* <LikertScale
|
|
34
|
+
* value={3}
|
|
35
|
+
* onChange={(v) => setRating(v)}
|
|
36
|
+
* lowLabel="Strongly Disagree"
|
|
37
|
+
* highLabel="Strongly Agree"
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
export interface LikertScaleProps {
|
|
41
|
+
/** Currently selected value (1-based), or null if none selected */
|
|
42
|
+
value: number | null;
|
|
43
|
+
/** Called when the user selects a point */
|
|
44
|
+
onChange: (value: number) => void;
|
|
45
|
+
/** Number of scale points */
|
|
46
|
+
points?: 5 | 7;
|
|
47
|
+
/** Label for the low end of the scale */
|
|
48
|
+
lowLabel?: string;
|
|
49
|
+
/** Label for the high end of the scale */
|
|
50
|
+
highLabel?: string;
|
|
51
|
+
/** Whether the input is disabled */
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
/** When true, disables interaction */
|
|
54
|
+
readOnly?: boolean;
|
|
55
|
+
/** CSS class name for the root element */
|
|
56
|
+
className?: string;
|
|
57
|
+
/** Inline styles for the root element */
|
|
58
|
+
style?: React.CSSProperties;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* StarRating renders a 1–5 star rating input with hover preview.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* <StarRating value={4} onChange={(v) => setRating(v)} />
|
|
66
|
+
*/
|
|
67
|
+
export interface StarRatingProps {
|
|
68
|
+
/** Current rating value */
|
|
69
|
+
value: number;
|
|
70
|
+
/** Called when the user selects a rating */
|
|
71
|
+
onChange: (value: number) => void;
|
|
72
|
+
/** Maximum number of stars */
|
|
73
|
+
maxStars?: number;
|
|
74
|
+
/** Whether to allow half-star increments */
|
|
75
|
+
allowHalf?: boolean;
|
|
76
|
+
/** Star size */
|
|
77
|
+
size?: "small" | "medium" | "large";
|
|
78
|
+
/** Whether the input is disabled */
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
/** When true, disables interaction */
|
|
81
|
+
readOnly?: boolean;
|
|
82
|
+
/** CSS class name for the root element */
|
|
83
|
+
className?: string;
|
|
84
|
+
/** Inline styles for the root element */
|
|
85
|
+
style?: React.CSSProperties;
|
|
86
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import { Flashcard } from "./flashcard";
|
|
4
|
+
import { Button } from "../ui/button";
|
|
5
|
+
import type { FlashcardDeckProps } from "./types";
|
|
6
|
+
|
|
7
|
+
function shuffle<T>(array: T[]): T[] {
|
|
8
|
+
const shuffled = [...array];
|
|
9
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
10
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
11
|
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
12
|
+
}
|
|
13
|
+
return shuffled;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const FlashcardDeck = ({
|
|
17
|
+
cards,
|
|
18
|
+
deckName,
|
|
19
|
+
deckDescription,
|
|
20
|
+
currentIndex: controlledIndex,
|
|
21
|
+
onNavigate,
|
|
22
|
+
onComplete,
|
|
23
|
+
readOnly = false,
|
|
24
|
+
showProgress = true,
|
|
25
|
+
shuffled = false,
|
|
26
|
+
}: FlashcardDeckProps) => {
|
|
27
|
+
const orderedCards = useMemo(
|
|
28
|
+
() => (shuffled ? shuffle(cards) : cards),
|
|
29
|
+
[cards, shuffled],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const [internalIndex, setInternalIndex] = useState(0);
|
|
33
|
+
const [isFlipped, setIsFlipped] = useState(false);
|
|
34
|
+
|
|
35
|
+
const isControlled = controlledIndex !== undefined;
|
|
36
|
+
const currentIndex = isControlled ? controlledIndex : internalIndex;
|
|
37
|
+
const currentCard = orderedCards[currentIndex];
|
|
38
|
+
|
|
39
|
+
const hasPrevious = currentIndex > 0;
|
|
40
|
+
const hasNext = currentIndex < orderedCards.length - 1;
|
|
41
|
+
|
|
42
|
+
const handleNavigate = (index: number) => {
|
|
43
|
+
setIsFlipped(false);
|
|
44
|
+
if (isControlled) {
|
|
45
|
+
onNavigate?.(index);
|
|
46
|
+
} else {
|
|
47
|
+
setInternalIndex(index);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const handlePrevious = () => {
|
|
52
|
+
if (hasPrevious) handleNavigate(currentIndex - 1);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const handleNext = () => {
|
|
56
|
+
if (hasNext) {
|
|
57
|
+
handleNavigate(currentIndex + 1);
|
|
58
|
+
} else {
|
|
59
|
+
onComplete?.();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (!orderedCards.length) {
|
|
64
|
+
return (
|
|
65
|
+
<span className="text-sm text-muted-foreground">
|
|
66
|
+
No flashcards in this deck.
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const progress = ((currentIndex + 1) / orderedCards.length) * 100;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="flex flex-col items-center gap-4">
|
|
75
|
+
{(deckName || deckDescription) && (
|
|
76
|
+
<div className="flex flex-col gap-0.5 text-center">
|
|
77
|
+
{deckName && (
|
|
78
|
+
<span className="text-lg font-semibold">{deckName}</span>
|
|
79
|
+
)}
|
|
80
|
+
{deckDescription && (
|
|
81
|
+
<span className="text-sm text-muted-foreground">{deckDescription}</span>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
|
|
86
|
+
{currentCard && (
|
|
87
|
+
<Flashcard
|
|
88
|
+
card={currentCard}
|
|
89
|
+
isFlipped={isFlipped}
|
|
90
|
+
onFlip={() => setIsFlipped((prev) => !prev)}
|
|
91
|
+
readOnly={readOnly}
|
|
92
|
+
/>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{showProgress && (
|
|
96
|
+
<div className="flex w-full items-center gap-2">
|
|
97
|
+
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
|
98
|
+
<div
|
|
99
|
+
className="h-full rounded-full bg-primary transition-[width] duration-300"
|
|
100
|
+
style={{ width: `${progress}%` }}
|
|
101
|
+
/>
|
|
102
|
+
</div>
|
|
103
|
+
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
|
104
|
+
{currentIndex + 1} of {orderedCards.length}
|
|
105
|
+
</span>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<div className="flex w-full items-center justify-between gap-3">
|
|
110
|
+
<Button
|
|
111
|
+
variant="outline"
|
|
112
|
+
size="sm"
|
|
113
|
+
className="rounded-lg"
|
|
114
|
+
disabled={!hasPrevious}
|
|
115
|
+
onClick={handlePrevious}
|
|
116
|
+
>
|
|
117
|
+
<ChevronLeft size={16} /> Previous
|
|
118
|
+
</Button>
|
|
119
|
+
|
|
120
|
+
<Button
|
|
121
|
+
size="sm"
|
|
122
|
+
className="rounded-lg"
|
|
123
|
+
onClick={handleNext}
|
|
124
|
+
>
|
|
125
|
+
{hasNext ? "Next" : "Finish"} <ChevronRight size={16} />
|
|
126
|
+
</Button>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
};
|