Files
meet-hub/src/components/admin/CurriculumEditor.tsx
gpt-engineer-app[bot] de98ccfc49 Changes
2025-12-18 17:15:45 +00:00

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>
);
}