Add video chapter/timeline navigation feature

Implement timeline chapters for webinar and bootcamp videos with click-to-jump functionality:

**Components:**
- VideoPlayerWithChapters: Plyr.io-based player with chapter support
- TimelineChapters: Clickable chapter markers with active state
- ChaptersEditor: Admin UI for managing video chapters

**Features:**
- YouTube videos: Clickable timestamps that jump to specific time
- Embed videos: Static timeline display (non-clickable)
- Real-time chapter tracking during playback
- Admin-defined accent color for Plyr theme
- Auto-hides timeline when no chapters configured

**Database:**
- Add chapters JSONB column to products table (webinars)
- Add chapters JSONB column to bootcamp_lessons table
- Create indexes for faster queries

**Updated Pages:**
- WebinarRecording: Two-column layout (video + timeline)
- Bootcamp: Per-lesson chapter support
- AdminProducts: Chapter editor for webinars
- CurriculumEditor: Chapter editor for lessons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
dwindown
2025-12-31 23:31:23 +07:00
parent 86b59c756f
commit 95fd4d3859
10 changed files with 737 additions and 74 deletions

View File

@@ -0,0 +1,122 @@
import { Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
interface VideoChapter {
time: number; // Time in seconds
title: string;
}
interface TimelineChaptersProps {
chapters: VideoChapter[];
isYouTube?: boolean;
onChapterClick?: (time: number) => void;
currentTime?: number; // Current video playback time in seconds
accentColor?: string;
}
export function TimelineChapters({
chapters,
isYouTube = true,
onChapterClick,
currentTime = 0,
accentColor = '#f97316',
}: TimelineChaptersProps) {
// Format time in seconds to MM:SS or HH:MM:SS
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
// Check if a chapter is currently active
const isChapterActive = (index: number): boolean => {
if (currentTime === 0) return false;
const chapter = chapters[index];
const nextChapter = chapters[index + 1];
return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time);
};
if (chapters.length === 0) {
return null;
}
return (
<Card className="border-2 border-border">
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
<Clock className="w-4 h-4 text-muted-foreground" />
<h3 className="font-semibold">Timeline</h3>
</div>
<div className="space-y-1">
{chapters.map((chapter, index) => {
const active = isChapterActive(index);
return (
<button
key={index}
onClick={() => isYouTube && onChapterClick && onChapterClick(chapter.time)}
disabled={!isYouTube}
className={`
w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left
${isYouTube
? 'hover:bg-muted cursor-pointer'
: 'cursor-default'
}
${active
? `bg-primary/10 border-l-4`
: 'border-l-4 border-transparent'
}
`}
style={
active
? { borderColor: accentColor, backgroundColor: `${accentColor}10` }
: undefined
}
title={isYouTube ? `Klik untuk lompat ke ${formatTime(chapter.time)}` : undefined}
>
{/* Timestamp */}
<div className={`
font-mono text-sm font-semibold
${active ? 'text-primary' : 'text-muted-foreground'}
`} style={active ? { color: accentColor } : undefined}>
{formatTime(chapter.time)}
</div>
{/* Chapter Title */}
<div className={`
flex-1 text-sm
${active ? 'font-medium' : 'text-muted-foreground'}
`}>
{chapter.title}
</div>
{/* Active indicator */}
{active && (
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: accentColor }}
/>
)}
</button>
);
})}
</div>
{!isYouTube && (
<p className="text-xs text-muted-foreground mt-3 italic">
Timeline hanya tersedia untuk video YouTube
</p>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,203 @@
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import Plyr from 'plyr';
import 'plyr/dist/plyr.css';
interface VideoChapter {
time: number; // Time in seconds
title: string;
}
interface VideoPlayerWithChaptersProps {
videoUrl: string;
embedCode?: string | null;
chapters?: VideoChapter[];
accentColor?: string;
onChapterChange?: (chapter: VideoChapter) => void;
onTimeUpdate?: (time: number) => void;
className?: string;
}
export interface VideoPlayerRef {
jumpToTime: (time: number) => void;
getCurrentTime: () => number;
}
export const VideoPlayerWithChapters = forwardRef<VideoPlayerRef, VideoPlayerWithChaptersProps>(({
videoUrl,
embedCode,
chapters = [],
accentColor = '#f97316', // Default orange
onChapterChange,
onTimeUpdate,
className = '',
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Plyr | null>(null);
const [currentChapterIndex, setCurrentChapterIndex] = useState<number>(-1);
// Detect if this is a YouTube URL
const isYouTube = videoUrl && (
videoUrl.includes('youtube.com') ||
videoUrl.includes('youtu.be')
);
// Get YouTube video ID
const getYouTubeId = (url: string): string | null => {
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s/]+)/);
return match ? match[1] : null;
};
// Convert embed code to YouTube URL if possible
const getYouTubeUrlFromEmbed = (embed: string): string | null => {
const match = embed.match(/src=["'](?:https?:)?\/\/(?:www\.)?youtube\.com\/embed\/([^"'\s?]*)/);
return match ? `https://www.youtube.com/watch?v=${match[1]}` : null;
};
// Determine which video source to use
const effectiveVideoUrl = isYouTube ? videoUrl : (embedCode ? getYouTubeUrlFromEmbed(embedCode) : videoUrl);
const useEmbed = !isYouTube && embedCode;
useEffect(() => {
if (!wrapperRef.current) return;
// Initialize Plyr
const player = new Plyr(wrapperRef.current, {
youtube: {
noCookie: true,
rel: 0,
showinfo: 0,
iv_load_policy: 3,
modestbranding: 1,
},
controls: [
'play-large',
'play',
'progress',
'current-time',
'duration',
'mute',
'volume',
'settings',
'pip',
'airplay',
],
settings: ['quality', 'speed'],
keyboard: {
global: true,
},
});
playerRef.current = player;
// Apply custom accent color
if (accentColor) {
const style = document.createElement('style');
style.textContent = `
.plyr--full-ui input[type=range] {
color: ${accentColor};
}
.plyr__control--overlaid,
.plyr__controls .plyr__control.plyr__tab-focus,
.plyr__controls .plyr__control:hover,
.plyr__controls .plyr__control[aria-expanded=true] {
background: ${accentColor};
}
.plyr__progress__buffer {
color: ${accentColor}40;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}
return () => {
player.destroy();
};
}, [accentColor]);
// Handle chapter tracking
useEffect(() => {
if (!playerRef.current || chapters.length === 0) return;
const player = playerRef.current;
const updateTime = () => {
const currentTime = player.currentTime;
// Report time update to parent
if (onTimeUpdate) {
onTimeUpdate(currentTime);
}
// Find current chapter
let index = chapters.findIndex((chapter, i) => {
const nextChapter = chapters[i + 1];
return currentTime >= chapter.time && (!nextChapter || currentTime < nextChapter.time);
});
// If before first chapter, no active chapter
if (index === -1 && currentTime < chapters[0].time) {
index = -1;
}
if (index !== currentChapterIndex) {
setCurrentChapterIndex(index);
if (index >= 0 && onChapterChange) {
onChapterChange(chapters[index]);
}
}
};
player.on('timeupdate', updateTime);
return () => {
player.off('timeupdate', updateTime);
};
}, [chapters, currentChapterIndex, onChapterChange, onTimeUpdate]);
// Jump to specific time
const jumpToTime = (time: number) => {
if (playerRef.current && isYouTube) {
playerRef.current.currentTime = time;
playerRef.current.play();
}
};
const getCurrentTime = () => {
return playerRef.current ? playerRef.current.currentTime : 0;
};
// Expose methods via ref
useImperativeHandle(ref, () => ({
jumpToTime,
getCurrentTime,
}));
if (useEmbed) {
// Custom embed (Adilo, Vimeo, etc.)
return (
<div
className={`aspect-video rounded-lg overflow-hidden ${className}`}
dangerouslySetInnerHTML={{ __html: embedCode }}
/>
);
}
const youtubeId = effectiveVideoUrl ? getYouTubeId(effectiveVideoUrl) : null;
return (
<div ref={wrapperRef} className={`plyr__video-embed ${className}`}>
{youtubeId && (
<iframe
src={`https://www.youtube.com/embed/${youtubeId}?origin=${window.location.origin}&iv_load_policy=3&modestbranding=1&playsinline=1`}
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
)}
</div>
);
});

View File

@@ -0,0 +1,128 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Plus, Trash2, GripVertical } from 'lucide-react';
interface VideoChapter {
time: number; // Time in seconds
title: string;
}
interface ChaptersEditorProps {
chapters: VideoChapter[];
onChange: (chapters: VideoChapter[]) => void;
className?: string;
}
export function ChaptersEditor({ chapters, onChange, className = '' }: ChaptersEditorProps) {
const [chaptersList, setChaptersList] = useState<VideoChapter[]>(
chapters.length > 0 ? chapters : [{ time: 0, title: '' }]
);
const updateTime = (index: number, value: string) => {
const newChapters = [...chaptersList];
const [minutes = 0, seconds = 0] = value.split(':').map(Number);
newChapters[index].time = minutes * 60 + seconds;
setChaptersList(newChapters);
onChange(newChapters);
};
const updateTitle = (index: number, title: string) => {
const newChapters = [...chaptersList];
newChapters[index].title = title;
setChaptersList(newChapters);
onChange(newChapters);
};
const addChapter = () => {
const newChapters = [...chaptersList, { time: 0, title: '' }];
setChaptersList(newChapters);
onChange(newChapters);
};
const removeChapter = (index: number) => {
if (chaptersList.length <= 1) return;
const newChapters = chaptersList.filter((_, i) => i !== index);
setChaptersList(newChapters);
onChange(newChapters);
};
const formatTimeForInput = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Timeline Chapters</CardTitle>
<Button size="sm" onClick={addChapter}>
<Plus className="w-4 h-4 mr-1" />
Add Chapter
</Button>
</div>
<p className="text-sm text-muted-foreground">
Add chapter markers to help users navigate through the video content
</p>
</CardHeader>
<CardContent className="space-y-3">
{chaptersList.map((chapter, index) => (
<div key={index} className="flex items-center gap-2">
<GripVertical className="w-5 h-5 text-muted-foreground cursor-move" />
{/* Time Input */}
<div className="flex-1">
<Label htmlFor={`time-${index}`} className="sr-only">
Time
</Label>
<Input
id={`time-${index}`}
type="text"
value={formatTimeForInput(chapter.time)}
onChange={(e) => updateTime(index, e.target.value)}
placeholder="0:00"
pattern="[0-9]+:[0-5][0-9]"
className="font-mono"
/>
</div>
{/* Title Input */}
<div className="flex-[3]">
<Label htmlFor={`title-${index}`} className="sr-only">
Chapter Title
</Label>
<Input
id={`title-${index}`}
type="text"
value={chapter.title}
onChange={(e) => updateTitle(index, e.target.value)}
placeholder="Chapter title"
/>
</div>
{/* Remove Button */}
<Button
size="sm"
variant="ghost"
onClick={() => removeChapter(index)}
disabled={chaptersList.length <= 1}
title="Remove chapter"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p>💡 <strong>Format:</strong> Enter time as MM:SS (e.g., 5:30 for 5 minutes 30 seconds)</p>
<p>📌 <strong>Note:</strong> Chapters only work with YouTube videos. Embed codes show static timeline.</p>
<p> <strong>Tip:</strong> Chapters are automatically sorted by time when displayed.</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -10,6 +10,12 @@ import { toast } from '@/hooks/use-toast';
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
import { RichTextEditor } from '@/components/RichTextEditor';
import { ChaptersEditor } from './ChaptersEditor';
interface VideoChapter {
time: number;
title: string;
}
interface Module {
id: string;
@@ -25,6 +31,7 @@ interface Lesson {
video_url: string | null;
position: number;
release_at: string | null;
chapters?: VideoChapter[];
}
interface CurriculumEditorProps {
@@ -48,6 +55,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
content: '',
video_url: '',
release_at: '',
chapters: [] as VideoChapter[],
});
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
@@ -182,6 +190,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
content: lesson.content || '',
video_url: lesson.video_url || '',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
chapters: lesson.chapters || [],
});
setLessonDialogOpen(true);
};
@@ -198,6 +207,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
content: lessonForm.content || null,
video_url: lessonForm.video_url || null,
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
chapters: lessonForm.chapters || [],
};
if (editingLesson) {
@@ -442,6 +452,10 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
className="border-2"
/>
</div>
<ChaptersEditor
chapters={lessonForm.chapters || []}
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
/>
<div className="space-y-2">
<Label>Content</Label>
<RichTextEditor