@hydralms/components 0.1.0 → 0.1.2
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/components.css +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +442 -110
- package/dist/modules/CoursePlayer/CoursePlayer.d.ts +2 -0
- package/dist/modules/CoursePlayer/types.d.ts +59 -0
- package/dist/modules/FlashcardLab/FlashcardLab.d.ts +2 -0
- package/dist/modules/FlashcardLab/types.d.ts +55 -0
- package/dist/modules/QuizModule/QuizModule.d.ts +2 -0
- package/dist/modules/QuizModule/types.d.ts +54 -0
- package/dist/modules/index.d.ts +6 -0
- package/dist/provider/HydraProvider.d.ts +1 -1
- package/dist/sections.cjs +1 -1
- package/dist/sections.js +261 -291
- package/dist/table-BrS5cDQu.js +2510 -0
- package/dist/table-D6AkBBEo.cjs +1 -0
- package/dist/ui/alert-dialog.d.ts +14 -8
- package/dist/ui/button.d.ts +1 -1
- package/dist/ui/tabs.d.ts +15 -5
- package/dist/ui/tooltip.d.ts +12 -5
- package/dist/video/index.d.ts +6 -1
- package/dist/video/types.d.ts +167 -0
- package/dist/video/video-bookmark.d.ts +2 -0
- package/dist/video/video-chapter-list.d.ts +2 -0
- package/dist/video/video-playlist-item.d.ts +2 -0
- package/dist/video/video-thumbnail-card.d.ts +2 -0
- package/dist/video/video-transcript.d.ts +2 -0
- package/package.json +135 -24
- 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
- package/dist/table-CW4_BYny.js +0 -9869
- package/dist/table-DSBBqb9X.cjs +0 -56
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Play, Video } from "lucide-react";
|
|
2
|
+
import type { VideoThumbnailCardProps } from "./types";
|
|
3
|
+
import { cn } from "../lib/utils";
|
|
4
|
+
import { formatDuration } from "../utils/format-duration";
|
|
5
|
+
import { Progress } from "../ui/progress";
|
|
6
|
+
|
|
7
|
+
export const VideoThumbnailCard = ({
|
|
8
|
+
poster,
|
|
9
|
+
title,
|
|
10
|
+
duration,
|
|
11
|
+
progress,
|
|
12
|
+
onClick,
|
|
13
|
+
className,
|
|
14
|
+
style,
|
|
15
|
+
}: VideoThumbnailCardProps) => {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn(
|
|
19
|
+
"group overflow-hidden rounded-lg border border-border transition-colors",
|
|
20
|
+
onClick && "cursor-pointer hover:border-primary/50 hover:shadow-sm",
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
style={style}
|
|
24
|
+
onClick={onClick}
|
|
25
|
+
>
|
|
26
|
+
{/* Poster area */}
|
|
27
|
+
<div className="relative overflow-hidden" style={{ aspectRatio: "16/9" }}>
|
|
28
|
+
{poster ? (
|
|
29
|
+
<img
|
|
30
|
+
src={poster}
|
|
31
|
+
alt={title}
|
|
32
|
+
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
33
|
+
/>
|
|
34
|
+
) : (
|
|
35
|
+
<div className="flex size-full items-center justify-center bg-muted">
|
|
36
|
+
<Video size={32} className="text-muted-foreground" />
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
|
|
40
|
+
{/* Play icon overlay */}
|
|
41
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-hover:bg-black/20">
|
|
42
|
+
<div className="flex size-10 items-center justify-center rounded-full bg-black/60 opacity-0 transition-opacity group-hover:opacity-100">
|
|
43
|
+
<Play size={20} className="ml-0.5 text-white" />
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
{/* Duration badge */}
|
|
48
|
+
{duration != null && duration > 0 && (
|
|
49
|
+
<span className="absolute bottom-2 right-2 rounded bg-black/75 px-1.5 py-0.5 text-xs font-medium tabular-nums text-white">
|
|
50
|
+
{formatDuration(duration)}
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Title */}
|
|
56
|
+
<div className="p-3">
|
|
57
|
+
<h4 className="truncate text-sm font-medium text-foreground">
|
|
58
|
+
{title}
|
|
59
|
+
</h4>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Optional progress bar */}
|
|
63
|
+
{progress != null && progress >= 0 && (
|
|
64
|
+
<div className="px-3 pb-3">
|
|
65
|
+
<Progress
|
|
66
|
+
value={progress}
|
|
67
|
+
size="sm"
|
|
68
|
+
variant={progress >= 100 ? "success" : "default"}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useRef, useEffect } from "react";
|
|
2
|
+
import { Clock } from "lucide-react";
|
|
3
|
+
import type { VideoTranscriptProps } from "./types";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { formatTimer } from "../utils/format-duration";
|
|
6
|
+
|
|
7
|
+
export const VideoTranscript = ({
|
|
8
|
+
entries,
|
|
9
|
+
currentTime = 0,
|
|
10
|
+
onSeek,
|
|
11
|
+
readOnly = false,
|
|
12
|
+
maxHeight = "400px",
|
|
13
|
+
className,
|
|
14
|
+
style,
|
|
15
|
+
}: VideoTranscriptProps) => {
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const activeRef = useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
// Last entry whose time <= currentTime
|
|
20
|
+
const activeIndex = entries.reduce<number>(
|
|
21
|
+
(acc, entry, i) => (entry.time <= currentTime ? i : acc),
|
|
22
|
+
-1,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (activeRef.current && containerRef.current) {
|
|
27
|
+
activeRef.current.scrollIntoView({
|
|
28
|
+
behavior: "smooth",
|
|
29
|
+
block: "nearest",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}, [activeIndex]);
|
|
33
|
+
|
|
34
|
+
if (entries.length === 0) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex flex-col items-center justify-center gap-2 rounded-md border border-border bg-muted p-6",
|
|
39
|
+
className,
|
|
40
|
+
)}
|
|
41
|
+
style={style}
|
|
42
|
+
>
|
|
43
|
+
<Clock size={20} className="text-muted-foreground" />
|
|
44
|
+
<span className="text-sm text-muted-foreground">
|
|
45
|
+
No transcript available
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
ref={containerRef}
|
|
54
|
+
className={cn(
|
|
55
|
+
"overflow-y-auto rounded-md border border-border",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
style={{ maxHeight, ...style }}
|
|
59
|
+
>
|
|
60
|
+
{entries.map((entry, i) => {
|
|
61
|
+
const isActive = i === activeIndex;
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
key={i}
|
|
65
|
+
ref={isActive ? activeRef : undefined}
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex gap-3 px-3 py-2 text-sm transition-colors",
|
|
68
|
+
isActive && "bg-primary/10",
|
|
69
|
+
!readOnly && onSeek && "cursor-pointer hover:bg-muted",
|
|
70
|
+
)}
|
|
71
|
+
onClick={() => !readOnly && onSeek?.(entry.time)}
|
|
72
|
+
>
|
|
73
|
+
<span
|
|
74
|
+
className={cn(
|
|
75
|
+
"shrink-0 font-mono text-xs tabular-nums pt-0.5",
|
|
76
|
+
isActive
|
|
77
|
+
? "text-primary font-medium"
|
|
78
|
+
: "text-muted-foreground",
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
{formatTimer(Math.floor(entry.time))}
|
|
82
|
+
</span>
|
|
83
|
+
<div className="min-w-0">
|
|
84
|
+
{entry.speaker && (
|
|
85
|
+
<span className="mr-1 font-semibold text-foreground">
|
|
86
|
+
{entry.speaker}:
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
<span
|
|
90
|
+
className={
|
|
91
|
+
isActive ? "text-foreground" : "text-muted-foreground"
|
|
92
|
+
}
|
|
93
|
+
>
|
|
94
|
+
{entry.text}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|