diff --git a/src/App.tsx b/src/App.tsx index 6368947..134cb8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import ProductDetail from "./pages/ProductDetail"; import Checkout from "./pages/Checkout"; import Dashboard from "./pages/Dashboard"; import Admin from "./pages/Admin"; +import Bootcamp from "./pages/Bootcamp"; import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); @@ -32,6 +33,7 @@ const App = () => ( } /> } /> } /> + } /> } /> diff --git a/src/components/admin/CurriculumEditor.tsx b/src/components/admin/CurriculumEditor.tsx new file mode 100644 index 0000000..e5a6bcd --- /dev/null +++ b/src/components/admin/CurriculumEditor.tsx @@ -0,0 +1,471 @@ +import { useState, useEffect } from 'react'; +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 { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { toast } from '@/hooks/use-toast'; +import { Plus, Pencil, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface Module { + id: string; + title: string; + position: number; +} + +interface Lesson { + id: string; + module_id: string; + title: string; + content: string | null; + video_url: string | null; + position: number; + release_at: string | null; +} + +interface CurriculumEditorProps { + productId: string; +} + +export function CurriculumEditor({ productId }: CurriculumEditorProps) { + const [modules, setModules] = useState([]); + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + + const [moduleDialogOpen, setModuleDialogOpen] = useState(false); + const [editingModule, setEditingModule] = useState(null); + const [moduleTitle, setModuleTitle] = useState(''); + + const [lessonDialogOpen, setLessonDialogOpen] = useState(false); + const [editingLesson, setEditingLesson] = useState(null); + const [lessonForm, setLessonForm] = useState({ + module_id: '', + title: '', + content: '', + video_url: '', + release_at: '', + }); + + const [expandedModules, setExpandedModules] = useState>(new Set()); + + useEffect(() => { + fetchData(); + }, [productId]); + + const fetchData = async () => { + const [modulesRes, lessonsRes] = await Promise.all([ + supabase + .from('bootcamp_modules') + .select('*') + .eq('product_id', productId) + .order('position'), + supabase + .from('bootcamp_lessons') + .select('*') + .order('position'), + ]); + + if (modulesRes.data) { + setModules(modulesRes.data); + // Expand all modules by default + 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); + }; + + // Module CRUD + const handleNewModule = () => { + setEditingModule(null); + setModuleTitle(''); + setModuleDialogOpen(true); + }; + + const handleEditModule = (module: Module) => { + setEditingModule(module); + setModuleTitle(module.title); + setModuleDialogOpen(true); + }; + + const handleSaveModule = async () => { + if (!moduleTitle.trim()) { + toast({ title: 'Error', description: 'Module title is required', variant: 'destructive' }); + return; + } + + if (editingModule) { + const { error } = await supabase + .from('bootcamp_modules') + .update({ title: moduleTitle }) + .eq('id', editingModule.id); + + if (error) { + toast({ title: 'Error', description: 'Failed to update module', variant: 'destructive' }); + } else { + toast({ title: 'Success', description: 'Module updated' }); + setModuleDialogOpen(false); + fetchData(); + } + } else { + const maxPosition = modules.length > 0 ? Math.max(...modules.map(m => m.position)) : 0; + const { error } = await supabase + .from('bootcamp_modules') + .insert({ product_id: productId, title: moduleTitle, position: maxPosition + 1 }); + + if (error) { + toast({ title: 'Error', description: 'Failed to create module', variant: 'destructive' }); + } else { + toast({ title: 'Success', description: 'Module created' }); + setModuleDialogOpen(false); + 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' }); + 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 handleNewLesson = (moduleId: string) => { + setEditingLesson(null); + setLessonForm({ + module_id: moduleId, + title: '', + content: '', + video_url: '', + release_at: '', + }); + setLessonDialogOpen(true); + }; + + const handleEditLesson = (lesson: Lesson) => { + setEditingLesson(lesson); + setLessonForm({ + module_id: lesson.module_id, + title: lesson.title, + content: lesson.content || '', + video_url: lesson.video_url || '', + release_at: lesson.release_at ? lesson.release_at.split('T')[0] : '', + }); + setLessonDialogOpen(true); + }; + + const handleSaveLesson = async () => { + if (!lessonForm.title.trim()) { + toast({ title: 'Error', description: 'Lesson title is required', variant: 'destructive' }); + return; + } + + const lessonData = { + module_id: lessonForm.module_id, + title: lessonForm.title, + content: lessonForm.content || null, + video_url: lessonForm.video_url || 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' }); + setLessonDialogOpen(false); + 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' }); + setLessonDialogOpen(false); + fetchData(); + } + } + }; + + 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' }); + 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
Loading curriculum...
; + } + + return ( +
+
+

Curriculum

+ +
+ + {modules.length === 0 ? ( + + +

No modules yet. Create your first module to start building the curriculum.

+
+
+ ) : ( +
+ {modules.map((module, moduleIndex) => ( + + +
+ +
+ + + + +
+
+
+ {expandedModules.has(module.id) && ( + +
+ {getLessonsForModule(module.id).map((lesson, lessonIndex) => ( +
+
+ + {lesson.title} + {lesson.video_url && ( + Video + )} +
+
+ + + + +
+
+ ))} + +
+
+ )} +
+ ))} +
+ )} + + {/* Module Dialog */} + + + + {editingModule ? 'Edit Module' : 'New Module'} + +
+
+ + setModuleTitle(e.target.value)} + placeholder="Module title" + className="border-2" + /> +
+ +
+
+
+ + {/* Lesson Dialog */} + + + + {editingLesson ? 'Edit Lesson' : 'New Lesson'} + +
+
+ + setLessonForm({ ...lessonForm, title: e.target.value })} + placeholder="Lesson title" + className="border-2" + /> +
+
+ + setLessonForm({ ...lessonForm, video_url: e.target.value })} + placeholder="https://youtube.com/... or https://vimeo.com/..." + className="border-2" + /> +
+
+ +