Enhance bootcamp with rich text editor, curriculum management, and video toggle
Phase 1: Rich Text Editor with Code Syntax Highlighting - Add TipTap CodeBlock extension with lowlight for syntax highlighting - Support multiple languages (JavaScript, TypeScript, Python, Java, C++, HTML, CSS, JSON) - Add copy-to-clipboard button on code blocks - Add line numbers display with CSS - Replace textarea with RichTextEditor in CurriculumEditor - Add DOMPurify sanitization in Bootcamp display - Add dark theme syntax highlighting styles Phase 2: Admin Curriculum Management Page - Create dedicated ProductCurriculum page at /admin/products/:id/curriculum - Three-column layout: Modules (3) | Lessons (5) | Editor (4) - Full-page UX with drag-and-drop reordering - Add "Manage Curriculum" button for bootcamp products in AdminProducts - Breadcrumb navigation back to products Phase 3: Product-Level Video Source Toggle - Add youtube_url and embed_code columns to bootcamp_lessons table - Add video_source and video_source_config columns to products table - Update ProductCurriculum with separate YouTube URL and Embed Code fields - Create smart VideoPlayer component in Bootcamp.tsx - Support YouTube ↔ Embed switching with smart fallback - Show "Konten tidak tersedia" warning when no video configured - Maintain backward compatibility with existing video_url field 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,13 @@ 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 DOMPurify from 'dompurify';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
video_source?: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
@@ -31,6 +33,8 @@ interface Lesson {
|
||||
title: string;
|
||||
content: string | null;
|
||||
video_url: string | null;
|
||||
youtube_url: string | null;
|
||||
embed_code: string | null;
|
||||
duration_seconds: number | null;
|
||||
position: number;
|
||||
release_at: string | null;
|
||||
@@ -76,7 +80,7 @@ export default function Bootcamp() {
|
||||
const checkAccessAndFetch = async () => {
|
||||
const { data: productData, error: productError } = await supabase
|
||||
.from('products')
|
||||
.select('id, title, slug')
|
||||
.select('id, title, slug, video_source')
|
||||
.eq('slug', slug)
|
||||
.eq('type', 'bootcamp')
|
||||
.maybeSingle();
|
||||
@@ -113,6 +117,8 @@ export default function Bootcamp() {
|
||||
title,
|
||||
content,
|
||||
video_url,
|
||||
youtube_url,
|
||||
embed_code,
|
||||
duration_seconds,
|
||||
position,
|
||||
release_at
|
||||
@@ -230,12 +236,93 @@ export default function Bootcamp() {
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoEmbed = (url: string) => {
|
||||
const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
if (youtubeMatch) return `https://www.youtube.com/embed/${youtubeMatch[1]}`;
|
||||
const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
|
||||
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
|
||||
return url;
|
||||
const getYouTubeEmbedUrl = (url: string): string => {
|
||||
const match = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([^&\s]+)/);
|
||||
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
|
||||
};
|
||||
|
||||
const VideoPlayer = ({ lesson }: { lesson: Lesson }) => {
|
||||
const activeSource = product?.video_source || 'youtube';
|
||||
|
||||
// Get video based on product's active source
|
||||
const getVideoSource = () => {
|
||||
if (activeSource === 'youtube') {
|
||||
if (lesson.youtube_url) {
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.youtube_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||
};
|
||||
} else if (lesson.video_url) {
|
||||
// Fallback to old video_url for backward compatibility
|
||||
return {
|
||||
type: 'youtube',
|
||||
url: lesson.video_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||
};
|
||||
} else {
|
||||
// Fallback to embed if YouTube not available
|
||||
return lesson.embed_code ? {
|
||||
type: 'embed',
|
||||
html: lesson.embed_code
|
||||
} : null;
|
||||
}
|
||||
} else {
|
||||
if (lesson.embed_code) {
|
||||
return {
|
||||
type: 'embed',
|
||||
html: lesson.embed_code
|
||||
};
|
||||
} else {
|
||||
// Fallback to YouTube if embed not available
|
||||
return lesson.youtube_url ? {
|
||||
type: 'youtube',
|
||||
url: lesson.youtube_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.youtube_url)
|
||||
} : lesson.video_url ? {
|
||||
type: 'youtube',
|
||||
url: lesson.video_url,
|
||||
embedUrl: getYouTubeEmbedUrl(lesson.video_url)
|
||||
} : null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const video = getVideoSource();
|
||||
|
||||
// Show warning if no video available
|
||||
if (!video) {
|
||||
return (
|
||||
<Card className="border-2 border-destructive bg-destructive/10 mb-6">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-destructive font-medium">Konten tidak tersedia</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Video belum dikonfigurasi untuk pelajaran ini.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
// YouTube or other URL-based videos
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
const completedCount = progress.length;
|
||||
@@ -273,7 +360,7 @@ export default function Bootcamp() {
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-4 h-4 shrink-0 text-accent" />
|
||||
) : lesson.video_url ? (
|
||||
) : lesson.video_url || lesson.youtube_url || lesson.embed_code ? (
|
||||
<Play className="w-4 h-4 shrink-0" />
|
||||
) : (
|
||||
<BookOpen className="w-4 h-4 shrink-0" />
|
||||
@@ -382,23 +469,23 @@ export default function Bootcamp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedLesson.video_url && (
|
||||
<div className="aspect-video bg-muted rounded-none overflow-hidden mb-6 border-2 border-border">
|
||||
<iframe
|
||||
src={getVideoEmbed(selectedLesson.video_url)}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<VideoPlayer lesson={selectedLesson} />
|
||||
|
||||
{selectedLesson.content && (
|
||||
<Card className="border-2 border-border mb-6">
|
||||
<CardContent className="pt-6">
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: selectedLesson.content }}
|
||||
className="prose prose-slate max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(selectedLesson.content, {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'a', 'ul', 'ol', 'li',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre',
|
||||
'img', 'div', 'span', 'iframe', 'table', 'thead', 'tbody', 'tr', 'td', 'th'],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'width', 'height', 'class', 'style',
|
||||
'target', 'rel', 'title', 'id', 'data-*'],
|
||||
ALLOW_DATA_ATTR: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user