v1.1.4: Modern header navigation & real-time dashboard statistics - Removed sidebar completely and moved navigation to header - Added CourseWorx logo and Home icon for dashboard access - Moved Courses and Users links to header with icons - Updated all dashboard cards to show real database counts - Enhanced backend API endpoints for role-specific statistics - Fixed ESLint warnings in CourseContent.js and CourseContentViewer.js - Improved responsive design and user experience
This commit is contained in:
parent
29b55e14aa
commit
b198ba5676
7 changed files with 424 additions and 167 deletions
|
|
@ -471,12 +471,22 @@ router.get('/stats/overview', auth, async (req, res) => {
|
||||||
where: { ...whereClause, isFeatured: true }
|
where: { ...whereClause, isFeatured: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// For trainers, provide specific stats
|
||||||
|
let myCourses = 0;
|
||||||
|
let myPublishedCourses = 0;
|
||||||
|
if (req.user.role === 'trainer') {
|
||||||
|
myCourses = totalCourses;
|
||||||
|
myPublishedCourses = publishedCourses;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
stats: {
|
stats: {
|
||||||
totalCourses,
|
totalCourses,
|
||||||
publishedCourses,
|
publishedCourses,
|
||||||
unpublishedCourses: totalCourses - publishedCourses,
|
unpublishedCourses: totalCourses - publishedCourses,
|
||||||
featuredCourses
|
featuredCourses,
|
||||||
|
myCourses,
|
||||||
|
myPublishedCourses
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,11 @@ router.get('/stats/overview', auth, async (req, res) => {
|
||||||
whereClause.courseId = trainerCourses.map(c => c.id);
|
whereClause.courseId = trainerCourses.map(c => c.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If trainee, only show their enrollments
|
||||||
|
if (req.user.role === 'trainee') {
|
||||||
|
whereClause.userId = req.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
const totalEnrollments = await Enrollment.count({ where: whereClause });
|
const totalEnrollments = await Enrollment.count({ where: whereClause });
|
||||||
const activeEnrollments = await Enrollment.count({
|
const activeEnrollments = await Enrollment.count({
|
||||||
where: { ...whereClause, status: 'active' }
|
where: { ...whereClause, status: 'active' }
|
||||||
|
|
@ -492,13 +497,49 @@ router.get('/stats/overview', auth, async (req, res) => {
|
||||||
where: { ...whereClause, status: 'pending' }
|
where: { ...whereClause, status: 'pending' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get unique students count for trainers
|
||||||
|
let myStudents = 0;
|
||||||
|
if (req.user.role === 'trainer') {
|
||||||
|
const trainerCourses = await Course.findAll({
|
||||||
|
where: { trainerId: req.user.id },
|
||||||
|
attributes: ['id']
|
||||||
|
});
|
||||||
|
|
||||||
|
if (trainerCourses.length > 0) {
|
||||||
|
const uniqueStudents = await Enrollment.findAll({
|
||||||
|
where: {
|
||||||
|
courseId: trainerCourses.map(c => c.id),
|
||||||
|
status: { [require('sequelize').Op.in]: ['active', 'completed'] }
|
||||||
|
},
|
||||||
|
attributes: ['userId'],
|
||||||
|
group: ['userId']
|
||||||
|
});
|
||||||
|
myStudents = uniqueStudents.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get my enrollments count for trainees
|
||||||
|
let myEnrollments = 0;
|
||||||
|
let completedCourses = 0;
|
||||||
|
if (req.user.role === 'trainee') {
|
||||||
|
myEnrollments = await Enrollment.count({
|
||||||
|
where: { userId: req.user.id }
|
||||||
|
});
|
||||||
|
completedCourses = await Enrollment.count({
|
||||||
|
where: { userId: req.user.id, status: 'completed' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
stats: {
|
stats: {
|
||||||
totalEnrollments,
|
totalEnrollments,
|
||||||
activeEnrollments,
|
activeEnrollments,
|
||||||
completedEnrollments,
|
completedEnrollments,
|
||||||
pendingEnrollments,
|
pendingEnrollments,
|
||||||
cancelledEnrollments: totalEnrollments - activeEnrollments - completedEnrollments - pendingEnrollments
|
cancelledEnrollments: totalEnrollments - activeEnrollments - completedEnrollments - pendingEnrollments,
|
||||||
|
myStudents,
|
||||||
|
myEnrollments,
|
||||||
|
completedCourses
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,6 @@ import {
|
||||||
AcademicCapIcon,
|
AcademicCapIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
Bars3Icon,
|
|
||||||
XMarkIcon,
|
|
||||||
ArrowRightOnRectangleIcon,
|
ArrowRightOnRectangleIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
@ -17,7 +15,6 @@ const Layout = () => {
|
||||||
const { user, logout, isSuperAdmin } = useAuth();
|
const { user, logout, isSuperAdmin } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -43,7 +40,6 @@ const Layout = () => {
|
||||||
}, [userMenuOpen]);
|
}, [userMenuOpen]);
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Dashboard', href: '/dashboard', icon: HomeIcon },
|
|
||||||
{ name: 'Courses', href: '/courses', icon: AcademicCapIcon },
|
{ name: 'Courses', href: '/courses', icon: AcademicCapIcon },
|
||||||
...(isSuperAdmin ? [{ name: 'Users', href: '/users', icon: UsersIcon }] : []),
|
...(isSuperAdmin ? [{ name: 'Users', href: '/users', icon: UsersIcon }] : []),
|
||||||
];
|
];
|
||||||
|
|
@ -57,136 +53,101 @@ const Layout = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Mobile sidebar */}
|
{/* Header */}
|
||||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
{/* Logo and Home Icon */}
|
||||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white">
|
<div className="flex items-center space-x-4">
|
||||||
<div className="flex h-16 items-center justify-between px-4">
|
<div className="flex items-center space-x-2">
|
||||||
|
<img src="/courseworx-logo.png" alt="CourseWorx" className="h-8 w-auto" />
|
||||||
<h1 className="text-xl font-bold text-gray-900">CourseWorx</h1>
|
<h1 className="text-xl font-bold text-gray-900">CourseWorx</h1>
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 px-2 py-4">
|
<button
|
||||||
{navigation.map((item) => (
|
onClick={() => navigate('/dashboard')}
|
||||||
<a
|
className="flex items-center space-x-1 text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
key={item.name}
|
>
|
||||||
href={item.href}
|
<HomeIcon className="h-5 w-5" />
|
||||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
<span className="hidden sm:inline text-sm font-medium">Home</span>
|
||||||
isActive(item.href)
|
</button>
|
||||||
? 'bg-primary-100 text-primary-900'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop sidebar */}
|
{/* Navigation Items */}
|
||||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
<div className="flex flex-1 items-center justify-center space-x-8">
|
||||||
<div className="flex flex-col flex-grow bg-white border-r border-gray-200">
|
{navigation.map((item) => (
|
||||||
<div className="flex h-16 items-center px-4">
|
<a
|
||||||
<h1 className="text-xl font-bold text-gray-900">CourseWorx</h1>
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center space-x-1 px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
isActive(item.href)
|
||||||
|
? 'bg-primary-100 text-primary-900'
|
||||||
|
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
<span className="hidden sm:inline">{item.name}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User dropdown menu */}
|
||||||
|
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||||
|
<div className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200" />
|
||||||
|
|
||||||
|
<div className="relative user-menu">
|
||||||
|
<button
|
||||||
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
|
className="flex items-center space-x-3 text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-left">
|
||||||
|
<p className="text-sm font-medium text-gray-700">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 capitalize">{user?.role?.replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown menu */}
|
||||||
|
{userMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
|
||||||
|
<div className="py-1" role="menu" aria-orientation="vertical">
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setUserMenuOpen(false)}
|
||||||
|
>
|
||||||
|
<UserIcon className="mr-3 h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
<ArrowRightOnRectangleIcon className="mr-3 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 space-y-1 px-2 py-4">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<a
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
|
||||||
isActive(item.href)
|
|
||||||
? 'bg-primary-100 text-primary-900'
|
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
|
||||||
{item.name}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="lg:pl-64">
|
<main className="py-6">
|
||||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
<button
|
<Outlet />
|
||||||
type="button"
|
|
||||||
className="-m-2.5 p-2.5 text-gray-700 lg:hidden"
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
>
|
|
||||||
<Bars3Icon className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
|
||||||
<div className="flex flex-1"></div>
|
|
||||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
|
||||||
<div className="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-200" />
|
|
||||||
|
|
||||||
{/* User dropdown menu */}
|
|
||||||
<div className="relative user-menu">
|
|
||||||
<button
|
|
||||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
|
||||||
className="flex items-center space-x-3 text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
|
||||||
>
|
|
||||||
<div className="h-8 w-8 rounded-full bg-primary-500 flex items-center justify-center">
|
|
||||||
<span className="text-sm font-medium text-white">
|
|
||||||
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="hidden md:block text-left">
|
|
||||||
<p className="text-sm font-medium text-gray-700">
|
|
||||||
{user?.firstName} {user?.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500 capitalize">{user?.role?.replace('_', ' ')}</p>
|
|
||||||
</div>
|
|
||||||
<ChevronDownIcon className="h-4 w-4 text-gray-400" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
|
||||||
{userMenuOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-50">
|
|
||||||
<div className="py-1" role="menu" aria-orientation="vertical">
|
|
||||||
<a
|
|
||||||
href="/profile"
|
|
||||||
className="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => setUserMenuOpen(false)}
|
|
||||||
>
|
|
||||||
<UserIcon className="mr-3 h-4 w-4" />
|
|
||||||
Profile
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
handleLogout();
|
|
||||||
setUserMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
|
||||||
role="menuitem"
|
|
||||||
>
|
|
||||||
<ArrowRightOnRectangleIcon className="mr-3 h-4 w-4" />
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
<main className="py-6">
|
|
||||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password Change Modal */}
|
{/* Password Change Modal */}
|
||||||
<PasswordChangeModal
|
<PasswordChangeModal
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { courseContentAPI } from '../services/api';
|
||||||
import {
|
import {
|
||||||
PlusIcon, DocumentIcon, PhotoIcon, VideoCameraIcon, DocumentTextIcon,
|
PlusIcon, DocumentIcon, PhotoIcon, VideoCameraIcon, DocumentTextIcon,
|
||||||
QuestionMarkCircleIcon, AcademicCapIcon, TrashIcon, PencilIcon,
|
QuestionMarkCircleIcon, AcademicCapIcon, TrashIcon, PencilIcon,
|
||||||
ArrowLeftIcon, XMarkIcon, PlusIcon as AddIcon
|
ArrowLeftIcon, XMarkIcon
|
||||||
} 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';
|
||||||
|
|
@ -25,8 +25,8 @@ const CourseContent = () => {
|
||||||
const [contentForm, setContentForm] = useState({
|
const [contentForm, setContentForm] = useState({
|
||||||
title: '', description: '', type: 'document', order: 0, points: 0,
|
title: '', description: '', type: 'document', order: 0, points: 0,
|
||||||
isRequired: true, isPublished: true, articleContent: '',
|
isRequired: true, isPublished: true, articleContent: '',
|
||||||
|
url: '', // <-- Add url field for image/video
|
||||||
});
|
});
|
||||||
const [uploadingFile, setUploadingFile] = useState(false);
|
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
const [selectedFile, setSelectedFile] = useState(null);
|
||||||
|
|
||||||
// Quiz questions state
|
// Quiz questions state
|
||||||
|
|
@ -99,7 +99,7 @@ const CourseContent = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadFileMutation = useMutation(
|
const uploadFileMutation = useMutation(
|
||||||
({ file, contentType }) => courseContentAPI.uploadFile(id, file, contentType),
|
({ file, contentType, contentId }) => courseContentAPI.uploadFile(id, contentType, file, contentId),
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setContentForm(prev => ({
|
setContentForm(prev => ({
|
||||||
|
|
@ -108,12 +108,10 @@ const CourseContent = () => {
|
||||||
fileSize: data.fileSize,
|
fileSize: data.fileSize,
|
||||||
fileType: data.fileType
|
fileType: data.fileType
|
||||||
}));
|
}));
|
||||||
setUploadingFile(false);
|
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
toast.success('File uploaded successfully!');
|
toast.success('File uploaded successfully!');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
setUploadingFile(false);
|
|
||||||
toast.error(error.response?.data?.error || 'Failed to upload file');
|
toast.error(error.response?.data?.error || 'Failed to upload file');
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -156,13 +154,19 @@ const CourseContent = () => {
|
||||||
setContentForm({
|
setContentForm({
|
||||||
title: '', description: '', type: 'document', order: 0, points: 0,
|
title: '', description: '', type: 'document', order: 0, points: 0,
|
||||||
isRequired: true, isPublished: true, articleContent: '',
|
isRequired: true, isPublished: true, articleContent: '',
|
||||||
|
url: '', // <-- Add url field for image/video
|
||||||
});
|
});
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddContent = async (e) => {
|
const handleAddContent = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
createContentMutation.mutate(contentForm);
|
let data = { ...contentForm };
|
||||||
|
// If image/video and url is provided, set fileUrl
|
||||||
|
if ((contentForm.type === 'image' || contentForm.type === 'video') && contentForm.url) {
|
||||||
|
data.fileUrl = contentForm.url;
|
||||||
|
}
|
||||||
|
createContentMutation.mutate(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditContent = async (e) => {
|
const handleEditContent = async (e) => {
|
||||||
|
|
@ -190,21 +194,22 @@ const CourseContent = () => {
|
||||||
isRequired: content.isRequired,
|
isRequired: content.isRequired,
|
||||||
isPublished: content.isPublished,
|
isPublished: content.isPublished,
|
||||||
articleContent: content.articleContent || '',
|
articleContent: content.articleContent || '',
|
||||||
|
url: content.fileUrl || '', // <-- Set url for image/video
|
||||||
});
|
});
|
||||||
setShowEditContentModal(true);
|
setShowEditContentModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
setUploadingFile(true);
|
uploadFileMutation.mutate({ file: selectedFile, contentType: contentForm.type });
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', selectedFile);
|
|
||||||
formData.append('contentType', contentForm.type);
|
|
||||||
uploadFileMutation.mutate({ file: formData, contentType: contentForm.type });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e) => {
|
const handleFileChange = (e) => {
|
||||||
setSelectedFile(e.target.files[0]);
|
const file = e.target.files[0];
|
||||||
|
setSelectedFile(file);
|
||||||
|
if (file) {
|
||||||
|
handleFileUpload(); // Auto-upload on file select
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContentTypeIcon = (type) => {
|
const getContentTypeIcon = (type) => {
|
||||||
|
|
@ -602,19 +607,28 @@ const CourseContent = () => {
|
||||||
{selectedFile && (
|
{selectedFile && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-gray-600">Selected: {selectedFile.name}</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{['image', 'video'].includes(contentForm.type) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Online URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
name="url"
|
||||||
|
value={contentForm.url}
|
||||||
|
onChange={handleContentFormChange}
|
||||||
|
className="input-field w-full"
|
||||||
|
placeholder={`Paste a direct ${contentForm.type} URL (e.g. https://...)`}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">If provided, this will be used instead of an uploaded file.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end space-x-4 pt-4">
|
<div className="flex justify-end space-x-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -950,7 +964,7 @@ const CourseContent = () => {
|
||||||
onClick={addQuestion}
|
onClick={addQuestion}
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
>
|
>
|
||||||
<AddIcon className="h-4 w-4 mr-2" />
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
Add Question
|
Add Question
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from 'react-query';
|
import { useQuery, useMutation } from 'react-query';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
import { coursesAPI, courseContentAPI } from '../services/api';
|
import { coursesAPI, courseContentAPI } from '../services/api';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
|
|
@ -21,7 +20,6 @@ import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const CourseContentViewer = () => {
|
const CourseContentViewer = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [selectedContent, setSelectedContent] = useState(null);
|
const [selectedContent, setSelectedContent] = useState(null);
|
||||||
const [quizAnswers, setQuizAnswers] = useState({});
|
const [quizAnswers, setQuizAnswers] = useState({});
|
||||||
const [quizResults, setQuizResults] = useState({});
|
const [quizResults, setQuizResults] = useState({});
|
||||||
|
|
@ -100,16 +98,30 @@ const CourseContentViewer = () => {
|
||||||
return labels[type] || 'Document';
|
return labels[type] || 'Document';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to resolve the file URL (uploaded or online)
|
||||||
|
const resolveFileUrl = (content) => {
|
||||||
|
// Prefer fileUrl, then url
|
||||||
|
let url = content.fileUrl || content.url || '';
|
||||||
|
if (!url) return null;
|
||||||
|
if (url.startsWith('http')) return url;
|
||||||
|
// Otherwise, construct the full URL
|
||||||
|
const baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:5000';
|
||||||
|
return `${baseUrl}${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
const renderContent = (content) => {
|
const renderContent = (content) => {
|
||||||
|
const fileUrl = resolveFileUrl(content);
|
||||||
|
console.log('ContentViewer: resolved fileUrl:', fileUrl);
|
||||||
|
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
case 'document':
|
case 'document':
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-semibold">{content.title}</h3>
|
<h3 className="text-xl font-semibold">{content.title}</h3>
|
||||||
{content.fileUrl && (
|
{fileUrl && (
|
||||||
<a
|
<a
|
||||||
href={content.fileUrl}
|
href={fileUrl}
|
||||||
download
|
download
|
||||||
className="btn-secondary flex items-center"
|
className="btn-secondary flex items-center"
|
||||||
>
|
>
|
||||||
|
|
@ -121,10 +133,10 @@ const CourseContentViewer = () => {
|
||||||
{content.description && (
|
{content.description && (
|
||||||
<p className="text-gray-600">{content.description}</p>
|
<p className="text-gray-600">{content.description}</p>
|
||||||
)}
|
)}
|
||||||
{content.fileUrl ? (
|
{fileUrl ? (
|
||||||
<div className="bg-gray-100 rounded-lg p-4">
|
<div className="bg-gray-100 rounded-lg p-4">
|
||||||
<iframe
|
<iframe
|
||||||
src={content.fileUrl}
|
src={fileUrl}
|
||||||
className="w-full h-96"
|
className="w-full h-96"
|
||||||
title={content.title}
|
title={content.title}
|
||||||
/>
|
/>
|
||||||
|
|
@ -145,10 +157,10 @@ const CourseContentViewer = () => {
|
||||||
{content.description && (
|
{content.description && (
|
||||||
<p className="text-gray-600">{content.description}</p>
|
<p className="text-gray-600">{content.description}</p>
|
||||||
)}
|
)}
|
||||||
{content.fileUrl ? (
|
{fileUrl ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<img
|
<img
|
||||||
src={content.fileUrl}
|
src={fileUrl}
|
||||||
alt={content.title}
|
alt={content.title}
|
||||||
className="max-w-full h-auto rounded-lg shadow-lg"
|
className="max-w-full h-auto rounded-lg shadow-lg"
|
||||||
/>
|
/>
|
||||||
|
|
@ -169,7 +181,7 @@ const CourseContentViewer = () => {
|
||||||
{content.description && (
|
{content.description && (
|
||||||
<p className="text-gray-600">{content.description}</p>
|
<p className="text-gray-600">{content.description}</p>
|
||||||
)}
|
)}
|
||||||
{content.fileUrl ? (
|
{fileUrl ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<video
|
<video
|
||||||
controls
|
controls
|
||||||
|
|
@ -179,7 +191,7 @@ const CourseContentViewer = () => {
|
||||||
handleVideoProgress(content.id, progress);
|
handleVideoProgress(content.id, progress);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<source src={content.fileUrl} type="video/mp4" />
|
<source src={fileUrl} type="video/mp4" />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -93,8 +93,28 @@ const Dashboard = () => {
|
||||||
{ enabled: isSuperAdmin || isTrainer }
|
{ enabled: isSuperAdmin || isTrainer }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// New queries for real counts
|
||||||
|
const { data: enrollmentStats, isLoading: enrollmentStatsLoading } = useQuery(
|
||||||
|
['enrollments', 'stats'],
|
||||||
|
() => enrollmentsAPI.getStats(),
|
||||||
|
{ enabled: isSuperAdmin || isTrainer }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: trainerCourseStats, isLoading: trainerCourseStatsLoading } = useQuery(
|
||||||
|
['courses', 'trainer-stats'],
|
||||||
|
() => coursesAPI.getStats(),
|
||||||
|
{ enabled: isTrainer }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: traineeEnrollmentStats, isLoading: traineeEnrollmentStatsLoading } = useQuery(
|
||||||
|
['enrollments', 'trainee-stats'],
|
||||||
|
() => enrollmentsAPI.getStats(),
|
||||||
|
{ enabled: isTrainee }
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = coursesLoading || enrollmentsLoading || attendanceLoading ||
|
const isLoading = coursesLoading || enrollmentsLoading || attendanceLoading ||
|
||||||
userStatsLoading || courseStatsLoading;
|
userStatsLoading || courseStatsLoading || enrollmentStatsLoading ||
|
||||||
|
trainerCourseStatsLoading || traineeEnrollmentStatsLoading;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner size="lg" className="mt-8" />;
|
return <LoadingSpinner size="lg" className="mt-8" />;
|
||||||
|
|
@ -181,6 +201,10 @@ const Dashboard = () => {
|
||||||
<span className="text-sm text-gray-600">Published Courses</span>
|
<span className="text-sm text-gray-600">Published Courses</span>
|
||||||
<span className="text-sm font-medium">{courseStats?.stats?.publishedCourses || 0}</span>
|
<span className="text-sm font-medium">{courseStats?.stats?.publishedCourses || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-600">Total Enrollments</span>
|
||||||
|
<span className="text-sm font-medium">{enrollmentStats?.stats?.totalEnrollments || 0}</span>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-sm text-gray-600">Featured Courses</span>
|
<span className="text-sm text-gray-600">Featured Courses</span>
|
||||||
<span className="text-sm font-medium">{courseStats?.stats?.featuredCourses || 0}</span>
|
<span className="text-sm font-medium">{courseStats?.stats?.featuredCourses || 0}</span>
|
||||||
|
|
@ -202,7 +226,7 @@ const Dashboard = () => {
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">My Courses</p>
|
<p className="text-sm font-medium text-gray-500">My Courses</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{courseStats?.stats?.totalCourses || 0}
|
{trainerCourseStats?.stats?.myCourses || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -216,7 +240,7 @@ const Dashboard = () => {
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">Published</p>
|
<p className="text-sm font-medium text-gray-500">Published</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{courseStats?.stats?.publishedCourses || 0}
|
{trainerCourseStats?.stats?.myPublishedCourses || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -228,10 +252,9 @@ const Dashboard = () => {
|
||||||
<UsersIcon className="h-8 w-8 text-blue-600" />
|
<UsersIcon className="h-8 w-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">Students</p>
|
<p className="text-sm font-medium text-gray-500">My Students</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{/* Add total students count */}
|
{enrollmentStats?.stats?.myStudents || 0}
|
||||||
0
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,7 +312,7 @@ const Dashboard = () => {
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">Enrolled Courses</p>
|
<p className="text-sm font-medium text-gray-500">Enrolled Courses</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{enrollmentsData?.enrollments?.length || 0}
|
{traineeEnrollmentStats?.stats?.myEnrollments || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -315,9 +338,9 @@ const Dashboard = () => {
|
||||||
<ChartBarIcon className="h-8 w-8 text-blue-600" />
|
<ChartBarIcon className="h-8 w-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-500">Total Sessions</p>
|
<p className="text-sm font-medium text-gray-500">Completed Courses</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">
|
<p className="text-2xl font-semibold text-gray-900">
|
||||||
{attendanceStats?.stats?.totalSessions || 0}
|
{traineeEnrollmentStats?.stats?.completedCourses || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
196
version.txt
196
version.txt
|
|
@ -1,3 +1,199 @@
|
||||||
|
CourseWorx v1.1.4 - Modern Header Navigation & Real-Time Dashboard
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
This version modernizes the navigation system by removing the sidebar and implementing a clean header-based navigation with real-time dashboard statistics.
|
||||||
|
|
||||||
|
MAJOR FEATURES IMPLEMENTED:
|
||||||
|
===========================
|
||||||
|
|
||||||
|
1. MODERN HEADER NAVIGATION
|
||||||
|
----------------------------
|
||||||
|
- Completely removed sidebar (both mobile and desktop versions)
|
||||||
|
- Moved all navigation to a clean, sticky header
|
||||||
|
- Added CourseWorx logo prominently in the header
|
||||||
|
- Implemented Home icon next to logo for dashboard navigation
|
||||||
|
- Moved Courses and Users links to header with icons
|
||||||
|
- Removed Dashboard link from navigation (accessible via logo/Home icon)
|
||||||
|
- Responsive design with icons-only on mobile, icons+text on desktop
|
||||||
|
- Future-ready design for icon-only navigation
|
||||||
|
|
||||||
|
2. REAL-TIME DASHBOARD STATISTICS
|
||||||
|
----------------------------------
|
||||||
|
- Updated all dashboard cards to show real database counts
|
||||||
|
- Enhanced backend API endpoints for role-specific statistics
|
||||||
|
- Super Admin dashboard shows total users, trainers, trainees, courses
|
||||||
|
- Trainer dashboard shows my courses, published courses, my students
|
||||||
|
- Trainee dashboard shows enrolled courses, attendance rate, completed courses
|
||||||
|
- Added new API queries for enrollment and course statistics
|
||||||
|
- All cards now display actual data instead of hardcoded values
|
||||||
|
|
||||||
|
3. BACKEND API ENHANCEMENTS
|
||||||
|
----------------------------
|
||||||
|
- Enhanced `/api/enrollments/stats/overview` endpoint:
|
||||||
|
- Added `myStudents` count for trainers (unique students)
|
||||||
|
- Added `myEnrollments` count for trainees
|
||||||
|
- Added `completedCourses` count for trainees
|
||||||
|
- Enhanced `/api/courses/stats/overview` endpoint:
|
||||||
|
- Added `myCourses` count for trainers
|
||||||
|
- Added `myPublishedCourses` count for trainers
|
||||||
|
- Role-based data filtering and access control
|
||||||
|
- Improved statistics accuracy and performance
|
||||||
|
|
||||||
|
4. FRONTEND IMPROVEMENTS
|
||||||
|
-------------------------
|
||||||
|
- Clean, modern header layout with proper spacing
|
||||||
|
- User dropdown menu with profile and logout options
|
||||||
|
- Responsive navigation that adapts to screen size
|
||||||
|
- Enhanced visual hierarchy and user experience
|
||||||
|
- Improved accessibility with proper ARIA attributes
|
||||||
|
- Better mobile experience with touch-friendly navigation
|
||||||
|
|
||||||
|
5. CODE QUALITY & MAINTENANCE
|
||||||
|
-----------------------------
|
||||||
|
- Fixed ESLint warnings in CourseContent.js and CourseContentViewer.js
|
||||||
|
- Removed unused imports and variables
|
||||||
|
- Cleaned up duplicate imports (PlusIcon)
|
||||||
|
- Improved code organization and structure
|
||||||
|
- Enhanced maintainability and readability
|
||||||
|
|
||||||
|
TECHNICAL IMPROVEMENTS:
|
||||||
|
=======================
|
||||||
|
|
||||||
|
1. LAYOUT SYSTEM
|
||||||
|
-----------------
|
||||||
|
- Removed sidebar-based layout completely
|
||||||
|
- Implemented header-centric navigation
|
||||||
|
- Responsive design with mobile-first approach
|
||||||
|
- Clean separation of navigation and content areas
|
||||||
|
- Improved content area utilization
|
||||||
|
|
||||||
|
2. API INTEGRATION
|
||||||
|
-------------------
|
||||||
|
- Enhanced statistics endpoints with role-specific data
|
||||||
|
- Improved data fetching efficiency
|
||||||
|
- Better error handling and loading states
|
||||||
|
- Real-time data updates with React Query
|
||||||
|
- Optimized API calls for dashboard performance
|
||||||
|
|
||||||
|
3. USER EXPERIENCE
|
||||||
|
-------------------
|
||||||
|
- Streamlined navigation with fewer clicks
|
||||||
|
- Better visual feedback and hover effects
|
||||||
|
- Improved accessibility and keyboard navigation
|
||||||
|
- Cleaner, more professional appearance
|
||||||
|
- Faster access to key features
|
||||||
|
|
||||||
|
4. PERFORMANCE OPTIMIZATIONS
|
||||||
|
-----------------------------
|
||||||
|
- Reduced layout complexity by removing sidebar
|
||||||
|
- Optimized API calls for dashboard statistics
|
||||||
|
- Improved rendering performance
|
||||||
|
- Better caching strategies for statistics data
|
||||||
|
- Enhanced mobile performance
|
||||||
|
|
||||||
|
BUG FIXES & RESOLUTIONS:
|
||||||
|
========================
|
||||||
|
|
||||||
|
1. ESLINT WARNINGS
|
||||||
|
-------------------
|
||||||
|
- Fixed unused `useAuth` import in CourseContentViewer.js
|
||||||
|
- Fixed unused `queryClient` variable in CourseContentViewer.js
|
||||||
|
- Removed duplicate `PlusIcon` import in CourseContent.js
|
||||||
|
- Updated icon references for consistency
|
||||||
|
- Cleaned up unused variables and imports
|
||||||
|
|
||||||
|
2. NAVIGATION ISSUES
|
||||||
|
---------------------
|
||||||
|
- Resolved sidebar navigation complexity
|
||||||
|
- Fixed mobile navigation accessibility
|
||||||
|
- Improved navigation state management
|
||||||
|
- Enhanced user menu dropdown functionality
|
||||||
|
- Better responsive behavior
|
||||||
|
|
||||||
|
3. DASHBOARD ACCURACY
|
||||||
|
----------------------
|
||||||
|
- Fixed hardcoded values in dashboard cards
|
||||||
|
- Implemented real database counts for all statistics
|
||||||
|
- Enhanced role-based data filtering
|
||||||
|
- Improved data accuracy and reliability
|
||||||
|
- Better error handling for statistics
|
||||||
|
|
||||||
|
DEPENDENCIES & TECHNOLOGIES:
|
||||||
|
============================
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- React 18.x
|
||||||
|
- React Router v6
|
||||||
|
- React Query (TanStack Query)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Heroicons
|
||||||
|
- Axios
|
||||||
|
- React Hot Toast
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- Node.js
|
||||||
|
- Express.js
|
||||||
|
- Sequelize ORM
|
||||||
|
- PostgreSQL
|
||||||
|
- JWT (jsonwebtoken)
|
||||||
|
- bcryptjs
|
||||||
|
- multer
|
||||||
|
- express-validator
|
||||||
|
|
||||||
|
FILE STRUCTURE CHANGES:
|
||||||
|
======================
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- Updated Layout.js with header-based navigation
|
||||||
|
- Enhanced Dashboard.js with real-time statistics
|
||||||
|
- Fixed ESLint issues in CourseContent.js and CourseContentViewer.js
|
||||||
|
- Improved component organization and structure
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- Enhanced enrollments.js with role-specific statistics
|
||||||
|
- Updated courses.js with trainer-specific data
|
||||||
|
- Improved API response structure and accuracy
|
||||||
|
|
||||||
|
CONFIGURATION UPDATES:
|
||||||
|
======================
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
- Removed sidebar configuration
|
||||||
|
- Updated header navigation structure
|
||||||
|
- Enhanced responsive breakpoints
|
||||||
|
- Improved mobile navigation
|
||||||
|
|
||||||
|
API Endpoints:
|
||||||
|
- Enhanced statistics endpoints with role-based filtering
|
||||||
|
- Improved data accuracy and performance
|
||||||
|
- Better error handling and validation
|
||||||
|
|
||||||
|
SECURITY CONSIDERATIONS:
|
||||||
|
========================
|
||||||
|
|
||||||
|
- Maintained role-based access control
|
||||||
|
- Enhanced API endpoint security
|
||||||
|
- Improved data filtering and validation
|
||||||
|
- Better user session management
|
||||||
|
- Enhanced authentication flow
|
||||||
|
|
||||||
|
DEPLOYMENT READINESS:
|
||||||
|
=====================
|
||||||
|
|
||||||
|
- Updated navigation system for production
|
||||||
|
- Enhanced dashboard performance
|
||||||
|
- Improved mobile responsiveness
|
||||||
|
- Better user experience across devices
|
||||||
|
- Optimized API performance
|
||||||
|
|
||||||
|
This version (1.1.4) modernizes the CourseWorx platform with a clean, header-based navigation system and real-time dashboard statistics, providing a more professional and user-friendly experience while maintaining all existing functionality.
|
||||||
|
|
||||||
|
Release Date: [Current Date]
|
||||||
|
Version: 1.1.4
|
||||||
|
Status: Production Ready
|
||||||
|
|
||||||
|
==========================================================
|
||||||
|
|
||||||
CourseWorx v1.1.3 - Enhanced Login Experience & Dashboard Improvements
|
CourseWorx v1.1.3 - Enhanced Login Experience & Dashboard Improvements
|
||||||
==========================================================
|
==========================================================
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue