Support horizontal scrolling lesson cards on course click and add thumbnail_url field for lessons in admin
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 37s Details

This commit is contained in:
Sidney Gomes 2026-07-24 17:49:58 -03:00
parent 97b0a0e880
commit 279a1510d3
6 changed files with 158 additions and 28 deletions

View File

@ -110,6 +110,7 @@ model Lesson {
videoType VideoType @map("video_type") videoType VideoType @map("video_type")
order Int order Int
content String? @db.Text content String? @db.Text
thumbnailUrl String? @map("thumbnail_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)

View File

@ -499,7 +499,18 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
const userProgressLessonIds = userProgress.map(p => p.lessonId); const userProgressLessonIds = userProgress.map(p => p.lessonId);
const coursesWithProgress = courses.map(course => { 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; const totalLessons = courseLessons.length;
let completedLessons = 0; let completedLessons = 0;
@ -520,7 +531,8 @@ app.get('/api/courses', authenticateToken, async (req: AuthenticatedRequest, res
totalLessons, totalLessons,
completedLessons, completedLessons,
progress: progressPercentage, 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 // 5. CRUD Lessons
app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: AuthenticatedRequest, res: Response) => { 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) { if (!moduleId || !title || !videoUrl || !videoType) {
res.status(400).json({ error: 'Campos obrigatórios faltando' }); res.status(400).json({ error: 'Campos obrigatórios faltando' });
@ -1593,6 +1605,7 @@ app.post('/api/admin/lessons', authenticateToken, requireAdmin, async (req: Auth
videoUrl, videoUrl,
videoType, videoType,
content: content || '', content: content || '',
thumbnailUrl: thumbnailUrl || '',
order: order !== undefined ? Number(order) : count + 1 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) => { 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; const lessonId = req.params.id;
try { try {
@ -1612,6 +1625,7 @@ app.put('/api/admin/lessons/:id', authenticateToken, requireAdmin, async (req: A
...(videoUrl && { videoUrl }), ...(videoUrl && { videoUrl }),
...(videoType && { videoType }), ...(videoType && { videoType }),
...(content !== undefined && { content }), ...(content !== undefined && { content }),
...(thumbnailUrl !== undefined && { thumbnailUrl }),
...(order !== undefined && { order: Number(order) }) ...(order !== undefined && { order: Number(order) })
} }
}); });

View File

@ -27,6 +27,8 @@ export default function App() {
const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content const [activeView, setView] = useState<string>('home'); // home, course-view, subscription, admin-dashboard, admin-content
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null); const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
const [unlockingCourse, setUnlockingCourse] = useState<CourseType | null>(null); const [unlockingCourse, setUnlockingCourse] = useState<CourseType | null>(null);
const [expandedCourseId, setExpandedCourseId] = useState<string | null>(null);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
// Authentication forms // Authentication forms
const [isRegister, setIsRegister] = useState(false); const [isRegister, setIsRegister] = useState(false);
@ -235,6 +237,8 @@ export default function App() {
setUser(null); setUser(null);
setView('home'); setView('home');
setSelectedCourseId(null); setSelectedCourseId(null);
setExpandedCourseId(null);
setSelectedLessonId(null);
}; };
const handleSelectCourse = (courseId: string) => { const handleSelectCourse = (courseId: string) => {
@ -243,8 +247,11 @@ export default function App() {
setUnlockingCourse(selected); setUnlockingCourse(selected);
return; return;
} }
setSelectedCourseId(courseId); if (expandedCourseId === courseId) {
setView('course-view'); setExpandedCourseId(null);
} else {
setExpandedCourseId(courseId);
}
}; };
// Group courses by Category // Group courses by Category
@ -297,16 +304,90 @@ export default function App() {
Nenhum curso disponível no momento. Nenhum curso disponível no momento.
</div> </div>
) : ( ) : (
categories.map(cat => ( categories.map(cat => {
<CourseCarousel const categoryCourses = courses.filter(c => c.category === cat);
key={cat} const expandedCourse = categoryCourses.find(c => c.id === expandedCourseId);
title={cat}
courses={courses.filter(c => c.category === cat)} return (
onSelectCourse={handleSelectCourse} <div key={cat} className="space-y-4">
onUnlockCourse={(course) => setUnlockingCourse(course)} <CourseCarousel
subscriptionStatus={user.subscriptionStatus} title={cat}
/> courses={categoryCourses}
)) onSelectCourse={handleSelectCourse}
onUnlockCourse={(course) => setUnlockingCourse(course)}
subscriptionStatus={user.subscriptionStatus}
/>
{/* Expanded Lessons Shelf */}
{expandedCourse && expandedCourse.lessons && expandedCourse.lessons.length > 0 && (
<div className="bg-[#141414]/90 border border-white/5 rounded-2xl p-6 space-y-4 shadow-2xl relative overflow-hidden animate-fadeIn">
<div className="absolute top-0 left-0 w-2 h-full bg-[#E50914]" />
<div className="flex items-center justify-between pl-2">
<div>
<h3 className="text-sm font-bold text-[#E50914] uppercase tracking-wider">Aulas de:</h3>
<p className="text-base md:text-lg font-extrabold text-white">{expandedCourse.title}</p>
</div>
<button
onClick={() => setExpandedCourseId(null)}
className="text-zinc-400 hover:text-white p-2 rounded-full hover:bg-zinc-800/50 transition-colors text-sm font-bold"
title="Fechar"
>
</button>
</div>
<div className="flex overflow-x-auto gap-4 py-2 pl-2 scrollbar-hide snap-x scroll-smooth">
{expandedCourse.lessons.map(lesson => (
<div
key={lesson.id}
onClick={() => {
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"
>
<div className="relative aspect-video w-full bg-zinc-950 overflow-hidden">
{lesson.thumbnailUrl ? (
<img
src={lesson.thumbnailUrl}
alt={lesson.title}
className="w-full h-full object-cover transition-transform duration-500 group-hover/lesson:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-zinc-900 to-zinc-950 flex flex-col items-center justify-center p-4 text-center">
<span className="text-[10px] text-zinc-500 font-extrabold tracking-widest uppercase mb-1">AULA {lesson.order}</span>
<span className="text-xs text-white/40 font-bold line-clamp-2">{lesson.title}</span>
</div>
)}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover/lesson:opacity-100 transition-opacity flex items-center justify-center">
<div className="bg-[#E50914] text-white p-2.5 rounded-full transform scale-75 group-hover/lesson:scale-100 transition-all duration-300 shadow-lg">
<svg className="w-5 h-5 fill-white" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
<div className="p-3 space-y-1">
<span className="text-[9px] font-bold text-[#E50914] tracking-wider uppercase">
Aula {lesson.order}
</span>
<h4 className="text-xs font-bold text-white group-hover/lesson:text-[#E50914] transition-colors line-clamp-2 leading-relaxed">
{lesson.title}
</h4>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
})
)} )}
</div> </div>
</div> </div>
@ -317,7 +398,8 @@ export default function App() {
<CourseView <CourseView
courseId={selectedCourseId} courseId={selectedCourseId}
token={token} token={token}
onBack={() => { setView('home'); setSelectedCourseId(null); fetchCourses(); }} initialLessonId={selectedLessonId}
onBack={() => { setView('home'); setSelectedCourseId(null); setSelectedLessonId(null); fetchCourses(); }}
/> />
)} )}

View File

@ -12,6 +12,7 @@ interface Lesson {
videoType: 'youtube' | 'direct'; videoType: 'youtube' | 'direct';
order: number; order: number;
content: string; content: string;
thumbnailUrl?: string;
} }
interface Module { interface Module {
@ -60,7 +61,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null); const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null); const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(null);
const [lessonForm, setLessonForm] = useState({ 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<string | null>(null); const [managingActivityForModule, setManagingActivityForModule] = useState<string | null>(null);
@ -294,7 +295,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
setShowLessonForm(false); setShowLessonForm(false);
setEditingLesson(null); setEditingLesson(null);
setActiveModuleForLesson(null); setActiveModuleForLesson(null);
setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '' }); setLessonForm({ title: '', videoUrl: '', videoType: 'youtube', order: 1, content: '', thumbnailUrl: '' });
} }
} catch (err) { } catch (err) {
console.error('Error saving lesson:', err); console.error('Error saving lesson:', err);
@ -309,7 +310,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
videoUrl: '', videoUrl: '',
videoType: 'youtube', videoType: 'youtube',
order: lessonsCount + 1, order: lessonsCount + 1,
content: '' content: '',
thumbnailUrl: ''
}); });
setShowLessonForm(true); setShowLessonForm(true);
setTimeout(() => { setTimeout(() => {
@ -325,7 +327,8 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
videoUrl: lesson.videoUrl, videoUrl: lesson.videoUrl,
videoType: lesson.videoType, videoType: lesson.videoType,
order: lesson.order, order: lesson.order,
content: lesson.content || '' content: lesson.content || '',
thumbnailUrl: lesson.thumbnailUrl || ''
}); });
setShowLessonForm(true); setShowLessonForm(true);
setTimeout(() => { setTimeout(() => {
@ -703,6 +706,17 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
/> />
</div> </div>
<div className="md:col-span-3 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase">Imagem de Capa da Aula (Thumbnail URL - Opcional)</label>
<input
type="text"
value={lessonForm.thumbnailUrl}
onChange={(e) => 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"
/>
</div>
<div className="md:col-span-3 space-y-2"> <div className="md:col-span-3 space-y-2">
<label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label> <label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label>
<textarea <textarea

View File

@ -40,9 +40,10 @@ interface CourseViewProps {
courseId: string; courseId: string;
token: string | null; token: string | null;
onBack: () => void; onBack: () => void;
initialLessonId?: string | null;
} }
export default function CourseView({ courseId, token, onBack }: CourseViewProps) { export default function CourseView({ courseId, token, onBack, initialLessonId }: CourseViewProps) {
const [course, setCourse] = useState<Course | null>(null); const [course, setCourse] = useState<Course | null>(null);
const [modules, setModules] = useState<Module[]>([]); const [modules, setModules] = useState<Module[]>([]);
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null); const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
@ -72,13 +73,29 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
setCourse(data.course); setCourse(data.course);
setModules(data.modules); setModules(data.modules);
// Default to the first lesson of the first module // Default to the selected initial lesson, or the first lesson of the first module
if (data.modules && data.modules.length > 0) { if (data.modules && data.modules.length > 0) {
const firstMod = data.modules[0]; let selectedLsn = null;
if (firstMod.lessons && firstMod.lessons.length > 0) { if (initialLessonId) {
const firstLsn = firstMod.lessons[0]; for (const mod of data.modules) {
setActiveLesson(firstLsn); const found = mod.lessons?.find((l: any) => l.id === initialLessonId);
setNoteContent(firstLsn.note || ''); if (found) {
selectedLsn = found;
break;
}
}
}
if (!selectedLsn) {
const firstMod = data.modules[0];
if (firstMod.lessons && firstMod.lessons.length > 0) {
selectedLsn = firstMod.lessons[0];
}
}
if (selectedLsn) {
setActiveLesson(selectedLsn);
setNoteContent(selectedLsn.note || '');
} }
} }
} catch (err: any) { } catch (err: any) {

View File

@ -43,6 +43,7 @@ export interface Course {
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
price?: number; // Preço para compra avulsa (vitalícia) price?: number; // Preço para compra avulsa (vitalícia)
createdAt: string; createdAt: string;
lessons?: Lesson[];
} }
export interface Plan { export interface Plan {
@ -72,6 +73,7 @@ export interface Lesson {
videoType: 'youtube' | 'direct'; videoType: 'youtube' | 'direct';
order: number; order: number;
content: string; // Additional lesson resources/description content: string; // Additional lesson resources/description
thumbnailUrl?: string;
createdAt: string; createdAt: string;
} }