@hydralms/components 0.1.2 → 0.2.0
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/ForumBoard-CHXU3mjC.js +2207 -0
- package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
- package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
- package/dist/assessment-toolbar/index.d.ts +5 -1
- package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
- package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
- package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
- package/dist/assessment-toolbar/types.d.ts +52 -4
- package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
- package/dist/common/index.d.ts +2 -1
- package/dist/common/stepper.d.ts +6 -0
- package/dist/common/types.d.ts +37 -0
- package/dist/components.css +1 -1
- package/dist/content/attachment-list.d.ts +6 -0
- package/dist/content/content-block.d.ts +1 -1
- package/dist/content/index.d.ts +2 -1
- package/dist/content/types.d.ts +39 -0
- package/dist/curriculum/curriculum-item.d.ts +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +551 -312
- package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
- package/dist/modules/AssignmentModule/types.d.ts +65 -0
- package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
- package/dist/modules/CertificateModule/types.d.ts +49 -0
- package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
- package/dist/modules/DiscussionModule/types.d.ts +47 -0
- package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
- package/dist/modules/ExamModule/types.d.ts +64 -0
- package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
- package/dist/modules/GradeCenterModule/types.d.ts +54 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
- package/dist/modules/QuizModule/types.d.ts +6 -1
- package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
- package/dist/modules/SurveyModule/types.d.ts +49 -0
- package/dist/modules/index.d.ts +12 -0
- package/dist/modules.cjs +1 -0
- package/dist/modules.js +1422 -0
- package/dist/progress/achievement-badge.d.ts +6 -0
- package/dist/progress/activity-timeline.d.ts +6 -0
- package/dist/progress/index.d.ts +4 -1
- package/dist/progress/stat-card.d.ts +1 -1
- package/dist/progress/streak-badge.d.ts +6 -0
- package/dist/progress/types.d.ts +97 -0
- package/dist/questions/essay.d.ts +1 -1
- package/dist/questions/hotspot.d.ts +21 -0
- package/dist/questions/index.d.ts +9 -1
- package/dist/questions/inline-choice.d.ts +21 -0
- package/dist/questions/matching.d.ts +22 -0
- package/dist/questions/numeric.d.ts +11 -0
- package/dist/questions/ordering.d.ts +12 -0
- package/dist/questions/scenario.d.ts +23 -0
- package/dist/questions/scoring.d.ts +22 -0
- package/dist/questions/spreadsheet.d.ts +29 -0
- package/dist/questions/types.d.ts +106 -1
- package/dist/questions/use-drag-reorder.d.ts +17 -0
- package/dist/sections/CertificateViewer/types.d.ts +7 -5
- package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
- package/dist/sections/ExamSession/types.d.ts +6 -1
- package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
- package/dist/sections/ForumBoard/types.d.ts +64 -0
- package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
- package/dist/sections/QuizSession/types.d.ts +6 -1
- package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
- package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
- package/dist/sections/RubricView/RubricView.d.ts +9 -0
- package/dist/sections/RubricView/types.d.ts +50 -0
- package/dist/sections/index.d.ts +7 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +250 -1715
- package/dist/social/post-card.d.ts +1 -1
- package/dist/tabs-DRM2Iq_J.cjs +172 -0
- package/dist/tabs-Wf3h_Cx3.js +21580 -0
- package/dist/ui/alert.d.ts +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/drawer.d.ts +84 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/progress.d.ts +1 -1
- package/dist/ui/rich-text-editor.d.ts +30 -0
- package/dist/ui/rich-text-toolbar.d.ts +8 -0
- package/dist/utils/array-utils.d.ts +4 -0
- package/dist/utils/flatten-leaves.d.ts +6 -0
- package/dist/utils/format-file-size.d.ts +1 -0
- package/dist/utils/format-timestamp.d.ts +1 -0
- package/dist/utils/is-empty-html.d.ts +5 -0
- package/dist/utils/shuffle.d.ts +1 -0
- package/dist/utils/string-utils.d.ts +12 -0
- package/dist/video/video-bookmark.d.ts +1 -1
- package/dist/video/video-playlist-item.d.ts +1 -1
- package/package.json +141 -3
- package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
- package/src/assessment-toolbar/index.ts +6 -0
- package/src/assessment-toolbar/question-header-bar.tsx +61 -0
- package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
- package/src/assessment-toolbar/question-navigator.tsx +3 -31
- package/src/assessment-toolbar/timer-display.tsx +2 -2
- package/src/assessment-toolbar/types.ts +54 -4
- package/src/assessment-toolbar/use-countdown.ts +153 -0
- package/src/common/index.ts +3 -0
- package/src/common/search-input.tsx +7 -6
- package/src/common/stepper.tsx +100 -0
- package/src/common/types.ts +39 -0
- package/src/content/attachment-list.tsx +90 -0
- package/src/content/content-block.tsx +4 -2
- package/src/content/file-upload-zone.tsx +1 -6
- package/src/content/index.ts +3 -0
- package/src/content/types.ts +41 -0
- package/src/curriculum/curriculum-item.tsx +7 -3
- package/src/feedback/feedback-banner.tsx +12 -14
- package/src/flashcards/flashcard-deck.tsx +1 -9
- package/src/flashcards/flashcard.tsx +1 -1
- package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
- package/src/modules/AssignmentModule/types.ts +73 -0
- package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
- package/src/modules/CertificateModule/types.ts +47 -0
- package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
- package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
- package/src/modules/DiscussionModule/types.ts +54 -0
- package/src/modules/ExamModule/ExamModule.tsx +285 -0
- package/src/modules/ExamModule/types.ts +66 -0
- package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
- package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
- package/src/modules/GradeCenterModule/types.ts +63 -0
- package/src/modules/QuizModule/QuizModule.tsx +88 -88
- package/src/modules/QuizModule/types.ts +6 -1
- package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
- package/src/modules/SurveyModule/types.ts +51 -0
- package/src/modules/index.ts +24 -0
- package/src/progress/achievement-badge.tsx +52 -0
- package/src/progress/activity-timeline.tsx +84 -0
- package/src/progress/index.ts +7 -0
- package/src/progress/stat-card.tsx +30 -18
- package/src/progress/streak-badge.tsx +35 -0
- package/src/progress/types.ts +101 -0
- package/src/questions/choice.tsx +7 -9
- package/src/questions/essay.tsx +23 -25
- package/src/questions/fill-in-the-blank.tsx +13 -16
- package/src/questions/hotspot.tsx +154 -0
- package/src/questions/index.ts +16 -0
- package/src/questions/inline-choice.tsx +151 -0
- package/src/questions/matching.tsx +228 -0
- package/src/questions/multiple-choice.tsx +7 -9
- package/src/questions/numeric.tsx +102 -0
- package/src/questions/ordering.tsx +159 -0
- package/src/questions/question-renderer.tsx +21 -0
- package/src/questions/scenario.tsx +140 -0
- package/src/questions/scoring.ts +201 -0
- package/src/questions/spreadsheet.tsx +259 -0
- package/src/questions/true-false.tsx +7 -9
- package/src/questions/types.ts +123 -1
- package/src/questions/use-drag-reorder.ts +80 -0
- package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
- package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
- package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
- package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
- package/src/sections/CertificateViewer/types.ts +13 -5
- package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
- package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
- package/src/sections/ExamSession/ExamSession.tsx +44 -7
- package/src/sections/ExamSession/types.ts +6 -1
- package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
- package/src/sections/ForumBoard/types.ts +67 -0
- package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
- package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
- package/src/sections/LessonPage/LessonPage.tsx +5 -9
- package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
- package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
- package/src/sections/QuizSession/QuizSession.tsx +67 -8
- package/src/sections/QuizSession/types.ts +6 -1
- package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
- package/src/sections/RequirementsChecklist/types.ts +38 -0
- package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
- package/src/sections/RubricView/RubricView.tsx +138 -0
- package/src/sections/RubricView/types.ts +52 -0
- package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
- package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
- package/src/sections/index.ts +20 -1
- package/src/social/post-card.tsx +8 -19
- package/src/social/user-avatar.tsx +1 -0
- package/src/styles/globals.css +13 -0
- package/src/ui/drawer.tsx +600 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/rich-text-editor.tsx +109 -0
- package/src/ui/rich-text-toolbar.tsx +156 -0
- package/src/utils/array-utils.ts +17 -0
- package/src/utils/flatten-leaves.ts +17 -0
- package/src/utils/format-file-size.ts +5 -0
- package/src/utils/format-timestamp.ts +13 -0
- package/src/utils/is-empty-html.ts +7 -0
- package/src/utils/shuffle.ts +8 -0
- package/src/utils/string-utils.ts +30 -0
- package/src/video/video-bookmark.tsx +4 -3
- package/src/video/video-chapter-list.tsx +9 -4
- package/src/video/video-player.tsx +11 -4
- package/src/video/video-playlist-item.tsx +8 -3
- package/src/video/video-thumbnail-card.tsx +4 -0
- package/src/video/video-transcript.tsx +8 -5
- package/dist/table-BrS5cDQu.js +0 -2510
- package/dist/table-D6AkBBEo.cjs +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useEditor, EditorContent } from "@tiptap/react";
|
|
3
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
4
|
+
import Placeholder from "@tiptap/extension-placeholder";
|
|
5
|
+
import UnderlineExtension from "@tiptap/extension-underline";
|
|
6
|
+
import LinkExtension from "@tiptap/extension-link";
|
|
7
|
+
import { cn } from "../lib/utils";
|
|
8
|
+
import { RichTextToolbar } from "./rich-text-toolbar";
|
|
9
|
+
|
|
10
|
+
export type RichTextEditorVariant = "default" | "minimal";
|
|
11
|
+
|
|
12
|
+
export interface RichTextEditorProps {
|
|
13
|
+
/** HTML string value */
|
|
14
|
+
value?: string;
|
|
15
|
+
/** Called with the HTML string on every content change */
|
|
16
|
+
onChange?: (html: string) => void;
|
|
17
|
+
/** Placeholder text shown when the editor is empty */
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** Makes the editor non-editable (shows content without toolbar) */
|
|
20
|
+
readOnly?: boolean;
|
|
21
|
+
/** Visually disables the editor */
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
/** `"default"` shows full toolbar; `"minimal"` shows only basic formatting */
|
|
24
|
+
variant?: RichTextEditorVariant;
|
|
25
|
+
className?: string;
|
|
26
|
+
style?: React.CSSProperties;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* RichTextEditor wraps Tiptap to provide a rich text editing experience
|
|
31
|
+
* styled to match the HydraLMS design system.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* <RichTextEditor
|
|
35
|
+
* value={html}
|
|
36
|
+
* onChange={setHtml}
|
|
37
|
+
* placeholder="Write your response..."
|
|
38
|
+
* variant="default"
|
|
39
|
+
* />
|
|
40
|
+
*/
|
|
41
|
+
export function RichTextEditor({
|
|
42
|
+
value = "",
|
|
43
|
+
onChange,
|
|
44
|
+
placeholder,
|
|
45
|
+
readOnly = false,
|
|
46
|
+
disabled = false,
|
|
47
|
+
variant = "default",
|
|
48
|
+
className,
|
|
49
|
+
style,
|
|
50
|
+
}: RichTextEditorProps) {
|
|
51
|
+
const editor = useEditor({
|
|
52
|
+
extensions: [
|
|
53
|
+
StarterKit,
|
|
54
|
+
UnderlineExtension,
|
|
55
|
+
LinkExtension.configure({
|
|
56
|
+
openOnClick: false,
|
|
57
|
+
HTMLAttributes: { class: "text-primary underline" },
|
|
58
|
+
}),
|
|
59
|
+
Placeholder.configure({ placeholder }),
|
|
60
|
+
],
|
|
61
|
+
content: value,
|
|
62
|
+
editable: !readOnly && !disabled,
|
|
63
|
+
onUpdate: ({ editor: e }) => {
|
|
64
|
+
onChange?.(e.getHTML());
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Sync external value changes into the editor
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!editor) return;
|
|
71
|
+
if (editor.getHTML() !== value) {
|
|
72
|
+
editor.commands.setContent(value, { emitUpdate: false });
|
|
73
|
+
}
|
|
74
|
+
}, [editor, value]);
|
|
75
|
+
|
|
76
|
+
// Sync editable state
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!editor) return;
|
|
79
|
+
editor.setEditable(!readOnly && !disabled);
|
|
80
|
+
}, [editor, readOnly, disabled]);
|
|
81
|
+
|
|
82
|
+
if (!editor) return null;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={cn(
|
|
87
|
+
"rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow]",
|
|
88
|
+
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
|
89
|
+
disabled && "pointer-events-none opacity-50",
|
|
90
|
+
className,
|
|
91
|
+
)}
|
|
92
|
+
style={style}
|
|
93
|
+
>
|
|
94
|
+
{!readOnly && !disabled && (
|
|
95
|
+
<RichTextToolbar editor={editor} variant={variant} />
|
|
96
|
+
)}
|
|
97
|
+
<EditorContent
|
|
98
|
+
editor={editor}
|
|
99
|
+
className={cn(
|
|
100
|
+
"px-3 py-2 [&_.tiptap]:min-h-16 [&_.tiptap]:outline-none",
|
|
101
|
+
"[&_.tiptap_p]:mb-1 [&_.tiptap_h2]:text-lg [&_.tiptap_h2]:font-semibold [&_.tiptap_h2]:mb-1",
|
|
102
|
+
"[&_.tiptap_ul]:list-disc [&_.tiptap_ul]:pl-5 [&_.tiptap_ol]:list-decimal [&_.tiptap_ol]:pl-5",
|
|
103
|
+
"[&_.tiptap_blockquote]:border-l-2 [&_.tiptap_blockquote]:border-muted-foreground/30 [&_.tiptap_blockquote]:pl-3 [&_.tiptap_blockquote]:italic",
|
|
104
|
+
"[&_.tiptap_pre]:rounded-md [&_.tiptap_pre]:bg-muted [&_.tiptap_pre]:p-3 [&_.tiptap_pre]:font-mono [&_.tiptap_pre]:text-sm",
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Editor } from "@tiptap/react";
|
|
2
|
+
import {
|
|
3
|
+
Bold,
|
|
4
|
+
Italic,
|
|
5
|
+
Underline,
|
|
6
|
+
Heading2,
|
|
7
|
+
List,
|
|
8
|
+
ListOrdered,
|
|
9
|
+
Quote,
|
|
10
|
+
Link,
|
|
11
|
+
Code,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { cn } from "../lib/utils";
|
|
14
|
+
import { Separator } from "./separator";
|
|
15
|
+
|
|
16
|
+
type RichTextToolbarVariant = "default" | "minimal";
|
|
17
|
+
|
|
18
|
+
interface RichTextToolbarProps {
|
|
19
|
+
editor: Editor;
|
|
20
|
+
variant?: RichTextToolbarVariant;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ToolbarButton({
|
|
24
|
+
active,
|
|
25
|
+
disabled,
|
|
26
|
+
onClick,
|
|
27
|
+
title,
|
|
28
|
+
children,
|
|
29
|
+
}: {
|
|
30
|
+
active?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
onClick: () => void;
|
|
33
|
+
title: string;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={onClick}
|
|
40
|
+
disabled={disabled}
|
|
41
|
+
title={title}
|
|
42
|
+
className={cn(
|
|
43
|
+
"inline-flex items-center justify-center rounded-sm p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50",
|
|
44
|
+
active && "bg-muted text-foreground",
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{children}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function RichTextToolbar({
|
|
53
|
+
editor,
|
|
54
|
+
variant = "default",
|
|
55
|
+
}: RichTextToolbarProps) {
|
|
56
|
+
const iconSize = 16;
|
|
57
|
+
|
|
58
|
+
function handleLink() {
|
|
59
|
+
const previousUrl = editor.getAttributes("link").href as string;
|
|
60
|
+
const url = window.prompt("Enter URL", previousUrl || "https://");
|
|
61
|
+
if (url === null) return;
|
|
62
|
+
if (url === "") {
|
|
63
|
+
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
|
64
|
+
} else {
|
|
65
|
+
editor
|
|
66
|
+
.chain()
|
|
67
|
+
.focus()
|
|
68
|
+
.extendMarkRange("link")
|
|
69
|
+
.setLink({ href: url })
|
|
70
|
+
.run();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex items-center gap-0.5 border-b border-border px-2 py-1.5">
|
|
76
|
+
<ToolbarButton
|
|
77
|
+
active={editor.isActive("bold")}
|
|
78
|
+
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
79
|
+
title="Bold"
|
|
80
|
+
>
|
|
81
|
+
<Bold size={iconSize} />
|
|
82
|
+
</ToolbarButton>
|
|
83
|
+
<ToolbarButton
|
|
84
|
+
active={editor.isActive("italic")}
|
|
85
|
+
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
86
|
+
title="Italic"
|
|
87
|
+
>
|
|
88
|
+
<Italic size={iconSize} />
|
|
89
|
+
</ToolbarButton>
|
|
90
|
+
<ToolbarButton
|
|
91
|
+
active={editor.isActive("underline")}
|
|
92
|
+
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
|
93
|
+
title="Underline"
|
|
94
|
+
>
|
|
95
|
+
<Underline size={iconSize} />
|
|
96
|
+
</ToolbarButton>
|
|
97
|
+
|
|
98
|
+
<Separator orientation="vertical" className="mx-1 h-5" />
|
|
99
|
+
|
|
100
|
+
<ToolbarButton
|
|
101
|
+
active={editor.isActive("bulletList")}
|
|
102
|
+
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
103
|
+
title="Bullet list"
|
|
104
|
+
>
|
|
105
|
+
<List size={iconSize} />
|
|
106
|
+
</ToolbarButton>
|
|
107
|
+
<ToolbarButton
|
|
108
|
+
active={editor.isActive("orderedList")}
|
|
109
|
+
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
110
|
+
title="Ordered list"
|
|
111
|
+
>
|
|
112
|
+
<ListOrdered size={iconSize} />
|
|
113
|
+
</ToolbarButton>
|
|
114
|
+
|
|
115
|
+
<Separator orientation="vertical" className="mx-1 h-5" />
|
|
116
|
+
|
|
117
|
+
<ToolbarButton
|
|
118
|
+
active={editor.isActive("link")}
|
|
119
|
+
onClick={handleLink}
|
|
120
|
+
title="Link"
|
|
121
|
+
>
|
|
122
|
+
<Link size={iconSize} />
|
|
123
|
+
</ToolbarButton>
|
|
124
|
+
|
|
125
|
+
{variant === "default" && (
|
|
126
|
+
<>
|
|
127
|
+
<Separator orientation="vertical" className="mx-1 h-5" />
|
|
128
|
+
|
|
129
|
+
<ToolbarButton
|
|
130
|
+
active={editor.isActive("heading", { level: 2 })}
|
|
131
|
+
onClick={() =>
|
|
132
|
+
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
|
133
|
+
}
|
|
134
|
+
title="Heading"
|
|
135
|
+
>
|
|
136
|
+
<Heading2 size={iconSize} />
|
|
137
|
+
</ToolbarButton>
|
|
138
|
+
<ToolbarButton
|
|
139
|
+
active={editor.isActive("blockquote")}
|
|
140
|
+
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
141
|
+
title="Blockquote"
|
|
142
|
+
>
|
|
143
|
+
<Quote size={iconSize} />
|
|
144
|
+
</ToolbarButton>
|
|
145
|
+
<ToolbarButton
|
|
146
|
+
active={editor.isActive("codeBlock")}
|
|
147
|
+
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
148
|
+
title="Code block"
|
|
149
|
+
>
|
|
150
|
+
<Code size={iconSize} />
|
|
151
|
+
</ToolbarButton>
|
|
152
|
+
</>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group an array of items by a key derived from each item.
|
|
3
|
+
*/
|
|
4
|
+
export function groupBy<T>(
|
|
5
|
+
arr: T[],
|
|
6
|
+
keyFn: (item: T) => string,
|
|
7
|
+
): Record<string, T[]> {
|
|
8
|
+
const result: Record<string, T[]> = {};
|
|
9
|
+
for (const item of arr) {
|
|
10
|
+
const key = keyFn(item);
|
|
11
|
+
if (!result[key]) {
|
|
12
|
+
result[key] = [];
|
|
13
|
+
}
|
|
14
|
+
result[key].push(item);
|
|
15
|
+
}
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CurriculumItem } from "../curriculum/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recursively collects the UIDs of all leaf nodes (items with no children)
|
|
5
|
+
* in a curriculum tree.
|
|
6
|
+
*/
|
|
7
|
+
export function flattenLeaves(items: CurriculumItem[]): string[] {
|
|
8
|
+
const leaves: string[] = [];
|
|
9
|
+
for (const item of items) {
|
|
10
|
+
if (!item.children || item.children.length === 0) {
|
|
11
|
+
leaves.push(item.uid);
|
|
12
|
+
} else {
|
|
13
|
+
leaves.push(...flattenLeaves(item.children));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return leaves;
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function formatTimestamp(iso: string): string {
|
|
2
|
+
const date = new Date(iso);
|
|
3
|
+
const now = new Date();
|
|
4
|
+
const diffMs = now.getTime() - date.getTime();
|
|
5
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
6
|
+
if (diffMins < 1) return "Just now";
|
|
7
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
8
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
9
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
10
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
11
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
12
|
+
return date.toLocaleDateString();
|
|
13
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if an HTML string contains no meaningful text content.
|
|
3
|
+
* Tiptap returns `"<p></p>"` for an empty editor, not an empty string.
|
|
4
|
+
*/
|
|
5
|
+
export function isEmptyHtml(html: string): boolean {
|
|
6
|
+
return html.replace(/<[^>]*>/g, "").trim().length === 0;
|
|
7
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truncate text to a maximum length, appending a suffix if truncated.
|
|
3
|
+
*/
|
|
4
|
+
export function truncateText(
|
|
5
|
+
text: string,
|
|
6
|
+
maxLength: number,
|
|
7
|
+
suffix = "...",
|
|
8
|
+
): string {
|
|
9
|
+
if (text.length <= maxLength) return text;
|
|
10
|
+
return text.slice(0, maxLength - suffix.length) + suffix;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Format a decimal value as a percentage string.
|
|
15
|
+
*/
|
|
16
|
+
export function formatPercentage(value: number, decimals = 0): string {
|
|
17
|
+
return `${value.toFixed(decimals)}%`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return the singular or plural form of a word based on count.
|
|
22
|
+
*/
|
|
23
|
+
export function pluralize(
|
|
24
|
+
count: number,
|
|
25
|
+
singular: string,
|
|
26
|
+
plural?: string,
|
|
27
|
+
): string {
|
|
28
|
+
if (count === 1) return singular;
|
|
29
|
+
return plural ?? `${singular}s`;
|
|
30
|
+
}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
1
2
|
import { Bookmark, Pencil, Trash2 } from "lucide-react";
|
|
2
3
|
import type { VideoBookmarkProps } from "./types";
|
|
3
4
|
import { cn } from "../lib/utils";
|
|
4
5
|
import { formatTimer } from "../utils/format-duration";
|
|
5
6
|
|
|
6
|
-
export const VideoBookmark = ({
|
|
7
|
+
export const VideoBookmark = memo(function VideoBookmark({
|
|
7
8
|
bookmark,
|
|
8
9
|
onSeek,
|
|
9
10
|
onDelete,
|
|
10
11
|
onEdit,
|
|
11
12
|
className,
|
|
12
13
|
style,
|
|
13
|
-
}: VideoBookmarkProps)
|
|
14
|
+
}: VideoBookmarkProps) {
|
|
14
15
|
return (
|
|
15
16
|
<div
|
|
16
17
|
className={cn(
|
|
@@ -73,4 +74,4 @@ export const VideoBookmark = ({
|
|
|
73
74
|
)}
|
|
74
75
|
</div>
|
|
75
76
|
);
|
|
76
|
-
};
|
|
77
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
1
2
|
import { Play } from "lucide-react";
|
|
2
3
|
import type { VideoChapterListProps } from "./types";
|
|
3
4
|
import { cn } from "../lib/utils";
|
|
@@ -11,9 +12,9 @@ export const VideoChapterList = ({
|
|
|
11
12
|
style,
|
|
12
13
|
}: VideoChapterListProps) => {
|
|
13
14
|
// Active chapter: last chapter whose time <= currentTime
|
|
14
|
-
const activeIndex =
|
|
15
|
-
(acc, ch, i) => (ch.time <= currentTime ? i : acc),
|
|
16
|
-
|
|
15
|
+
const activeIndex = useMemo(
|
|
16
|
+
() => chapters.reduce<number>((acc, ch, i) => (ch.time <= currentTime ? i : acc), -1),
|
|
17
|
+
[chapters, currentTime],
|
|
17
18
|
);
|
|
18
19
|
|
|
19
20
|
return (
|
|
@@ -28,13 +29,16 @@ export const VideoChapterList = ({
|
|
|
28
29
|
const isActive = i === activeIndex;
|
|
29
30
|
return (
|
|
30
31
|
<div
|
|
31
|
-
key={
|
|
32
|
+
key={chapter.time}
|
|
32
33
|
className={cn(
|
|
33
34
|
"flex items-center gap-3 p-3 transition-colors",
|
|
34
35
|
isActive && "bg-primary/10",
|
|
35
36
|
onSeek && "cursor-pointer hover:bg-muted",
|
|
36
37
|
)}
|
|
37
38
|
onClick={() => onSeek?.(chapter.time)}
|
|
39
|
+
role={onSeek ? "button" : undefined}
|
|
40
|
+
tabIndex={onSeek ? 0 : undefined}
|
|
41
|
+
onKeyDown={onSeek ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSeek(chapter.time); } } : undefined}
|
|
38
42
|
>
|
|
39
43
|
{chapter.thumbnail ? (
|
|
40
44
|
<div
|
|
@@ -44,6 +48,7 @@ export const VideoChapterList = ({
|
|
|
44
48
|
<img
|
|
45
49
|
src={chapter.thumbnail}
|
|
46
50
|
alt={chapter.title}
|
|
51
|
+
loading="lazy"
|
|
47
52
|
className="size-full object-cover"
|
|
48
53
|
/>
|
|
49
54
|
{isActive && (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef } from "react";
|
|
1
|
+
import { useRef, useCallback } from "react";
|
|
2
2
|
import { Video, Play } from "lucide-react";
|
|
3
3
|
import type { VideoPlayerProps } from "./types";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
@@ -18,13 +18,18 @@ export const VideoPlayer = ({
|
|
|
18
18
|
style,
|
|
19
19
|
}: VideoPlayerProps) => {
|
|
20
20
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
21
|
+
const lastReportedTime = useRef(-1);
|
|
21
22
|
|
|
22
|
-
const handleTimeUpdate = () => {
|
|
23
|
+
const handleTimeUpdate = useCallback(() => {
|
|
23
24
|
const video = videoRef.current;
|
|
24
25
|
if (video && onTimeUpdate) {
|
|
25
|
-
|
|
26
|
+
const rounded = Math.floor(video.currentTime * 4) / 4; // 250ms granularity
|
|
27
|
+
if (rounded !== lastReportedTime.current) {
|
|
28
|
+
lastReportedTime.current = rounded;
|
|
29
|
+
onTimeUpdate(video.currentTime, video.duration);
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
|
-
};
|
|
32
|
+
}, [onTimeUpdate]);
|
|
28
33
|
|
|
29
34
|
if (!src) {
|
|
30
35
|
return (
|
|
@@ -54,6 +59,7 @@ export const VideoPlayer = ({
|
|
|
54
59
|
<img
|
|
55
60
|
src={poster}
|
|
56
61
|
alt={title || "Video poster"}
|
|
62
|
+
loading="lazy"
|
|
57
63
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
58
64
|
/>
|
|
59
65
|
) : (
|
|
@@ -90,6 +96,7 @@ export const VideoPlayer = ({
|
|
|
90
96
|
src={src}
|
|
91
97
|
poster={poster}
|
|
92
98
|
controls
|
|
99
|
+
preload="metadata"
|
|
93
100
|
autoPlay={autoPlay}
|
|
94
101
|
onPlay={onPlay}
|
|
95
102
|
onPause={onPause}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
1
2
|
import { Play, CheckCircle2, Circle } from "lucide-react";
|
|
2
3
|
import type { VideoPlaylistItemProps } from "./types";
|
|
3
4
|
import { cn } from "../lib/utils";
|
|
4
5
|
import { formatDuration } from "../utils/format-duration";
|
|
5
6
|
|
|
6
|
-
export const VideoPlaylistItem = ({
|
|
7
|
+
export const VideoPlaylistItem = memo(function VideoPlaylistItem({
|
|
7
8
|
thumbnail,
|
|
8
9
|
title,
|
|
9
10
|
duration,
|
|
@@ -13,7 +14,7 @@ export const VideoPlaylistItem = ({
|
|
|
13
14
|
onClick,
|
|
14
15
|
className,
|
|
15
16
|
style,
|
|
16
|
-
}: VideoPlaylistItemProps)
|
|
17
|
+
}: VideoPlaylistItemProps) {
|
|
17
18
|
return (
|
|
18
19
|
<div
|
|
19
20
|
className={cn(
|
|
@@ -24,6 +25,9 @@ export const VideoPlaylistItem = ({
|
|
|
24
25
|
)}
|
|
25
26
|
style={style}
|
|
26
27
|
onClick={onClick}
|
|
28
|
+
role={onClick ? "button" : undefined}
|
|
29
|
+
tabIndex={onClick ? 0 : undefined}
|
|
30
|
+
onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } } : undefined}
|
|
27
31
|
>
|
|
28
32
|
{/* Index / Status icon */}
|
|
29
33
|
<div className="flex shrink-0 items-center justify-center size-6">
|
|
@@ -55,6 +59,7 @@ export const VideoPlaylistItem = ({
|
|
|
55
59
|
<img
|
|
56
60
|
src={thumbnail}
|
|
57
61
|
alt={title}
|
|
62
|
+
loading="lazy"
|
|
58
63
|
className={cn(
|
|
59
64
|
"size-full object-cover",
|
|
60
65
|
status === "completed" && "opacity-60",
|
|
@@ -87,4 +92,4 @@ export const VideoPlaylistItem = ({
|
|
|
87
92
|
</div>
|
|
88
93
|
</div>
|
|
89
94
|
);
|
|
90
|
-
};
|
|
95
|
+
});
|
|
@@ -22,6 +22,9 @@ export const VideoThumbnailCard = ({
|
|
|
22
22
|
)}
|
|
23
23
|
style={style}
|
|
24
24
|
onClick={onClick}
|
|
25
|
+
role={onClick ? "button" : undefined}
|
|
26
|
+
tabIndex={onClick ? 0 : undefined}
|
|
27
|
+
onKeyDown={onClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } } : undefined}
|
|
25
28
|
>
|
|
26
29
|
{/* Poster area */}
|
|
27
30
|
<div className="relative overflow-hidden" style={{ aspectRatio: "16/9" }}>
|
|
@@ -29,6 +32,7 @@ export const VideoThumbnailCard = ({
|
|
|
29
32
|
<img
|
|
30
33
|
src={poster}
|
|
31
34
|
alt={title}
|
|
35
|
+
loading="lazy"
|
|
32
36
|
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
33
37
|
/>
|
|
34
38
|
) : (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useRef, useEffect } from "react";
|
|
1
|
+
import { useRef, useEffect, useMemo } from "react";
|
|
2
2
|
import { Clock } from "lucide-react";
|
|
3
3
|
import type { VideoTranscriptProps } from "./types";
|
|
4
4
|
import { cn } from "../lib/utils";
|
|
@@ -17,9 +17,9 @@ export const VideoTranscript = ({
|
|
|
17
17
|
const activeRef = useRef<HTMLDivElement>(null);
|
|
18
18
|
|
|
19
19
|
// Last entry whose time <= currentTime
|
|
20
|
-
const activeIndex =
|
|
21
|
-
(acc, entry, i) => (entry.time <= currentTime ? i : acc),
|
|
22
|
-
|
|
20
|
+
const activeIndex = useMemo(
|
|
21
|
+
() => entries.reduce<number>((acc, entry, i) => (entry.time <= currentTime ? i : acc), -1),
|
|
22
|
+
[entries, currentTime],
|
|
23
23
|
);
|
|
24
24
|
|
|
25
25
|
useEffect(() => {
|
|
@@ -61,7 +61,7 @@ export const VideoTranscript = ({
|
|
|
61
61
|
const isActive = i === activeIndex;
|
|
62
62
|
return (
|
|
63
63
|
<div
|
|
64
|
-
key={
|
|
64
|
+
key={`${entry.time}-${entry.text.slice(0, 20)}`}
|
|
65
65
|
ref={isActive ? activeRef : undefined}
|
|
66
66
|
className={cn(
|
|
67
67
|
"flex gap-3 px-3 py-2 text-sm transition-colors",
|
|
@@ -69,6 +69,9 @@ export const VideoTranscript = ({
|
|
|
69
69
|
!readOnly && onSeek && "cursor-pointer hover:bg-muted",
|
|
70
70
|
)}
|
|
71
71
|
onClick={() => !readOnly && onSeek?.(entry.time)}
|
|
72
|
+
role={!readOnly && onSeek ? "button" : undefined}
|
|
73
|
+
tabIndex={!readOnly && onSeek ? 0 : undefined}
|
|
74
|
+
onKeyDown={!readOnly && onSeek ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSeek(entry.time); } } : undefined}
|
|
72
75
|
>
|
|
73
76
|
<span
|
|
74
77
|
className={cn(
|