683 lines
32 KiB
TypeScript
683 lines
32 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
X,
|
|
Send,
|
|
User as UserIcon,
|
|
Lightbulb,
|
|
Paperclip,
|
|
Lock,
|
|
Unlock,
|
|
ListVideo,
|
|
Play,
|
|
Download,
|
|
Sparkles,
|
|
MessageSquare,
|
|
CheckCircle2,
|
|
Check,
|
|
Share2,
|
|
FileText
|
|
} from 'lucide-react';
|
|
import { Course, Comment, Lesson, Attachment } from '../types';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
|
|
import { LoginModal } from './LoginModal';
|
|
import { MaterialCard } from './MaterialCard';
|
|
import { courseApi, LessonPlayback } from '../services/api';
|
|
|
|
declare global {
|
|
interface Window {
|
|
playerjs?: {
|
|
Player: new (element: HTMLIFrameElement) => {
|
|
on: (event: string, listener: (data?: { seconds?: number; duration?: number }) => void) => void;
|
|
};
|
|
};
|
|
}
|
|
}
|
|
|
|
let bunnyPlayerJsPromise: Promise<void> | null = null;
|
|
|
|
const loadBunnyPlayerJs = () => {
|
|
if (window.playerjs) return Promise.resolve();
|
|
if (bunnyPlayerJsPromise) return bunnyPlayerJsPromise;
|
|
bunnyPlayerJsPromise = new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://assets.mediadelivery.net/playerjs/player-0.1.0.min.js';
|
|
script.async = true;
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('Bunny player events could not be loaded'));
|
|
document.head.appendChild(script);
|
|
});
|
|
return bunnyPlayerJsPromise;
|
|
};
|
|
|
|
interface CoursePlayerModalProps {
|
|
course: Course | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, onClose }) => {
|
|
const { user } = useAuth();
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [newComment, setNewComment] = useState('');
|
|
const [currentLesson, setCurrentLesson] = useState<Lesson | null>(null);
|
|
const [completedLessonIds, setCompletedLessonIds] = useState<string[]>([]);
|
|
const [watchedSecondsByLesson, setWatchedSecondsByLesson] = useState<Record<string, number>>({});
|
|
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<'playlist' | 'materials' | 'discussion'>('playlist');
|
|
const [copiedLink, setCopiedLink] = useState(false);
|
|
const [playback, setPlayback] = useState<LessonPlayback | null>(null);
|
|
const [playbackError, setPlaybackError] = useState('');
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const bunnyFrameRef = useRef<HTMLIFrameElement>(null);
|
|
const lastProgressSave = useRef<Record<string, number>>({});
|
|
|
|
// Load course data and this learner's saved state from PostgreSQL.
|
|
useEffect(() => {
|
|
if (course) {
|
|
incrementViews(course.id);
|
|
setCompletedLessonIds([]);
|
|
setWatchedSecondsByLesson({});
|
|
lastProgressSave.current = {};
|
|
if (user) {
|
|
getComments(course.id).then(setComments).catch(() => setComments([]));
|
|
getLessonProgress(course.id).then((progress) => {
|
|
const completed = progress.filter((item) => item.completedAt).map((item) => item.lessonId);
|
|
setCompletedLessonIds(completed);
|
|
setWatchedSecondsByLesson(Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds])));
|
|
lastProgressSave.current = Object.fromEntries(progress.map((item) => [item.lessonId, item.watchedSeconds]));
|
|
const nextIncomplete = course.lessons.find((lesson) => !completed.includes(lesson.id));
|
|
if (nextIncomplete) setCurrentLesson(nextIncomplete);
|
|
}).catch(() => setCompletedLessonIds([]));
|
|
} else {
|
|
setComments([]);
|
|
}
|
|
|
|
// Select first lesson by default
|
|
if (course.lessons && course.lessons.length > 0) {
|
|
setCurrentLesson(course.lessons[0]);
|
|
}
|
|
}
|
|
}, [course, user]);
|
|
|
|
// The API verifies the session before returning a time-limited Bunny embed
|
|
// URL. A video provider key is never sent to the browser.
|
|
useEffect(() => {
|
|
if (!course || !user || !currentLesson) {
|
|
setPlayback(null);
|
|
setPlaybackError('');
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setPlayback(null);
|
|
setPlaybackError('');
|
|
courseApi.playback(course.id, currentLesson.id)
|
|
.then((response) => {
|
|
if (!cancelled) setPlayback(response.data);
|
|
})
|
|
.catch((error) => {
|
|
if (!cancelled) setPlaybackError(error instanceof Error ? error.message : 'Não foi possível carregar este vídeo.');
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [course, user, currentLesson]);
|
|
|
|
// Persist completed lessons and recalculate progress
|
|
const updateCompletedLessons = (newCompleted: string[]) => {
|
|
setCompletedLessonIds(newCompleted);
|
|
if (course) {
|
|
}
|
|
};
|
|
|
|
const toggleLessonCompleted = (lessonId: string) => {
|
|
if (!user) {
|
|
setShowLoginPrompt(true);
|
|
return;
|
|
}
|
|
if (completedLessonIds.includes(lessonId)) {
|
|
const updated = completedLessonIds.filter(id => id !== lessonId);
|
|
updateCompletedLessons(updated);
|
|
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
|
|
} else {
|
|
markLessonCompleted(lessonId);
|
|
}
|
|
};
|
|
|
|
const markLessonCompleted = (lessonId: string, watchedSeconds?: number) => {
|
|
if (!user || completedLessonIds.includes(lessonId)) return;
|
|
const previous = completedLessonIds;
|
|
updateCompletedLessons([...previous, lessonId]);
|
|
saveLessonCompletion(lessonId, true, watchedSeconds).catch(() => updateCompletedLessons(previous));
|
|
};
|
|
|
|
// Video finished automatically -> mark lesson as completed
|
|
const handleVideoEnded = () => {
|
|
if (currentLesson && !completedLessonIds.includes(currentLesson.id)) {
|
|
markLessonCompleted(currentLesson.id, Math.round(videoRef.current?.duration || 0));
|
|
}
|
|
|
|
// Auto-advance to next lesson if available
|
|
if (course?.lessons && currentLesson) {
|
|
const currentIndex = course.lessons.findIndex(l => l.id === currentLesson.id);
|
|
if (currentIndex >= 0 && currentIndex < course.lessons.length - 1) {
|
|
const nextLesson = course.lessons[currentIndex + 1];
|
|
if (user) {
|
|
setTimeout(() => {
|
|
setCurrentLesson(nextLesson);
|
|
}, 1000);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Track video progress
|
|
const handleTimeUpdate = () => {
|
|
if (videoRef.current) {
|
|
const { currentTime, duration } = videoRef.current;
|
|
if (duration > 0) {
|
|
if (user && currentLesson && currentTime - (lastProgressSave.current[currentLesson.id] || 0) >= 30) {
|
|
const watchedSeconds = Math.floor(currentTime);
|
|
lastProgressSave.current[currentLesson.id] = watchedSeconds;
|
|
setWatchedSecondsByLesson((current) => ({ ...current, [currentLesson.id]: watchedSeconds }));
|
|
saveLessonProgress(currentLesson.id, watchedSeconds).catch(() => undefined);
|
|
}
|
|
const percent = Math.round((currentTime / duration) * 100);
|
|
// Automatically mark as complete when reaching 95%+
|
|
if (percent >= 95 && currentLesson && !completedLessonIds.includes(currentLesson.id)) {
|
|
markLessonCompleted(currentLesson.id, Math.round(currentTime));
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Bunny's iframe does not expose an HTMLVideoElement. Player.js receives
|
|
// Bunny's trusted playback events and sends the same progress updates used
|
|
// for external video providers.
|
|
useEffect(() => {
|
|
if (!playback || playback.kind !== 'embed' || !currentLesson || !user) return;
|
|
let disposed = false;
|
|
const lessonId = currentLesson.id;
|
|
void loadBunnyPlayerJs().then(() => {
|
|
if (disposed || !bunnyFrameRef.current || !window.playerjs) return;
|
|
const player = new window.playerjs.Player(bunnyFrameRef.current);
|
|
player.on('timeupdate', (data) => {
|
|
const watchedSeconds = Math.floor(data?.seconds || 0);
|
|
if (disposed || watchedSeconds <= 0 || watchedSeconds - (lastProgressSave.current[lessonId] || 0) < 30) return;
|
|
lastProgressSave.current[lessonId] = watchedSeconds;
|
|
setWatchedSecondsByLesson((current) => ({ ...current, [lessonId]: watchedSeconds }));
|
|
saveLessonProgress(lessonId, watchedSeconds).catch(() => undefined);
|
|
});
|
|
player.on('ended', (data) => {
|
|
if (disposed) return;
|
|
markLessonCompleted(lessonId, Math.floor(data?.duration || data?.seconds || 0));
|
|
});
|
|
}).catch(() => undefined);
|
|
return () => { disposed = true; };
|
|
}, [playback, currentLesson, user, completedLessonIds]);
|
|
|
|
const restoreWatchPosition = () => {
|
|
if (!currentLesson || !videoRef.current) return;
|
|
const watchedSeconds = watchedSecondsByLesson[currentLesson.id] || 0;
|
|
if (watchedSeconds > 0 && watchedSeconds < videoRef.current.duration - 5) videoRef.current.currentTime = watchedSeconds;
|
|
};
|
|
|
|
const handleAddComment = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newComment.trim() || !course) return;
|
|
|
|
const comment = {
|
|
courseId: course.id,
|
|
lessonId: currentLesson?.id,
|
|
text: newComment,
|
|
};
|
|
|
|
try {
|
|
const savedComment = await saveComment(comment);
|
|
setComments([savedComment, ...comments]);
|
|
setNewComment('');
|
|
} catch {
|
|
// The server remains the source of truth; leave the text intact for a retry.
|
|
}
|
|
};
|
|
|
|
const changeLesson = (lesson: Lesson) => {
|
|
setCurrentLesson(lesson);
|
|
};
|
|
|
|
const handleShare = () => {
|
|
navigator.clipboard.writeText(window.location.href);
|
|
setCopiedLink(true);
|
|
setTimeout(() => setCopiedLink(false), 2500);
|
|
};
|
|
|
|
// Collect all downloadable materials for this course (Global + All Lessons)
|
|
const allCourseAttachments: Array<{ attachment: Attachment; source: string }> = [];
|
|
|
|
if (course?.attachments) {
|
|
course.attachments.forEach(att => {
|
|
allCourseAttachments.push({ attachment: att, source: 'Material Geral do Curso' });
|
|
});
|
|
}
|
|
|
|
if (course?.lessons) {
|
|
course.lessons.forEach(l => {
|
|
if (l.attachments) {
|
|
l.attachments.forEach(att => {
|
|
allCourseAttachments.push({ attachment: att, source: l.title });
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
const currentLessonAttachments = currentLesson?.attachments || [];
|
|
|
|
// Access Control Logic
|
|
const canWatch = currentLesson && Boolean(user);
|
|
|
|
if (!course) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] bg-[#09090b] text-white overflow-y-auto animate-fade-in-up">
|
|
{/* Top Bar Floating Controls */}
|
|
<div className="fixed top-0 left-0 right-0 p-4 md:p-6 z-30 flex items-center justify-between bg-gradient-to-b from-black/90 via-black/40 to-transparent pointer-events-none">
|
|
<div className="flex items-center gap-3 pointer-events-auto bg-black/60 backdrop-blur-md px-4 py-2 rounded-[980px] border border-white/10">
|
|
<span className="w-2 h-2 rounded-[980px] bg-orange-500 animate-pulse"></span>
|
|
<span className="text-caption font-semibold tracking-caption text-gray-200 truncate max-w-[200px] md:max-w-md">
|
|
{course.title}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 pointer-events-auto">
|
|
<button
|
|
onClick={handleShare}
|
|
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 backdrop-blur-md text-white text-body-sm font-semibold tracking-body-sm px-4 py-2.5 rounded-[980px] transition-all active:scale-95 shadow-xl"
|
|
title="Copiar Link"
|
|
>
|
|
<Share2 className="w-4 h-4 text-orange-400" />
|
|
<span className="hidden sm:inline">{copiedLink ? 'Link Copiado!' : 'Compartilhar'}</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={onClose}
|
|
className="bg-white/10 hover:bg-white/20 backdrop-blur-md p-2.5 rounded-[980px] transition-all active:scale-95 text-white shadow-xl"
|
|
title="Fechar"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-[1440px] mx-auto min-h-screen pt-16 md:pt-20 pb-20">
|
|
<div className="flex flex-col lg:flex-row gap-8 px-4 md:px-8">
|
|
|
|
{/* Main Left Column: Video & Lesson Details */}
|
|
<div className="flex-1 lg:max-w-[65%] xl:max-w-[68%]">
|
|
|
|
{/* Video Player Section */}
|
|
<div className="w-full aspect-video bg-black rounded-[8px] overflow-hidden relative shadow-xl border border-white/10 group">
|
|
{canWatch ? (
|
|
playback ? (
|
|
playback.kind === 'embed' && playback.embedUrl ? (
|
|
<iframe
|
|
key={currentLesson?.id}
|
|
ref={bunnyFrameRef}
|
|
src={playback.embedUrl}
|
|
title={currentLesson?.title || 'Vídeo da aula'}
|
|
className="w-full h-full border-0 rounded-[8px]"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; fullscreen"
|
|
allowFullScreen
|
|
/>
|
|
) : playback.source ? (
|
|
<video
|
|
key={currentLesson?.id}
|
|
ref={videoRef}
|
|
src={playback.source}
|
|
className="w-full h-full object-contain rounded-[8px]"
|
|
controls
|
|
autoPlay
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onEnded={handleVideoEnded}
|
|
onLoadedMetadata={restoreWatchPosition}
|
|
>
|
|
Seu navegador não suporta a tag de vídeo.
|
|
</video>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-gray-400">Vídeo indisponível.</div>
|
|
)
|
|
) : (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-6 text-sm text-gray-400">
|
|
{playbackError ? <span className="text-amber-300">{playbackError}</span> : <span>Carregando vídeo seguro...</span>}
|
|
</div>
|
|
)
|
|
) : (
|
|
// Locked State
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-950/90 backdrop-blur-md z-10 text-center p-6 rounded-[8px]">
|
|
<div className="bg-orange-500/10 border border-orange-500/20 p-4 rounded-[8px] mb-4 text-orange-400">
|
|
<Lock className="w-8 h-8" />
|
|
</div>
|
|
<h2 className="font-display text-subheading md:text-heading-sm font-bold tracking-heading-sm leading-heading-sm mb-2">Aula Exclusiva (Pro)</h2>
|
|
<p className="text-body-sm font-normal text-gray-400 mb-6 max-w-md leading-body-sm tracking-body-sm">
|
|
Esta aula e seus materiais de download complementares estão disponíveis para membros. Faça login para continuar assistindo.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowLoginPrompt(true)}
|
|
className="bg-orange-500 hover:bg-orange-600 text-white text-body-sm font-semibold tracking-body-sm px-8 py-3 rounded-[980px] transition-all shadow-xl shadow-orange-500/25 active:scale-95"
|
|
>
|
|
Fazer Login para Acessar
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Current Lesson Header Info */}
|
|
<div className="mt-6 mb-8">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
|
<div className="flex items-center gap-2.5 text-caption font-semibold uppercase tracking-caption text-orange-400">
|
|
<span>{course.category}</span>
|
|
<span className="w-1 h-1 rounded-[980px] bg-zinc-700"></span>
|
|
<span className="text-gray-400">{currentLesson?.duration || '15m'}</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Mark as completed toggle button */}
|
|
{currentLesson && (
|
|
<button
|
|
onClick={() => toggleLessonCompleted(currentLesson.id)}
|
|
className={`inline-flex items-center gap-1.5 text-xs font-semibold px-3.5 py-1.5 rounded-[980px] transition-all active:scale-95 ${
|
|
completedLessonIds.includes(currentLesson.id)
|
|
? 'bg-emerald-500/20 text-emerald-400 hover:bg-emerald-500/30 border border-emerald-500/30 font-bold'
|
|
: 'bg-white/10 hover:bg-emerald-500/20 hover:text-emerald-300 text-gray-300'
|
|
}`}
|
|
>
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
<span>{completedLessonIds.includes(currentLesson.id) ? 'Aula Concluída' : 'Concluir Aula'}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Direct Button to Materials Tab if Available */}
|
|
{allCourseAttachments.length > 0 && (
|
|
<button
|
|
onClick={() => setActiveTab('materials')}
|
|
className="inline-flex items-center gap-2 text-caption font-semibold tracking-caption bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20 px-3.5 py-1.5 rounded-[980px] transition-all"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>{allCourseAttachments.length} {allCourseAttachments.length === 1 ? 'Material' : 'Materiais'}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<h1 className="font-display text-subheading sm:text-heading-sm md:text-heading font-bold tracking-heading leading-heading text-white mb-3">
|
|
{currentLesson?.title || course.title}
|
|
</h1>
|
|
|
|
<p className="text-body-sm sm:text-body text-gray-300 leading-body tracking-body font-normal mb-6">
|
|
{currentLesson?.description || course.description}
|
|
</p>
|
|
|
|
{/* Lesson Specific Quick Downloads (if present) */}
|
|
{currentLessonAttachments.length > 0 && (
|
|
<div className="p-6 rounded-[8px] bg-zinc-900/80 border border-orange-500/20 mb-8 space-y-4 shadow-xl">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-caption font-semibold uppercase tracking-caption text-orange-400 flex items-center gap-2">
|
|
<Download className="w-4 h-4" />
|
|
Downloads Desta Aula
|
|
</h4>
|
|
<span className="text-caption font-mono text-gray-400">
|
|
{currentLessonAttachments.length} {currentLessonAttachments.length === 1 ? 'arquivo' : 'arquivos'}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{currentLessonAttachments.map(att => (
|
|
<MaterialCard key={att.id} attachment={att} compact />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Instructor Key Takeaways / Tips */}
|
|
{course.tips && course.tips.length > 0 && (
|
|
<div className="bg-gradient-to-br from-zinc-900 to-black border border-white/10 rounded-[8px] p-6 mb-8 shadow-xl">
|
|
<h3 className="font-display text-subheading font-bold text-white mb-4 flex items-center gap-2.5 tracking-subheading leading-subheading">
|
|
<Lightbulb className="w-5 h-5 text-amber-400" />
|
|
Recomendações Práticas do Instrutor
|
|
</h3>
|
|
<ul className="space-y-3">
|
|
{course.tips.map((tip, i) => (
|
|
<li key={i} className="flex gap-3 text-gray-300 text-body-sm font-normal leading-body-sm tracking-body-sm">
|
|
<span className="block w-2 h-2 rounded-[980px] bg-orange-500 mt-2 shrink-0"></span>
|
|
<span>{tip}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Sidebar: Tabs for Playlist, Materials, and Discussion */}
|
|
<div className="lg:w-[35%] xl:w-[32%] shrink-0 flex flex-col space-y-6">
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="flex items-center justify-between bg-zinc-950/90 p-1.5 rounded-[980px] border border-white/10 shadow-xl gap-1">
|
|
<button
|
|
onClick={() => setActiveTab('playlist')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
|
|
activeTab === 'playlist'
|
|
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<ListVideo className="w-3.5 h-3.5 shrink-0" />
|
|
<span>Aulas ({course.lessons.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('materials')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
|
|
activeTab === 'materials'
|
|
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<Download className="w-3.5 h-3.5 shrink-0" />
|
|
<span>Downloads ({allCourseAttachments.length})</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setActiveTab('discussion')}
|
|
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 sm:px-3.5 rounded-[980px] text-xs font-semibold whitespace-nowrap transition-all ${
|
|
activeTab === 'discussion'
|
|
? 'bg-orange-500 text-white shadow-md shadow-orange-500/25'
|
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
|
<span>Dúvidas ({comments.length})</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* TAB CONTENT: Playlist */}
|
|
{activeTab === 'playlist' && (
|
|
<div className="bg-zinc-950/80 rounded-2xl p-5 space-y-3 shadow-xl">
|
|
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
|
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">Trilha de Aulas</span>
|
|
<span className="text-xs text-orange-400 font-mono font-bold">{course.duration}</span>
|
|
</div>
|
|
|
|
<div className="space-y-2 max-h-[600px] overflow-y-auto pr-1">
|
|
{course.lessons.map((lesson, index) => {
|
|
const isActive = currentLesson?.id === lesson.id;
|
|
const isCompleted = completedLessonIds.includes(lesson.id);
|
|
const isLocked = !user;
|
|
const hasAttachments = lesson.attachments && lesson.attachments.length > 0;
|
|
|
|
return (
|
|
<div
|
|
key={lesson.id}
|
|
onClick={() => changeLesson(lesson)}
|
|
className={`
|
|
group flex items-start gap-3.5 p-3.5 rounded-xl cursor-pointer transition-all border
|
|
${isActive
|
|
? 'bg-orange-500/15 border-orange-500/40 text-orange-400 shadow-md shadow-orange-500/10'
|
|
: isCompleted
|
|
? 'bg-emerald-950/40 hover:bg-emerald-900/50 border-emerald-500/30 text-emerald-300 shadow-sm'
|
|
: 'bg-zinc-900/80 hover:bg-zinc-800/90 border-transparent text-gray-200'
|
|
}
|
|
${isLocked ? 'opacity-70' : 'opacity-100'}
|
|
`}
|
|
>
|
|
{/* Status Icon */}
|
|
<div className="mt-0.5 shrink-0">
|
|
{isActive ? (
|
|
<div className="p-2 rounded-lg bg-orange-500 text-white shadow-md">
|
|
<Play className="w-3.5 h-3.5 fill-white" />
|
|
</div>
|
|
) : isCompleted ? (
|
|
<div className="p-2 rounded-lg bg-emerald-500 text-white shadow-md">
|
|
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
|
</div>
|
|
) : isLocked ? (
|
|
<div className="p-2 rounded-lg bg-white/5 text-gray-400">
|
|
<Lock className="w-3.5 h-3.5" />
|
|
</div>
|
|
) : (
|
|
<div className="p-2 rounded-lg bg-white/5 text-gray-400 font-mono text-xs font-bold w-8 h-8 flex items-center justify-center">
|
|
{index + 1}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className={`text-xs sm:text-sm font-semibold truncate ${
|
|
isActive
|
|
? 'text-orange-400 font-bold'
|
|
: isCompleted
|
|
? 'text-emerald-300 group-hover:text-emerald-200 font-semibold'
|
|
: 'text-gray-200 group-hover:text-white'
|
|
}`}>
|
|
{lesson.title}
|
|
</h4>
|
|
<div className="flex items-center gap-2 text-xs text-gray-400 mt-1 font-normal">
|
|
<span>{lesson.duration}</span>
|
|
|
|
{isCompleted && (
|
|
<span className="bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase flex items-center gap-1">
|
|
<CheckCircle2 className="w-2.5 h-2.5" /> Concluída
|
|
</span>
|
|
)}
|
|
|
|
{lesson.isFree ? (
|
|
<span className="bg-emerald-500/15 text-emerald-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase">Grátis</span>
|
|
) : (
|
|
<span className="bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded-md text-[10px] font-bold uppercase">Exclusiva</span>
|
|
)}
|
|
{hasAttachments && (
|
|
<span className="flex items-center gap-1 text-[10px] text-orange-400 bg-orange-500/15 px-2 py-0.5 rounded-md font-bold">
|
|
<Paperclip className="w-2.5 h-2.5" />
|
|
{lesson.attachments!.length} anexo(s)
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB CONTENT: Materials & Downloads */}
|
|
{activeTab === 'materials' && (
|
|
<div className="bg-zinc-950/80 rounded-2xl p-5 space-y-4 shadow-xl">
|
|
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
|
<div>
|
|
<h3 className="text-sm font-bold text-white">Materiais para Download</h3>
|
|
<p className="text-xs text-gray-400 mt-0.5">Arquivos anexados a este curso</p>
|
|
</div>
|
|
<span className="text-xs bg-orange-500/15 text-orange-400 font-bold px-2.5 py-1 rounded-full">
|
|
{allCourseAttachments.length} Total
|
|
</span>
|
|
</div>
|
|
|
|
{allCourseAttachments.length === 0 ? (
|
|
<div className="py-12 text-center text-gray-500">
|
|
<Download className="w-8 h-8 mx-auto mb-2 opacity-30 text-orange-400" />
|
|
<p className="text-xs text-gray-400">Nenhum material cadastrado para este curso ainda.</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-1">
|
|
{allCourseAttachments.map((item, idx) => (
|
|
<div key={`${item.attachment.id}-${idx}`} className="space-y-1.5">
|
|
<span className="text-xs text-gray-400 font-semibold px-1 flex items-center gap-1.5">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-orange-500"></span>
|
|
{item.source}
|
|
</span>
|
|
<MaterialCard attachment={item.attachment} compact />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAB CONTENT: Discussion */}
|
|
{activeTab === 'discussion' && (
|
|
<div className="bg-zinc-950/80 rounded-2xl p-5 flex flex-col space-y-4 shadow-xl">
|
|
<h3 className="text-sm font-bold text-white pb-2 border-b border-white/5">Dúvidas & Comentários</h3>
|
|
|
|
{/* Comment Form */}
|
|
<form onSubmit={handleAddComment} className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={newComment}
|
|
onChange={(e) => setNewComment(e.target.value)}
|
|
placeholder={user ? "Faça uma pergunta sobre a aula..." : "Faça login para comentar"}
|
|
disabled={!user}
|
|
className="flex-1 bg-zinc-900/90 rounded-xl px-3.5 py-2.5 text-xs text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newComment.trim() || !user}
|
|
className="p-2.5 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-30 text-white transition-colors"
|
|
>
|
|
<Send className="w-4 h-4" />
|
|
</button>
|
|
</form>
|
|
|
|
{/* Comments List */}
|
|
<div className="space-y-3 max-h-[450px] overflow-y-auto pr-1">
|
|
{comments.length === 0 ? (
|
|
<p className="text-xs text-gray-500 text-center py-8">Nenhum comentário ainda. Seja o primeiro a perguntar!</p>
|
|
) : (
|
|
comments.map(c => (
|
|
<div key={c.id} className="p-3 rounded-xl bg-zinc-900/90 space-y-1">
|
|
<div className="flex items-center justify-between text-xs font-semibold">
|
|
<span className="text-gray-200">{c.user}</span>
|
|
<span className="text-gray-500 font-mono text-[11px]">{c.date}</span>
|
|
</div>
|
|
<p className="text-xs text-gray-300 font-normal leading-relaxed">{c.text}</p>
|
|
|
|
{c.reply && (
|
|
<div className="mt-2 pl-3 border-l-2 border-orange-500 text-xs bg-white/[0.02] p-2 rounded-r-lg">
|
|
<span className="font-bold text-orange-400 uppercase text-[10px]">Instrutor:</span>
|
|
<p className="text-gray-300 text-xs mt-0.5">{c.reply}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<LoginModal isOpen={showLoginPrompt} onClose={() => setShowLoginPrompt(false)} />
|
|
</div>
|
|
);
|
|
};
|