feat: improve learning and platform operations
All checks were successful
CI / Validate frontend and API (push) Successful in 45s
CI / Build and publish Docker images (push) Successful in 24s

This commit is contained in:
Cauê Faleiros
2026-09-01 15:38:14 -03:00
parent 4736fb5208
commit 3d4c72e85c
20 changed files with 292 additions and 45 deletions

View File

@@ -19,7 +19,7 @@ import {
} from 'lucide-react';
import { Course, Comment, Lesson, Attachment } from '../types';
import { useAuth } from '../context/AuthContext';
import { getComments, getCompletedLessonIds, saveComment, saveLessonCompletion, incrementViews } from '../services/db';
import { getComments, getLessonProgress, saveComment, saveLessonCompletion, saveLessonProgress, incrementViews } from '../services/db';
import { LoginModal } from './LoginModal';
import { MaterialCard } from './MaterialCard';
@@ -34,21 +34,30 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
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 videoRef = useRef<HTMLVideoElement>(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([]));
getCompletedLessonIds(course.id).then((completed) => {
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([]);
@@ -78,17 +87,21 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, false).catch(() => updateCompletedLessons(completedLessonIds));
} else {
const updated = [...completedLessonIds, lessonId];
updateCompletedLessons(updated);
saveLessonCompletion(lessonId, true).catch(() => updateCompletedLessons(completedLessonIds));
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)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
markLessonCompleted(currentLesson.id, Math.round(videoRef.current?.duration || 0));
}
// Auto-advance to next lesson if available
@@ -110,16 +123,27 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
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)) {
const updated = [...completedLessonIds, currentLesson.id];
updateCompletedLessons(updated);
markLessonCompleted(currentLesson.id, Math.round(currentTime));
}
}
}
};
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;
@@ -224,6 +248,7 @@ export const CoursePlayerModal: React.FC<CoursePlayerModalProps> = ({ course, on
autoPlay
onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded}
onLoadedMetadata={restoreWatchPosition}
>
Seu navegador não suporta a tag de vídeo.
</video>