diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d262b8a..b0bf2dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,6 +110,7 @@ model Lesson { videoType VideoType @map("video_type") order Int content String? @db.Text + thumbnailUrl String? @map("thumbnail_url") @db.Text createdAt DateTime @default(now()) @map("created_at") module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) diff --git a/server.ts b/server.ts index 6c998ba..7dca7c1 100644 --- a/server.ts +++ b/server.ts @@ -499,7 +499,18 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res const userProgressLessonIds = userProgress.map(p => p.lessonId); const coursesWithProgress = courses.map(course => { - const courseLessons = course.modules.flatMap(m => m.lessons); + const sortedModules = [...course.modules].sort((a, b) => a.order - b.order); + const courseLessons = sortedModules.flatMap(m => { + const sortedLessons = [...m.lessons].sort((a, b) => a.order - b.order); + return sortedLessons.map(l => ({ + id: l.id, + title: l.title, + thumbnailUrl: l.thumbnailUrl, + order: l.order, + moduleId: m.id + })); + }); + const totalLessons = courseLessons.length; let completedLessons = 0; @@ -520,7 +531,8 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res totalLessons, completedLessons, progress: progressPercentage, - isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin + isUnlocked: !course.isLocked || unlockedCourses.includes(course.id) || isAdmin, + lessons: courseLessons }; }); @@ -1577,7 +1589,7 @@ app.delete('/api/admin/modules/:id', authenticateToken, requireAdmin, async (req // 5. CRUD Lessons app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { - const { moduleId, title, videoUrl, videoType, order, content } = req.body; + const { moduleId, title, videoUrl, videoType, order, content, thumbnailUrl } = req.body; if (!moduleId || !title || !videoUrl || !videoType) { res.status(400).json({ error: 'Campos obrigatórios faltando' }); @@ -1593,6 +1605,7 @@ app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: Auth videoUrl, videoType, content: content || '', + thumbnailUrl: thumbnailUrl || '', order: order !== undefined ? Number(order) : count + 1 } }); @@ -1601,7 +1614,7 @@ app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: Auth }); app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { - const { title, videoUrl, videoType, order, content } = req.body; + const { title, videoUrl, videoType, order, content, thumbnailUrl } = req.body; const lessonId = req.params.id; try { @@ -1612,6 +1625,7 @@ app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: A ...(videoUrl && { videoUrl }), ...(videoType && { videoType }), ...(content !== undefined && { content }), + ...(thumbnailUrl !== undefined && { thumbnailUrl }), ...(order !== undefined && { order: Number(order) }) } }); diff --git a/src/App.tsx b/src/App.tsx index 7f9fe16..ac60e65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,8 @@ export default function App() { const [activeView, setView] = useState('home'); // home, course-view, subscription, admin-dashboard, admin-content const [selectedCourseId, setSelectedCourseId] = useState(null); const [unlockingCourse, setUnlockingCourse] = useState(null); + const [expandedCourseId, setExpandedCourseId] = useState(null); + const [selectedLessonId, setSelectedLessonId] = useState(null); // Authentication forms const [isRegister, setIsRegister] = useState(false); @@ -235,6 +237,8 @@ export default function App() { setUser(null); setView('home'); setSelectedCourseId(null); + setExpandedCourseId(null); + setSelectedLessonId(null); }; const handleSelectCourse = (courseId: string) => { @@ -243,8 +247,11 @@ export default function App() { setUnlockingCourse(selected); return; } - setSelectedCourseId(courseId); - setView('course-view'); + if (expandedCourseId === courseId) { + setExpandedCourseId(null); + } else { + setExpandedCourseId(courseId); + } }; // Group courses by Category @@ -297,16 +304,90 @@ export default function App() { Nenhum curso disponível no momento. ) : ( - categories.map(cat => ( - c.category === cat)} - onSelectCourse={handleSelectCourse} - onUnlockCourse={(course) => setUnlockingCourse(course)} - subscriptionStatus={user.subscriptionStatus} - /> - )) + categories.map(cat => { + const categoryCourses = courses.filter(c => c.category === cat); + const expandedCourse = categoryCourses.find(c => c.id === expandedCourseId); + + return ( +
+ setUnlockingCourse(course)} + subscriptionStatus={user.subscriptionStatus} + /> + + {/* Expanded Lessons Shelf */} + {expandedCourse && expandedCourse.lessons && expandedCourse.lessons.length > 0 && ( +
+
+ +
+
+

Aulas de:

+

{expandedCourse.title}

+
+ +
+ +
+ {expandedCourse.lessons.map(lesson => ( +
{ + setSelectedCourseId(expandedCourse.id); + setSelectedLessonId(lesson.id); + setView('course-view'); + }} + className="flex-shrink-0 w-60 md:w-64 bg-zinc-900/80 border border-white/5 rounded-xl overflow-hidden hover:border-[#E50914]/80 transition-all duration-300 group/lesson cursor-pointer snap-start" + > +
+ {lesson.thumbnailUrl ? ( + {lesson.title} + ) : ( +
+ AULA {lesson.order} + {lesson.title} +
+ )} + +
+
+ + + +
+
+
+ +
+ + Aula {lesson.order} + +

+ {lesson.title} +

+
+
+ ))} +
+
+ )} +
+ ); + }) )}
@@ -317,7 +398,8 @@ export default function App() { { setView('home'); setSelectedCourseId(null); fetchCourses(); }} + initialLessonId={selectedLessonId} + onBack={() => { setView('home'); setSelectedCourseId(null); setSelectedLessonId(null); fetchCourses(); }} /> )} diff --git a/src/components/AdminContentManager.tsx b/src/components/AdminContentManager.tsx index d4bf831..40c6206 100644 --- a/src/components/AdminContentManager.tsx +++ b/src/components/AdminContentManager.tsx @@ -12,6 +12,7 @@ interface Lesson { videoType: 'youtube' | 'direct'; order: number; content: string; + thumbnailUrl?: string; } interface Module { @@ -60,7 +61,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) const [editingLesson, setEditingLesson] = useState(null); const [activeModuleForLesson, setActiveModuleForLesson] = useState(null); const [lessonForm, setLessonForm] = useState({ - title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: '' + title: '', videoUrl: '', videoType: 'youtube' as 'youtube' | 'direct', order: 1, content: '', thumbnailUrl: '' }); const [managingActivityForModule, setManagingActivityForModule] = useState(null); @@ -294,7 +295,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) setShowLessonForm(false); setEditingLesson(null); setActiveModuleForLesson(null); - setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '' }); + setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '', thumbnailUrl: '' }); } } catch (err) { console.error('Error saving lesson:', err); @@ -309,7 +310,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) videoUrl: '', videoType: 'youtube', order: lessonsCount + 1, - content: '' + content: '', + thumbnailUrl: '' }); setShowLessonForm(true); setTimeout(() => { @@ -325,7 +327,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) videoUrl: lesson.videoUrl, videoType: lesson.videoType, order: lesson.order, - content: lesson.content || '' + content: lesson.content || '', + thumbnailUrl: lesson.thumbnailUrl || '' }); setShowLessonForm(true); setTimeout(() => { @@ -703,6 +706,17 @@ export default function AdminContentManager({ token }: AdminContentManagerProps) /> +
+ + setLessonForm({ ...lessonForm, thumbnailUrl: e.target.value })} + placeholder="Cole a URL da imagem de capa para esta aula (ex: https://meuservidor.com/capa.jpg)" + className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none" + /> +
+