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:
dwindown
2025-12-30 17:07:31 +07:00
parent 52ec0b9b86
commit da71acb431
10 changed files with 1114 additions and 34 deletions

View File

@@ -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>