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>

View File

@@ -15,7 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Plus, Pencil, Trash2, Search, X } from 'lucide-react';
import { Plus, Pencil, Trash2, Search, X, BookOpen } from 'lucide-react';
import { CurriculumEditor } from '@/components/admin/CurriculumEditor';
import { RichTextEditor } from '@/components/RichTextEditor';
import { formatIDR } from '@/lib/format';
@@ -315,6 +315,17 @@ export default function AdminProducts() {
</span>
</TableCell>
<TableCell className="text-right">
{product.type === 'bootcamp' && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/products/${product.id}/curriculum`)}
className="mr-1"
>
<BookOpen className="w-4 h-4 mr-1" />
Curriculum
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
<Pencil className="w-4 h-4" />
</Button>
@@ -349,6 +360,15 @@ export default function AdminProducts() {
<p className="text-sm text-muted-foreground capitalize">{product.type}</p>
</div>
<div className="flex gap-1 shrink-0">
{product.type === 'bootcamp' && (
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/admin/products/${product.id}/curriculum`)}
>
<BookOpen className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEdit(product)}>
<Pencil className="w-4 h-4" />
</Button>

View File

@@ -0,0 +1,614 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '@/integrations/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from '@/hooks/use-toast';
import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical, ArrowLeft, Save, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { RichTextEditor } from '@/components/RichTextEditor';
import { AppLayout } from '@/components/AppLayout';
interface Module {
id: string;
title: string;
position: number;
}
interface Lesson {
id: string;
module_id: string;
title: string;
content: string | null;
video_url: string | null;
youtube_url: string | null;
embed_code: string | null;
position: number;
release_at: string | null;
}
export default function ProductCurriculum() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [product, setProduct] = useState<any>(null);
const [modules, setModules] = useState<Module[]>([]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
// Lesson editing state
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [lessonForm, setLessonForm] = useState({
module_id: '',
title: '',
content: '',
video_url: '',
youtube_url: '',
embed_code: '',
release_at: '',
});
useEffect(() => {
if (id) {
fetchData();
}
}, [id]);
const fetchData = async () => {
if (!id) return;
const [productRes, modulesRes, lessonsRes] = await Promise.all([
supabase.from('products').select('id, title, slug').eq('id', id).single(),
supabase.from('bootcamp_modules').select('*').eq('product_id', id).order('position'),
supabase.from('bootcamp_lessons').select('*').order('position'),
]);
if (productRes.data) {
setProduct(productRes.data);
}
if (modulesRes.data) {
setModules(modulesRes.data);
setExpandedModules(new Set(modulesRes.data.map(m => m.id)));
}
if (lessonsRes.data) {
setLessons(lessonsRes.data);
}
setLoading(false);
};
const getLessonsForModule = (moduleId: string) => {
return lessons.filter(l => l.module_id === moduleId).sort((a, b) => a.position - b.position);
};
const getSelectedModule = () => {
return modules.find(m => m.id === selectedModuleId) || null;
};
const getSelectedLesson = () => {
return lessons.find(l => l.id === selectedLessonId) || null;
};
// Module CRUD
const handleAddModule = async () => {
if (!id) return;
const title = prompt('Module title:');
if (!title?.trim()) return;
const maxPosition = modules.length > 0 ? Math.max(...modules.map(m => m.position)) : 0;
const { error } = await supabase
.from('bootcamp_modules')
.insert({ product_id: id, title, position: maxPosition + 1 });
if (error) {
toast({ title: 'Error', description: 'Failed to create module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module created' });
fetchData();
}
};
const handleEditModule = async (module: Module) => {
const title = prompt('Module title:', module.title);
if (!title?.trim()) return;
const { error } = await supabase.from('bootcamp_modules').update({ title }).eq('id', module.id);
if (error) {
toast({ title: 'Error', description: 'Failed to update module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module updated' });
fetchData();
}
};
const handleDeleteModule = async (moduleId: string) => {
if (!confirm('Delete this module and all its lessons?')) return;
const { error } = await supabase.from('bootcamp_modules').delete().eq('id', moduleId);
if (error) {
toast({ title: 'Error', description: 'Failed to delete module', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Module deleted' });
if (selectedModuleId === moduleId) {
setSelectedModuleId(null);
setSelectedLessonId(null);
}
fetchData();
}
};
const moveModule = async (moduleId: string, direction: 'up' | 'down') => {
const index = modules.findIndex(m => m.id === moduleId);
if ((direction === 'up' && index === 0) || (direction === 'down' && index === modules.length - 1)) return;
const swapIndex = direction === 'up' ? index - 1 : index + 1;
const currentModule = modules[index];
const swapModule = modules[swapIndex];
await Promise.all([
supabase.from('bootcamp_modules').update({ position: swapModule.position }).eq('id', currentModule.id),
supabase.from('bootcamp_modules').update({ position: currentModule.position }).eq('id', swapModule.id),
]);
fetchData();
};
// Lesson CRUD
const handleAddLesson = (moduleId: string) => {
setEditingLesson(null);
setLessonForm({
module_id: moduleId,
title: '',
content: '',
video_url: '',
youtube_url: '',
embed_code: '',
release_at: '',
});
setSelectedModuleId(moduleId);
setSelectedLessonId('new');
};
const handleEditLesson = (lesson: Lesson) => {
setEditingLesson(lesson);
setLessonForm({
module_id: lesson.module_id,
title: lesson.title,
content: lesson.content || '',
video_url: lesson.video_url || '',
youtube_url: lesson.youtube_url || '',
embed_code: lesson.embed_code || '',
release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '',
});
setSelectedModuleId(lesson.module_id);
setSelectedLessonId(lesson.id);
};
const handleSaveLesson = async () => {
if (!lessonForm.title.trim()) {
toast({ title: 'Error', description: 'Lesson title is required', variant: 'destructive' });
return;
}
setSaving(true);
const lessonData = {
module_id: lessonForm.module_id,
title: lessonForm.title,
content: lessonForm.content || null,
video_url: lessonForm.video_url || null,
youtube_url: lessonForm.youtube_url || null,
embed_code: lessonForm.embed_code || null,
release_at: lessonForm.release_at ? new Date(lessonForm.release_at).toISOString() : null,
};
if (editingLesson) {
const { error } = await supabase.from('bootcamp_lessons').update(lessonData).eq('id', editingLesson.id);
if (error) {
toast({ title: 'Error', description: 'Failed to update lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson updated' });
fetchData();
}
} else {
const moduleLessons = getLessonsForModule(lessonForm.module_id);
const maxPosition = moduleLessons.length > 0 ? Math.max(...moduleLessons.map(l => l.position)) : 0;
const { error } = await supabase.from('bootcamp_lessons').insert({ ...lessonData, position: maxPosition + 1 });
if (error) {
toast({ title: 'Error', description: 'Failed to create lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson created' });
fetchData();
}
}
setSaving(false);
setSelectedLessonId(null);
};
const handleDeleteLesson = async (lessonId: string) => {
if (!confirm('Delete this lesson?')) return;
const { error } = await supabase.from('bootcamp_lessons').delete().eq('id', lessonId);
if (error) {
toast({ title: 'Error', description: 'Failed to delete lesson', variant: 'destructive' });
} else {
toast({ title: 'Success', description: 'Lesson deleted' });
if (selectedLessonId === lessonId) {
setSelectedLessonId(null);
}
fetchData();
}
};
const moveLesson = async (lessonId: string, direction: 'up' | 'down') => {
const lesson = lessons.find(l => l.id === lessonId);
if (!lesson) return;
const moduleLessons = getLessonsForModule(lesson.module_id);
const index = moduleLessons.findIndex(l => l.id === lessonId);
if ((direction === 'up' && index === 0) || (direction === 'down' && index === moduleLessons.length - 1)) return;
const swapIndex = direction === 'up' ? index - 1 : index + 1;
const swapLesson = moduleLessons[swapIndex];
await Promise.all([
supabase.from('bootcamp_lessons').update({ position: swapLesson.position }).eq('id', lesson.id),
supabase.from('bootcamp_lessons').update({ position: lesson.position }).eq('id', swapLesson.id),
]);
fetchData();
};
const toggleModule = (moduleId: string) => {
const newExpanded = new Set(expandedModules);
if (newExpanded.has(moduleId)) {
newExpanded.delete(moduleId);
} else {
newExpanded.add(moduleId);
}
setExpandedModules(newExpanded);
};
if (loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<div className="text-center text-muted-foreground">Loading...</div>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
{/* Header with breadcrumb */}
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" onClick={() => navigate('/admin/products')}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Products
</Button>
<div>
<h1 className="text-2xl font-bold">{product?.title}</h1>
<p className="text-sm text-muted-foreground">Curriculum Management</p>
</div>
</div>
<div className="grid grid-cols-12 gap-6">
{/* Left: Modules List (3 columns) */}
<div className="col-span-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Modules</CardTitle>
<Button size="sm" onClick={handleAddModule} className="w-full">
<Plus className="w-4 h-4 mr-2" />
Add Module
</Button>
</CardHeader>
<CardContent className="space-y-2">
{modules.map((module, index) => {
const moduleLessons = getLessonsForModule(module.id);
const isSelected = selectedModuleId === module.id;
return (
<div
key={module.id}
className={cn(
"p-3 border rounded cursor-pointer transition-colors group",
isSelected ? "border-primary bg-primary/5" : "hover:bg-gray-50 border-border"
)}
>
<div className="flex items-center justify-between">
<div
className="flex items-center gap-2 flex-1 min-w-0"
onClick={() => {
setSelectedModuleId(module.id);
if (expandedModules.has(module.id)) {
toggleModule(module.id);
} else {
const newExpanded = new Set(expandedModules);
newExpanded.add(module.id);
setExpandedModules(newExpanded);
}
}}
>
<GripVertical className="w-4 h-4 text-muted-foreground shrink-0" />
<span className="font-medium truncate">{module.title}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => moveModule(module.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => moveModule(module.id, 'down')}
disabled={index === modules.length - 1}
>
<ChevronDown className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleEditModule(module)}
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleDeleteModule(module.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground mt-1 pl-6">
{moduleLessons.length} lesson{moduleLessons.length !== 1 ? 's' : ''}
</div>
</div>
);
})}
{modules.length === 0 && (
<div className="text-center text-sm text-muted-foreground py-4">
No modules yet
</div>
)}
</CardContent>
</Card>
</div>
{/* Middle: Lessons List (5 columns) */}
<div className="col-span-5">
<Card>
<CardHeader>
<CardTitle className="text-base">Lessons</CardTitle>
{selectedModuleId && (
<Button size="sm" onClick={() => handleAddLesson(selectedModuleId)} className="w-full">
<Plus className="w-4 h-4 mr-2" />
Add Lesson
</Button>
)}
</CardHeader>
<CardContent>
{!selectedModuleId ? (
<p className="text-muted-foreground text-center py-8 text-sm">
Select a module to view lessons
</p>
) : (
<div className="space-y-2">
{getLessonsForModule(selectedModuleId).map((lesson, index) => {
const isSelected = selectedLessonId === lesson.id;
return (
<div
key={lesson.id}
className={cn(
"p-3 border rounded cursor-pointer transition-colors group",
isSelected ? "border-primary bg-primary/5" : "hover:bg-gray-50 border-border"
)}
>
<div className="flex items-center justify-between">
<div
className="flex-1 min-w-0"
onClick={() => handleEditLesson(lesson)}
>
<p className="font-medium text-sm truncate">{lesson.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{index + 1}. {lesson.video_url || lesson.youtube_url || lesson.embed_code ? '✓ Video' : 'No video'}
</span>
{lesson.youtube_url && (
<span className="text-xs text-blue-600">YouTube</span>
)}
{lesson.embed_code && (
<span className="text-xs text-purple-600">Embed</span>
)}
{lesson.content && (
<span className="text-xs text-muted-foreground"> Content</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => moveLesson(lesson.id, 'up')}
disabled={index === 0}
>
<ChevronUp className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => moveLesson(lesson.id, 'down')}
disabled={index === getLessonsForModule(selectedModuleId).length - 1}
>
<ChevronDown className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleDeleteLesson(lesson.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</div>
);
})}
{getLessonsForModule(selectedModuleId).length === 0 && (
<div className="text-center text-sm text-muted-foreground py-4">
No lessons yet
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Right: Lesson Editor (4 columns) */}
<div className="col-span-4">
<Card className="sticky top-4">
<CardHeader>
<CardTitle className="text-base">Lesson Editor</CardTitle>
</CardHeader>
<CardContent>
{!selectedLessonId ? (
<p className="text-muted-foreground text-center py-8 text-sm">
Select a lesson to edit
</p>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Title *</Label>
<Input
value={lessonForm.title}
onChange={(e) => setLessonForm({ ...lessonForm, title: e.target.value })}
placeholder="Lesson title"
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>YouTube URL (Primary)</Label>
<Input
value={lessonForm.youtube_url}
onChange={(e) => setLessonForm({ ...lessonForm, youtube_url: e.target.value })}
placeholder="https://www.youtube.com/watch?v=..."
className="border-2"
/>
{lessonForm.youtube_url && (
<p className="text-xs text-green-600"> YouTube configured</p>
)}
</div>
<div className="space-y-2">
<Label>Embed Code (Backup)</Label>
<textarea
value={lessonForm.embed_code}
onChange={(e) => setLessonForm({ ...lessonForm, embed_code: e.target.value })}
placeholder="<iframe>...</iframe>"
rows={4}
className="w-full px-3 py-2 border-2 border-border rounded-md font-mono text-sm"
/>
{lessonForm.embed_code && (
<p className="text-xs text-green-600"> Embed code configured</p>
)}
</div>
<div className="p-3 bg-muted rounded-md">
<p className="text-xs text-muted-foreground">
💡 <strong>Tip:</strong> Configure both YouTube URL and embed code for redundancy. Use product settings to toggle between sources.
</p>
</div>
<div className="space-y-2">
<Label>Content</Label>
<RichTextEditor
content={lessonForm.content}
onChange={(html) => setLessonForm({ ...lessonForm, content: html })}
placeholder="Write your lesson content here... Use code blocks for syntax highlighting."
className="min-h-[300px]"
/>
<p className="text-xs text-muted-foreground">
Supports rich text formatting, code blocks with syntax highlighting, images, and more.
</p>
</div>
<div className="space-y-2">
<Label>Release Date (optional)</Label>
<Input
type="date"
value={lessonForm.release_at}
onChange={(e) => setLessonForm({ ...lessonForm, release_at: e.target.value })}
className="border-2"
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveLesson} disabled={saving} className="flex-1">
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Lesson
</>
)}
</Button>
<Button
variant="outline"
onClick={() => setSelectedLessonId(null)}
disabled={saving}
className="border-2"
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</AppLayout>
);
}