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:
88
package-lock.json
generated
88
package-lock.json
generated
@@ -59,6 +59,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"plyr-react": "^6.0.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
@@ -4254,6 +4255,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-js": {
|
||||||
|
"version": "3.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz",
|
||||||
|
"integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/core-js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/crelt": {
|
"node_modules/crelt": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
@@ -4291,6 +4304,13 @@
|
|||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/custom-event-polyfill": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
@@ -5373,6 +5393,13 @@
|
|||||||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loadjs": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/loadjs/-/loadjs-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
@@ -6217,6 +6244,36 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/plyr": {
|
||||||
|
"version": "3.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/plyr/-/plyr-3.8.3.tgz",
|
||||||
|
"integrity": "sha512-0+iI5uw0WRvtKBpgPCkmQQv7ucHVQKTEo6UFJjgJ8cy/JZhy0dQqshHQVitHXV6l2O3MzhgnuvQ95VSkWcWeSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^3.45.1",
|
||||||
|
"custom-event-polyfill": "^1.0.7",
|
||||||
|
"loadjs": "^4.3.0",
|
||||||
|
"rangetouch": "^2.0.1",
|
||||||
|
"url-polyfill": "^1.1.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/plyr-react": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/plyr-react/-/plyr-react-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-P8M+BuQoGrCd7m6K4QwwQlcSS1E26OeXuJTAmgLx11B9UqJrdc3Ka4TFwPwF3jul4EsVxSK9Zn1ME3DV8m9gdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-aptor": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"plyr": "^3.7.7",
|
||||||
|
"react": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
@@ -6630,6 +6687,13 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/rangetouch": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rangetouch/-/rangetouch-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-sln+pNSc8NGaHoLzwNBssFSf/rSYkqeBXzX1AtJlkJiUaVSJSbRAWJk+4omsXkN+EJalzkZhWQ3th1m0FpR5xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -6642,6 +6706,23 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-aptor": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aptor/-/react-aptor-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-YnCayokuhAwmBBP4Oc0bbT2l6ApfsjbY3DEEVUddIKZEBlGl1npzjHHzWnSqWuboSbMZvRqUM01Io9yiIp1wcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-day-picker": {
|
"node_modules/react-day-picker": {
|
||||||
"version": "8.10.1",
|
"version": "8.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz",
|
||||||
@@ -7453,6 +7534,13 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/url-polyfill": {
|
||||||
|
"version": "1.1.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz",
|
||||||
|
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/use-callback-ref": {
|
"node_modules/use-callback-ref": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
"plyr-react": "^6.0.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
|
|||||||
122
src/components/TimelineChapters.tsx
Normal file
122
src/components/TimelineChapters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
src/components/VideoPlayerWithChapters.tsx
Normal file
203
src/components/VideoPlayerWithChapters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
128
src/components/admin/ChaptersEditor.tsx
Normal file
128
src/components/admin/ChaptersEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import { toast } from '@/hooks/use-toast';
|
|||||||
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { RichTextEditor } from '@/components/RichTextEditor';
|
import { RichTextEditor } from '@/components/RichTextEditor';
|
||||||
|
import { ChaptersEditor } from './ChaptersEditor';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,6 +31,7 @@ interface Lesson {
|
|||||||
video_url: string | null;
|
video_url: string | null;
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CurriculumEditorProps {
|
interface CurriculumEditorProps {
|
||||||
@@ -48,6 +55,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
content: '',
|
content: '',
|
||||||
video_url: '',
|
video_url: '',
|
||||||
release_at: '',
|
release_at: '',
|
||||||
|
chapters: [] as VideoChapter[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
|
||||||
@@ -182,6 +190,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
content: lesson.content || '',
|
content: lesson.content || '',
|
||||||
video_url: lesson.video_url || '',
|
video_url: lesson.video_url || '',
|
||||||
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
|
||||||
|
chapters: lesson.chapters || [],
|
||||||
});
|
});
|
||||||
setLessonDialogOpen(true);
|
setLessonDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -198,6 +207,7 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
content: lessonForm.content || null,
|
content: lessonForm.content || null,
|
||||||
video_url: lessonForm.video_url || null,
|
video_url: lessonForm.video_url || null,
|
||||||
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
|
||||||
|
chapters: lessonForm.chapters || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingLesson) {
|
if (editingLesson) {
|
||||||
@@ -442,6 +452,10 @@ export function CurriculumEditor({ productId }: CurriculumEditorProps) {
|
|||||||
className="border-2"
|
className="border-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<ChaptersEditor
|
||||||
|
chapters={lessonForm.chapters || []}
|
||||||
|
onChange={(chapters) => setLessonForm({ ...lessonForm, chapters })}
|
||||||
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Content</Label>
|
<Label>Content</Label>
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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 { cn } from '@/lib/utils';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
import { ReviewModal } from '@/components/reviews/ReviewModal';
|
||||||
|
import { VideoPlayerWithChapters, VideoPlayerRef } from '@/components/VideoPlayerWithChapters';
|
||||||
|
import { TimelineChapters } from '@/components/TimelineChapters';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -38,6 +45,7 @@ interface Lesson {
|
|||||||
duration_seconds: number | null;
|
duration_seconds: number | null;
|
||||||
position: number;
|
position: number;
|
||||||
release_at: string | null;
|
release_at: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Progress {
|
interface Progress {
|
||||||
@@ -68,6 +76,9 @@ export default function Bootcamp() {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [accentColor, setAccentColor] = useState('#f97316');
|
||||||
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
@@ -93,6 +104,16 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
setProduct(productData);
|
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
|
const { data: accessData } = await supabase
|
||||||
.from('user_access')
|
.from('user_access')
|
||||||
.select('id')
|
.select('id')
|
||||||
@@ -121,7 +142,8 @@ export default function Bootcamp() {
|
|||||||
embed_code,
|
embed_code,
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
position,
|
position,
|
||||||
release_at
|
release_at,
|
||||||
|
chapters
|
||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq('product_id', productData.id)
|
.eq('product_id', productData.id)
|
||||||
@@ -262,6 +284,7 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||||
const activeSource = product?.video_source || 'youtube';
|
const activeSource = product?.video_source || 'youtube';
|
||||||
|
const hasChapters = lesson.chapters && lesson.chapters.length > 0;
|
||||||
|
|
||||||
// Get video based on product's active source
|
// Get video based on product's active source
|
||||||
const getVideoSource = () => {
|
const getVideoSource = () => {
|
||||||
@@ -324,22 +347,55 @@ export default function Bootcamp() {
|
|||||||
// Render based on video type
|
// Render based on video type
|
||||||
if (video.type === 'embed') {
|
if (video.type === 'embed') {
|
||||||
return (
|
return (
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
<div className="mb-6">
|
||||||
<div dangerouslySetInnerHTML={{ __html: video.html }} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// YouTube or other URL-based videos
|
// YouTube with chapters support
|
||||||
|
const isYouTube = video.type === 'youtube';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6" : "mb-6"}>
|
||||||
<iframe
|
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||||
src={video.embedUrl}
|
<VideoPlayerWithChapters
|
||||||
className="w-full h-full"
|
ref={playerRef}
|
||||||
allowFullScreen
|
videoUrl={video.url}
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
embedCode={lesson.embed_code}
|
||||||
title={lesson.title}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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 { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { ChevronLeft, Play } from 'lucide-react';
|
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 {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,6 +22,7 @@ interface Product {
|
|||||||
slug: string;
|
slug: string;
|
||||||
recording_url: string | null;
|
recording_url: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WebinarRecording() {
|
export default function WebinarRecording() {
|
||||||
@@ -24,6 +32,9 @@ export default function WebinarRecording() {
|
|||||||
|
|
||||||
const [product, setProduct] = useState<Product | null>(null);
|
const [product, setProduct] = useState<Product | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [accentColor, setAccentColor] = useState('#f97316');
|
||||||
|
const playerRef = useRef<VideoPlayerRef>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
@@ -36,7 +47,7 @@ export default function WebinarRecording() {
|
|||||||
const checkAccessAndFetch = async () => {
|
const checkAccessAndFetch = async () => {
|
||||||
const { data: productData, error: productError } = await supabase
|
const { data: productData, error: productError } = await supabase
|
||||||
.from('products')
|
.from('products')
|
||||||
.select('id, title, slug, recording_url, description')
|
.select('id, title, slug, recording_url, description, chapters')
|
||||||
.eq('slug', slug)
|
.eq('slug', slug)
|
||||||
.eq('type', 'webinar')
|
.eq('type', 'webinar')
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
@@ -55,6 +66,16 @@ export default function WebinarRecording() {
|
|||||||
return;
|
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
|
// Check access via user_access or paid orders
|
||||||
const [accessRes, paidOrdersRes] = await Promise.all([
|
const [accessRes, paidOrdersRes] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
@@ -84,27 +105,20 @@ export default function WebinarRecording() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVideoEmbed = (url: string) => {
|
const isYouTube = product?.recording_url && (
|
||||||
// YouTube
|
product.recording_url.includes('youtube.com') ||
|
||||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
product.recording_url.includes('youtu.be')
|
||||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
);
|
||||||
|
|
||||||
// Vimeo
|
const handleChapterClick = (time: number) => {
|
||||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
// VideoPlayerWithChapters will handle the jump
|
||||||
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
if (playerRef.current && playerRef.current.jumpToTime) {
|
||||||
|
playerRef.current.jumpToTime(time);
|
||||||
// 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 isDirectVideo = (url: string) => {
|
const handleTimeUpdate = (time: number) => {
|
||||||
return url.match(/\.(mp4|webm|ogg)$/i) || url.includes('drive.google.com');
|
setCurrentTime(time);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading || loading) {
|
if (authLoading || loading) {
|
||||||
@@ -120,7 +134,7 @@ export default function WebinarRecording() {
|
|||||||
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
|
|
||||||
const embedUrl = product.recording_url ? getVideoEmbed(product.recording_url) : null;
|
const hasChapters = product.chapters && product.chapters.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
@@ -135,29 +149,33 @@ export default function WebinarRecording() {
|
|||||||
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
|
<CardTitle className="text-2xl md:text-3xl">{product.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Video Embed */}
|
{/* Video Player with Chapters */}
|
||||||
{embedUrl && (
|
<div className={hasChapters ? "grid grid-cols-1 lg:grid-cols-3 gap-6" : ""}>
|
||||||
<div className="aspect-video bg-muted rounded-lg overflow-hidden border-2 border-border">
|
<div className={hasChapters ? "lg:col-span-2" : ""}>
|
||||||
{isDirectVideo(embedUrl) ? (
|
{product.recording_url && (
|
||||||
<video
|
<VideoPlayerWithChapters
|
||||||
src={embedUrl}
|
ref={playerRef}
|
||||||
controls
|
videoUrl={product.recording_url}
|
||||||
className="w-full h-full"
|
chapters={product.chapters}
|
||||||
>
|
accentColor={accentColor}
|
||||||
<source src={embedUrl} type="video/mp4" />
|
onTimeUpdate={handleTimeUpdate}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Description */}
|
||||||
{product.description && (
|
{product.description && (
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ import { formatIDR } from '@/lib/format';
|
|||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Info } from 'lucide-react';
|
import { Info } from 'lucide-react';
|
||||||
|
import { ChaptersEditor } from '@/components/admin/ChaptersEditor';
|
||||||
|
|
||||||
|
interface VideoChapter {
|
||||||
|
time: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -36,6 +42,7 @@ interface Product {
|
|||||||
sale_price: number | null;
|
sale_price: number | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
video_source?: string;
|
video_source?: string;
|
||||||
|
chapters?: VideoChapter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyProduct = {
|
const emptyProduct = {
|
||||||
@@ -52,6 +59,7 @@ const emptyProduct = {
|
|||||||
sale_price: null as number | null,
|
sale_price: null as number | null,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
video_source: 'youtube' as string,
|
video_source: 'youtube' as string,
|
||||||
|
chapters: [] as VideoChapter[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminProducts() {
|
export default function AdminProducts() {
|
||||||
@@ -119,6 +127,7 @@ export default function AdminProducts() {
|
|||||||
sale_price: product.sale_price,
|
sale_price: product.sale_price,
|
||||||
is_active: product.is_active,
|
is_active: product.is_active,
|
||||||
video_source: product.video_source || 'youtube',
|
video_source: product.video_source || 'youtube',
|
||||||
|
chapters: product.chapters || [],
|
||||||
});
|
});
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
@@ -149,6 +158,7 @@ export default function AdminProducts() {
|
|||||||
sale_price: form.sale_price || null,
|
sale_price: form.sale_price || null,
|
||||||
is_active: form.is_active,
|
is_active: form.is_active,
|
||||||
video_source: form.video_source || 'youtube',
|
video_source: form.video_source || 'youtube',
|
||||||
|
chapters: form.chapters || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingProduct) {
|
if (editingProduct) {
|
||||||
@@ -462,27 +472,33 @@ export default function AdminProducts() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{form.type === 'webinar' && (
|
{form.type === 'webinar' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<>
|
||||||
<div className="space-y-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Label>Tanggal & Waktu Webinar</Label>
|
<div className="space-y-2">
|
||||||
<Input
|
<Label>Tanggal & Waktu Webinar</Label>
|
||||||
type="datetime-local"
|
<Input
|
||||||
value={form.event_start || ''}
|
type="datetime-local"
|
||||||
onChange={(e) => setForm({ ...form, event_start: e.target.value || null })}
|
value={form.event_start || ''}
|
||||||
className="border-2"
|
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>
|
||||||
<div className="space-y-2">
|
<ChaptersEditor
|
||||||
<Label>Durasi (menit)</Label>
|
chapters={form.chapters || []}
|
||||||
<Input
|
onChange={(chapters) => setForm({ ...form, chapters })}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
{form.type === 'bootcamp' && (
|
{form.type === 'bootcamp' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
17
supabase/migrations/20251231000000_add_video_chapters.sql
Normal file
17
supabase/migrations/20251231000000_add_video_chapters.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Add chapters support to products table (for webinars)
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS chapters JSONB DEFAULT '[]';
|
||||||
|
|
||||||
|
-- Add chapters support to bootcamp_lessons table
|
||||||
|
ALTER TABLE bootcamp_lessons
|
||||||
|
ADD COLUMN IF NOT EXISTS chapters JSONB DEFAULT '[]';
|
||||||
|
|
||||||
|
-- Add comments for documentation
|
||||||
|
COMMENT ON COLUMN products.chapters IS 'Video chapters/timeline markers stored as JSON array of {time: number, title: string}';
|
||||||
|
COMMENT ON COLUMN bootcamp_lessons.chapters IS 'Video chapters/timeline markers stored as JSON array of {time: number, title: string}';
|
||||||
|
|
||||||
|
-- Create index for faster queries on products with chapters
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_has_chapters ON products ((jsonb_array_length(chapters) > 0));
|
||||||
|
|
||||||
|
-- Create index for faster queries on lessons with chapters
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bootcamp_lessons_has_chapters ON bootcamp_lessons ((jsonb_array_length(chapters) > 0));
|
||||||
Reference in New Issue
Block a user