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

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { useAuth } from '@/hooks/useAuth';
@@ -8,6 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast';
import { ChevronLeft, Play } from 'lucide-react';
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
import { TimelineChapters } from '@/components/TimelineChapters';
interface VideoChapter {
time: number;
title: string;
}
interface Product {
id: string;
@@ -15,6 +22,7 @@ interface Product {
slug: string;
recording_url: string | null;
description: string | null;
chapters?: VideoChapter[];
}
export default function WebinarRecording() {
@@ -24,6 +32,9 @@ export default function WebinarRecording() {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [currentTime, setCurrentTime] = useState(0);
const [accentColor, setAccentColor] = useState('#f97316');
const playerRef = useRef<VideoPlayerRef>(null);
useEffect(() => {
if (!authLoading && !user) {
@@ -36,7 +47,7 @@ export default function WebinarRecording() {
const checkAccessAndFetch = async () => {
const { data: productData, error: productError } = await supabase
.from('products')
.select('id, title, slug, recording_url, description')
.select('id, title, slug, recording_url, description, chapters')
.eq('slug', slug)
.eq('type', 'webinar')
.maybeSingle();
@@ -55,6 +66,16 @@ export default function WebinarRecording() {
return;
}
// Fetch accent color from settings
const { data: settings } = await supabase
.from('site_settings')
.select('brand_accent_color')
.single();
if (settings?.brand_accent_color) {
setAccentColor(settings.brand_accent_color);
}
// Check access via user_access or paid orders
const [accessRes, paidOrdersRes] = await Promise.all([
supabase
@@ -84,27 +105,20 @@ export default function WebinarRecording() {
setLoading(false);
};
const getVideoEmbed = (url: string) => {
// YouTube
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
const isYouTube = product?.recording_url && (
product.recording_url.includes('youtube.com') ||
product.recording_url.includes('youtu.be')
);
// Vimeo
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
// Google Drive
const driveMatch = url.match(/drive\.google\.com\/file\/d\/([^\/]+)/);
if (driveMatch) return `https://drive.google.com/file/d/${driveMatch[1]}/preview`;
// Direct MP4 or other video files
if (url.match(/\.(mp4|webm|ogg)$/i)) return url;
return url;
const handleChapterClick = (time: number) => {
// VideoPlayerWithChapters will handle the jump
if (playerRef.current && playerRef.current.jumpToTime) {
playerRef.current.jumpToTime(time);
}
};
const isDirectVideo = (url: string) => {
return url.match(/\.(mp4|webm|ogg)$/i) || url.includes('drive.google.com');
const handleTimeUpdate = (time: number) => {
setCurrentTime(time);
};
if (authLoading || loading) {
@@ -120,7 +134,7 @@ export default function WebinarRecording() {
if (!product) return null;
const embedUrl = product.recording_url ? getVideoEmbed(product.recording_url) : null;
const hasChapters = product.chapters && product.chapters.length > 0;
return (
<AppLayout>
@@ -135,29 +149,33 @@ export default function WebinarRecording() {
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Video Embed */}
{embedUrl && (
<div className="aspect-video bg-muted rounded-lg overflow-hidden border-2 border-border">
{isDirectVideo(embedUrl) ? (
<video
src={embedUrl}
controls
className="w-full h-full"
>
<source src={embedUrl} type="video/mp4" />
Browser Anda tidak mendukung pemutaran video.
</video>
) : (
<iframe
src={embedUrl}
className="w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title={product.title}
{/* Video Player with Chapters */}
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""}>
<div className={hasChapters ? "lg:col-span-2" : ""}>
{product.recording_url && (
<VideoPlayerWithChapters
ref={playerRef}
videoUrl={product.recording_url}
chapters={product.chapters}
accentColor={accentColor}
onTimeUpdate={handleTimeUpdate}
/>
)}
</div>
)}
{/* Timeline Chapters */}
{hasChapters && (
<div className="lg:col-span-1">
<TimelineChapters
chapters={product.chapters}
isYouTube={isYouTube}
onChapterClick={handleChapterClick}
currentTime={currentTime}
accentColor={accentColor}
/>
</div>
)}
</div>
{/* Description */}
{product.description && (