diff --git a/backend/routes/courseContent.js b/backend/routes/courseContent.js index cb937a9..d1216f7 100644 --- a/backend/routes/courseContent.js +++ b/backend/routes/courseContent.js @@ -2,20 +2,81 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { CourseContent, QuizQuestion, Course } = require('../models'); const { auth, requireTrainer } = require('../middleware/auth'); +const { requirePaidEnrollment, requireEnrollment, requireCourseAccess } = require('../middleware/courseAccess'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); +const { createSafeDirectoryName } = require('../utils/folderNaming'); +const ffprobe = require('ffprobe-static'); +const { spawn } = require('child_process'); const router = express.Router(); +// Function to extract video duration using ffprobe +const getVideoDuration = (filePath) => { + return new Promise((resolve, reject) => { + const ffprobePath = ffprobe.path; + const args = [ + '-v', 'quiet', + '-show_entries', 'format=duration', + '-of', 'csv=p=0', + filePath + ]; + + const process = spawn(ffprobePath, args); + let output = ''; + let error = ''; + + process.stdout.on('data', (data) => { + output += data.toString(); + }); + + process.stderr.on('data', (data) => { + error += data.toString(); + }); + + process.on('close', (code) => { + if (code === 0) { + const duration = parseFloat(output.trim()); + if (!isNaN(duration)) { + resolve(Math.round(duration)); // Return duration in seconds + } else { + resolve(null); + } + } else { + console.log('FFprobe error:', error); + resolve(null); + } + }); + + process.on('error', (err) => { + console.log('FFprobe spawn error:', err); + resolve(null); + }); + }); +}; + // Multer storage for course content files const contentFileStorage = multer.diskStorage({ - destination: function (req, file, cb) { - const courseId = req.params.courseId; - const contentType = req.params.contentType || 'documents'; - const dir = path.join(__dirname, '../uploads/courses', courseId, contentType); - fs.mkdirSync(dir, { recursive: true }); - cb(null, dir); + destination: async function (req, file, cb) { + try { + const courseId = req.params.courseId; + const contentType = req.params.contentType || 'documents'; + + // Get course title to create consistent folder naming + const course = await require('../models/Course').findByPk(courseId); + if (!course) { + return cb(new Error('Course not found'), null); + } + + // Use the consistent folder naming utility + const courseDirName = createSafeDirectoryName(course.title, course.language); + const dir = path.join(__dirname, '../uploads/courses', courseDirName, contentType); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + } catch (error) { + cb(error, null); + } }, filename: function (req, file, cb) { const timestamp = Date.now(); @@ -45,8 +106,8 @@ const uploadContentFile = multer({ // @route GET /api/courses/:courseId/content // @desc Get all content for a course -// @access Private (Course owner or enrolled students) -router.get('/:courseId/content', auth, async (req, res) => { +// @access Private (Course owner or enrolled students who have paid) +router.get('/:courseId/content', auth, requireCourseAccess, async (req, res) => { try { const { courseId } = req.params; const { type, isPublished } = req.query; @@ -78,8 +139,8 @@ router.get('/:courseId/content', auth, async (req, res) => { // @route GET /api/courses/:courseId/content/:contentId // @desc Get specific content by ID -// @access Private (Course owner or enrolled students) -router.get('/:courseId/content/:contentId', auth, async (req, res) => { +// @access Private (Course owner or enrolled students who have paid) +router.get('/:courseId/content/:contentId', auth, requireCourseAccess, async (req, res) => { try { const { contentId } = req.params; @@ -115,7 +176,8 @@ router.post('/:courseId/content', [ body('description').optional().isLength({ max: 1000 }), body('order').optional().isInt({ min: 0 }), body('points').optional().isInt({ min: 0 }), - body('isRequired').optional().isBoolean() + body('isRequired').optional().isBoolean(), + body('sectionId').optional().isUUID() ], async (req, res) => { try { const errors = validationResult(req); @@ -134,7 +196,8 @@ router.post('/:courseId/content', [ isRequired, articleContent, quizData, - certificateTemplate + certificateTemplate, + sectionId } = req.body; // Verify course ownership @@ -142,12 +205,21 @@ router.post('/:courseId/content', [ if (!course) { return res.status(404).json({ error: 'Course not found.' }); } - if (course.trainerId !== req.user.id && req.user.role !== 'super_admin') { + + // Allow super admins and course trainers to add content + // If trainerId is not set, allow the user to add content (they might be the creator) + if (course.trainerId && course.trainerId !== req.user.id && req.user.role !== 'super_admin') { return res.status(403).json({ error: 'Not authorized to add content to this course.' }); } + + // If course doesn't have a trainer assigned, assign the current user as trainer + if (!course.trainerId && req.user.role === 'trainer') { + await course.update({ trainerId: req.user.id }); + } const courseContent = await CourseContent.create({ courseId, + sectionId, title, description, type, @@ -181,7 +253,8 @@ router.put('/:courseId/content/:contentId', [ body('order').optional().isInt({ min: 0 }), body('points').optional().isInt({ min: 0 }), body('isRequired').optional().isBoolean(), - body('isPublished').optional().isBoolean() + body('isPublished').optional().isBoolean(), + body('sectionId').optional().isUUID() ], async (req, res) => { try { const errors = validationResult(req); @@ -191,20 +264,49 @@ router.put('/:courseId/content/:contentId', [ const { contentId } = req.params; - const content = await CourseContent.findByPk(contentId, { - include: [{ model: Course, as: 'course' }] - }); + const content = await CourseContent.findByPk(contentId); if (!content) { return res.status(404).json({ error: 'Content not found.' }); } - // Verify course ownership - if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') { - return res.status(403).json({ error: 'Not authorized to update this content.' }); + // Get the course to verify ownership + const course = await Course.findByPk(content.courseId); + if (!course) { + return res.status(404).json({ error: 'Course not found.' }); + } + + // Verify course ownership + // Allow super admins and course trainers to update content + // If trainerId is not set, allow the user to update content (they might be the creator) + if (course.trainerId && course.trainerId !== req.user.id && req.user.role !== 'super_admin') { + return res.status(403).json({ error: 'Not authorized to update this content.' }); + } + + // If course doesn't have a trainer assigned, assign the current user as trainer + if (!course.trainerId && req.user.role === 'trainer') { + await course.update({ trainerId: req.user.id }); + } + + const updateData = { ...req.body }; + + // Handle articleContent transformation - convert object to string if needed + if (updateData.articleContent !== undefined) { + if (typeof updateData.articleContent === 'object') { + updateData.articleContent = JSON.stringify(updateData.articleContent); + } + // If it's already a string, keep it as is + } + + // Handle content field transformation + if (updateData.content && typeof updateData.content === 'string') { + try { + updateData.content = JSON.parse(updateData.content); + } catch (e) { + // If it's not valid JSON, keep it as is + } } - const updateData = req.body; await content.update(updateData); res.json({ @@ -213,7 +315,9 @@ router.put('/:courseId/content/:contentId', [ }); } catch (error) { console.error('Update content error:', error); - res.status(500).json({ error: 'Server error.' }); + console.error('Error details:', error.message); + console.error('Error stack:', error.stack); + res.status(500).json({ error: 'Server error.', details: error.message }); } }); @@ -233,9 +337,16 @@ router.delete('/:courseId/content/:contentId', auth, requireTrainer, async (req, } // Verify course ownership - if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') { + // Allow super admins and course trainers to delete content + // If trainerId is not set, allow the user to delete content (they might be the creator) + if (content.course.trainerId && content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') { return res.status(403).json({ error: 'Not authorized to delete this content.' }); } + + // If course doesn't have a trainer assigned, assign the current user as trainer + if (!content.course.trainerId && req.user.role === 'trainer') { + await content.course.update({ trainerId: req.user.id }); + } // Delete associated file if exists if (content.fileUrl) { @@ -270,34 +381,77 @@ router.post('/:courseId/content/:contentType/upload', [ const { courseId, contentType } = req.params; const { contentId } = req.body; + console.log('📤 Upload request details:', { + courseId, + contentType, + contentId, + fileName: req.file?.filename, + fileSize: req.file?.size, + fileMimeType: req.file?.mimetype + }); + // Verify course ownership const course = await Course.findByPk(courseId); if (!course) { return res.status(404).json({ error: 'Course not found.' }); } - if (course.trainerId !== req.user.id && req.user.role !== 'super_admin') { + // Allow super admins and course trainers to upload content + // If trainerId is not set, allow the user to upload content (they might be the creator) + if (course.trainerId && course.trainerId !== req.user.id && req.user.role !== 'super_admin') { return res.status(403).json({ error: 'Not authorized to upload content to this course.' }); } + + // If course doesn't have a trainer assigned, assign the current user as trainer + if (!course.trainerId && req.user.role === 'trainer') { + await course.update({ trainerId: req.user.id }); + } - const fileUrl = `/uploads/courses/${courseId}/${contentType}/${req.file.filename}`; + // Use consistent folder naming based on course title + const courseDirName = createSafeDirectoryName(course.title, course.language); + const fileUrl = `/uploads/courses/${courseDirName}/${contentType}/${req.file.filename}`; + + // Extract video duration if it's a video file + let duration = null; + if (contentType === 'video') { + try { + duration = await getVideoDuration(req.file.path); + console.log(`📹 Video duration extracted: ${duration} seconds`); + } catch (error) { + console.log('❌ Error extracting video duration:', error); + } + } // Update content if contentId is provided if (contentId) { + console.log('🔍 Looking for content with ID:', contentId); const content = await CourseContent.findByPk(contentId); if (content) { + console.log('✅ Found content, updating with file details:', { + fileUrl, + fileSize: req.file.size, + fileType: req.file.mimetype, + duration + }); await content.update({ fileUrl, fileSize: req.file.size, - fileType: req.file.mimetype + fileType: req.file.mimetype, + duration: duration // Store the extracted duration }); + console.log('✅ Content updated successfully'); + } else { + console.log('❌ Content not found with ID:', contentId); } + } else { + console.log('⚠️ No contentId provided, file uploaded but not associated with content'); } res.json({ message: 'File uploaded successfully.', fileUrl, fileSize: req.file.size, - fileType: req.file.mimetype + fileType: req.file.mimetype, + duration: duration }); } catch (error) { console.error('Upload content file error:', error); @@ -335,9 +489,16 @@ router.post('/:courseId/content/:contentId/questions', [ } // Verify course ownership - if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') { + // Allow super admins and course trainers to add questions + // If trainerId is not set, allow the user to add questions (they might be the creator) + if (content.course.trainerId && content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') { return res.status(403).json({ error: 'Not authorized to add questions to this content.' }); } + + // If course doesn't have a trainer assigned, assign the current user as trainer + if (!content.course.trainerId && req.user.role === 'trainer') { + await content.course.update({ trainerId: req.user.id }); + } const createdQuestions = await QuizQuestion.bulkCreate( questions.map((q, index) => ({ diff --git a/frontend/src/hooks/useContentManagement.js b/frontend/src/hooks/useContentManagement.js new file mode 100644 index 0000000..438bce6 --- /dev/null +++ b/frontend/src/hooks/useContentManagement.js @@ -0,0 +1,387 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useMutation, useQueryClient } from 'react-query'; +import { courseContentAPI } from '../services/api'; +import toast from 'react-hot-toast'; + +export const useContentManagement = (courseId) => { + const queryClient = useQueryClient(); + + // Content form state + const [contentForm, setContentForm] = useState({ + title: '', + description: '', + type: 'document', + order: 0, + points: 0, + isRequired: true, + isPublished: true, + articleContent: '', + url: '', + sectionId: null + }); + + const [editingContent, setEditingContent] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + // Debug selectedFile changes + useEffect(() => { + console.log('📁 useContentManagement: selectedFile changed:', selectedFile?.name || 'null'); + // Log the stack trace to see what's causing the reset + if (selectedFile === null) { + console.trace('🔍 selectedFile reset to null - stack trace:'); + } + }, [selectedFile]); + + // Mutations + const createContentMutation = useMutation( + (data) => { + console.log('🔄 createContentMutation called with data:', data); + return courseContentAPI.create(courseId, data); + }, + { + onSuccess: (response) => { + console.log('✅ Content created successfully:', response); + queryClient.invalidateQueries(['course-content', courseId]); + queryClient.invalidateQueries(['course-sections', courseId]); + resetContentForm(); + toast.success('Content added successfully!'); + }, + onError: (error) => { + console.error('❌ Content creation failed:', error); + console.error('❌ Error response:', error.response); + console.error('❌ Error data:', error.response?.data); + + // Show validation errors if available + if (error.response?.data?.errors && Array.isArray(error.response.data.errors)) { + const validationErrors = error.response.data.errors; + console.error('❌ Validation errors:', validationErrors); + + // Show first validation error to user + const firstError = validationErrors[0]; + toast.error(`Validation failed: ${firstError.msg || firstError.error || 'Invalid data'}`); + } else { + toast.error(error.response?.data?.error || 'Failed to add content'); + } + }, + } + ); + + const updateContentMutation = useMutation( + ({ contentId, data }) => courseContentAPI.update(courseId, contentId, data), + { + onSuccess: () => { + queryClient.invalidateQueries(['course-content', courseId]); + queryClient.invalidateQueries(['course-sections', courseId]); + setEditingContent(null); + resetContentForm(); + toast.success('Content updated successfully!'); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to update content'); + }, + } + ); + + const deleteContentMutation = useMutation( + (contentId) => courseContentAPI.delete(courseId, contentId), + { + onSuccess: () => { + queryClient.invalidateQueries(['course-content', courseId]); + queryClient.invalidateQueries(['course-sections', courseId]); + toast.success('Content deleted successfully!'); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to delete content'); + }, + } + ); + + const addQuizQuestionsMutation = useMutation( + ({ contentId, questions }) => courseContentAPI.addQuizQuestions(courseId, contentId, questions), + { + onSuccess: () => { + queryClient.invalidateQueries(['course-content', courseId]); + toast.success('Quiz questions added successfully!'); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to add quiz questions'); + }, + } + ); + + // Handlers + const handleContentFormChange = useCallback((e) => { + const { name, value, type, checked } = e.target; + setContentForm(prev => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value + })); + }, []); + + const resetContentForm = useCallback(() => { + setContentForm({ + title: '', + description: '', + type: 'document', + order: 0, + points: 0, + isRequired: true, + isPublished: true, + articleContent: '', + url: '', + sectionId: null + }); + // Don't reset selectedFile here - it should persist until content is created + console.log('🔄 resetContentForm called - selectedFile preserved:', selectedFile?.name || 'null'); + }, [selectedFile]); + + const handleAddContent = useCallback(async (e, selectedFileParam = null, uploadFileMutation = null) => { + e.preventDefault(); + console.log('🚀 handleAddContent called'); + console.log('📋 Form data:', contentForm); + console.log('📁 Selected file parameter:', selectedFileParam); + console.log('📁 Selected file from state:', selectedFile); + console.log('📁 Upload mutation available:', !!uploadFileMutation); + console.log('📁 Content type:', contentForm.type); + console.log('📁 File upload required:', ['document', 'image', 'video'].includes(contentForm.type)); + + // Use parameter if provided, otherwise use state + const fileToUpload = selectedFileParam || selectedFile; + console.log('📁 File to upload (final decision):', fileToUpload?.name || 'null'); + + let data = { ...contentForm }; + + // Clean up sectionId - convert empty string to null + if (data.sectionId === '') { + data.sectionId = null; + } + + // Transform articleContent to content field for articles + if (contentForm.type === 'article' && contentForm.articleContent) { + data.content = contentForm.articleContent; + delete data.articleContent; // Remove the form field + } + + // If image/video and url is provided, set fileUrl + if ((contentForm.type === 'image' || contentForm.type === 'video') && contentForm.url) { + data.fileUrl = contentForm.url; + } + + console.log('📤 Sending data to API:', data); + console.log('🔗 Course ID:', courseId); + console.log('📏 Title length:', data.title?.length || 0); + console.log('🔍 Data validation check:', { + title: data.title, + type: data.type, + order: typeof data.order, + points: typeof data.points, + sectionId: data.sectionId + }); + + try { + return new Promise((resolve, reject) => { + createContentMutation.mutate(data, { + onSuccess: async (response) => { + console.log('✅ Content created successfully:', response); + + // Extract content ID from response (handle different response structures) + console.log('🔍 Response structure debug:', { + response: response, + responseContent: response.content, + responseId: response.id, + responseData: response.data, + responseDataContent: response.data?.content, + responseDataId: response.data?.id + }); + + const contentId = response.content?.id || response.id || response.data?.id || response.data?.content?.id; + if (!contentId) { + console.error('❌ No content ID found in response:', response); + console.error('❌ Response structure:', JSON.stringify(response, null, 2)); + reject(new Error('Server response format error: No content ID found')); + return; + } + + // Invalidate queries to refresh the page data + console.log('🔄 Invalidating queries to refresh page data...'); + queryClient.invalidateQueries(['course-content', courseId]); + queryClient.invalidateQueries(['course-sections', courseId]); + queryClient.invalidateQueries(['courses', courseId]); + + // If there's a file to upload and we have the upload mutation + console.log('🔍 File upload check:', { + hasSelectedFile: !!fileToUpload, + hasUploadMutation: !!uploadFileMutation, + contentType: contentForm.type, + requiresFileUpload: ['document', 'image', 'video'].includes(contentForm.type), + willUploadFile: !!(fileToUpload && uploadFileMutation && ['document', 'image', 'video'].includes(contentForm.type)) + }); + + if (fileToUpload && uploadFileMutation && ['document', 'image', 'video'].includes(contentForm.type)) { + console.log('📤 Uploading file for content:', { + contentId, + fileName: fileToUpload.name, + fileType: fileToUpload.type, + contentType: contentForm.type + }); + + try { + // Upload file with the newly created content ID + const uploadResponse = await new Promise((uploadResolve, uploadReject) => { + uploadFileMutation.mutate({ + file: fileToUpload, + contentType: contentForm.type, + contentId: contentId + }, { + onSuccess: (uploadData) => { + console.log('✅ File uploaded successfully:', uploadData); + uploadResolve(uploadData); + }, + onError: (uploadError) => { + console.error('❌ File upload failed:', uploadError); + uploadReject(uploadError); + } + }); + }); + + console.log('✅ Content creation and file upload completed'); + // Reset selectedFile after successful upload + setSelectedFile(null); + resolve({ ...response, uploadData: uploadResponse }); + } catch (uploadError) { + console.error('❌ File upload failed, but content was created:', uploadError); + // Still resolve with content creation success, but log the upload failure + resolve(response); + } + } else { + console.log('✅ Content created without file upload'); + resolve(response); + } + }, + onError: (error) => { + console.error('❌ Error in handleAddContent:', error); + reject(error); + } + }); + }); + } catch (error) { + console.error('❌ Error in handleAddContent:', error); + throw error; + } + }, [contentForm, courseId, createContentMutation, queryClient, setSelectedFile, selectedFile]); + + const handleEditContent = useCallback(async (e, selectedFile = null, uploadFileMutation = null) => { + e.preventDefault(); + + let data = { ...contentForm }; + + // Transform articleContent to content field for articles + if (contentForm.type === 'article' && contentForm.articleContent) { + data.content = contentForm.articleContent; + } + + // Always remove articleContent from the data sent to backend + // since it's only used in the frontend form + delete data.articleContent; + + try { + return new Promise((resolve, reject) => { + updateContentMutation.mutate({ + contentId: editingContent.id, + data: data + }, { + onSuccess: async (response) => { + console.log('✅ Content updated successfully:', response); + + // If there's a selected file and upload mutation, upload the file after updating content + if (selectedFile && uploadFileMutation && ['document', 'image', 'video'].includes(contentForm.type)) { + console.log('📤 Uploading file for updated content:', { + file: selectedFile.name, + contentType: contentForm.type, + contentId: editingContent.id + }); + + try { + const uploadResponse = await new Promise((uploadResolve, uploadReject) => { + uploadFileMutation.mutate({ + file: selectedFile, + contentType: contentForm.type, + contentId: editingContent.id + }, { + onSuccess: (uploadData) => { + console.log('✅ File uploaded successfully:', uploadData); + uploadResolve(uploadData); + }, + onError: (uploadError) => { + console.error('❌ File upload failed:', uploadError); + uploadReject(uploadError); + } + }); + }); + resolve({ ...response, uploadData: uploadResponse }); + } catch (uploadError) { + console.error('❌ File upload failed, but content update succeeded:', uploadError); + resolve(response); // Still resolve content update, log upload failure + } + } else { + resolve(response); + } + }, + onError: (error) => { + console.error('❌ Error in handleEditContent:', error); + reject(error); + } + }); + }); + } catch (error) { + console.error('❌ Error in handleEditContent:', error); + throw error; + } + }, [editingContent, contentForm, updateContentMutation]); + + const handleDeleteContent = useCallback((contentId) => { + if (window.confirm('Are you sure you want to delete this content?')) { + deleteContentMutation.mutate(contentId); + } + }, [deleteContentMutation]); + + const handleEditContentClick = useCallback((content) => { + setEditingContent(content); + setContentForm({ + title: content.title, + description: content.description, + type: content.type, + order: content.order, + points: content.points, + isRequired: content.isRequired, + isPublished: content.isPublished, + articleContent: content.content || content.articleContent || '', + url: content.fileUrl || '', + sectionId: content.sectionId + }); + }, []); + + return { + // State + contentForm, + setContentForm, + editingContent, + setEditingContent, + selectedFile, + setSelectedFile, + + // Mutations + createContentMutation, + updateContentMutation, + deleteContentMutation, + addQuizQuestionsMutation, + + // Handlers + handleContentFormChange, + resetContentForm, + handleAddContent, + handleEditContent, + handleDeleteContent, + handleEditContentClick + }; +}; diff --git a/frontend/src/hooks/useFileUpload.js b/frontend/src/hooks/useFileUpload.js new file mode 100644 index 0000000..466ec44 --- /dev/null +++ b/frontend/src/hooks/useFileUpload.js @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; +import { useMutation } from 'react-query'; +import { courseContentAPI } from '../services/api'; +import toast from 'react-hot-toast'; + +export const useFileUpload = (courseId, setContentForm, setSelectedFile) => { + const uploadFileMutation = useMutation( + ({ file, contentType, contentId }) => courseContentAPI.uploadFile(courseId, contentType, file, contentId), + { + onSuccess: (data) => { + setContentForm(prev => ({ + ...prev, + fileUrl: data.fileUrl, + fileSize: data.fileSize, + fileType: data.fileType + })); + setSelectedFile(null); + toast.success('File uploaded successfully!'); + }, + onError: (error) => { + toast.error(error.response?.data?.error || 'Failed to upload file'); + }, + } + ); + + const handleFileChange = useCallback((e) => { + const file = e.target.files[0]; + console.log('📁 useFileUpload: File selected:', file?.name); + if (file) { + setSelectedFile(file); + console.log('📁 useFileUpload: File set in state'); + } + }, [setSelectedFile]); + + const handleFileUpload = useCallback(async (contentType, selectedFile) => { + if (!selectedFile) { + toast.error('Please select a file first'); + return; + } + + uploadFileMutation.mutate({ + file: selectedFile, + contentType, + contentId: null + }); + }, [uploadFileMutation]); + + return { + uploadFileMutation, + handleFileChange, + handleFileUpload, + selectedFile: null // This should be managed by the parent hook + }; +}; diff --git a/frontend/src/pages/CourseContentViewer.js b/frontend/src/pages/CourseContentViewer.js index c21036f..a3f1d28 100644 --- a/frontend/src/pages/CourseContentViewer.js +++ b/frontend/src/pages/CourseContentViewer.js @@ -1,29 +1,63 @@ -import React, { useState } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import React, { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useQuery, useMutation } from 'react-query'; -import { coursesAPI, courseContentAPI } from '../services/api'; +import { coursesAPI, lessonCompletionAPI, courseSectionAPI, courseContentAPI, courseStatsAPI, userNotesAPI } from '../services/api'; import { - ArrowLeftIcon, DocumentIcon, PhotoIcon, VideoCameraIcon, DocumentTextIcon, QuestionMarkCircleIcon, AcademicCapIcon as CertificateIcon, - CheckIcon, - XMarkIcon, - EyeIcon, ArrowDownTrayIcon as DownloadIcon, + Bars3Icon, + ShareIcon, + ChevronDownIcon, } from '@heroicons/react/24/outline'; import LoadingSpinner from '../components/LoadingSpinner'; +import CourseSidebar from '../components/CourseSidebar'; +import { useAuth } from '../contexts/AuthContext'; import toast from 'react-hot-toast'; +import { getFileServingUrl, getBestImageUrl, getMediaUrl } from '../utils/imageUtils'; +import ProfessionalVideoPlayer from '../components/ProfessionalVideoPlayer'; + + + +// Import hooks and components for content management +import { useSectionManagement } from '../hooks/useSectionManagement'; +import { useContentManagement } from '../hooks/useContentManagement'; +import { useFileUpload } from '../hooks/useFileUpload'; +import AddSectionModal from '../components/modals/AddSectionModal'; +import AddContentModal from '../components/modals/AddContentModal'; const CourseContentViewer = () => { const { id } = useParams(); + const [searchParams] = useSearchParams(); + const { user } = useAuth(); const [selectedContent, setSelectedContent] = useState(null); const [quizAnswers, setQuizAnswers] = useState({}); const [quizResults, setQuizResults] = useState({}); const [videoProgress, setVideoProgress] = useState({}); + const [courseProgress, setCourseProgress] = useState({ + progress: 0, + totalLessons: 0, + completedLessons: 0 + }); + const [isEnrolled, setIsEnrolled] = useState(false); + const [sidebarOpen, setSidebarOpen] = useState(true); // Show sidebar by default on desktop + const [activeTab, setActiveTab] = useState('overview'); + const [courseStats, setCourseStats] = useState(null); + const [userNotes, setUserNotes] = useState({}); + const [currentNote, setCurrentNote] = useState(''); + + // Modal states for content management (only for trainers) + const [showAddSectionModal, setShowAddSectionModal] = useState(false); + const [showAddContentModal, setShowAddContentModal] = useState(false); + + // Content and section management hooks (always called, but only used for trainers) + const sectionManagement = useSectionManagement(id); + const contentManagement = useContentManagement(id); + const fileUpload = useFileUpload(id, contentManagement.setContentForm, contentManagement.setSelectedFile); // Get course details const { data: courseData, isLoading: courseLoading } = useQuery( @@ -32,39 +66,223 @@ const CourseContentViewer = () => { { enabled: !!id } ); - // Get course content - const { data: contentData, isLoading: contentLoading } = useQuery( + + + // Get course sections with content + const { data: sectionsData, isLoading: sectionsLoading } = useQuery( + ['course-sections', id], + () => courseSectionAPI.getAll(id), + { enabled: !!id } + ); + + // Get all course content (including uncategorized) + const { data: allContentData, isLoading: contentLoading } = useQuery( ['course-content', id], () => courseContentAPI.getAll(id), { enabled: !!id } ); - // Quiz submission mutation - const submitQuizMutation = useMutation( - (data) => courseContentAPI.submitQuiz(id, selectedContent.id, data), - { + // Set enrollment status based on user role and course access + useEffect(() => { + if (user?.role === 'trainer' || user?.role === 'super_admin') { + // Trainers and admins are always considered "enrolled" for course management + setIsEnrolled(true); + } else if (user?.role === 'trainee') { + // For trainees, if they can access this page, they are enrolled + // This should not be overridden by course progress query failures + setIsEnrolled(true); + } + }, [user?.role]); + + // Get course progress + const { isLoading: progressLoading } = useQuery( + ['course-progress', id], + () => lessonCompletionAPI.getProgress(id), + { + enabled: !!id && user?.role !== 'trainer', // Don't call for trainers + retry: false, // Don't retry failed requests onSuccess: (data) => { - setQuizResults(data.results); - toast.success('Quiz submitted successfully!'); + setCourseProgress({ + progress: data.progress || 0, + totalLessons: data.totalLessons || 0, + completedLessons: data.completedLessons || 0 + }); + // Don't override enrollment status - if we can access this page, user is enrolled }, onError: (error) => { - toast.error(error.response?.data?.error || 'Failed to submit quiz'); - }, + // Set default progress values but don't change enrollment status + setCourseProgress({ + progress: 0, + totalLessons: 0, + completedLessons: 0 + }); + // Don't set isEnrolled to false - if user can access this page, they are enrolled + } } ); - const handleQuizAnswer = (questionId, answer) => { - setQuizAnswers(prev => ({ - ...prev, - [questionId]: answer - })); - }; + // Get course statistics + const { isLoading: statsLoading } = useQuery( + ['course-stats', id], + () => courseStatsAPI.getByCourseId(id), + { + enabled: !!id, + onSuccess: (data) => { + setCourseStats(data); + } + } + ); - const handleQuizSubmit = () => { - if (!selectedContent) return; - submitQuizMutation.mutate({ - answers: quizAnswers + // Get user notes for the course + const { isLoading: notesLoading } = useQuery( + ['user-notes', id], + () => userNotesAPI.getByCourseId(id), + { + enabled: !!id, + onSuccess: (data) => { + const notesMap = {}; + // Handle different data formats - check if data is an array or has a data property + const notesArray = Array.isArray(data) ? data : (data?.data || []); + + if (Array.isArray(notesArray)) { + notesArray.forEach(note => { + const key = note.contentId || 'course'; + if (!notesMap[key]) notesMap[key] = {}; + notesMap[key][note.tabType] = note; + }); + } + setUserNotes(notesMap); + } + } + ); + + // Quiz submission mutation + const submitQuizMutation = useMutation( + (data) => lessonCompletionAPI.submitQuiz(data), + { + onSuccess: (data) => { + setQuizResults(data); + toast.success('Quiz submitted successfully!'); + }, + onError: (error) => { + toast.error('Failed to submit quiz. Please try again.'); + console.error('Quiz submission error:', error); + } + } + ); + + // Mark lesson as completed + const markCompletedMutation = useMutation( + (data) => lessonCompletionAPI.markCompleted(data), + { + onSuccess: () => { + toast.success('Lesson marked as completed!'); + // Refresh progress data + window.location.reload(); + }, + onError: (error) => { + toast.error('Failed to mark lesson as completed.'); + console.error('Mark completed error:', error); + } + } + ); + + // Create/Update user note + const saveNoteMutation = useMutation( + (data) => { + if (data.noteId) { + return userNotesAPI.update(data.noteId, data); + } else { + return userNotesAPI.create(data.courseId, data); + } + }, + { + onSuccess: (data) => { + toast.success('Note saved successfully!'); + // Refresh notes data + window.location.reload(); + }, + onError: (error) => { + toast.error('Failed to save note.'); + console.error('Save note error:', error); + } + } + ); + + useEffect(() => { + if (sectionsData && sectionsData.length > 0 && !selectedContent) { + // Auto-select first content item + const firstSection = sectionsData[0]; + if (firstSection.contents && firstSection.contents.length > 0) { + setSelectedContent(firstSection.contents[0]); + } + } + }, [sectionsData, selectedContent]); + + // Set default progress for trainers + useEffect(() => { + if (user?.role === 'trainer') { + setCourseProgress({ + progress: 0, + totalLessons: 0, + completedLessons: 0 + }); + } + }, [user?.role]); + + // Merge sections with uncategorized content + const sections = React.useMemo(() => { + const baseSections = sectionsData?.sections || []; + const allContent = allContentData?.contents || []; + + // Find content that's not assigned to any section + const sectionContentIds = new Set(); + baseSections.forEach(section => { + if (section.contents) { + section.contents.forEach(content => sectionContentIds.add(content.id)); + } }); + + const uncategorizedContent = allContent.filter(content => !sectionContentIds.has(content.id)); + + // If there's uncategorized content, add it as a virtual section + const sectionsWithUncategorized = [...baseSections]; + if (uncategorizedContent.length > 0) { + sectionsWithUncategorized.push({ + id: 'uncategorized', + title: 'Uncategorized Content', + description: 'Content not assigned to any section', + contents: uncategorizedContent, + isVirtual: true + }); + } + + return sectionsWithUncategorized; + }, [sectionsData, allContentData]); + + // Auto-select content based on URL parameter + useEffect(() => { + const contentParam = searchParams.get('content'); + if (contentParam && sections.length > 0) { + // Find the content in all sections + let foundContent = null; + for (const section of sections) { + if (section.contents) { + foundContent = section.contents.find(content => content.id === contentParam); + if (foundContent) break; + } + } + if (foundContent) { + setSelectedContent(foundContent); + } + } + }, [searchParams, sections]); + + const formatDuration = (seconds) => { + if (!seconds) return '0:00'; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; }; const handleVideoProgress = (contentId, progress) => { @@ -72,79 +290,306 @@ const CourseContentViewer = () => { ...prev, [contentId]: progress })); + + // Only auto-mark as completed if user is enrolled (has progress data) and is not a trainer + if (courseProgress.totalLessons > 0 && user?.role !== 'trainer' && ((progress >= 90 && !videoProgress[contentId]) || videoProgress[contentId] < 90)) { + markCompletedMutation.mutate({ + courseId: id, + contentId, + userId: user?.id, + progress: 100 + }); + } }; - const getContentTypeIcon = (type) => { - const icons = { - document: DocumentIcon, - image: PhotoIcon, - video: VideoCameraIcon, - article: DocumentTextIcon, - quiz: QuestionMarkCircleIcon, - certificate: CertificateIcon, + const handleQuizSubmit = (contentId) => { + const answers = quizAnswers[contentId] || {}; + submitQuizMutation.mutate({ + courseId: id, + contentId, + userId: user?.id, + answers + }); + }; + + const handleNotesChange = (contentId, tabType, value) => { + setCurrentNote(value); + }; + + const handleSaveNotes = (contentId, tabType) => { + const noteContent = currentNote.trim(); + if (!noteContent) { + toast.error('Please enter some notes before saving.'); + return; + } + + const existingNote = userNotes[contentId]?.[tabType]; + const noteData = { + courseId: id, + contentId: contentId || null, + notes: noteContent, + tabType, + isPublic: false }; - return icons[type] || DocumentIcon; + + if (existingNote) { + noteData.noteId = existingNote.id; + } + + saveNoteMutation.mutate(noteData); }; - const getContentTypeLabel = (type) => { - const labels = { - document: 'Document', - image: 'Image', - video: 'Video', - article: 'Article', - quiz: 'Quiz', - certificate: 'Certificate', - }; - return labels[type] || 'Document'; - }; - - // Helper to resolve the file URL (uploaded or online) - const resolveFileUrl = (content) => { - // Prefer fileUrl, then url - let url = content.fileUrl || content.url || ''; - if (!url) return null; - if (url.startsWith('http')) return url; - // Otherwise, construct the full URL - const baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000'; - return `${baseUrl}${url}`; + const getCurrentNote = (contentId, tabType) => { + return userNotes[contentId]?.[tabType]?.notes || ''; }; const renderContent = (content) => { - const fileUrl = resolveFileUrl(content); - console.log('ContentViewer: resolved fileUrl:', fileUrl); - switch (content.type) { + case 'video': + return ( +
+ {/* Secure Video Player - Responsive width based on sidebar state */} +
+ {content.fileUrl ? ( + { + // Auto-close sidebar when video starts + setSidebarOpen(false); + }} + onSidebarToggle={(isOpen) => { + setSidebarOpen(isOpen); + }} + onVideoProgress={(progress) => { + handleVideoProgress(content.id, progress); + }} + onNextLesson={() => { + // Navigate to next lesson + const currentIndex = allContentData?.contents?.findIndex(c => c.id === content.id) || -1; + if (currentIndex >= 0 && currentIndex < allContentData.contents.length - 1) { + const nextContent = allContentData.contents[currentIndex + 1]; + setSelectedContent(nextContent); + } + }} + onPreviousLesson={() => { + // Navigate to previous lesson + const currentIndex = allContentData?.contents?.findIndex(c => c.id === content.id) || -1; + if (currentIndex > 0) { + const prevContent = allContentData.contents[currentIndex - 1]; + setSelectedContent(prevContent); + } + }} + hasNextLesson={(() => { + const currentIndex = allContentData?.contents?.findIndex(c => c.id === content.id) || -1; + return currentIndex >= 0 && currentIndex < (allContentData?.contents?.length || 0) - 1; + })()} + hasPreviousLesson={(() => { + const currentIndex = allContentData?.contents?.findIndex(c => c.id === content.id) || -1; + return currentIndex > 0; + })()} + autoPlay={true} + className="w-full aspect-video" + /> + ) : ( +
+
+ +

No video file available

+

Please contact your instructor if you believe this is an error.

+
+
+ )} +
+ + {/* Lesson Title Below Video - Left aligned with padding */} +
+

{content.title}

+ {content.description && ( +

{content.description}

+ )} +
+ + {/* Content Tabs */} +
+
+ {['overview', 'notes', 'announcements', 'reviews', 'learning-tools'].map((tab) => ( + + ))} +
+ +
+ {activeTab === 'overview' && ( +
+

Lesson Overview

+

+ {content.description || 'This lesson provides comprehensive coverage of the topic with practical examples and hands-on exercises.'} +

+
+ )} + + {activeTab === 'notes' && ( +
+

Lesson Notes

+