feat: adicionar reordenação por drag-and-drop para cursos módulos e aulas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s Details

This commit is contained in:
Sidney Gomes 2026-07-25 00:34:12 -03:00
parent 54d1646cba
commit 8ff22ca6d7
1 changed files with 154 additions and 8 deletions

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight, GraduationCap, Plus, Edit, Trash2, FolderPlus, FilePlus, ChevronRight,
ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2 ArrowLeft, Film, Youtube, RefreshCw, Layers, CheckCircle, CheckCircle2,
GripVertical
} from 'lucide-react'; } from 'lucide-react';
import { useModal } from '../contexts/ModalContext'; import { useModal } from '../contexts/ModalContext';
@ -75,6 +76,128 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [uploadingImage, setUploadingImage] = useState(false); const [uploadingImage, setUploadingImage] = useState(false);
// Drag and Drop States
const [draggedCourseIndex, setDraggedCourseIndex] = useState<number | null>(null);
const [draggedModuleIndex, setDraggedModuleIndex] = useState<number | null>(null);
const [draggedLessonInfo, setDraggedLessonInfo] = useState<{ lessonId: string, moduleId: string, index: number } | null>(null);
const handleCourseDragStart = (e: React.DragEvent, index: number) => {
setDraggedCourseIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleCourseDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleCourseDrop = async (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedCourseIndex === null || draggedCourseIndex === targetIndex) return;
const reorderedCourses = [...courses];
const [draggedCourse] = reorderedCourses.splice(draggedCourseIndex, 1);
reorderedCourses.splice(targetIndex, 0, draggedCourse);
setCourses(reorderedCourses);
setDraggedCourseIndex(null);
const itemsToSave = reorderedCourses.map((c, idx) => ({ id: c.id, order: idx + 1 }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'courses', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering courses:', err);
}
};
const handleModuleDragStart = (e: React.DragEvent, index: number) => {
setDraggedModuleIndex(index);
e.dataTransfer.effectAllowed = 'move';
};
const handleModuleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleModuleDrop = async (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedModuleIndex === null || draggedModuleIndex === targetIndex) return;
const reorderedModules = [...modules];
const [draggedModule] = reorderedModules.splice(draggedModuleIndex, 1);
reorderedModules.splice(targetIndex, 0, draggedModule);
const updatedModules = reorderedModules.map((m, idx) => ({ ...m, order: idx + 1 }));
setModules(updatedModules);
setDraggedModuleIndex(null);
const itemsToSave = updatedModules.map((m) => ({ id: m.id, order: m.order }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'modules', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering modules:', err);
}
};
const handleLessonDragStart = (e: React.DragEvent, lessonId: string, moduleId: string, index: number) => {
setDraggedLessonInfo({ lessonId, moduleId, index });
e.dataTransfer.effectAllowed = 'move';
};
const handleLessonDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleLessonDrop = async (e: React.DragEvent, targetModuleId: string, targetIndex: number) => {
e.preventDefault();
if (!draggedLessonInfo || draggedLessonInfo.moduleId !== targetModuleId || draggedLessonInfo.index === targetIndex) return;
const targetModule = modules.find(m => m.id === targetModuleId);
if (!targetModule) return;
const reorderedLessons = [...targetModule.lessons];
const [draggedLesson] = reorderedLessons.splice(draggedLessonInfo.index, 1);
reorderedLessons.splice(targetIndex, 0, draggedLesson);
const updatedLessons = reorderedLessons.map((l, idx) => ({ ...l, order: idx + 1 }));
const updatedModules = modules.map(m => {
if (m.id === targetModuleId) {
return { ...m, lessons: updatedLessons };
}
return m;
});
setModules(updatedModules);
setDraggedLessonInfo(null);
const itemsToSave = updatedLessons.map((l) => ({ id: l.id, order: l.order }));
try {
await fetch('/api/admin/reorder', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ type: 'lessons', items: itemsToSave })
});
} catch (err) {
console.error('Error reordering lessons:', err);
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@ -817,11 +940,19 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
</div> </div>
) : ( ) : (
modules.map((mod, modIdx) => ( modules.map((mod, modIdx) => (
<div key={mod.id} className="glass-card rounded-xl overflow-hidden"> <div
key={mod.id}
className="glass-card rounded-xl overflow-hidden"
draggable
onDragStart={(e) => handleModuleDragStart(e, modIdx)}
onDragOver={handleModuleDragOver}
onDrop={(e) => handleModuleDrop(e, modIdx)}
>
{/* Module Header Panel */} {/* Module Header Panel */}
<div className="glass-accordion-header px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div className="glass-accordion-header px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2 cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-zinc-500" />
<span className="bg-zinc-800 border border-zinc-700 text-[#E50914] text-[10px] font-extrabold px-1.5 py-0.5 rounded"> <span className="bg-zinc-800 border border-zinc-700 text-[#E50914] text-[10px] font-extrabold px-1.5 py-0.5 rounded">
M{mod.order} M{mod.order}
</span> </span>
@ -875,9 +1006,17 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
{mod.lessons.length === 0 ? ( {mod.lessons.length === 0 ? (
<div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div> <div className="p-4 text-xs text-zinc-600 italic text-center">Nenhuma aula cadastrada neste módulo.</div>
) : ( ) : (
mod.lessons.map(lesson => ( mod.lessons.map((lesson, lessonIdx) => (
<div key={lesson.id} className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors"> <div
<div className="flex items-center space-x-3 min-w-0"> key={lesson.id}
className="px-6 py-3 flex items-center justify-between text-xs hover:bg-zinc-900/10 transition-colors"
draggable
onDragStart={(e) => handleLessonDragStart(e, lesson.id, mod.id, lessonIdx)}
onDragOver={handleLessonDragOver}
onDrop={(e) => handleLessonDrop(e, mod.id, lessonIdx)}
>
<div className="flex items-center space-x-3 min-w-0 cursor-grab active:cursor-grabbing">
<GripVertical className="w-3.5 h-3.5 text-zinc-600 flex-shrink-0" />
<span className="text-zinc-500 font-mono font-bold w-4 text-right">#{lesson.order}</span> <span className="text-zinc-500 font-mono font-bold w-4 text-right">#{lesson.order}</span>
{lesson.videoType === 'youtube' ? ( {lesson.videoType === 'youtube' ? (
<Youtube className="w-4 h-4 text-red-500 flex-shrink-0" /> <Youtube className="w-4 h-4 text-red-500 flex-shrink-0" />
@ -921,18 +1060,25 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
) : ( ) : (
/* --- LIST ALL COURSES IN CATALOGUE --- */ /* --- LIST ALL COURSES IN CATALOGUE --- */
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(course => ( {courses.map((course, courseIdx) => (
<div <div
key={course.id} key={course.id}
className="glass-card rounded-2xl overflow-hidden flex flex-col justify-between" className="glass-card rounded-2xl overflow-hidden flex flex-col justify-between"
draggable
onDragStart={(e) => handleCourseDragStart(e, courseIdx)}
onDragOver={handleCourseDragOver}
onDrop={(e) => handleCourseDrop(e, courseIdx)}
> >
<div> <div>
{/* Thumbnail */} {/* Thumbnail */}
<div className="relative w-full aspect-video"> <div className="relative w-full aspect-video cursor-grab active:cursor-grabbing">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" /> <img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" referrerPolicy="no-referrer" />
<span className="absolute top-3 left-3 bg-[#E50914]/85 text-white text-[10px] font-black px-2 py-0.5 rounded uppercase tracking-wider glass-badge"> <span className="absolute top-3 left-3 bg-[#E50914]/85 text-white text-[10px] font-black px-2 py-0.5 rounded uppercase tracking-wider glass-badge">
{course.category} {course.category}
</span> </span>
<span className="absolute top-3 right-3 bg-black/60 text-white p-1 rounded backdrop-blur-sm border border-white/10 opacity-70 hover:opacity-100 transition-opacity">
<GripVertical className="w-3.5 h-3.5" />
</span>
</div> </div>
{/* Info */} {/* Info */}