@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,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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { StatusBadge } from "./status-badge";
|
|
3
|
+
|
|
4
|
+
describe("StatusBadge", () => {
|
|
5
|
+
it("renders the status text with title case", () => {
|
|
6
|
+
render(<StatusBadge status="submitted" />);
|
|
7
|
+
expect(screen.getByText("Submitted")).toBeInTheDocument();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("converts underscores to spaces in label", () => {
|
|
11
|
+
render(<StatusBadge status="not_started" />);
|
|
12
|
+
expect(screen.getByText("Not Started")).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("renders with default color mapping for graded", () => {
|
|
16
|
+
const { container } = render(<StatusBadge status="graded" />);
|
|
17
|
+
expect(container.firstChild).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders with custom color map", () => {
|
|
21
|
+
render(
|
|
22
|
+
<StatusBadge
|
|
23
|
+
status="pending"
|
|
24
|
+
colorMap={{ pending: "warning" }}
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
expect(screen.getByText("Pending")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("falls back to gray for unknown statuses", () => {
|
|
31
|
+
render(<StatusBadge status="unknown_status" />);
|
|
32
|
+
expect(screen.getByText("Unknown Status")).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders all default status types", () => {
|
|
36
|
+
const statuses = ["graded", "submitted", "pending", "missing", "late", "excused", "draft", "resubmit"];
|
|
37
|
+
for (const status of statuses) {
|
|
38
|
+
const { unmount } = render(<StatusBadge status={status} />);
|
|
39
|
+
expect(screen.getByText(status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()))).toBeInTheDocument();
|
|
40
|
+
unmount();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { StatusBadgeProps } from "./types";
|
|
2
|
+
import { Badge } from "../ui/badge";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_COLOR_MAP: Record<string, string> = {
|
|
6
|
+
graded: "green",
|
|
7
|
+
submitted: "teal",
|
|
8
|
+
pending: "gray",
|
|
9
|
+
missing: "red",
|
|
10
|
+
late: "orange",
|
|
11
|
+
excused: "gray",
|
|
12
|
+
draft: "gray",
|
|
13
|
+
not_started: "gray",
|
|
14
|
+
resubmit: "orange",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MUI_TO_COLOR: Record<string, string> = {
|
|
18
|
+
success: "green",
|
|
19
|
+
error: "red",
|
|
20
|
+
warning: "orange",
|
|
21
|
+
info: "teal",
|
|
22
|
+
default: "gray",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const COLOR_TO_VARIANT: Record<string, "success" | "destructive" | "warning" | "muted" | "info"> = {
|
|
26
|
+
green: "success",
|
|
27
|
+
red: "destructive",
|
|
28
|
+
orange: "warning",
|
|
29
|
+
gray: "muted",
|
|
30
|
+
teal: "info",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const OUTLINED_CLASS: Record<string, string> = {
|
|
34
|
+
green: "bg-transparent border-success text-success",
|
|
35
|
+
red: "bg-transparent border-destructive text-destructive",
|
|
36
|
+
orange: "bg-transparent border-warning text-warning",
|
|
37
|
+
gray: "bg-transparent border-border text-muted-foreground",
|
|
38
|
+
teal: "bg-transparent border-info text-info",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const FILLED_OVERRIDE: Record<string, string> = {
|
|
42
|
+
green: "bg-success text-success-foreground",
|
|
43
|
+
red: "bg-destructive text-destructive-foreground",
|
|
44
|
+
orange: "bg-warning text-warning-foreground",
|
|
45
|
+
teal: "bg-info text-info-foreground",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function StatusBadge({
|
|
49
|
+
status,
|
|
50
|
+
colorMap,
|
|
51
|
+
size = "small",
|
|
52
|
+
variant = "filled",
|
|
53
|
+
}: StatusBadgeProps) {
|
|
54
|
+
const map = { ...DEFAULT_COLOR_MAP };
|
|
55
|
+
if (colorMap) {
|
|
56
|
+
for (const [key, value] of Object.entries(colorMap)) {
|
|
57
|
+
map[key] = MUI_TO_COLOR[value] ?? value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const colorPalette = map[status] ?? "gray";
|
|
61
|
+
const label = status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
62
|
+
|
|
63
|
+
const badgeVariant = COLOR_TO_VARIANT[colorPalette] ?? "muted";
|
|
64
|
+
|
|
65
|
+
const extraClass = variant === "outlined"
|
|
66
|
+
? OUTLINED_CLASS[colorPalette] ?? OUTLINED_CLASS.gray
|
|
67
|
+
: FILLED_OVERRIDE[colorPalette] ?? undefined;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Badge
|
|
71
|
+
variant={badgeVariant}
|
|
72
|
+
className={cn(
|
|
73
|
+
size === "small" ? "text-xs px-2 h-5.5" : "text-sm px-2.5 h-6.5",
|
|
74
|
+
"leading-none",
|
|
75
|
+
extraClass,
|
|
76
|
+
)}
|
|
77
|
+
>
|
|
78
|
+
{label}
|
|
79
|
+
</Badge>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* EmptyState displays a centered placeholder with an icon, title, and optional description
|
|
5
|
+
* for empty lists or views.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <EmptyState
|
|
9
|
+
* title="No announcements"
|
|
10
|
+
* description="Check back later for updates"
|
|
11
|
+
* />
|
|
12
|
+
*/
|
|
13
|
+
export interface EmptyStateProps {
|
|
14
|
+
/** Optional icon rendered above the title */
|
|
15
|
+
icon?: ReactNode;
|
|
16
|
+
/** Primary message text */
|
|
17
|
+
title: string;
|
|
18
|
+
/** Secondary descriptive text */
|
|
19
|
+
description?: string;
|
|
20
|
+
/** Optional call-to-action element below the description */
|
|
21
|
+
action?: ReactNode;
|
|
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
|
+
* ConfirmDialog renders a modal confirmation dialog with customizable title,
|
|
30
|
+
* message, and confirm/cancel actions.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* <ConfirmDialog
|
|
34
|
+
* open={showDialog}
|
|
35
|
+
* title="Submit exam?"
|
|
36
|
+
* message="You cannot change your answers after submitting."
|
|
37
|
+
* onConfirm={handleSubmit}
|
|
38
|
+
* onCancel={() => setShowDialog(false)}
|
|
39
|
+
* />
|
|
40
|
+
*/
|
|
41
|
+
export interface ConfirmDialogProps {
|
|
42
|
+
/** Whether the dialog is open */
|
|
43
|
+
open: boolean;
|
|
44
|
+
/** Dialog title */
|
|
45
|
+
title: string;
|
|
46
|
+
/** Dialog body content */
|
|
47
|
+
message: ReactNode;
|
|
48
|
+
/** Label for the confirm button */
|
|
49
|
+
confirmLabel?: string;
|
|
50
|
+
/** Label for the cancel button */
|
|
51
|
+
cancelLabel?: string;
|
|
52
|
+
/** Color of the confirm button */
|
|
53
|
+
confirmColor?: "primary" | "error" | "warning";
|
|
54
|
+
/** Called when the user confirms */
|
|
55
|
+
onConfirm: () => void;
|
|
56
|
+
/** Called when the user cancels */
|
|
57
|
+
onCancel: () => void;
|
|
58
|
+
/** Whether the confirm action is in progress */
|
|
59
|
+
isLoading?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SearchInput provides a debounced text field with a search icon and clear button.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* <SearchInput
|
|
67
|
+
* value={query}
|
|
68
|
+
* onChange={setQuery}
|
|
69
|
+
* placeholder="Search resources..."
|
|
70
|
+
* />
|
|
71
|
+
*/
|
|
72
|
+
export interface SearchInputProps {
|
|
73
|
+
/** Current search value */
|
|
74
|
+
value: string;
|
|
75
|
+
/** Called with the new value after debounce */
|
|
76
|
+
onChange: (value: string) => void;
|
|
77
|
+
/** Placeholder text */
|
|
78
|
+
placeholder?: string;
|
|
79
|
+
/** Debounce delay in milliseconds */
|
|
80
|
+
debounceMs?: number;
|
|
81
|
+
/** Whether the input spans full width */
|
|
82
|
+
fullWidth?: boolean;
|
|
83
|
+
/** Input size variant */
|
|
84
|
+
size?: "small" | "medium";
|
|
85
|
+
/** CSS class name for the root element */
|
|
86
|
+
className?: string;
|
|
87
|
+
/** Inline styles for the root element */
|
|
88
|
+
style?: React.CSSProperties;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* StatusBadge renders a semantic colored chip for displaying entity statuses
|
|
93
|
+
* such as "submitted", "graded", "late", or "missing".
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* <StatusBadge status="submitted" />
|
|
97
|
+
*/
|
|
98
|
+
export interface StatusBadgeProps {
|
|
99
|
+
/** Status string to display */
|
|
100
|
+
status: string;
|
|
101
|
+
/** Custom color mapping override — falls back to built-in defaults */
|
|
102
|
+
colorMap?: Record<string, "success" | "error" | "warning" | "info" | "default">;
|
|
103
|
+
/** Chip size */
|
|
104
|
+
size?: "small" | "medium";
|
|
105
|
+
/** Chip variant */
|
|
106
|
+
variant?: "filled" | "outlined";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* DueDateDisplay shows a due date with relative time ("in 2 days", "3 days ago")
|
|
111
|
+
* and urgency-based coloring.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* <DueDateDisplay dueDate="2025-03-15T23:59:00Z" />
|
|
115
|
+
*/
|
|
116
|
+
export interface DueDateDisplayProps {
|
|
117
|
+
/** Due date as an ISO 8601 string */
|
|
118
|
+
dueDate: string;
|
|
119
|
+
/** Submission date — if provided, shows "submitted" state instead of urgency */
|
|
120
|
+
submittedDate?: string;
|
|
121
|
+
/** Whether to show the relative time label */
|
|
122
|
+
showRelative?: boolean;
|
|
123
|
+
/** Display size */
|
|
124
|
+
size?: "small" | "medium";
|
|
125
|
+
/** CSS class name for the root element */
|
|
126
|
+
className?: string;
|
|
127
|
+
/** Inline styles for the root element */
|
|
128
|
+
style?: React.CSSProperties;
|
|
129
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Info, AlertTriangle, Lightbulb } from "lucide-react";
|
|
2
|
+
import { VideoPlayer } from "../video";
|
|
3
|
+
import { QuestionRenderer } from "../questions";
|
|
4
|
+
import { FlashcardDeck } from "../flashcards";
|
|
5
|
+
import { Alert, AlertDescription } from "../ui/alert";
|
|
6
|
+
import { Separator } from "../ui/separator";
|
|
7
|
+
import type { ContentBlockProps } from "./types";
|
|
8
|
+
import { cn } from "../lib/utils";
|
|
9
|
+
|
|
10
|
+
const CALLOUT_CONFIG = {
|
|
11
|
+
info: { variant: "info" as const, Icon: Info },
|
|
12
|
+
warning: { variant: "warning" as const, Icon: AlertTriangle },
|
|
13
|
+
tip: { variant: "success" as const, Icon: Lightbulb },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function ContentBlock({
|
|
17
|
+
block,
|
|
18
|
+
onQuestionAnswer,
|
|
19
|
+
readOnly = false,
|
|
20
|
+
className,
|
|
21
|
+
style,
|
|
22
|
+
}: ContentBlockProps) {
|
|
23
|
+
const wrapper = (children: React.ReactNode) => (
|
|
24
|
+
<div className={cn(className)} style={style}>
|
|
25
|
+
{children}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
switch (block.type) {
|
|
30
|
+
case "video":
|
|
31
|
+
return wrapper(<VideoPlayer {...block.video} readOnly={readOnly} />);
|
|
32
|
+
|
|
33
|
+
case "richtext":
|
|
34
|
+
return wrapper(
|
|
35
|
+
<div
|
|
36
|
+
className="[&_p]:mb-[1.5em] [&_ul]:pl-[1.5em] [&_ol]:pl-[1.5em]"
|
|
37
|
+
dangerouslySetInnerHTML={{ __html: block.html }}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
case "heading": {
|
|
42
|
+
const Tag = block.level === 1 ? "h2" : block.level === 3 ? "h4" : "h3";
|
|
43
|
+
const headingClass =
|
|
44
|
+
block.level === 1
|
|
45
|
+
? "text-xl"
|
|
46
|
+
: block.level === 3
|
|
47
|
+
? "text-base"
|
|
48
|
+
: "text-lg";
|
|
49
|
+
return wrapper(
|
|
50
|
+
<Tag className={cn(headingClass, "font-semibold m-0")}>{block.text}</Tag>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case "image":
|
|
55
|
+
return wrapper(
|
|
56
|
+
<figure className="m-0">
|
|
57
|
+
<img
|
|
58
|
+
src={block.src}
|
|
59
|
+
alt={block.alt ?? ""}
|
|
60
|
+
className="max-w-full rounded-sm"
|
|
61
|
+
/>
|
|
62
|
+
{block.caption && (
|
|
63
|
+
<figcaption className="text-xs text-muted-foreground mt-0.5">
|
|
64
|
+
{block.caption}
|
|
65
|
+
</figcaption>
|
|
66
|
+
)}
|
|
67
|
+
</figure>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
case "callout": {
|
|
71
|
+
const config = CALLOUT_CONFIG[block.variant ?? "info"];
|
|
72
|
+
return wrapper(
|
|
73
|
+
<Alert variant={config.variant}>
|
|
74
|
+
<config.Icon size={20} />
|
|
75
|
+
<AlertDescription>{block.content}</AlertDescription>
|
|
76
|
+
</Alert>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case "question":
|
|
81
|
+
return wrapper(
|
|
82
|
+
<QuestionRenderer
|
|
83
|
+
question={block.question}
|
|
84
|
+
onAnswer={(answers) =>
|
|
85
|
+
onQuestionAnswer?.(
|
|
86
|
+
block.question.uid,
|
|
87
|
+
answers.map((a) => ({
|
|
88
|
+
uid: block.question.uid,
|
|
89
|
+
answerUid: a.uid,
|
|
90
|
+
content: a.content,
|
|
91
|
+
})),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
readOnly={readOnly}
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
case "flashcards":
|
|
99
|
+
return wrapper(
|
|
100
|
+
<FlashcardDeck
|
|
101
|
+
cards={block.cards}
|
|
102
|
+
deckName={block.deckName}
|
|
103
|
+
readOnly={readOnly}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
case "divider":
|
|
108
|
+
return wrapper(<Separator />);
|
|
109
|
+
|
|
110
|
+
case "custom":
|
|
111
|
+
return wrapper(<>{block.render}</>);
|
|
112
|
+
|
|
113
|
+
default:
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { Upload, File, X } from "lucide-react";
|
|
3
|
+
import { Button } from "../ui/button";
|
|
4
|
+
import type { FileUploadZoneProps } from "./types";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
function formatFileSize(bytes: number): string {
|
|
8
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
9
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
10
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function FileUploadZone({
|
|
14
|
+
files,
|
|
15
|
+
onFilesAdded,
|
|
16
|
+
onFileRemove,
|
|
17
|
+
accept,
|
|
18
|
+
maxFiles,
|
|
19
|
+
maxSizeMB,
|
|
20
|
+
disabled = false,
|
|
21
|
+
label = "Drag files here or click to browse",
|
|
22
|
+
}: FileUploadZoneProps) {
|
|
23
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
24
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
25
|
+
const atLimit = maxFiles != null && files.length >= maxFiles;
|
|
26
|
+
|
|
27
|
+
function validateAndAdd(incoming: FileList | null) {
|
|
28
|
+
if (!incoming) return;
|
|
29
|
+
let valid = Array.from(incoming);
|
|
30
|
+
if (maxSizeMB) {
|
|
31
|
+
valid = valid.filter((f) => f.size <= maxSizeMB * 1024 * 1024);
|
|
32
|
+
}
|
|
33
|
+
if (maxFiles) {
|
|
34
|
+
valid = valid.slice(0, maxFiles - files.length);
|
|
35
|
+
}
|
|
36
|
+
if (valid.length > 0) onFilesAdded(valid);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div>
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"border-2 border-dashed border-border rounded-md p-8 text-center cursor-pointer transition-all duration-200",
|
|
44
|
+
isDragging && "border-primary bg-muted",
|
|
45
|
+
disabled && "cursor-default opacity-50",
|
|
46
|
+
atLimit && "cursor-default",
|
|
47
|
+
)}
|
|
48
|
+
onDragOver={(e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
if (!disabled && !atLimit) setIsDragging(true);
|
|
51
|
+
}}
|
|
52
|
+
onDragLeave={() => setIsDragging(false)}
|
|
53
|
+
onDrop={(e) => {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
setIsDragging(false);
|
|
56
|
+
if (!disabled && !atLimit) validateAndAdd(e.dataTransfer.files);
|
|
57
|
+
}}
|
|
58
|
+
onClick={() => !disabled && !atLimit && inputRef.current?.click()}
|
|
59
|
+
>
|
|
60
|
+
<Upload size={32} className="mx-auto mb-2 opacity-50" />
|
|
61
|
+
<span className="text-sm text-muted-foreground">
|
|
62
|
+
{atLimit ? `Maximum ${maxFiles} files reached` : label}
|
|
63
|
+
</span>
|
|
64
|
+
{(accept || maxSizeMB) && (
|
|
65
|
+
<span className="text-xs text-muted-foreground mt-1 block">
|
|
66
|
+
{[accept && `Accepted: ${accept}`, maxSizeMB && `Max: ${maxSizeMB}MB`]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join(" \u00B7 ")}
|
|
69
|
+
</span>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
<input
|
|
73
|
+
ref={inputRef}
|
|
74
|
+
type="file"
|
|
75
|
+
accept={accept}
|
|
76
|
+
multiple={!maxFiles || maxFiles > 1}
|
|
77
|
+
hidden
|
|
78
|
+
onChange={(e) => {
|
|
79
|
+
validateAndAdd(e.target.files);
|
|
80
|
+
e.target.value = "";
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
{files.length > 0 && (
|
|
84
|
+
<div className="flex flex-col gap-1 mt-2">
|
|
85
|
+
{files.map((file, index) => (
|
|
86
|
+
<div key={`${file.name}-${index}`} className="flex items-center gap-3 py-1">
|
|
87
|
+
<div className="shrink-0 w-9 flex justify-center text-muted-foreground">
|
|
88
|
+
<File size={18} />
|
|
89
|
+
</div>
|
|
90
|
+
<div className="flex-1 min-w-0">
|
|
91
|
+
<span className="text-sm block truncate">{file.name}</span>
|
|
92
|
+
<span className="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
|
|
93
|
+
</div>
|
|
94
|
+
<Button
|
|
95
|
+
variant="ghost"
|
|
96
|
+
size="icon-xs"
|
|
97
|
+
aria-label="Remove file"
|
|
98
|
+
onClick={() => onFileRemove(index)}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
>
|
|
101
|
+
<X size={16} />
|
|
102
|
+
</Button>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { VideoPlayerProps } from "../video/types";
|
|
3
|
+
import type { QuestionData, SessionAnswer } from "../questions/types";
|
|
4
|
+
import type { FlashcardData } from "../flashcards/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single content block within a lesson page.
|
|
8
|
+
*/
|
|
9
|
+
export type LessonBlock =
|
|
10
|
+
| { type: "video"; video: VideoPlayerProps }
|
|
11
|
+
| { type: "richtext"; html: string }
|
|
12
|
+
| { type: "heading"; text: string; level?: 1 | 2 | 3 }
|
|
13
|
+
| { type: "image"; src: string; alt?: string; caption?: string }
|
|
14
|
+
| { type: "callout"; content: string; variant?: "info" | "warning" | "tip" }
|
|
15
|
+
| { type: "question"; question: QuestionData }
|
|
16
|
+
| { type: "flashcards"; cards: FlashcardData[]; deckName?: string }
|
|
17
|
+
| { type: "divider" }
|
|
18
|
+
| { type: "custom"; render: ReactNode };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ContentBlock renders a single typed content block within a lesson,
|
|
22
|
+
* dispatching to the appropriate component based on the block type.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* <ContentBlock
|
|
26
|
+
* block={{ type: "callout", content: "Remember to save your work!", variant: "tip" }}
|
|
27
|
+
* />
|
|
28
|
+
*/
|
|
29
|
+
export interface ContentBlockProps {
|
|
30
|
+
/** The content block to render */
|
|
31
|
+
block: LessonBlock;
|
|
32
|
+
/** Called when the user answers an embedded question */
|
|
33
|
+
onQuestionAnswer?: (questionUid: string, answers: SessionAnswer[]) => void;
|
|
34
|
+
/** When true, disables interactive elements */
|
|
35
|
+
readOnly?: boolean;
|
|
36
|
+
/** CSS class name for the root element */
|
|
37
|
+
className?: string;
|
|
38
|
+
/** Inline styles for the root element */
|
|
39
|
+
style?: React.CSSProperties;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* FileUploadZone provides a drag-and-drop file upload area with file list
|
|
44
|
+
* preview, type icons, size display, and remove capability.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* <FileUploadZone
|
|
48
|
+
* files={files}
|
|
49
|
+
* onFilesAdded={(newFiles) => setFiles([...files, ...newFiles])}
|
|
50
|
+
* onFileRemove={(index) => setFiles(files.filter((_, i) => i !== index))}
|
|
51
|
+
* accept=".pdf,.docx"
|
|
52
|
+
* maxFiles={5}
|
|
53
|
+
* />
|
|
54
|
+
*/
|
|
55
|
+
export interface FileUploadZoneProps {
|
|
56
|
+
/** Currently selected files */
|
|
57
|
+
files: File[];
|
|
58
|
+
/** Called when new files are added via drop or browse */
|
|
59
|
+
onFilesAdded: (files: File[]) => void;
|
|
60
|
+
/** Called when a file is removed by index */
|
|
61
|
+
onFileRemove: (index: number) => void;
|
|
62
|
+
/** Accepted file types (e.g. ".pdf,.docx") */
|
|
63
|
+
accept?: string;
|
|
64
|
+
/** Maximum number of files allowed */
|
|
65
|
+
maxFiles?: number;
|
|
66
|
+
/** Maximum file size in megabytes */
|
|
67
|
+
maxSizeMB?: number;
|
|
68
|
+
/** Whether the upload zone is disabled */
|
|
69
|
+
disabled?: boolean;
|
|
70
|
+
/** Label text inside the drop zone */
|
|
71
|
+
label?: string;
|
|
72
|
+
/** CSS class name for the root element */
|
|
73
|
+
className?: string;
|
|
74
|
+
/** Inline styles for the root element */
|
|
75
|
+
style?: React.CSSProperties;
|
|
76
|
+
}
|