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")
|
||||
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)
|
||||
|
|
|
|||
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 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) })
|
||||
}
|
||||
});
|
||||
|
|
|
|||
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 [selectedCourseId, setSelectedCourseId] = useState<string | 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
|
||||
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.
|
||||
</div>
|
||||
) : (
|
||||
categories.map(cat => (
|
||||
<CourseCarousel
|
||||
key={cat}
|
||||
title={cat}
|
||||
courses={courses.filter(c => 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 (
|
||||
<div key={cat} className="space-y-4">
|
||||
<CourseCarousel
|
||||
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>
|
||||
|
|
@ -317,7 +398,8 @@ export default function App() {
|
|||
<CourseView
|
||||
courseId={selectedCourseId}
|
||||
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';
|
||||
order: number;
|
||||
content: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
interface Module {
|
||||
|
|
@ -60,7 +61,7 @@ export default function AdminContentManager({ token }: AdminContentManagerProps)
|
|||
const [editingLesson, setEditingLesson] = useState<Lesson | null>(null);
|
||||
const [activeModuleForLesson, setActiveModuleForLesson] = useState<string | null>(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<string | null>(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)
|
|||
/>
|
||||
</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">
|
||||
<label className="text-xs text-zinc-400 font-bold uppercase font-sans">Recursos Extras & Material de Apoio (Opcional)</label>
|
||||
<textarea
|
||||
|
|
|
|||
|
|
@ -40,9 +40,10 @@ interface CourseViewProps {
|
|||
courseId: string;
|
||||
token: string | null;
|
||||
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 [modules, setModules] = useState<Module[]>([]);
|
||||
const [activeLesson, setActiveLesson] = useState<Lesson | null>(null);
|
||||
|
|
@ -72,13 +73,29 @@ export default function CourseView({ courseId, token, onBack }: CourseViewProps)
|
|||
setCourse(data.course);
|
||||
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) {
|
||||
const firstMod = data.modules[0];
|
||||
if (firstMod.lessons && firstMod.lessons.length > 0) {
|
||||
const firstLsn = firstMod.lessons[0];
|
||||
setActiveLesson(firstLsn);
|
||||
setNoteContent(firstLsn.note || '');
|
||||
let selectedLsn = null;
|
||||
if (initialLessonId) {
|
||||
for (const mod of data.modules) {
|
||||
const found = mod.lessons?.find((l: any) => l.id === initialLessonId);
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export interface Course {
|
|||
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
|
||||
price?: number; // Preço para compra avulsa (vitalícia)
|
||||
createdAt: string;
|
||||
lessons?: Lesson[];
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
|
|
@ -72,6 +73,7 @@ export interface Lesson {
|
|||
videoType: 'youtube' | 'direct';
|
||||
order: number;
|
||||
content: string; // Additional lesson resources/description
|
||||
thumbnailUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue