472 lines
17 KiB
TypeScript
472 lines
17 KiB
TypeScript
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<Module[]>([]);
|
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [moduleDialogOpen, setModuleDialogOpen] = useState(false);
|
|
const [editingModule, setEditingModule] = useState<Module | null>(null);
|
|
const [moduleTitle, setModuleTitle] = useState('');
|
|
|
|
const [lessonDialogOpen, setLessonDialogOpen] = useState(false);
|
|
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
|
const [lessonForm, setLessonForm] = useState({
|
|
module_id: '',
|
|
title: '',
|
|
content: '',
|
|
video_url: '',
|
|
release_at: '',
|
|
});
|
|
|
|
const [expandedModules, setExpandedModules] = useState<Set<string>>(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 <div className="text-muted-foreground">Loading curriculum...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-lg font-semibold">Curriculum</h3>
|
|
<Button onClick={handleNewModule} size="sm" className="shadow-sm">
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Module
|
|
</Button>
|
|
</div>
|
|
|
|
{modules.length === 0 ? (
|
|
<Card className="border-2 border-dashed border-border">
|
|
<CardContent className="py-8 text-center">
|
|
<p className="text-muted-foreground mb-4">No modules yet. Create your first module to start building the curriculum.</p>
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{modules.map((module, moduleIndex) => (
|
|
<Card key={module.id} className="border-2 border-border">
|
|
<CardHeader className="py-3">
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => toggleModule(module.id)}
|
|
className="flex items-center gap-2 text-left"
|
|
>
|
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
|
<CardTitle className="text-base">{module.title}</CardTitle>
|
|
<span className="text-sm text-muted-foreground">
|
|
({getLessonsForModule(module.id).length} lessons)
|
|
</span>
|
|
</button>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveModule(module.id, 'up')}
|
|
disabled={moduleIndex === 0}
|
|
>
|
|
<ChevronUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveModule(module.id, 'down')}
|
|
disabled={moduleIndex === modules.length - 1}
|
|
>
|
|
<ChevronDown className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleEditModule(module)}>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteModule(module.id)}>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
{expandedModules.has(module.id) && (
|
|
<CardContent className="pt-0">
|
|
<div className="space-y-2">
|
|
{getLessonsForModule(module.id).map((lesson, lessonIndex) => (
|
|
<div
|
|
key={lesson.id}
|
|
className="flex items-center justify-between p-2 bg-muted rounded-md"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
|
<span className="text-sm">{lesson.title}</span>
|
|
{lesson.video_url && (
|
|
<span className="text-xs bg-secondary px-1.5 py-0.5 rounded">Video</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveLesson(lesson.id, 'up')}
|
|
disabled={lessonIndex === 0}
|
|
>
|
|
<ChevronUp className="w-3 h-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => moveLesson(lesson.id, 'down')}
|
|
disabled={lessonIndex === getLessonsForModule(module.id).length - 1}
|
|
>
|
|
<ChevronDown className="w-3 h-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleEditLesson(lesson)}>
|
|
<Pencil className="w-3 h-3" />
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => handleDeleteLesson(lesson.id)}>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleNewLesson(module.id)}
|
|
className="w-full border-dashed"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Add Lesson
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Module Dialog */}
|
|
<Dialog open={moduleDialogOpen} onOpenChange={setModuleDialogOpen}>
|
|
<DialogContent className="border-2 border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingModule ? 'Edit Module' : 'New Module'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label>Title *</Label>
|
|
<Input
|
|
value={moduleTitle}
|
|
onChange={(e) => setModuleTitle(e.target.value)}
|
|
placeholder="Module title"
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
<Button onClick={handleSaveModule} className="w-full shadow-sm">
|
|
Save Module
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Lesson Dialog */}
|
|
<Dialog open={lessonDialogOpen} onOpenChange={setLessonDialogOpen}>
|
|
<DialogContent className="max-w-xl border-2 border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingLesson ? 'Edit Lesson' : 'New Lesson'}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-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>Video URL</Label>
|
|
<Input
|
|
value={lessonForm.video_url}
|
|
onChange={(e) => setLessonForm({ ...lessonForm, video_url: e.target.value })}
|
|
placeholder="https://youtube.com/... or https://vimeo.com/..."
|
|
className="border-2"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Content (HTML)</Label>
|
|
<Textarea
|
|
value={lessonForm.content}
|
|
onChange={(e) => setLessonForm({ ...lessonForm, content: e.target.value })}
|
|
placeholder="Lesson content..."
|
|
rows={6}
|
|
className="border-2 font-mono text-sm"
|
|
/>
|
|
</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>
|
|
<Button onClick={handleSaveLesson} className="w-full shadow-sm">
|
|
Save Lesson
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|