527 lines
21 KiB
TypeScript
527 lines
21 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { ArrowLeft, BookOpen, ChevronRight, FileText, CheckCircle2, Circle, MessageSquare, AlertCircle, Award, Download } from 'lucide-react';
|
|
import VideoPlayer from './VideoPlayer';
|
|
import CourseActivity from './CourseActivity';
|
|
|
|
interface LessonProgress {
|
|
watchedPercentage: number;
|
|
completed: boolean;
|
|
}
|
|
|
|
interface Lesson {
|
|
id: string;
|
|
moduleId: string;
|
|
title: string;
|
|
videoUrl: string;
|
|
videoType: 'youtube' | 'direct';
|
|
order: number;
|
|
content: string;
|
|
progress: LessonProgress;
|
|
note: string;
|
|
}
|
|
|
|
interface Module {
|
|
id: string;
|
|
courseId: string;
|
|
title: string;
|
|
order: number;
|
|
lessons: Lesson[];
|
|
}
|
|
|
|
interface Course {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
thumbnail: string;
|
|
category: string;
|
|
studyMaterialUrl?: string;
|
|
}
|
|
|
|
interface CourseViewProps {
|
|
courseId: string;
|
|
token: string | null;
|
|
onBack: () => void;
|
|
initialLessonId?: string | null;
|
|
}
|
|
|
|
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);
|
|
const [expandedModules, setExpandedModules] = useState<string[]>([]);
|
|
const [noteContent, setNoteContent] = useState('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
const [activeActivityModuleId, setActiveActivityModuleId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchCourseDetails();
|
|
}, [courseId]);
|
|
|
|
const fetchCourseDetails = async () => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch(`/api/courses/${courseId}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error('Falha ao carregar detalhes do curso');
|
|
}
|
|
const data = await res.json();
|
|
setCourse(data.course);
|
|
setModules(data.modules);
|
|
|
|
// Default to the selected initial lesson, or the first lesson of the first module
|
|
if (data.modules && data.modules.length > 0) {
|
|
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) {
|
|
handleSelectLesson(selectedLsn, data.modules.find((m: any) => m.lessons.some((l: any) => l.id === selectedLsn.id))?.id);
|
|
}
|
|
}
|
|
} catch (err: any) {
|
|
setError(err.message || 'Erro inesperado.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectLesson = (lesson: Lesson, moduleId?: string) => {
|
|
setActiveLesson(lesson);
|
|
setNoteContent(lesson.note || '');
|
|
setSaveStatus('idle');
|
|
if (moduleId) {
|
|
setExpandedModules(prev => prev.includes(moduleId) ? prev : [...prev, moduleId]);
|
|
} else {
|
|
// Find module if not passed
|
|
const parentMod = modules.find(m => m.lessons.some(l => l.id === lesson.id));
|
|
if (parentMod) {
|
|
setExpandedModules(prev => prev.includes(parentMod.id) ? prev : [...prev, parentMod.id]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const toggleModule = (moduleId: string) => {
|
|
setExpandedModules(prev =>
|
|
prev.includes(moduleId) ? prev.filter(id => id !== moduleId) : [...prev, moduleId]
|
|
);
|
|
};
|
|
|
|
const totalLessonsCount = modules.reduce((acc, mod) => acc + mod.lessons.length, 0);
|
|
const completedLessonsCount = modules.reduce((acc, mod) => acc + mod.lessons.filter(l => l.progress?.completed).length, 0);
|
|
const overallProgress = totalLessonsCount === 0 ? 0 : Math.round((completedLessonsCount / totalLessonsCount) * 100);
|
|
|
|
const handleReact = async (type: string | null) => {
|
|
if (!activeLesson) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/lessons/${activeLesson.id}/react`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ type })
|
|
});
|
|
|
|
if (res.ok) {
|
|
// Update local state for lesson reaction
|
|
setModules(prev => prev.map(mod => {
|
|
return {
|
|
...mod,
|
|
lessons: mod.lessons.map(lsn => {
|
|
if (lsn.id === activeLesson.id) {
|
|
return { ...lsn, reaction: type };
|
|
}
|
|
return lsn;
|
|
})
|
|
};
|
|
}));
|
|
}
|
|
} catch (err) {
|
|
console.error('Error saving reaction:', err);
|
|
}
|
|
};
|
|
|
|
const handleProgressUpdate = async (watchedPercentage: number) => {
|
|
if (!activeLesson) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/lessons/${activeLesson.id}/progress`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ watchedPercentage })
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
|
|
// Update local state for lesson progress
|
|
setModules(prev => prev.map(mod => {
|
|
return {
|
|
...mod,
|
|
lessons: mod.lessons.map(lsn => {
|
|
if (lsn.id === activeLesson.id) {
|
|
return {
|
|
...lsn,
|
|
progress: {
|
|
watchedPercentage: Math.max(lsn.progress.watchedPercentage, watchedPercentage),
|
|
completed: data.completed || lsn.progress.completed
|
|
}
|
|
};
|
|
}
|
|
return lsn;
|
|
})
|
|
};
|
|
}));
|
|
|
|
// Also update the active lesson progress locally
|
|
setActiveLesson(prev => {
|
|
if (!prev) return null;
|
|
return {
|
|
...prev,
|
|
progress: {
|
|
watchedPercentage: Math.max(prev.progress.watchedPercentage, watchedPercentage),
|
|
completed: data.completed || prev.progress.completed
|
|
}
|
|
};
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error saving progress:', err);
|
|
}
|
|
};
|
|
|
|
const handleSaveNote = async () => {
|
|
if (!activeLesson) return;
|
|
setSaveStatus('saving');
|
|
|
|
try {
|
|
const res = await fetch(`/api/lessons/${activeLesson.id}/notes`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${token}`
|
|
},
|
|
body: JSON.stringify({ content: noteContent })
|
|
});
|
|
|
|
if (res.ok) {
|
|
setSaveStatus('saved');
|
|
|
|
// Update local modules array so state matches
|
|
setModules(prev => prev.map(mod => {
|
|
return {
|
|
...mod,
|
|
lessons: mod.lessons.map(lsn => {
|
|
if (lsn.id === activeLesson.id) {
|
|
return { ...lsn, note: noteContent };
|
|
}
|
|
return lsn;
|
|
})
|
|
};
|
|
}));
|
|
|
|
setTimeout(() => setSaveStatus('idle'), 3000);
|
|
} else {
|
|
setSaveStatus('error');
|
|
}
|
|
} catch (err) {
|
|
setSaveStatus('error');
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[60vh] text-zinc-400 space-y-4">
|
|
<div className="w-12 h-12 border-4 border-[#E50914] border-t-transparent rounded-full animate-spin" />
|
|
<p className="text-sm font-semibold tracking-wide">Preparando seu ambiente de estudos...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !course) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center max-w-md mx-auto p-4 space-y-4">
|
|
<AlertCircle className="w-12 h-12 text-[#E50914]" />
|
|
<h2 className="text-xl font-bold text-white">Oops! Algo deu errado</h2>
|
|
<p className="text-sm text-zinc-400">{error || 'Não foi possível carregar as informações deste curso.'}</p>
|
|
<button
|
|
onClick={onBack}
|
|
className="bg-zinc-800 hover:bg-zinc-700 text-white font-bold px-6 py-2 rounded-lg transition-colors flex items-center space-x-2"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
<span>Voltar para cursos</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeActivityModuleId) {
|
|
return (
|
|
<CourseActivity
|
|
token={token}
|
|
moduleId={activeActivityModuleId}
|
|
onBack={() => setActiveActivityModuleId(null)}
|
|
onCompleted={(passed) => {
|
|
if (passed) {
|
|
// Re-fetch course details to update any unlocked certificates if applicable
|
|
fetchCourseDetails();
|
|
}
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Back Header */}
|
|
<div className="flex items-center justify-between pb-4 border-b border-white/10">
|
|
<button
|
|
onClick={onBack}
|
|
className="group flex items-center space-x-2 text-zinc-400 hover:text-white transition-colors font-semibold text-sm cursor-pointer"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
|
<span>Voltar ao Catálogo</span>
|
|
</button>
|
|
<span className="text-zinc-500 text-xs font-bold uppercase tracking-widest">{course.category}</span>
|
|
</div>
|
|
|
|
{/* Main Grid: Player on left, lessons sidebar on right */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
|
|
{/* Left Hand: Video & Notes */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{activeLesson ? (
|
|
<div className="space-y-4">
|
|
<h2 className="text-xl md:text-2xl font-sans font-extrabold text-white tracking-tight">
|
|
{activeLesson.title}
|
|
</h2>
|
|
|
|
<VideoPlayer
|
|
videoUrl={activeLesson.videoUrl}
|
|
videoType={activeLesson.videoType}
|
|
lessonId={activeLesson.id}
|
|
onProgressUpdate={handleProgressUpdate}
|
|
initialProgress={activeLesson.progress?.watchedPercentage || 0}
|
|
reaction={activeLesson.reaction}
|
|
onReact={handleReact}
|
|
/>
|
|
|
|
{/* Lesson Description Content */}
|
|
{activeLesson.content && (
|
|
<div className="glass-card p-5 rounded-xl space-y-2">
|
|
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-widest flex items-center space-x-1.5">
|
|
<FileText className="w-3.5 h-3.5 text-[#E50914]" />
|
|
<span>Recursos & Descrição da Aula</span>
|
|
</h3>
|
|
<p className="text-zinc-300 text-sm leading-relaxed whitespace-pre-line">
|
|
{activeLesson.content}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes & Doubts panel */}
|
|
<div className="glass-card p-5 rounded-xl space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-xs font-bold text-zinc-400 uppercase tracking-widest flex items-center space-x-1.5">
|
|
<MessageSquare className="w-3.5 h-3.5 text-[#E50914]" />
|
|
<span>Suas Anotações e Dúvidas</span>
|
|
</h3>
|
|
{saveStatus === 'saving' && <span className="text-[10px] text-zinc-500">Salvando...</span>}
|
|
{saveStatus === 'saved' && <span className="text-[10px] text-emerald-500 font-bold">Salvo com sucesso!</span>}
|
|
{saveStatus === 'error' && <span className="text-[10px] text-red-500 font-bold">Erro ao salvar.</span>}
|
|
</div>
|
|
|
|
<textarea
|
|
value={noteContent}
|
|
onChange={(e) => setNoteContent(e.target.value)}
|
|
placeholder="Escreva aqui suas anotações pessoais, dúvidas ou resumos sobre esta aula... Seus apontamentos são salvos automaticamente."
|
|
className="w-full h-32 glass-input rounded-lg p-3 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none resize-none"
|
|
/>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSaveNote}
|
|
disabled={saveStatus === 'saving'}
|
|
className="glass-btn-secondary text-white font-bold text-xs px-4 py-2 rounded-lg cursor-pointer"
|
|
>
|
|
Salvar Anotações
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-[40vh] glass-card rounded-xl flex flex-col items-center justify-center text-zinc-500 text-center p-6">
|
|
<BookOpen className="w-12 h-12 text-zinc-700 mb-2" />
|
|
<p className="text-sm font-semibold text-zinc-400">Nenhuma aula cadastrada neste curso ainda.</p>
|
|
<p className="text-xs text-zinc-600 mt-1">O administrador adicionará conteúdo em breve!</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Hand: Interactive Modules Accordion */}
|
|
<div className="space-y-4">
|
|
<div className="glass-card p-4 rounded-xl space-y-3">
|
|
<div>
|
|
<h3 className="font-sans font-extrabold text-sm text-white tracking-tight">Conteúdo do Curso</h3>
|
|
<p className="text-xs text-zinc-500 font-medium mt-1">{course.title}</p>
|
|
</div>
|
|
|
|
{course.studyMaterialUrl && (
|
|
<a
|
|
href={course.studyMaterialUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center justify-center gap-2 w-full glass-btn-secondary text-[#E50914] text-xs font-bold py-2 px-3 rounded-lg hover:scale-[1.02] transition-all"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Baixar Material de Estudo
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overall Progress Block */}
|
|
<div className="glass-card rounded-xl p-4 mb-4 border border-white/5 flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="relative w-10 h-10 flex items-center justify-center flex-shrink-0">
|
|
<svg viewBox="0 0 36 36" className="w-10 h-10 -rotate-90 absolute inset-0">
|
|
<path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
<path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${overallProgress}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
</svg>
|
|
<span className="text-[10px] font-bold text-white z-10">{overallProgress}%</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm font-bold text-white">Meu progresso</h4>
|
|
<p className="text-xs text-zinc-500">{completedLessonsCount} de {totalLessonsCount} {totalLessonsCount === 1 ? 'aula' : 'aulas'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{modules.map((mod, modIdx) => {
|
|
const isExpanded = expandedModules.includes(mod.id);
|
|
const moduleTotal = mod.lessons.length;
|
|
const moduleCompleted = mod.lessons.filter(l => l.progress?.completed).length;
|
|
const isModuleFullyCompleted = moduleTotal > 0 && moduleTotal === moduleCompleted;
|
|
const moduleProgressPercent = moduleTotal > 0 ? Math.round((moduleCompleted / moduleTotal) * 100) : 0;
|
|
|
|
return (
|
|
<div
|
|
key={mod.id}
|
|
className="glass-card rounded-xl overflow-hidden border border-white/5"
|
|
>
|
|
{/* Module Title bar (Clickable) */}
|
|
<button
|
|
onClick={() => toggleModule(mod.id)}
|
|
className="w-full text-left px-4 py-3.5 flex items-center justify-between hover:bg-white/5 transition-colors"
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="relative w-8 h-8 flex items-center justify-center flex-shrink-0">
|
|
<svg viewBox="0 0 36 36" className="w-8 h-8 -rotate-90 absolute inset-0">
|
|
<path className="text-zinc-800" strokeWidth="3" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
<path className="text-[#E50914] transition-all duration-1000 ease-out" strokeDasharray={`${moduleProgressPercent}, 100`} strokeWidth="3" strokeLinecap="round" stroke="currentColor" fill="none" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
|
</svg>
|
|
<span className="text-[8px] font-bold text-white z-10">{moduleProgressPercent}%</span>
|
|
</div>
|
|
<div>
|
|
<h4 className="font-sans font-bold text-xs text-zinc-200 tracking-tight leading-snug line-clamp-1">
|
|
{mod.title}
|
|
</h4>
|
|
<p className="text-[10px] text-zinc-500 mt-0.5">
|
|
{moduleTotal} {moduleTotal === 1 ? 'aula' : 'aulas'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Lessons list inside module */}
|
|
{isExpanded && (
|
|
<div className="px-4 pb-3">
|
|
<div className="ml-2 border-l border-zinc-700 pl-4 py-1 space-y-1">
|
|
{mod.lessons.length === 0 ? (
|
|
<div className="py-2 text-xs text-zinc-600 italic">Nenhuma aula neste módulo.</div>
|
|
) : (
|
|
mod.lessons.map(lesson => {
|
|
const isActive = activeLesson?.id === lesson.id;
|
|
const isLsnCompleted = lesson.progress?.completed;
|
|
|
|
return (
|
|
<button
|
|
key={lesson.id}
|
|
onClick={() => handleSelectLesson(lesson, mod.id)}
|
|
className="relative w-full py-2.5 text-left flex items-start space-x-3 group"
|
|
>
|
|
{/* Timeline Dot indicator overlaying the border */}
|
|
<div className={`absolute -left-[21px] top-3.5 w-2 h-2 rounded-full ring-4 ring-[#141414] ${
|
|
isLsnCompleted ? 'bg-emerald-500' : isActive ? 'bg-[#E50914]' : 'bg-zinc-600'
|
|
}`} />
|
|
|
|
<div className="space-y-0.5">
|
|
<span className={`font-medium text-xs leading-snug line-clamp-2 transition-colors ${
|
|
isActive ? 'text-[#E50914] font-bold' : 'text-zinc-400 group-hover:text-zinc-200'
|
|
}`}>
|
|
{lesson.order}. {lesson.title}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{/* Module Activity Button (End of Timeline) */}
|
|
{mod.lessons.length > 0 && (
|
|
<div className="relative pt-4 pb-1">
|
|
<div className="absolute -left-[21px] top-[22px] w-2 h-2 rounded-full bg-zinc-700 ring-4 ring-[#141414]" />
|
|
<button
|
|
onClick={() => isModuleFullyCompleted && setActiveActivityModuleId(mod.id)}
|
|
disabled={!isModuleFullyCompleted}
|
|
className={`w-full flex items-center justify-center space-x-2 transition-all py-2.5 px-3 rounded-lg text-xs font-extrabold uppercase tracking-wider shadow-md border ${
|
|
isModuleFullyCompleted
|
|
? 'glass-btn-primary hover:scale-[1.02] text-white cursor-pointer border-transparent'
|
|
: 'bg-zinc-800/50 text-zinc-500 cursor-not-allowed border-zinc-700/50'
|
|
}`}
|
|
title={!isModuleFullyCompleted ? "Conclua todas as aulas deste módulo para liberar a avaliação" : ""}
|
|
>
|
|
<Award className="w-4 h-4 flex-shrink-0" />
|
|
<span className="truncate">Avaliação do Módulo</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|