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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
614
src/pages/admin/ProductCurriculum.tsx
Normal file
614
src/pages/admin/ProductCurriculum.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user