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
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 59s
Details
This commit is contained in:
parent
54d1646cba
commit
8ff22ca6d7
|
|
@ -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 */}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue