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:
@@ -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';
|
||||
@@ -12,8 +12,15 @@ import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, Ch
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
||||
import { TimelineChapters } from '@/components/TimelineChapters';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -38,6 +45,7 @@ interface Lesson {
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
release_at: string | null;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
interface Progress {
|
||||
@@ -68,6 +76,9 @@ export default function Bootcamp() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [accentColor, setAccentColor] = useState('#f97316');
|
||||
const playerRef = useRef<VideoPlayerRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
@@ -93,6 +104,16 @@ export default function Bootcamp() {
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const { data: accessData } = await supabase
|
||||
.from('user_access')
|
||||
.select('id')
|
||||
@@ -121,7 +142,8 @@ export default function Bootcamp() {
|
||||
embed_code,
|
||||
duration_seconds,
|
||||
position,
|
||||
release_at
|
||||
release_at,
|
||||
chapters
|
||||
)
|
||||
`)
|
||||
.eq('product_id', productData.id)
|
||||
@@ -262,6 +284,7 @@ export default function Bootcamp() {
|
||||
|
||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||
const activeSource = product?.video_source || 'youtube';
|
||||
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||
|
||||
// Get video based on product's active source
|
||||
const getVideoSource = () => {
|
||||
@@ -324,22 +347,55 @@ export default function Bootcamp() {
|
||||
// Render based on video type
|
||||
if (video.type === 'embed') {
|
||||
return (
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||
<div className="mb-6">
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden border-2 border-border">
|
||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
||||
</div>
|
||||
{hasChapters && (
|
||||
<div className="mt-4">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
isYouTube={false}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube or other URL-based videos
|
||||
// YouTube with chapters support
|
||||
const isYouTube = video.type === 'youtube';
|
||||
|
||||
return (
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
||||
<iframe
|
||||
src={video.embedUrl}
|
||||
className="w-full h-full"
|
||||
allowFullScreen
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
title={lesson.title}
|
||||
/>
|
||||
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||
<VideoPlayerWithChapters
|
||||
ref={playerRef}
|
||||
videoUrl={video.url}
|
||||
embedCode={lesson.embed_code}
|
||||
chapters={lesson.chapters}
|
||||
accentColor={accentColor}
|
||||
onTimeUpdate={setCurrentTime}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasChapters && (
|
||||
<div className="lg:col-span-1">
|
||||
<TimelineChapters
|
||||
chapters={lesson.chapters}
|
||||
isYouTube={isYouTube}
|
||||
onChapterClick={(time) => {
|
||||
if (playerRef.current) {
|
||||
playerRef.current.jumpToTime(time);
|
||||
}
|
||||
}}
|
||||
currentTime={currentTime}
|
||||
accentColor={accentColor}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -20,6 +20,12 @@ import { formatIDR } from '@/lib/format';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info } from 'lucide-react';
|
||||
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||
|
||||
interface VideoChapter {
|
||||
time: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -36,6 +42,7 @@ interface Product {
|
||||
sale_price: number | null;
|
||||
is_active: boolean;
|
||||
video_source?: string;
|
||||
chapters?: VideoChapter[];
|
||||
}
|
||||
|
||||
const emptyProduct = {
|
||||
@@ -52,6 +59,7 @@ const emptyProduct = {
|
||||
sale_price: null as number | null,
|
||||
is_active: true,
|
||||
video_source: 'youtube' as string,
|
||||
chapters: [] as VideoChapter[],
|
||||
};
|
||||
|
||||
export default function AdminProducts() {
|
||||
@@ -119,6 +127,7 @@ export default function AdminProducts() {
|
||||
sale_price: product.sale_price,
|
||||
is_active: product.is_active,
|
||||
video_source: product.video_source || 'youtube',
|
||||
chapters: product.chapters || [],
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
@@ -149,6 +158,7 @@ export default function AdminProducts() {
|
||||
sale_price: form.sale_price || null,
|
||||
is_active: form.is_active,
|
||||
video_source: form.video_source || 'youtube',
|
||||
chapters: form.chapters || [],
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
@@ -462,27 +472,33 @@ export default function AdminProducts() {
|
||||
</div>
|
||||
</div>
|
||||
{form.type === 'webinar' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal & Waktu Webinar</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.event_start || ''}
|
||||
onChange={(e) => setForm({ ...form, event_start: e.target.value || null })}
|
||||
className="border-2"
|
||||
/>
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Tanggal & Waktu Webinar</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={form.event_start || ''}
|
||||
onChange={(e) => setForm({ ...form, event_start: e.target.value || null })}
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Durasi (menit)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.duration_minutes || ''}
|
||||
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="60"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Durasi (menit)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.duration_minutes || ''}
|
||||
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value ? parseInt(e.target.value) : null })}
|
||||
placeholder="60"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ChaptersEditor
|
||||
chapters={form.chapters || []}
|
||||
onChange={(chapters) => setForm({ ...form, chapters })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{form.type === 'bootcamp' && (
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user