@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.
Files changed (199) hide show
  1. package/dist/ForumBoard-CHXU3mjC.js +2207 -0
  2. package/dist/ForumBoard-d1w5-r6n.cjs +1 -0
  3. package/dist/assessment-toolbar/assessment-toolbar.d.ts +1 -1
  4. package/dist/assessment-toolbar/index.d.ts +5 -1
  5. package/dist/assessment-toolbar/question-header-bar.d.ts +2 -0
  6. package/dist/assessment-toolbar/question-materials-drawer.d.ts +2 -0
  7. package/dist/assessment-toolbar/question-navigator.d.ts +1 -1
  8. package/dist/assessment-toolbar/types.d.ts +52 -4
  9. package/dist/assessment-toolbar/use-countdown.d.ts +43 -0
  10. package/dist/common/index.d.ts +2 -1
  11. package/dist/common/stepper.d.ts +6 -0
  12. package/dist/common/types.d.ts +37 -0
  13. package/dist/components.css +1 -1
  14. package/dist/content/attachment-list.d.ts +6 -0
  15. package/dist/content/content-block.d.ts +1 -1
  16. package/dist/content/index.d.ts +2 -1
  17. package/dist/content/types.d.ts +39 -0
  18. package/dist/curriculum/curriculum-item.d.ts +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.js +551 -312
  21. package/dist/modules/AssignmentModule/AssignmentModule.d.ts +8 -0
  22. package/dist/modules/AssignmentModule/types.d.ts +65 -0
  23. package/dist/modules/CertificateModule/CertificateModule.d.ts +9 -0
  24. package/dist/modules/CertificateModule/types.d.ts +49 -0
  25. package/dist/modules/DiscussionModule/DiscussionModule.d.ts +8 -0
  26. package/dist/modules/DiscussionModule/types.d.ts +47 -0
  27. package/dist/modules/ExamModule/ExamModule.d.ts +8 -0
  28. package/dist/modules/ExamModule/types.d.ts +64 -0
  29. package/dist/modules/GradeCenterModule/GradeCenterModule.d.ts +9 -0
  30. package/dist/modules/GradeCenterModule/types.d.ts +54 -0
  31. package/dist/modules/QuizModule/QuizModule.d.ts +1 -1
  32. package/dist/modules/QuizModule/types.d.ts +6 -1
  33. package/dist/modules/SurveyModule/SurveyModule.d.ts +7 -0
  34. package/dist/modules/SurveyModule/types.d.ts +49 -0
  35. package/dist/modules/index.d.ts +12 -0
  36. package/dist/modules.cjs +1 -0
  37. package/dist/modules.js +1422 -0
  38. package/dist/progress/achievement-badge.d.ts +6 -0
  39. package/dist/progress/activity-timeline.d.ts +6 -0
  40. package/dist/progress/index.d.ts +4 -1
  41. package/dist/progress/stat-card.d.ts +1 -1
  42. package/dist/progress/streak-badge.d.ts +6 -0
  43. package/dist/progress/types.d.ts +97 -0
  44. package/dist/questions/essay.d.ts +1 -1
  45. package/dist/questions/hotspot.d.ts +21 -0
  46. package/dist/questions/index.d.ts +9 -1
  47. package/dist/questions/inline-choice.d.ts +21 -0
  48. package/dist/questions/matching.d.ts +22 -0
  49. package/dist/questions/numeric.d.ts +11 -0
  50. package/dist/questions/ordering.d.ts +12 -0
  51. package/dist/questions/scenario.d.ts +23 -0
  52. package/dist/questions/scoring.d.ts +22 -0
  53. package/dist/questions/spreadsheet.d.ts +29 -0
  54. package/dist/questions/types.d.ts +106 -1
  55. package/dist/questions/use-drag-reorder.d.ts +17 -0
  56. package/dist/sections/CertificateViewer/types.d.ts +7 -5
  57. package/dist/sections/ExamSession/ExamSession.d.ts +1 -1
  58. package/dist/sections/ExamSession/types.d.ts +6 -1
  59. package/dist/sections/ForumBoard/ForumBoard.d.ts +8 -0
  60. package/dist/sections/ForumBoard/types.d.ts +64 -0
  61. package/dist/sections/QuizSession/QuizSession.d.ts +1 -1
  62. package/dist/sections/QuizSession/types.d.ts +6 -1
  63. package/dist/sections/RequirementsChecklist/RequirementsChecklist.d.ts +8 -0
  64. package/dist/sections/RequirementsChecklist/types.d.ts +37 -0
  65. package/dist/sections/RubricView/RubricView.d.ts +9 -0
  66. package/dist/sections/RubricView/types.d.ts +50 -0
  67. package/dist/sections/index.d.ts +7 -1
  68. package/dist/sections.cjs +1 -1
  69. package/dist/sections.js +250 -1715
  70. package/dist/social/post-card.d.ts +1 -1
  71. package/dist/tabs-DRM2Iq_J.cjs +172 -0
  72. package/dist/tabs-Wf3h_Cx3.js +21580 -0
  73. package/dist/ui/alert.d.ts +1 -1
  74. package/dist/ui/badge.d.ts +1 -1
  75. package/dist/ui/button.d.ts +1 -1
  76. package/dist/ui/drawer.d.ts +84 -0
  77. package/dist/ui/index.d.ts +3 -0
  78. package/dist/ui/progress.d.ts +1 -1
  79. package/dist/ui/rich-text-editor.d.ts +30 -0
  80. package/dist/ui/rich-text-toolbar.d.ts +8 -0
  81. package/dist/utils/array-utils.d.ts +4 -0
  82. package/dist/utils/flatten-leaves.d.ts +6 -0
  83. package/dist/utils/format-file-size.d.ts +1 -0
  84. package/dist/utils/format-timestamp.d.ts +1 -0
  85. package/dist/utils/is-empty-html.d.ts +5 -0
  86. package/dist/utils/shuffle.d.ts +1 -0
  87. package/dist/utils/string-utils.d.ts +12 -0
  88. package/dist/video/video-bookmark.d.ts +1 -1
  89. package/dist/video/video-playlist-item.d.ts +1 -1
  90. package/package.json +141 -3
  91. package/src/assessment-toolbar/assessment-toolbar.tsx +54 -49
  92. package/src/assessment-toolbar/index.ts +6 -0
  93. package/src/assessment-toolbar/question-header-bar.tsx +61 -0
  94. package/src/assessment-toolbar/question-materials-drawer.tsx +55 -0
  95. package/src/assessment-toolbar/question-navigator.tsx +3 -31
  96. package/src/assessment-toolbar/timer-display.tsx +2 -2
  97. package/src/assessment-toolbar/types.ts +54 -4
  98. package/src/assessment-toolbar/use-countdown.ts +153 -0
  99. package/src/common/index.ts +3 -0
  100. package/src/common/search-input.tsx +7 -6
  101. package/src/common/stepper.tsx +100 -0
  102. package/src/common/types.ts +39 -0
  103. package/src/content/attachment-list.tsx +90 -0
  104. package/src/content/content-block.tsx +4 -2
  105. package/src/content/file-upload-zone.tsx +1 -6
  106. package/src/content/index.ts +3 -0
  107. package/src/content/types.ts +41 -0
  108. package/src/curriculum/curriculum-item.tsx +7 -3
  109. package/src/feedback/feedback-banner.tsx +12 -14
  110. package/src/flashcards/flashcard-deck.tsx +1 -9
  111. package/src/flashcards/flashcard.tsx +1 -1
  112. package/src/modules/AssignmentModule/AssignmentModule.tsx +305 -0
  113. package/src/modules/AssignmentModule/types.ts +73 -0
  114. package/src/modules/CertificateModule/CertificateModule.tsx +161 -0
  115. package/src/modules/CertificateModule/types.ts +47 -0
  116. package/src/modules/CoursePlayer/CoursePlayer.tsx +44 -48
  117. package/src/modules/DiscussionModule/DiscussionModule.tsx +110 -0
  118. package/src/modules/DiscussionModule/types.ts +54 -0
  119. package/src/modules/ExamModule/ExamModule.tsx +285 -0
  120. package/src/modules/ExamModule/types.ts +66 -0
  121. package/src/modules/FlashcardLab/FlashcardLab.tsx +29 -16
  122. package/src/modules/GradeCenterModule/GradeCenterModule.tsx +169 -0
  123. package/src/modules/GradeCenterModule/types.ts +63 -0
  124. package/src/modules/QuizModule/QuizModule.tsx +88 -88
  125. package/src/modules/QuizModule/types.ts +6 -1
  126. package/src/modules/SurveyModule/SurveyModule.tsx +180 -0
  127. package/src/modules/SurveyModule/types.ts +51 -0
  128. package/src/modules/index.ts +24 -0
  129. package/src/progress/achievement-badge.tsx +52 -0
  130. package/src/progress/activity-timeline.tsx +84 -0
  131. package/src/progress/index.ts +7 -0
  132. package/src/progress/stat-card.tsx +30 -18
  133. package/src/progress/streak-badge.tsx +35 -0
  134. package/src/progress/types.ts +101 -0
  135. package/src/questions/choice.tsx +7 -9
  136. package/src/questions/essay.tsx +23 -25
  137. package/src/questions/fill-in-the-blank.tsx +13 -16
  138. package/src/questions/hotspot.tsx +154 -0
  139. package/src/questions/index.ts +16 -0
  140. package/src/questions/inline-choice.tsx +151 -0
  141. package/src/questions/matching.tsx +228 -0
  142. package/src/questions/multiple-choice.tsx +7 -9
  143. package/src/questions/numeric.tsx +102 -0
  144. package/src/questions/ordering.tsx +159 -0
  145. package/src/questions/question-renderer.tsx +21 -0
  146. package/src/questions/scenario.tsx +140 -0
  147. package/src/questions/scoring.ts +201 -0
  148. package/src/questions/spreadsheet.tsx +259 -0
  149. package/src/questions/true-false.tsx +7 -9
  150. package/src/questions/types.ts +123 -1
  151. package/src/questions/use-drag-reorder.ts +80 -0
  152. package/src/sections/AnnouncementFeed/AnnouncementFeed.tsx +2 -15
  153. package/src/sections/AssessmentReview/AssessmentReview.tsx +13 -2
  154. package/src/sections/AssignmentSubmission/AssignmentSubmission.tsx +7 -5
  155. package/src/sections/CertificateViewer/CertificateViewer.tsx +409 -56
  156. package/src/sections/CertificateViewer/types.ts +13 -5
  157. package/src/sections/CourseOutline/CourseOutline.tsx +4 -14
  158. package/src/sections/DiscussionThread/DiscussionThread.tsx +13 -10
  159. package/src/sections/ExamSession/ExamSession.tsx +44 -7
  160. package/src/sections/ExamSession/types.ts +6 -1
  161. package/src/sections/ForumBoard/ForumBoard.tsx +284 -0
  162. package/src/sections/ForumBoard/types.ts +67 -0
  163. package/src/sections/GradebookTable/GradebookTable.tsx +1 -1
  164. package/src/sections/LecturePlayer/LecturePlayer.tsx +1 -1
  165. package/src/sections/LessonPage/LessonPage.tsx +5 -9
  166. package/src/sections/PracticeQuiz/PracticeQuiz.tsx +15 -26
  167. package/src/sections/ProgressDashboard/ProgressDashboard.tsx +65 -65
  168. package/src/sections/QuizSession/QuizSession.tsx +67 -8
  169. package/src/sections/QuizSession/types.ts +6 -1
  170. package/src/sections/RequirementsChecklist/RequirementsChecklist.tsx +107 -0
  171. package/src/sections/RequirementsChecklist/types.ts +38 -0
  172. package/src/sections/ResourceLibrary/ResourceLibrary.tsx +4 -9
  173. package/src/sections/RubricView/RubricView.tsx +138 -0
  174. package/src/sections/RubricView/types.ts +52 -0
  175. package/src/sections/ScrollableQuiz/ScrollableQuiz.tsx +23 -9
  176. package/src/sections/SurveyForm/SurveyForm.tsx +8 -5
  177. package/src/sections/index.ts +20 -1
  178. package/src/social/post-card.tsx +8 -19
  179. package/src/social/user-avatar.tsx +1 -0
  180. package/src/styles/globals.css +13 -0
  181. package/src/ui/drawer.tsx +600 -0
  182. package/src/ui/index.ts +19 -0
  183. package/src/ui/rich-text-editor.tsx +109 -0
  184. package/src/ui/rich-text-toolbar.tsx +156 -0
  185. package/src/utils/array-utils.ts +17 -0
  186. package/src/utils/flatten-leaves.ts +17 -0
  187. package/src/utils/format-file-size.ts +5 -0
  188. package/src/utils/format-timestamp.ts +13 -0
  189. package/src/utils/is-empty-html.ts +7 -0
  190. package/src/utils/shuffle.ts +8 -0
  191. package/src/utils/string-utils.ts +30 -0
  192. package/src/video/video-bookmark.tsx +4 -3
  193. package/src/video/video-chapter-list.tsx +9 -4
  194. package/src/video/video-player.tsx +11 -4
  195. package/src/video/video-playlist-item.tsx +8 -3
  196. package/src/video/video-thumbnail-card.tsx +4 -0
  197. package/src/video/video-transcript.tsx +8 -5
  198. package/dist/table-BrS5cDQu.js +0 -2510
  199. 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,5 @@
1
+ export function formatFileSize(bytes: number): string {
2
+ if (bytes < 1024) return `${bytes} B`;
3
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
5
+ }
@@ -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,8 @@
1
+ export function shuffle<T>(array: T[]): T[] {
2
+ const shuffled = [...array];
3
+ for (let i = shuffled.length - 1; i > 0; i--) {
4
+ const j = Math.floor(Math.random() * (i + 1));
5
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
6
+ }
7
+ return shuffled;
8
+ }
@@ -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 = chapters.reduce<number>(
15
- (acc, ch, i) => (ch.time <= currentTime ? i : acc),
16
- -1,
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={i}
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
- onTimeUpdate(video.currentTime, video.duration);
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 = entries.reduce<number>(
21
- (acc, entry, i) => (entry.time <= currentTime ? i : acc),
22
- -1,
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={i}
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(