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';
@@ -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>
);
};