From c06600f263d31ca95f6d8c3bcaf7fd53333f5034 Mon Sep 17 00:00:00 2001
From: mmabdalla <101379618+mmabdalla@users.noreply.github.com>
Date: Sun, 14 Sep 2025 04:12:23 +0300
Subject: [PATCH] v2.0.1 - CRITICAL FIX: Video Upload Bug - Content Creation
File Upload Issue
- Fixed parameter shadowing in useContentManagement.js handleAddContent function
- Changed selectedFile parameter to selectedFileParam to avoid state variable shadowing
- Added fallback logic: fileToUpload = selectedFileParam || selectedFile
- Updated all upload logic references to use fileToUpload instead of selectedFile
- Enhanced debugging with useEffect tracking and stack traces
- Fixed React error in LessonDetail.js with null checks for nextSibling
- Fixed media authentication by adding token to query parameters in imageUtils.js
- Updated dependency arrays for proper state management
- Resolved video upload issue during initial content creation
Files modified:
- frontend/src/hooks/useContentManagement.js
- frontend/src/hooks/useFileUpload.js
- frontend/src/pages/CourseContentViewer.js
- frontend/src/pages/LessonDetail.js
- frontend/src/utils/imageUtils.js
- backend/routes/courseContent.js
- version.txt
---
backend/routes/courseContent.js | 217 +-
frontend/src/hooks/useContentManagement.js | 387 ++
frontend/src/hooks/useFileUpload.js | 54 +
frontend/src/pages/CourseContentViewer.js | 1469 ++++--
frontend/src/pages/LessonDetail.js | 565 ++
frontend/src/utils/imageUtils.js | 52 +
version.txt | 5527 +++++++++++++++++++-
7 files changed, 7759 insertions(+), 512 deletions(-)
create mode 100644 frontend/src/hooks/useContentManagement.js
create mode 100644 frontend/src/hooks/useFileUpload.js
create mode 100644 frontend/src/pages/LessonDetail.js
create mode 100644 frontend/src/utils/imageUtils.js
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' && (
+
+ )}
+
+ {activeTab === 'announcements' && (
+
+
Announcements
+
No announcements for this lesson.
+
+ )}
+
+ {activeTab === 'reviews' && (
+
+
Lesson Reviews
+
No reviews available for this lesson.
+
+ )}
+
+ {activeTab === 'learning-tools' && (
+
+
Learning Tools
+
+
+
Practice Exercises
+
Reinforce learning with hands-on practice
+
+
+
Additional Resources
+
Access supplementary materials and references
+
+
+
+ )}
+
+
+
+ );
+
case 'document':
return (
-
+
{content.title}
{content.description && (
{content.description}
)}
- {fileUrl ? (
-
-
+ {content.fileUrl ? (
+
+ {/* Document Preview (for PDFs) */}
+ {content.fileType === 'application/pdf' && (
+
+
+
+ )}
+
+ {/* Document Download Card */}
+
+
+
+
+
+
{content.title}
+
+ {content.fileType === 'application/pdf' ? 'PDF Document' : 'Document'}
+ {content.fileSize && ` • ${Math.round(content.fileSize / 1024)} KB`}
+
+
+
+
+
+
) : (
-
-
-
No document file available
+
+
+
No document file available
+
This lesson doesn't have a document file uploaded yet.
+ {user?.role === 'trainer' && (
+
+
+ 🎯 Trainer Action Required
+
+
+ Go to Course Management → Edit this content → Upload a document file
+
+
+ )}
+ {user?.role !== 'trainer' && (
+
+ Please contact your instructor if you believe this is an error.
+
+ )}
+
+ )}
+ {activeTab === 'notes' && (
+
)}
@@ -153,53 +598,134 @@ const CourseContentViewer = () => {
case 'image':
return (
-
{content.title}
+
+
+ {content.title}
+
{content.description && (
{content.description}
)}
- {fileUrl ? (
-
+ {content.fileUrl ? (
+
) : (
-
-
-
No image file available
+
+
+
No image file available
+
This lesson doesn't have an image file uploaded yet.
+ {user?.role === 'trainer' && (
+
+
+ 🎯 Trainer Action Required
+
+
+ Go to Course Management → Edit this content → Upload an image file
+
+
+ )}
+ {user?.role !== 'trainer' && (
+
+ Please contact your instructor if you believe this is an error.
+
+ )}
+
+ )}
+ {activeTab === 'notes' && (
+
)}
);
- case 'video':
+ case 'quiz':
return (
-
-
{content.title}
+
+
+
+ {content.title}
+
{content.description && (
{content.description}
)}
- {fileUrl ? (
-
-
+
+ {content.questions && content.questions.length > 0 ? (
+
+ {content.questions.map((question, qIndex) => (
+
+
+ Question {qIndex + 1}: {question.question}
+
+
+
+ {question.options.map((option, oIndex) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+ {/* Quiz Results Display */}
+ {quizResults[content.id] && (
+
+
Quiz Results
+
+
Score: {quizResults[content.id].score || 'N/A'}
+
Correct Answers: {quizResults[content.id].correctAnswers || 'N/A'}
+
Total Questions: {quizResults[content.id].totalQuestions || 'N/A'}
+
+
+ )}
) : (
-
-
-
No video file available
-
+
No questions available for this quiz.
)}
);
@@ -207,286 +733,523 @@ const CourseContentViewer = () => {
case 'article':
return (
-
{content.title}
+
+
+ {content.title}
+
{content.description && (
{content.description}
)}
-
-
- );
-
- case 'quiz':
- return (
-
-
{content.title}
- {content.description && (
-
{content.description}
- )}
-
- {content.questions && content.questions.length > 0 ? (
-
- {content.questions.map((question, index) => (
-
-
-
- {index + 1}
-
-
-
{question.question}
-
Points: {question.points}
-
-
-
- {question.questionType === 'multiple_choice' && (
-
- {question.options.map((option, optionIndex) => (
-
- ))}
-
- )}
-
- {question.questionType === 'single_choice' && (
-
- {question.options.map((option, optionIndex) => (
-
- ))}
-
- )}
-
- {question.questionType === 'true_false' && (
-
-
-
-
- )}
-
- {question.questionType === 'text' && (
-
- ))}
-
-
-
-
-
- ) : (
-
-
-
No questions available for this quiz
+ {(content.content || content.articleContent) && (
+
)}
);
- case 'certificate':
- return (
-
-
{content.title}
- {content.description && (
-
{content.description}
- )}
-
-
-
Certificate template will be displayed here
-
-
- );
-
default:
return (
-
-
Unsupported content type
+
+
+
Content Not Supported
+
This content type is not yet supported.
);
}
};
- if (courseLoading || contentLoading) {
- return
;
- }
-
- if (!courseData?.course) {
+ if (courseLoading || sectionsLoading || contentLoading || progressLoading || statsLoading || notesLoading) {
return (
-
-
Course not found
-
The course you're looking for doesn't exist.
+
+
);
}
- const course = courseData.course;
- const contents = contentData?.contents || [];
+ if (!courseData) {
+ return (
+
+
+
Course Not Found
+
The course you're looking for doesn't exist or you don't have access to it.
+
+
+ );
+ }
return (
-
- {/* Header */}
-
-
-
-
-
-
-
-
{course.title}
-
Course Content
+ <>
+ {/* Custom CSS for rich text content */}
+
+
+
+ {/* Mobile Sidebar Toggle - Fixed at top right */}
+
+
+ {/* Course Header - Full width, extends to browser edges */}
+
+
+
+ {/* Left side - Logo and Course Name - Allow wrapping */}
+
+
+ CX
+
+
+ {/* Course Title - Mobile responsive */}
+
+
+ {courseLoading ? 'Loading...' : (courseData?.course?.title || courseData?.title || 'Course Title')}
+
+
+
+
+ {/* Right side - Progress, Sidebar Toggle, and Share - Fixed width */}
+
+ {/* Progress Indicator - Moved to right side */}
+
+ {isEnrolled ? (
+
+
+ {/* Background circle */}
+
+
+
+ {courseProgress.progress}%
+
+
+
+
+
{courseProgress.completedLessons} of {courseProgress.totalLessons} lessons
+
+
+ ) : (
+
+ )}
+
+
+ {/* Sidebar Toggle - Desktop */}
+
+
+ {/* Share Button */}
+
+
+ {/* Homework Help Button */}
+
+
+ {/* Star Icon */}
+
-
- {/* Content Sidebar */}
-
-
-
Course Materials
-
- {contents.length > 0 ? (
- contents.map((content, index) => {
- const IconComponent = getContentTypeIcon(content.type);
- const isSelected = selectedContent?.id === content.id;
- const isCompleted = videoProgress[content.id] >= 90; // 90% watched for videos
+ {/* Main Content Area - Full width from top */}
+
+ {/* Content Area - Responsive to sidebar state */}
+
- return (
-