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
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 37s
Details
This commit is contained in:
parent
97b0a0e880
commit
279a1510d3
|
|
@ -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)
|
||||||
|
|
|
||||||
22
server.ts
22
server.ts
|
|
@ -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) })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
108
src/App.tsx
108
src/App.tsx
|
|
@ -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(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue