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:
parent
b4d90c650f
commit
15281c52cb
5 changed files with 820 additions and 6 deletions
|
|
@ -12,6 +12,7 @@ import Profile from './pages/Profile';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import LoadingSpinner from './components/LoadingSpinner';
|
import LoadingSpinner from './components/LoadingSpinner';
|
||||||
import CourseEdit from './pages/CourseEdit';
|
import CourseEdit from './pages/CourseEdit';
|
||||||
|
import CourseContent from './pages/CourseContent';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
|
|
||||||
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
const PrivateRoute = ({ children, allowedRoles = [] }) => {
|
||||||
|
|
@ -58,6 +59,11 @@ const AppRoutes = () => {
|
||||||
<CourseEdit />
|
<CourseEdit />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/courses/:id/content" element={
|
||||||
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
|
<CourseContent />
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/users/import" element={
|
<Route path="/users/import" element={
|
||||||
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
<PrivateRoute allowedRoles={['super_admin', 'trainer']}>
|
||||||
<UserImport />
|
<UserImport />
|
||||||
|
|
|
||||||
745
frontend/src/pages/CourseContent.js
Normal file
745
frontend/src/pages/CourseContent.js
Normal 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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
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 { useQuery, useMutation, useQueryClient } from 'react-query';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { coursesAPI, enrollmentsAPI } from '../services/api';
|
import { coursesAPI, enrollmentsAPI } from '../services/api';
|
||||||
|
|
@ -9,13 +9,14 @@ import {
|
||||||
UserIcon,
|
UserIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
BookOpenIcon,
|
BookOpenIcon,
|
||||||
|
CogIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import LoadingSpinner from '../components/LoadingSpinner';
|
import LoadingSpinner from '../components/LoadingSpinner';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const CourseDetail = () => {
|
const CourseDetail = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { user, isTrainee } = useAuth();
|
const { user, isTrainee, isTrainer, isSuperAdmin } = useAuth();
|
||||||
const [enrolling, setEnrolling] = useState(false);
|
const [enrolling, setEnrolling] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|
@ -108,8 +109,21 @@ const CourseDetail = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{courseData.title}</h1>
|
<div className="flex justify-between items-start">
|
||||||
<p className="text-gray-600 mt-2">{courseData.shortDescription}</p>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ const CourseEdit = () => {
|
||||||
const [imagePreview, setImagePreview] = useState(null);
|
const [imagePreview, setImagePreview] = useState(null);
|
||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleImageChange = (e) => {
|
const handleImageChange = (e) => {
|
||||||
const f = e.target.files[0];
|
const f = e.target.files[0];
|
||||||
setImageFile(f);
|
setImageFile(f);
|
||||||
|
|
@ -54,6 +56,8 @@ const CourseEdit = () => {
|
||||||
{ enabled: !!id }
|
{ enabled: !!id }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (courseData && courseData.course) {
|
if (courseData && courseData.course) {
|
||||||
const c = courseData.course;
|
const c = courseData.course;
|
||||||
|
|
@ -93,6 +97,8 @@ const CourseEdit = () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -116,6 +122,8 @@ const CourseEdit = () => {
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!isTrainer && !isSuperAdmin) {
|
if (!isTrainer && !isSuperAdmin) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|
@ -425,6 +433,8 @@ const CourseEdit = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Submit Buttons */}
|
{/* Submit Buttons */}
|
||||||
<div className="flex justify-end space-x-4">
|
<div className="flex justify-end space-x-4">
|
||||||
<button
|
<button
|
||||||
|
|
@ -453,6 +463,8 @@ const CourseEdit = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
41
version.txt
41
version.txt
|
|
@ -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.
|
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
|
- Assignment management
|
||||||
- Database migrations and seeding
|
- 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:
|
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.
|
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]
|
Release Date: [Current Date]
|
||||||
Version: 1.1.0
|
Version: 1.1.1
|
||||||
Status: Production Ready
|
Status: Production Ready
|
||||||
Loading…
Reference in a new issue