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
This commit is contained in:
mmabdalla 2025-09-14 04:12:23 +03:00
parent a45c858d3b
commit c06600f263
7 changed files with 7759 additions and 512 deletions

View file

@ -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.' });
}
// 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
if (content.course.trainerId !== req.user.id && req.user.role !== 'super_admin') {
// 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.' });
}
const updateData = req.body;
// 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
}
}
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,10 +337,17 @@ 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) {
const filePath = path.join(__dirname, '..', 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.' });
}
const fileUrl = `/uploads/courses/${courseId}/${contentType}/${req.file.filename}`;
// 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 });
}
// 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,10 +489,17 @@ 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) => ({
contentId,

View file

@ -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
};
};

View file

@ -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
};
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,565 @@
import React, { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { courseContentAPI, coursesAPI } from '../services/api';
import {
ArrowLeftIcon,
PencilIcon,
TrashIcon,
EyeIcon,
ClockIcon,
DocumentIcon,
PhotoIcon,
VideoCameraIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
AcademicCapIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
import { getFileServingUrl, getBestImageUrl } from '../utils/imageUtils';
const LessonDetail = () => {
const { courseId, contentId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Fetch course details
const { data: courseData, isLoading: courseLoading } = useQuery(
['course', courseId],
() => coursesAPI.getById(courseId),
{ enabled: !!courseId }
);
// Fetch lesson content details
const { data: contentData, isLoading: contentLoading } = useQuery(
['lesson-detail', courseId, contentId],
() => courseContentAPI.getById(courseId, contentId),
{ enabled: !!courseId && !!contentId }
);
// Delete mutation
const deleteMutation = useMutation(
() => courseContentAPI.delete(courseId, contentId),
{
onSuccess: () => {
toast.success('Lesson deleted successfully!');
queryClient.invalidateQueries(['course-content', courseId]);
queryClient.invalidateQueries(['course-sections', courseId]);
navigate(`/courses/${courseId}/content`);
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to delete lesson');
}
}
);
// Toggle publish mutation
const togglePublishMutation = useMutation(
(isPublished) => courseContentAPI.update(courseId, contentId, { isPublished }),
{
onSuccess: (data, isPublished) => {
toast.success(`Lesson ${isPublished ? 'published' : 'unpublished'} successfully!`);
queryClient.invalidateQueries(['lesson-detail', courseId, contentId]);
queryClient.invalidateQueries(['course-content', courseId]);
queryClient.invalidateQueries(['course-sections', courseId]);
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to update lesson');
}
}
);
const handleDelete = () => {
deleteMutation.mutate();
setShowDeleteConfirm(false);
};
const getContentTypeIcon = (type) => {
switch (type) {
case 'document': return <DocumentIcon className="h-6 w-6" />;
case 'image': return <PhotoIcon className="h-6 w-6" />;
case 'video': return <VideoCameraIcon className="h-6 w-6" />;
case 'article': return <DocumentTextIcon className="h-6 w-6" />;
case 'quiz': return <QuestionMarkCircleIcon className="h-6 w-6" />;
case 'certificate': return <AcademicCapIcon className="h-6 w-6" />;
default: return <DocumentIcon className="h-6 w-6" />;
}
};
const getContentTypeColor = (type) => {
switch (type) {
case 'document': return 'text-green-600 bg-green-100';
case 'image': return 'text-blue-600 bg-blue-100';
case 'video': return 'text-purple-600 bg-purple-100';
case 'article': return 'text-orange-600 bg-orange-100';
case 'quiz': return 'text-red-600 bg-red-100';
case 'certificate': return 'text-yellow-600 bg-yellow-100';
default: return 'text-gray-600 bg-gray-100';
}
};
const formatFileSize = (bytes) => {
if (!bytes) return 'N/A';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
};
if (courseLoading || contentLoading) {
return <LoadingSpinner />;
}
if (!contentData) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-900 mb-2">Lesson Not Found</h2>
<p className="text-gray-600 mb-4">The requested lesson could not be found.</p>
<Link
to={`/courses/${courseId}/content`}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Back to Course Content
</Link>
</div>
</div>
);
}
const content = contentData.content || contentData;
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-4">
<button
onClick={() => navigate(`/courses/${courseId}/content`)}
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors p-2 rounded-lg hover:bg-gray-100"
title="Back to Course Content"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<div className="h-6 border-l border-gray-300"></div>
<h1 className="text-xl font-semibold text-gray-900">{content?.title || 'Loading...'}</h1>
</div>
<div className="flex items-center space-x-3">
<Link
to={`/courses/${courseId}/learn?content=${contentId}`}
className="flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition-colors"
>
<EyeIcon className="h-4 w-4 mr-2" />
Preview
</Link>
<button
onClick={() => togglePublishMutation.mutate(!content.isPublished)}
disabled={togglePublishMutation.isLoading}
className={`flex items-center px-4 py-2 rounded-lg transition-colors ${
content.isPublished
? 'bg-yellow-600 hover:bg-yellow-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{content.isPublished ? (
<>
<XCircleIcon className="h-4 w-4 mr-2" />
{togglePublishMutation.isLoading ? 'Unpublishing...' : 'Unpublish'}
</>
) : (
<>
<CheckCircleIcon className="h-4 w-4 mr-2" />
{togglePublishMutation.isLoading ? 'Publishing...' : 'Publish'}
</>
)}
</button>
<button
onClick={() => navigate(`/courses/${courseId}/content?edit=${contentId}`)}
className="flex items-center bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors"
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete
</button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Lesson Overview */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-start justify-between mb-6">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-lg ${getContentTypeColor(content.type)}`}>
{getContentTypeIcon(content.type)}
</div>
<div>
<h2 className="text-2xl font-bold text-gray-900">{content.title}</h2>
<p className="text-gray-600 capitalize">{content.type} Content</p>
</div>
</div>
<div className="flex items-center space-x-2">
{content.isPublished ? (
<span className="flex items-center text-green-600 bg-green-100 px-2 py-1 rounded-full text-sm">
<CheckCircleIcon className="h-4 w-4 mr-1" />
Published
</span>
) : (
<span className="flex items-center text-yellow-600 bg-yellow-100 px-2 py-1 rounded-full text-sm">
<ClockIcon className="h-4 w-4 mr-1" />
Draft
</span>
)}
{content.isRequired && (
<span className="text-red-600 bg-red-100 px-2 py-1 rounded-full text-sm">
Required
</span>
)}
</div>
</div>
{content.description && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Description</h3>
<p className="text-gray-700 whitespace-pre-wrap">{content.description}</p>
</div>
)}
{/* Article Content */}
{content.type === 'article' && (content.content || content.articleContent) && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Article Content</h3>
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{
__html: typeof content.content === 'string' ? content.content :
typeof content.articleContent === 'string' ? content.articleContent :
JSON.stringify(content.content || content.articleContent)
}}
/>
</div>
</div>
)}
{/* File Information */}
{['document', 'image', 'video'].includes(content.type) && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">File Information</h3>
<div className="border border-gray-200 rounded-lg p-4">
{content.fileUrl ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">File URL:</span>
<span className="text-sm text-blue-600 font-mono break-all">{content.fileUrl}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">File Type:</span>
<span className="text-sm text-gray-900">{content.fileType || 'Unknown'}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">File Size:</span>
<span className="text-sm text-gray-900">{formatFileSize(content.fileSize)}</span>
</div>
<div className="mt-4 pt-4 border-t border-gray-200">
<p className="text-sm font-medium text-gray-700 mb-2">File Preview:</p>
{content.type === 'image' && (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<img
src={getBestImageUrl(content.fileUrl)}
alt={content.title}
className="w-full max-h-96 object-contain"
onError={(e) => {
e.target.style.display = 'none';
if (e.target.nextSibling) {
e.target.nextSibling.style.display = 'block';
}
}}
/>
<div className="hidden p-4 text-center text-red-600">
<XCircleIcon className="h-8 w-8 mx-auto mb-2" />
<p>Image failed to load</p>
<p className="text-sm text-gray-500">Check file path: {getFileServingUrl(content.fileUrl)}</p>
</div>
</div>
)}
{content.type === 'video' && (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<video
controls
className="w-full max-h-96"
onError={(e) => {
e.target.style.display = 'none';
if (e.target.nextSibling) {
e.target.nextSibling.style.display = 'block';
}
}}
>
<source src={getFileServingUrl(content.fileUrl)} type={content.fileType || "video/mp4"} />
Your browser does not support the video tag.
</video>
<div className="hidden p-4 text-center text-red-600">
<XCircleIcon className="h-8 w-8 mx-auto mb-2" />
<p>Video failed to load</p>
<p className="text-sm text-gray-500">Check file path: {getFileServingUrl(content.fileUrl)}</p>
</div>
</div>
)}
{content.type === 'document' && (
<div className="space-y-2">
<a
href={getFileServingUrl(content.fileUrl)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<DocumentIcon className="h-4 w-4 mr-2" />
Open Document
</a>
{content.fileType === 'application/pdf' && (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<iframe
src={getFileServingUrl(content.fileUrl)}
className="w-full h-96"
title={content.title}
/>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="text-center py-8">
<XCircleIcon className="h-12 w-12 text-red-400 mx-auto mb-4" />
<p className="text-gray-600">No file uploaded for this {content.type} content.</p>
</div>
)}
</div>
</div>
)}
{/* Quiz Information */}
{content.type === 'quiz' && (
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quiz Information</h3>
<div className="border border-gray-200 rounded-lg p-4">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium text-gray-700">Points:</span>
<span className="ml-2 text-sm text-gray-900">{content.points || 0}</span>
</div>
<div>
<span className="text-sm font-medium text-gray-700">Questions:</span>
<span className="ml-2 text-sm text-gray-900">
{content.quizData?.questions?.length || 0}
</span>
</div>
</div>
{content.quizData?.questions?.length > 0 && (
<div className="mt-4">
<p className="text-sm font-medium text-gray-700 mb-2">Questions:</p>
<div className="space-y-2">
{content.quizData.questions.map((question, index) => (
<div key={index} className="bg-gray-50 p-3 rounded-lg">
<p className="text-sm font-medium">Q{index + 1}: {question.question}</p>
<div className="mt-2 space-y-1">
{question.options.map((option, optionIndex) => (
<div
key={optionIndex}
className={`text-xs px-2 py-1 rounded ${
optionIndex === question.correctAnswer
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}
>
{option} {optionIndex === question.correctAnswer && '✓'}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
</div>
{/* Raw Data (for debugging) */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Raw Data (Debug)</h3>
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-auto">
<pre>{JSON.stringify(content, null, 2)}</pre>
</div>
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Quick Stats */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quick Info</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-gray-600">Order:</span>
<span className="text-sm font-medium">{content.order || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Points:</span>
<span className="text-sm font-medium">{content.points || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Required:</span>
<span className={`text-sm font-medium ${content.isRequired ? 'text-green-600' : 'text-gray-400'}`}>
{content.isRequired ? 'Yes' : 'No'}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Published:</span>
<span className={`text-sm font-medium ${content.isPublished ? 'text-green-600' : 'text-yellow-600'}`}>
{content.isPublished ? 'Yes' : 'No'}
</span>
</div>
<div className="pt-3 border-t border-gray-200">
<div className="flex justify-between mb-2">
<span className="text-sm text-gray-600">Created:</span>
<span className="text-sm font-medium">{formatDate(content.createdAt)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">Updated:</span>
<span className="text-sm font-medium">{formatDate(content.updatedAt)}</span>
</div>
</div>
</div>
</div>
{/* Course Info */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Course Info</h3>
<div className="space-y-3">
<div>
<span className="text-sm text-gray-600">Course:</span>
<p className="text-sm font-medium">{courseData?.course?.title || courseData?.title || 'Unknown'}</p>
</div>
<div>
<span className="text-sm text-gray-600">Section:</span>
<p className="text-sm font-medium">{content.sectionId || 'Uncategorized'}</p>
</div>
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Actions</h3>
<div className="space-y-3">
<Link
to={`/courses/${courseId}/learn?content=${contentId}`}
className="w-full flex items-center justify-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<EyeIcon className="h-4 w-4 mr-2" />
View in Learning Page
</Link>
<button
onClick={() => togglePublishMutation.mutate(!content.isPublished)}
disabled={togglePublishMutation.isLoading}
className={`w-full flex items-center justify-center px-4 py-2 rounded-lg transition-colors ${
content.isPublished
? 'bg-yellow-600 hover:bg-yellow-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{content.isPublished ? (
<>
<XCircleIcon className="h-4 w-4 mr-2" />
{togglePublishMutation.isLoading ? 'Unpublishing...' : 'Unpublish'}
</>
) : (
<>
<CheckCircleIcon className="h-4 w-4 mr-2" />
{togglePublishMutation.isLoading ? 'Publishing...' : 'Publish'}
</>
)}
</button>
<button
onClick={() => navigate(`/courses/${courseId}/content?edit=${contentId}`)}
className="w-full flex items-center justify-center bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit Content
</button>
<button
onClick={() => setShowDeleteConfirm(true)}
className="w-full flex items-center justify-center bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors"
>
<TrashIcon className="h-4 w-4 mr-2" />
Delete Content
</button>
</div>
</div>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-md">
<div className="flex items-center mb-4">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 mr-3" />
<h3 className="text-lg font-semibold text-gray-900">Delete Lesson</h3>
</div>
<p className="text-gray-600 mb-6">
Are you sure you want to delete "{content.title}"? This action cannot be undone.
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleteMutation.isLoading}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
{deleteMutation.isLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default LessonDetail;

View file

@ -0,0 +1,52 @@
/**
* Media Utilities
* Clean, secure media URL handling for CourseWorx
* Supports future video security and anti-theft measures
*/
// Utility function to get secure media URL from relative path
export const getImageUrl = (imagePath) => {
if (!imagePath) return null;
// If it's already a full URL, return as is
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) {
return imagePath;
}
// Get authentication token for media access
const token = localStorage.getItem('token');
// Convert to secure media endpoint
if (imagePath.startsWith('/uploads/')) {
// Use relative path from frontend origin
const relativePath = imagePath.replace('/uploads/', '');
const baseUrl = `/api/media/${relativePath}`;
// Add token as query parameter for authentication
return token ? `${baseUrl}?token=${token}` : baseUrl;
}
// If it's just a filename, construct secure media URL
const baseUrl = `/api/media/courses/${imagePath}`;
// Add token as query parameter for authentication
return token ? `${baseUrl}?token=${token}` : baseUrl;
};
// Utility function for all media types (images, videos, documents)
export const getMediaUrl = (mediaPath) => {
return getImageUrl(mediaPath); // Same logic for all media types
};
// Legacy compatibility functions (to fix compilation errors)
export const getFileServingUrl = (filePath) => {
return getImageUrl(filePath); // Use same secure endpoint for all files
};
export const getBestImageUrl = (imagePath) => {
return getImageUrl(imagePath); // Use same secure endpoint for images
};
// Utility function to get thumbnail URL from thumbnail string
export const getThumbnailUrl = (thumbnailPath) => {
if (!thumbnailPath) return null;
return getImageUrl(thumbnailPath);
};

File diff suppressed because it is too large Load diff