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:
parent
a45c858d3b
commit
c06600f263
7 changed files with 7759 additions and 512 deletions
|
|
@ -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,
|
||||
|
|
|
|||
387
frontend/src/hooks/useContentManagement.js
Normal file
387
frontend/src/hooks/useContentManagement.js
Normal 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
|
||||
};
|
||||
};
|
||||
54
frontend/src/hooks/useFileUpload.js
Normal file
54
frontend/src/hooks/useFileUpload.js
Normal 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
565
frontend/src/pages/LessonDetail.js
Normal file
565
frontend/src/pages/LessonDetail.js
Normal 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;
|
||||
52
frontend/src/utils/imageUtils.js
Normal file
52
frontend/src/utils/imageUtils.js
Normal 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);
|
||||
};
|
||||
5527
version.txt
5527
version.txt
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue