@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,10 @@
|
|
|
1
|
+
export function debounce<T extends (...args: Parameters<T>) => void>(
|
|
2
|
+
fn: T,
|
|
3
|
+
delay: number,
|
|
4
|
+
): (...args: Parameters<T>) => void {
|
|
5
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
6
|
+
return (...args: Parameters<T>) => {
|
|
7
|
+
clearTimeout(timer);
|
|
8
|
+
timer = setTimeout(() => fn(...args), delay);
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { formatDuration, formatTimer } from "./format-duration";
|
|
2
|
+
|
|
3
|
+
describe("formatDuration", () => {
|
|
4
|
+
it("returns dash for values less than 1", () => {
|
|
5
|
+
expect(formatDuration(0)).toBe("-");
|
|
6
|
+
expect(formatDuration(-5)).toBe("-");
|
|
7
|
+
expect(formatDuration(0.5)).toBe("-");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("formats seconds only", () => {
|
|
11
|
+
expect(formatDuration(45)).toBe("45s");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("formats minutes and seconds", () => {
|
|
15
|
+
expect(formatDuration(125)).toBe("2m 5s");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("formats hours, minutes, and seconds", () => {
|
|
19
|
+
expect(formatDuration(3661)).toBe("1h 1m 1s");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("formats exact hours", () => {
|
|
23
|
+
expect(formatDuration(7200)).toBe("2h");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("formats hours and minutes without seconds", () => {
|
|
27
|
+
expect(formatDuration(3660)).toBe("1h 1m");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("formats hours and seconds without minutes", () => {
|
|
31
|
+
expect(formatDuration(3601)).toBe("1h 1s");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("formatTimer", () => {
|
|
36
|
+
it("formats zero as 00:00", () => {
|
|
37
|
+
expect(formatTimer(0)).toBe("00:00");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("formats seconds with padding", () => {
|
|
41
|
+
expect(formatTimer(5)).toBe("00:05");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("formats minutes and seconds", () => {
|
|
45
|
+
expect(formatTimer(125)).toBe("02:05");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("includes hours when applicable", () => {
|
|
49
|
+
expect(formatTimer(3661)).toBe("01:01:01");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("pads all segments", () => {
|
|
53
|
+
expect(formatTimer(3600)).toBe("01:00:00");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const formatDuration = (duration: number): string => {
|
|
2
|
+
if (duration < 1) {
|
|
3
|
+
return "-";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const hours = Math.floor(duration / 3600);
|
|
7
|
+
const minutes = Math.floor((duration % 3600) / 60);
|
|
8
|
+
const seconds = duration % 60;
|
|
9
|
+
const parts: string[] = [];
|
|
10
|
+
|
|
11
|
+
if (hours > 0) parts.push(`${hours}h`);
|
|
12
|
+
if (minutes > 0) parts.push(`${minutes}m`);
|
|
13
|
+
if (seconds > 0) parts.push(`${seconds}s`);
|
|
14
|
+
|
|
15
|
+
return parts.join(" ");
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const formatTimer = (totalSeconds: number): string => {
|
|
19
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
20
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
21
|
+
const seconds = totalSeconds % 60;
|
|
22
|
+
|
|
23
|
+
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
24
|
+
|
|
25
|
+
if (hours > 0) {
|
|
26
|
+
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return `${pad(minutes)}:${pad(seconds)}`;
|
|
30
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { VideoPlayer } from "./video-player";
|
|
2
|
+
export { VideoTranscript } from "./video-transcript";
|
|
3
|
+
export { VideoChapterList } from "./video-chapter-list";
|
|
4
|
+
export { VideoThumbnailCard } from "./video-thumbnail-card";
|
|
5
|
+
export { VideoBookmark } from "./video-bookmark";
|
|
6
|
+
export { VideoPlaylistItem } from "./video-playlist-item";
|
|
7
|
+
export type {
|
|
8
|
+
VideoPlayerProps,
|
|
9
|
+
VideoTranscriptProps,
|
|
10
|
+
VideoChapterListProps,
|
|
11
|
+
VideoThumbnailCardProps,
|
|
12
|
+
VideoBookmarkProps,
|
|
13
|
+
VideoPlaylistItemProps,
|
|
14
|
+
TranscriptEntry,
|
|
15
|
+
VideoChapter,
|
|
16
|
+
VideoBookmarkData,
|
|
17
|
+
} from "./types";
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayer component for rendering video content with interactive playback controls.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* <VideoPlayer
|
|
6
|
+
* src="https://example.com/lesson.mp4"
|
|
7
|
+
* poster="https://example.com/thumbnail.jpg"
|
|
8
|
+
* title="Introduction to React"
|
|
9
|
+
* onEnded={() => markLessonComplete()}
|
|
10
|
+
* />
|
|
11
|
+
*/
|
|
12
|
+
export interface VideoPlayerProps {
|
|
13
|
+
/** URL of the video source */
|
|
14
|
+
src?: string;
|
|
15
|
+
/** URL of the poster/thumbnail image */
|
|
16
|
+
poster?: string;
|
|
17
|
+
/** Title displayed above the video */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** Whether the video should autoplay */
|
|
20
|
+
autoPlay?: boolean;
|
|
21
|
+
/** Called when the video starts playing */
|
|
22
|
+
onPlay?: () => void;
|
|
23
|
+
/** Called when the video is paused */
|
|
24
|
+
onPause?: () => void;
|
|
25
|
+
/** Called when the video ends */
|
|
26
|
+
onEnded?: () => void;
|
|
27
|
+
/** Called on each time update tick with currentTime and duration */
|
|
28
|
+
onTimeUpdate?: (currentTime: number, duration: number) => void;
|
|
29
|
+
/** When true, shows a poster image instead of interactive controls */
|
|
30
|
+
readOnly?: boolean;
|
|
31
|
+
/** Aspect ratio of the video container */
|
|
32
|
+
aspectRatio?: "16/9" | "4/3" | "1/1";
|
|
33
|
+
/** CSS class name for the root element */
|
|
34
|
+
className?: string;
|
|
35
|
+
/** Inline styles for the root element */
|
|
36
|
+
style?: React.CSSProperties;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Data Types ───
|
|
40
|
+
|
|
41
|
+
export interface TranscriptEntry {
|
|
42
|
+
/** Timestamp in seconds */
|
|
43
|
+
time: number;
|
|
44
|
+
/** Optional speaker name */
|
|
45
|
+
speaker?: string;
|
|
46
|
+
/** Transcript text content */
|
|
47
|
+
text: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface VideoChapter {
|
|
51
|
+
/** Chapter start time in seconds */
|
|
52
|
+
time: number;
|
|
53
|
+
/** Chapter title */
|
|
54
|
+
title: string;
|
|
55
|
+
/** Optional chapter thumbnail URL */
|
|
56
|
+
thumbnail?: string;
|
|
57
|
+
/** Chapter duration in seconds */
|
|
58
|
+
duration?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface VideoBookmarkData {
|
|
62
|
+
/** Unique bookmark identifier */
|
|
63
|
+
id: string;
|
|
64
|
+
/** Bookmark timestamp in seconds */
|
|
65
|
+
time: number;
|
|
66
|
+
/** Optional note attached to the bookmark */
|
|
67
|
+
note?: string;
|
|
68
|
+
/** ISO 8601 creation timestamp */
|
|
69
|
+
createdAt?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Component Props ───
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* VideoTranscript renders a scrollable timestamped transcript with active-line
|
|
76
|
+
* highlighting based on the current playback time.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* <VideoTranscript
|
|
80
|
+
* entries={transcript}
|
|
81
|
+
* currentTime={42}
|
|
82
|
+
* onSeek={(time) => player.seekTo(time)}
|
|
83
|
+
* />
|
|
84
|
+
*/
|
|
85
|
+
export interface VideoTranscriptProps {
|
|
86
|
+
/** Array of transcript entries with timestamps */
|
|
87
|
+
entries: TranscriptEntry[];
|
|
88
|
+
/** Current playback time in seconds — used to highlight the active entry */
|
|
89
|
+
currentTime?: number;
|
|
90
|
+
/** Called when the user clicks a transcript entry to seek */
|
|
91
|
+
onSeek?: (time: number) => void;
|
|
92
|
+
/** When true, disables click-to-seek interaction */
|
|
93
|
+
readOnly?: boolean;
|
|
94
|
+
/** Maximum height of the scrollable container (CSS value) */
|
|
95
|
+
maxHeight?: string;
|
|
96
|
+
/** CSS class name for the root element */
|
|
97
|
+
className?: string;
|
|
98
|
+
/** Inline styles for the root element */
|
|
99
|
+
style?: React.CSSProperties;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* VideoChapterList renders an ordered list of video chapters with timestamps,
|
|
104
|
+
* active chapter highlighting, and optional thumbnails.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* <VideoChapterList
|
|
108
|
+
* chapters={chapters}
|
|
109
|
+
* currentTime={120}
|
|
110
|
+
* onSeek={(time) => player.seekTo(time)}
|
|
111
|
+
* />
|
|
112
|
+
*/
|
|
113
|
+
export interface VideoChapterListProps {
|
|
114
|
+
/** Array of chapter markers */
|
|
115
|
+
chapters: VideoChapter[];
|
|
116
|
+
/** Current playback time in seconds — used to determine the active chapter */
|
|
117
|
+
currentTime?: number;
|
|
118
|
+
/** Called when the user clicks a chapter to seek */
|
|
119
|
+
onSeek?: (time: number) => void;
|
|
120
|
+
/** CSS class name for the root element */
|
|
121
|
+
className?: string;
|
|
122
|
+
/** Inline styles for the root element */
|
|
123
|
+
style?: React.CSSProperties;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* VideoThumbnailCard renders a clickable card-style video preview with a poster
|
|
128
|
+
* image, play icon overlay, duration badge, title, and optional progress bar.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* <VideoThumbnailCard
|
|
132
|
+
* poster="https://example.com/thumb.jpg"
|
|
133
|
+
* title="Introduction to React"
|
|
134
|
+
* duration={420}
|
|
135
|
+
* progress={65}
|
|
136
|
+
* onClick={() => navigate('/lesson/1')}
|
|
137
|
+
* />
|
|
138
|
+
*/
|
|
139
|
+
export interface VideoThumbnailCardProps {
|
|
140
|
+
/** URL of the poster/thumbnail image */
|
|
141
|
+
poster?: string;
|
|
142
|
+
/** Video title displayed below the poster */
|
|
143
|
+
title: string;
|
|
144
|
+
/** Video duration in seconds */
|
|
145
|
+
duration?: number;
|
|
146
|
+
/** Watch progress percentage (0–100) */
|
|
147
|
+
progress?: number;
|
|
148
|
+
/** Called when the card is clicked */
|
|
149
|
+
onClick?: () => void;
|
|
150
|
+
/** CSS class name for the root element */
|
|
151
|
+
className?: string;
|
|
152
|
+
/** Inline styles for the root element */
|
|
153
|
+
style?: React.CSSProperties;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* VideoBookmark renders a single timestamped bookmark row with an optional note,
|
|
158
|
+
* clickable timestamp for seeking, and edit/delete action buttons.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* <VideoBookmark
|
|
162
|
+
* bookmark={{ id: "b1", time: 125, note: "Key concept explained here" }}
|
|
163
|
+
* onSeek={(time) => player.seekTo(time)}
|
|
164
|
+
* onDelete={(id) => removeBookmark(id)}
|
|
165
|
+
* />
|
|
166
|
+
*/
|
|
167
|
+
export interface VideoBookmarkProps {
|
|
168
|
+
/** Bookmark data object */
|
|
169
|
+
bookmark: VideoBookmarkData;
|
|
170
|
+
/** Called when the user clicks the timestamp to seek */
|
|
171
|
+
onSeek?: (time: number) => void;
|
|
172
|
+
/** Called when the user clicks the delete button */
|
|
173
|
+
onDelete?: (id: string) => void;
|
|
174
|
+
/** Called when the user clicks the edit button */
|
|
175
|
+
onEdit?: (id: string) => void;
|
|
176
|
+
/** CSS class name for the root element */
|
|
177
|
+
className?: string;
|
|
178
|
+
/** Inline styles for the root element */
|
|
179
|
+
style?: React.CSSProperties;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* VideoPlaylistItem renders a horizontal row for a sequential video playlist,
|
|
184
|
+
* showing a thumbnail, title, duration, completion status, and active state.
|
|
185
|
+
*
|
|
186
|
+
* @example
|
|
187
|
+
* <VideoPlaylistItem
|
|
188
|
+
* thumbnail="https://example.com/thumb.jpg"
|
|
189
|
+
* title="Lesson 1: Getting Started"
|
|
190
|
+
* duration={420}
|
|
191
|
+
* status="completed"
|
|
192
|
+
* isActive={false}
|
|
193
|
+
* index={1}
|
|
194
|
+
* onClick={() => navigate('/lesson/1')}
|
|
195
|
+
* />
|
|
196
|
+
*/
|
|
197
|
+
export interface VideoPlaylistItemProps {
|
|
198
|
+
/** URL of the video thumbnail */
|
|
199
|
+
thumbnail?: string;
|
|
200
|
+
/** Video title */
|
|
201
|
+
title: string;
|
|
202
|
+
/** Video duration in seconds */
|
|
203
|
+
duration?: number;
|
|
204
|
+
/** Watch status of this video */
|
|
205
|
+
status?: "unwatched" | "in-progress" | "completed";
|
|
206
|
+
/** Whether this is the currently playing item */
|
|
207
|
+
isActive?: boolean;
|
|
208
|
+
/** 1-based display index in the playlist */
|
|
209
|
+
index?: number;
|
|
210
|
+
/** Called when the item is clicked */
|
|
211
|
+
onClick?: () => void;
|
|
212
|
+
/** CSS class name for the root element */
|
|
213
|
+
className?: string;
|
|
214
|
+
/** Inline styles for the root element */
|
|
215
|
+
style?: React.CSSProperties;
|
|
216
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Bookmark, Pencil, Trash2 } from "lucide-react";
|
|
2
|
+
import type { VideoBookmarkProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { formatTimer } from "../utils/format-duration";
|
|
5
|
+
|
|
6
|
+
export const VideoBookmark = ({
|
|
7
|
+
bookmark,
|
|
8
|
+
onSeek,
|
|
9
|
+
onDelete,
|
|
10
|
+
onEdit,
|
|
11
|
+
className,
|
|
12
|
+
style,
|
|
13
|
+
}: VideoBookmarkProps) => {
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className={cn(
|
|
17
|
+
"flex items-start gap-3 rounded-md border border-border p-3",
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
style={style}
|
|
21
|
+
>
|
|
22
|
+
<div className="flex shrink-0 items-center justify-center size-8 rounded-full bg-primary/10 text-primary">
|
|
23
|
+
<Bookmark size={16} />
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div className="min-w-0 flex-1">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
className={cn(
|
|
30
|
+
"font-mono text-xs font-medium tabular-nums",
|
|
31
|
+
onSeek
|
|
32
|
+
? "cursor-pointer text-primary hover:underline"
|
|
33
|
+
: "text-muted-foreground",
|
|
34
|
+
)}
|
|
35
|
+
onClick={() => onSeek?.(bookmark.time)}
|
|
36
|
+
disabled={!onSeek}
|
|
37
|
+
>
|
|
38
|
+
{formatTimer(Math.floor(bookmark.time))}
|
|
39
|
+
</button>
|
|
40
|
+
{bookmark.note && (
|
|
41
|
+
<p className="mt-0.5 text-sm text-foreground">{bookmark.note}</p>
|
|
42
|
+
)}
|
|
43
|
+
{bookmark.createdAt && (
|
|
44
|
+
<span className="text-xs text-muted-foreground">
|
|
45
|
+
{new Date(bookmark.createdAt).toLocaleDateString()}
|
|
46
|
+
</span>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{(onEdit || onDelete) && (
|
|
51
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
52
|
+
{onEdit && (
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
56
|
+
aria-label="Edit bookmark"
|
|
57
|
+
onClick={() => onEdit(bookmark.id)}
|
|
58
|
+
>
|
|
59
|
+
<Pencil size={14} />
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
62
|
+
{onDelete && (
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
className="inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
66
|
+
aria-label="Delete bookmark"
|
|
67
|
+
onClick={() => onDelete(bookmark.id)}
|
|
68
|
+
>
|
|
69
|
+
<Trash2 size={14} />
|
|
70
|
+
</button>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Play } from "lucide-react";
|
|
2
|
+
import type { VideoChapterListProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { formatTimer, formatDuration } from "../utils/format-duration";
|
|
5
|
+
|
|
6
|
+
export const VideoChapterList = ({
|
|
7
|
+
chapters,
|
|
8
|
+
currentTime = 0,
|
|
9
|
+
onSeek,
|
|
10
|
+
className,
|
|
11
|
+
style,
|
|
12
|
+
}: VideoChapterListProps) => {
|
|
13
|
+
// Active chapter: last chapter whose time <= currentTime
|
|
14
|
+
const activeIndex = chapters.reduce<number>(
|
|
15
|
+
(acc, ch, i) => (ch.time <= currentTime ? i : acc),
|
|
16
|
+
-1,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
"divide-y divide-border rounded-md border border-border",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
style={style}
|
|
26
|
+
>
|
|
27
|
+
{chapters.map((chapter, i) => {
|
|
28
|
+
const isActive = i === activeIndex;
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
key={i}
|
|
32
|
+
className={cn(
|
|
33
|
+
"flex items-center gap-3 p-3 transition-colors",
|
|
34
|
+
isActive && "bg-primary/10",
|
|
35
|
+
onSeek && "cursor-pointer hover:bg-muted",
|
|
36
|
+
)}
|
|
37
|
+
onClick={() => onSeek?.(chapter.time)}
|
|
38
|
+
>
|
|
39
|
+
{chapter.thumbnail ? (
|
|
40
|
+
<div
|
|
41
|
+
className="relative shrink-0 w-16 overflow-hidden rounded"
|
|
42
|
+
style={{ aspectRatio: "16/9" }}
|
|
43
|
+
>
|
|
44
|
+
<img
|
|
45
|
+
src={chapter.thumbnail}
|
|
46
|
+
alt={chapter.title}
|
|
47
|
+
className="size-full object-cover"
|
|
48
|
+
/>
|
|
49
|
+
{isActive && (
|
|
50
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
51
|
+
<Play size={14} className="ml-px text-white" />
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
) : (
|
|
56
|
+
<div
|
|
57
|
+
className={cn(
|
|
58
|
+
"flex shrink-0 items-center justify-center size-8 rounded-full",
|
|
59
|
+
isActive
|
|
60
|
+
? "bg-primary text-primary-foreground"
|
|
61
|
+
: "bg-muted text-muted-foreground",
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<span className="text-xs font-semibold">{i + 1}</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
<div className="min-w-0 flex-1">
|
|
69
|
+
<div
|
|
70
|
+
className={cn(
|
|
71
|
+
"truncate text-sm font-medium",
|
|
72
|
+
isActive ? "text-primary" : "text-foreground",
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{chapter.title}
|
|
76
|
+
</div>
|
|
77
|
+
<div className="mt-0.5 flex items-center gap-2">
|
|
78
|
+
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
|
79
|
+
{formatTimer(Math.floor(chapter.time))}
|
|
80
|
+
</span>
|
|
81
|
+
{chapter.duration != null && chapter.duration > 0 && (
|
|
82
|
+
<span className="text-xs text-muted-foreground">
|
|
83
|
+
{formatDuration(chapter.duration)}
|
|
84
|
+
</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import { Video, Play } from "lucide-react";
|
|
3
|
+
import type { VideoPlayerProps } from "./types";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
|
|
6
|
+
export const VideoPlayer = ({
|
|
7
|
+
src,
|
|
8
|
+
poster,
|
|
9
|
+
title,
|
|
10
|
+
autoPlay = false,
|
|
11
|
+
onPlay,
|
|
12
|
+
onPause,
|
|
13
|
+
onEnded,
|
|
14
|
+
onTimeUpdate,
|
|
15
|
+
readOnly = false,
|
|
16
|
+
aspectRatio = "16/9",
|
|
17
|
+
className,
|
|
18
|
+
style,
|
|
19
|
+
}: VideoPlayerProps) => {
|
|
20
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
21
|
+
|
|
22
|
+
const handleTimeUpdate = () => {
|
|
23
|
+
const video = videoRef.current;
|
|
24
|
+
if (video && onTimeUpdate) {
|
|
25
|
+
onTimeUpdate(video.currentTime, video.duration);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (!src) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={cn("relative overflow-hidden rounded-lg border border-border bg-muted", className)}
|
|
33
|
+
style={{ aspectRatio, ...style }}
|
|
34
|
+
>
|
|
35
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
|
36
|
+
<div className="flex items-center justify-center size-12 rounded-full bg-muted-foreground/10">
|
|
37
|
+
<Video size={24} />
|
|
38
|
+
</div>
|
|
39
|
+
<span className="text-sm text-muted-foreground">
|
|
40
|
+
No video source provided
|
|
41
|
+
</span>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (readOnly) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className={cn("relative overflow-hidden rounded-lg", className)}
|
|
51
|
+
style={{ aspectRatio, ...style }}
|
|
52
|
+
>
|
|
53
|
+
{poster ? (
|
|
54
|
+
<img
|
|
55
|
+
src={poster}
|
|
56
|
+
alt={title || "Video poster"}
|
|
57
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
58
|
+
/>
|
|
59
|
+
) : (
|
|
60
|
+
<div className="absolute inset-0 flex items-center justify-center bg-muted">
|
|
61
|
+
<div className="flex items-center justify-center size-14 rounded-full bg-black/60">
|
|
62
|
+
<Play size={28} className="text-white ml-0.5" />
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{poster && (
|
|
68
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/30 transition-opacity">
|
|
69
|
+
<div className="flex items-center justify-center size-14 rounded-full bg-black/60">
|
|
70
|
+
<Play size={28} className="text-white ml-0.5" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
|
|
75
|
+
{title && (
|
|
76
|
+
<div className="absolute bottom-0 left-0 right-0 bg-linear-to-t from-black/60 to-transparent p-2">
|
|
77
|
+
<span className="text-sm text-white font-medium">{title}</span>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={className} style={style}>
|
|
86
|
+
{title && <p className="mb-2 text-sm font-medium">{title}</p>}
|
|
87
|
+
<div className="rounded-lg overflow-hidden">
|
|
88
|
+
<video
|
|
89
|
+
ref={videoRef}
|
|
90
|
+
src={src}
|
|
91
|
+
poster={poster}
|
|
92
|
+
controls
|
|
93
|
+
autoPlay={autoPlay}
|
|
94
|
+
onPlay={onPlay}
|
|
95
|
+
onPause={onPause}
|
|
96
|
+
onEnded={onEnded}
|
|
97
|
+
onTimeUpdate={handleTimeUpdate}
|
|
98
|
+
style={{ width: "100%", aspectRatio, display: "block" }}
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Play, CheckCircle2, Circle } from "lucide-react";
|
|
2
|
+
import type { VideoPlaylistItemProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { formatDuration } from "../utils/format-duration";
|
|
5
|
+
|
|
6
|
+
export const VideoPlaylistItem = ({
|
|
7
|
+
thumbnail,
|
|
8
|
+
title,
|
|
9
|
+
duration,
|
|
10
|
+
status = "unwatched",
|
|
11
|
+
isActive = false,
|
|
12
|
+
index,
|
|
13
|
+
onClick,
|
|
14
|
+
className,
|
|
15
|
+
style,
|
|
16
|
+
}: VideoPlaylistItemProps) => {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className={cn(
|
|
20
|
+
"flex items-center gap-3 rounded-md px-3 py-2 transition-colors",
|
|
21
|
+
isActive && "bg-primary/10",
|
|
22
|
+
onClick && "cursor-pointer hover:bg-muted",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
style={style}
|
|
26
|
+
onClick={onClick}
|
|
27
|
+
>
|
|
28
|
+
{/* Index / Status icon */}
|
|
29
|
+
<div className="flex shrink-0 items-center justify-center size-6">
|
|
30
|
+
{status === "completed" ? (
|
|
31
|
+
<span className="text-success">
|
|
32
|
+
<CheckCircle2 size={18} />
|
|
33
|
+
</span>
|
|
34
|
+
) : isActive ? (
|
|
35
|
+
<span className="text-primary">
|
|
36
|
+
<Play size={16} className="ml-px" />
|
|
37
|
+
</span>
|
|
38
|
+
) : index != null ? (
|
|
39
|
+
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
40
|
+
{index}
|
|
41
|
+
</span>
|
|
42
|
+
) : (
|
|
43
|
+
<span className="text-muted-foreground">
|
|
44
|
+
<Circle size={16} />
|
|
45
|
+
</span>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* Thumbnail */}
|
|
50
|
+
{thumbnail && (
|
|
51
|
+
<div
|
|
52
|
+
className="relative shrink-0 w-16 overflow-hidden rounded"
|
|
53
|
+
style={{ aspectRatio: "16/9" }}
|
|
54
|
+
>
|
|
55
|
+
<img
|
|
56
|
+
src={thumbnail}
|
|
57
|
+
alt={title}
|
|
58
|
+
className={cn(
|
|
59
|
+
"size-full object-cover",
|
|
60
|
+
status === "completed" && "opacity-60",
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
{isActive && (
|
|
64
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
65
|
+
<Play size={12} className="ml-px text-white" />
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{/* Title + duration */}
|
|
72
|
+
<div className="min-w-0 flex-1">
|
|
73
|
+
<div
|
|
74
|
+
className={cn(
|
|
75
|
+
"truncate text-sm",
|
|
76
|
+
isActive ? "font-medium text-primary" : "text-foreground",
|
|
77
|
+
status === "completed" && !isActive && "text-muted-foreground",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{title}
|
|
81
|
+
</div>
|
|
82
|
+
{duration != null && duration > 0 && (
|
|
83
|
+
<span className="text-xs text-muted-foreground">
|
|
84
|
+
{formatDuration(duration)}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
};
|