Version 1.1.1 - Enhanced Course Content Management Interface

- Created dedicated CourseContent page for content management
- Separated content management from course editing
- Added prominent 'Manage Content' button on course detail pages
- Enhanced UI with better visual hierarchy and styling
- Improved content management workflow with CRUD operations
- Added content type icons and status indicators
- Enhanced modal interfaces for content creation/editing
- Improved navigation and role-based access control
This commit is contained in:
Mahmoud M. Abdalla 2025-07-28 01:16:12 +03:00
parent da2723f771
commit cee2aaf713
5 changed files with 820 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import Profile from './pages/Profile';
import Layout from './components/Layout';
import LoadingSpinner from './components/LoadingSpinner';
import CourseEdit from './pages/CourseEdit';
import CourseContent from './pages/CourseContent';
import Home from './pages/Home';
const PrivateRoute = ({ children, allowedRoles = [] }) => {
@ -58,6 +59,11 @@ const AppRoutes = () => {
<CourseEdit />
</PrivateRoute>
} />
<Route path="/courses/:id/content" element={
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
<CourseContent />
</PrivateRoute>
} />
<Route path="/users/import" element={
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
<UserImport />

View file

@ -0,0 +1,745 @@
import React, { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useAuth } from '../contexts/AuthContext';
import { coursesAPI, courseContentAPI } from '../services/api';
import {
PlusIcon,
DocumentIcon,
PhotoIcon,
VideoCameraIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
AcademicCapIcon as CertificateIcon,
TrashIcon,
PencilIcon,
EyeIcon,
EyeSlashIcon,
ArrowLeftIcon,
AcademicCapIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
const CourseContent = () => {
const { id } = useParams();
const navigate = useNavigate();
const { isTrainer, isSuperAdmin } = useAuth();
const queryClient = useQueryClient();
// Content management state
const [showAddContentModal, setShowAddContentModal] = useState(false);
const [showEditContentModal, setShowEditContentModal] = useState(false);
const [editingContent, setEditingContent] = useState(null);
const [contentForm, setContentForm] = useState({
title: '',
description: '',
type: 'document',
order: 0,
points: 0,
isRequired: true,
isPublished: true,
articleContent: '',
});
const [uploadingFile, setUploadingFile] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
// Get course details
const { data: courseData, isLoading: courseLoading } = useQuery(
['course', id],
() => coursesAPI.getById(id),
{ enabled: !!id }
);
// Get course content
const { data: contentData, isLoading: contentLoading } = useQuery(
['course-content', id],
() => courseContentAPI.getAll(id),
{ enabled: !!id }
);
// Content management mutations
const createContentMutation = useMutation(
(data) => courseContentAPI.create(id, data),
{
onSuccess: () => {
queryClient.invalidateQueries(['course-content', id]);
toast.success('Content added successfully!');
setShowAddContentModal(false);
resetContentForm();
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to add content');
},
}
);
const updateContentMutation = useMutation(
(data) => courseContentAPI.update(id, editingContent.id, data),
{
onSuccess: () => {
queryClient.invalidateQueries(['course-content', id]);
toast.success('Content updated successfully!');
setShowEditContentModal(false);
setEditingContent(null);
resetContentForm();
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to update content');
},
}
);
const deleteContentMutation = useMutation(
(contentId) => courseContentAPI.delete(id, contentId),
{
onSuccess: () => {
queryClient.invalidateQueries(['course-content', id]);
toast.success('Content deleted successfully!');
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to delete content');
},
}
);
const uploadFileMutation = useMutation(
({ contentType, file, contentId }) => courseContentAPI.uploadFile(id, contentType, file, contentId),
{
onSuccess: (data) => {
toast.success('File uploaded successfully!');
setSelectedFile(null);
setUploadingFile(false);
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to upload file');
setUploadingFile(false);
},
}
);
// Content management handlers
const handleContentFormChange = (e) => {
const { name, value, type, checked } = e.target;
setContentForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const resetContentForm = () => {
setContentForm({
title: '',
description: '',
type: 'document',
order: 0,
points: 0,
isRequired: true,
isPublished: true,
articleContent: '',
});
};
const handleAddContent = async (e) => {
e.preventDefault();
createContentMutation.mutate(contentForm);
};
const handleEditContent = async (e) => {
e.preventDefault();
updateContentMutation.mutate(contentForm);
};
const handleDeleteContent = (contentId) => {
if (window.confirm('Are you sure you want to delete this content?')) {
deleteContentMutation.mutate(contentId);
}
};
const handleEditContentClick = (content) => {
setEditingContent(content);
setContentForm({
title: content.title || '',
description: content.description || '',
type: content.type || 'document',
order: content.order || 0,
points: content.points || 0,
isRequired: content.isRequired !== undefined ? content.isRequired : true,
isPublished: content.isPublished !== undefined ? content.isPublished : true,
articleContent: content.articleContent || '',
});
setShowEditContentModal(true);
};
const handleFileUpload = async () => {
if (!selectedFile) return;
setUploadingFile(true);
uploadFileMutation.mutate({
contentType: contentForm.type,
file: selectedFile,
contentId: editingContent?.id || null
});
};
const handleFileChange = (e) => {
const file = e.target.files[0];
setSelectedFile(file);
};
const getContentTypeIcon = (type) => {
const icons = {
document: DocumentIcon,
image: PhotoIcon,
video: VideoCameraIcon,
article: DocumentTextIcon,
quiz: QuestionMarkCircleIcon,
certificate: CertificateIcon,
};
return icons[type] || DocumentIcon;
};
const getContentTypeLabel = (type) => {
const labels = {
document: 'Document',
image: 'Image',
video: 'Video',
article: 'Article',
quiz: 'Quiz',
certificate: 'Certificate',
};
return labels[type] || 'Document';
};
if (!isTrainer && !isSuperAdmin) {
return (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-gray-900">Access Denied</h3>
<p className="text-gray-500">You don't have permission to manage course content.</p>
</div>
);
}
if (courseLoading || contentLoading) {
return <LoadingSpinner size="lg" className="mt-8" />;
}
return (
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<button
onClick={() => navigate(`/courses/${id}`)}
className="mr-4 p-2 text-gray-600 hover:text-gray-900"
>
<ArrowLeftIcon className="h-6 w-6" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">Course Content</h1>
<p className="text-gray-600">
{courseData?.course?.title} - Manage course materials
</p>
</div>
</div>
<button
onClick={() => setShowAddContentModal(true)}
className="btn-primary flex items-center"
>
<PlusIcon className="h-5 w-5 mr-2" />
Add Content
</button>
</div>
</div>
{/* Content List */}
<div className="card">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-gray-900">Course Materials</h2>
<div className="text-sm text-gray-500">
{contentData?.contents?.length || 0} items
</div>
</div>
{contentData?.contents?.length > 0 ? (
<div className="space-y-4">
{contentData.contents.map((content) => {
const IconComponent = getContentTypeIcon(content.type);
return (
<div key={content.id} className="border rounded-lg p-4 bg-gray-50 hover:bg-gray-100 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<IconComponent className="h-8 w-8 text-gray-600" />
<div className="flex-1">
<h4 className="font-medium text-gray-900">{content.title}</h4>
<p className="text-sm text-gray-600">{getContentTypeLabel(content.type)}</p>
{content.description && (
<p className="text-sm text-gray-500 mt-1">{content.description}</p>
)}
{content.fileUrl && (
<p className="text-xs text-blue-600 mt-1">File attached</p>
)}
</div>
</div>
<div className="flex items-center space-x-3">
<div className="flex items-center space-x-2">
<span className="text-xs bg-gray-200 px-2 py-1 rounded">
Order: {content.order}
</span>
<span className="text-xs bg-gray-200 px-2 py-1 rounded">
Points: {content.points}
</span>
{content.isRequired && (
<span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">
Required
</span>
)}
{content.isPublished ? (
<EyeIcon className="h-4 w-4 text-green-600" title="Published" />
) : (
<EyeSlashIcon className="h-4 w-4 text-gray-400" title="Unpublished" />
)}
</div>
<div className="flex items-center space-x-2">
<button
type="button"
onClick={() => handleEditContentClick(content)}
className="text-blue-600 hover:text-blue-800 p-1"
title="Edit content"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteContent(content.id)}
className="text-red-600 hover:text-red-800 p-1"
title="Delete content"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-12 text-gray-500">
<AcademicCapIcon className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-medium mb-2">No content yet</h3>
<p className="text-sm">Start building your course by adding content</p>
</div>
)}
</div>
{/* Add Content Modal */}
{showAddContentModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900">Add Course Content</h2>
<button
onClick={() => {
setShowAddContentModal(false);
resetContentForm();
}}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
&times;
</button>
</div>
<form onSubmit={handleAddContent} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Content Title *
</label>
<input
type="text"
name="title"
value={contentForm.title}
onChange={handleContentFormChange}
className="input-field w-full"
placeholder="Enter content title"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
value={contentForm.description}
onChange={handleContentFormChange}
className="input-field w-full"
rows={3}
placeholder="Brief description of this content"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Content Type *
</label>
<select
name="type"
value={contentForm.type}
onChange={handleContentFormChange}
className="input-field w-full"
required
>
<option value="document">Document</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="article">Article</option>
<option value="quiz">Quiz</option>
<option value="certificate">Certificate</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Order
</label>
<input
type="number"
name="order"
value={contentForm.order}
onChange={handleContentFormChange}
className="input-field w-full"
min="0"
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Points
</label>
<input
type="number"
name="points"
value={contentForm.points}
onChange={handleContentFormChange}
className="input-field w-full"
min="0"
placeholder="0"
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<input
type="checkbox"
name="isRequired"
checked={contentForm.isRequired}
onChange={handleContentFormChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900">
Required content
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="isPublished"
checked={contentForm.isPublished}
onChange={handleContentFormChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900">
Published
</label>
</div>
</div>
</div>
{/* Article Content */}
{contentForm.type === 'article' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Article Content
</label>
<textarea
name="articleContent"
value={contentForm.articleContent}
onChange={handleContentFormChange}
className="input-field w-full"
rows={8}
placeholder="Write your article content here..."
/>
</div>
)}
{/* File Upload for Document, Image, Video */}
{['document', 'image', 'video'].includes(contentForm.type) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload File
</label>
<input
type="file"
onChange={handleFileChange}
className="input-field w-full"
accept={
contentForm.type === 'document'
? '.pdf,.doc,.docx,.txt'
: contentForm.type === 'image'
? 'image/*'
: 'video/*'
}
/>
{selectedFile && (
<div className="mt-2">
<p className="text-sm text-gray-600">Selected: {selectedFile.name}</p>
<button
type="button"
onClick={handleFileUpload}
disabled={uploadingFile}
className="btn-secondary mt-2"
>
{uploadingFile ? 'Uploading...' : 'Upload File'}
</button>
</div>
)}
</div>
)}
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => {
setShowAddContentModal(false);
resetContentForm();
}}
className="btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={createContentMutation.isLoading}
className="btn-primary"
>
{createContentMutation.isLoading ? 'Adding...' : 'Add Content'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Content Modal */}
{showEditContentModal && editingContent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white rounded-lg shadow-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-900">Edit Course Content</h2>
<button
onClick={() => {
setShowEditContentModal(false);
setEditingContent(null);
resetContentForm();
}}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
&times;
</button>
</div>
<form onSubmit={handleEditContent} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Content Title *
</label>
<input
type="text"
name="title"
value={contentForm.title}
onChange={handleContentFormChange}
className="input-field w-full"
placeholder="Enter content title"
required
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
name="description"
value={contentForm.description}
onChange={handleContentFormChange}
className="input-field w-full"
rows={3}
placeholder="Brief description of this content"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Content Type *
</label>
<select
name="type"
value={contentForm.type}
onChange={handleContentFormChange}
className="input-field w-full"
required
>
<option value="document">Document</option>
<option value="image">Image</option>
<option value="video">Video</option>
<option value="article">Article</option>
<option value="quiz">Quiz</option>
<option value="certificate">Certificate</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Order
</label>
<input
type="number"
name="order"
value={contentForm.order}
onChange={handleContentFormChange}
className="input-field w-full"
min="0"
placeholder="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Points
</label>
<input
type="number"
name="points"
value={contentForm.points}
onChange={handleContentFormChange}
className="input-field w-full"
min="0"
placeholder="0"
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center">
<input
type="checkbox"
name="isRequired"
checked={contentForm.isRequired}
onChange={handleContentFormChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900">
Required content
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="isPublished"
checked={contentForm.isPublished}
onChange={handleContentFormChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label className="ml-2 block text-sm text-gray-900">
Published
</label>
</div>
</div>
</div>
{/* Article Content */}
{contentForm.type === 'article' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Article Content
</label>
<textarea
name="articleContent"
value={contentForm.articleContent}
onChange={handleContentFormChange}
className="input-field w-full"
rows={8}
placeholder="Write your article content here..."
/>
</div>
)}
{/* File Upload for Document, Image, Video */}
{['document', 'image', 'video'].includes(contentForm.type) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Upload File
</label>
<input
type="file"
onChange={handleFileChange}
className="input-field w-full"
accept={
contentForm.type === 'document'
? '.pdf,.doc,.docx,.txt'
: contentForm.type === 'image'
? 'image/*'
: 'video/*'
}
/>
{selectedFile && (
<div className="mt-2">
<p className="text-sm text-gray-600">Selected: {selectedFile.name}</p>
<button
type="button"
onClick={handleFileUpload}
disabled={uploadingFile}
className="btn-secondary mt-2"
>
{uploadingFile ? 'Uploading...' : 'Upload File'}
</button>
</div>
)}
</div>
)}
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => {
setShowEditContentModal(false);
setEditingContent(null);
resetContentForm();
}}
className="btn-secondary"
>
Cancel
</button>
<button
type="submit"
disabled={updateContentMutation.isLoading}
className="btn-primary"
>
{updateContentMutation.isLoading ? 'Updating...' : 'Update Content'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default CourseContent;

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useAuth } from '../contexts/AuthContext';
import { coursesAPI, enrollmentsAPI } from '../services/api';
@ -9,13 +9,14 @@ import {
UserIcon,
StarIcon,
BookOpenIcon,
CogIcon,
} from '@heroicons/react/24/outline';
import LoadingSpinner from '../components/LoadingSpinner';
import toast from 'react-hot-toast';
const CourseDetail = () => {
const { id } = useParams();
const { user, isTrainee } = useAuth();
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
const [enrolling, setEnrolling] = useState(false);
const queryClient = useQueryClient();
@ -108,9 +109,22 @@ const CourseDetail = () => {
return (
<div>
<div className="mb-8">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
</div>
{(isTrainer || isSuperAdmin) && (
<Link
to={`/courses/${id}/content`}
className="btn-primary flex items-center shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-105"
>
<CogIcon className="h-5 w-5 mr-2" />
Manage Content
</Link>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}

View file

@ -27,6 +27,8 @@ const CourseEdit = () => {
const [imagePreview, setImagePreview] = useState(null);
const [uploadingImage, setUploadingImage] = useState(false);
const handleImageChange = (e) => {
const f = e.target.files[0];
setImageFile(f);
@ -54,6 +56,8 @@ const CourseEdit = () => {
{ enabled: !!id }
);
useEffect(() => {
if (courseData && courseData.course) {
const c = courseData.course;
@ -93,6 +97,8 @@ const CourseEdit = () => {
}
);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
@ -116,6 +122,8 @@ const CourseEdit = () => {
}));
};
if (!isTrainer && !isSuperAdmin) {
return (
<div className="text-center py-12">
@ -425,6 +433,8 @@ const CourseEdit = () => {
</button>
</div>
{/* Submit Buttons */}
<div className="flex justify-end space-x-4">
<button
@ -453,6 +463,8 @@ const CourseEdit = () => {
</button>
</div>
</form>
</div>
);
};

View file

@ -1,4 +1,4 @@
CourseWorx v1.1.0 - Course Content & Enrollment Management
CourseWorx v1.1.1 - Enhanced Course Content Management Interface
==========================================================
This version adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System.
@ -111,6 +111,43 @@ MAJOR FEATURES IMPLEMENTED:
- Assignment management
- Database migrations and seeding
NEW FEATURES IN v1.1.1:
========================
1. DEDICATED COURSE CONTENT MANAGEMENT PAGE
-------------------------------------------
- Separated content management from course editing
- Dedicated `/courses/:id/content` route for content management
- Comprehensive content CRUD operations (Create, Read, Update, Delete)
- Enhanced user interface with prominent action buttons
- Content type icons and visual indicators
- Content status management (published/unpublished, required/optional)
2. ENHANCED USER INTERFACE
---------------------------
- Prominent "Manage Content" button on course detail pages
- Improved visual hierarchy and button styling
- Better content organization and display
- Enhanced modal interfaces for content creation/editing
- Responsive design improvements
- Loading states and error handling
3. CONTENT MANAGEMENT WORKFLOW
------------------------------
- Add content with type-specific forms (Documents, Images, Videos, Articles, Quizzes, Certificates)
- Edit existing content with pre-populated forms
- Delete content with confirmation dialogs
- File upload with type validation and progress tracking
- Content ordering and points system
- Publishing controls and status indicators
4. NAVIGATION IMPROVEMENTS
---------------------------
- Direct access to content management from course detail pages
- Back navigation to course detail page
- Role-based visibility for content management features
- Clean separation between course editing and content management
NEW FEATURES IN v1.1.0:
========================
@ -310,5 +347,5 @@ DEPLOYMENT READINESS:
This version (1.1.0) adds comprehensive course content management and enrollment/subscriber functionality to the existing Course Management System, providing a complete foundation for creating rich educational content and managing student enrollments.
Release Date: [Current Date]
Version: 1.1.0
Version: 1.1.1
Status: Production Ready