init
This commit is contained in:
parent
2541ab1c8e
commit
3ab00bfa3b
@ -83,6 +83,16 @@ router.post('/', wrapAsync(async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
|
||||
router.post('/enroll-course', wrapAsync(async (req, res) => {
|
||||
const payload = await EnrollmentsService.enrollInCourse(
|
||||
req.body.courseId,
|
||||
req.currentUser,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/budgets/bulk-import:
|
||||
@ -383,6 +393,23 @@ router.get('/autocomplete', async (req, res) => {
|
||||
res.status(200).send(payload);
|
||||
});
|
||||
|
||||
|
||||
router.get('/my-learning', wrapAsync(async (req, res) => {
|
||||
const payload = await EnrollmentsService.getMyLearning(req.currentUser);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
router.post('/:id/toggle-lesson', wrapAsync(async (req, res) => {
|
||||
const payload = await EnrollmentsService.toggleLessonCompletion(
|
||||
req.params.id,
|
||||
req.body.lessonId,
|
||||
req.currentUser,
|
||||
);
|
||||
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/enrollments/{id}:
|
||||
|
||||
@ -3,9 +3,8 @@ const EnrollmentsDBApi = require('../db/api/enrollments');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const Op = db.Sequelize.Op;
|
||||
|
||||
|
||||
|
||||
@ -28,9 +27,9 @@ module.exports = class EnrollmentsService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
||||
static async bulkImport(req, res) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
@ -95,7 +94,7 @@ module.exports = class EnrollmentsService {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
@ -113,6 +112,329 @@ module.exports = class EnrollmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async getMyLearning(currentUser) {
|
||||
if (!currentUser || !currentUser.id) {
|
||||
const error = new Error('Authentication required.');
|
||||
error.code = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [courses, lessons, enrollments] = await Promise.all([
|
||||
db.courses.findAll({
|
||||
where: { status: 'published' },
|
||||
include: [
|
||||
{
|
||||
model: db.users,
|
||||
as: 'instructor',
|
||||
attributes: ['id', 'firstName', 'lastName', 'email'],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['is_featured', 'DESC'],
|
||||
['published_at', 'DESC'],
|
||||
['title', 'ASC'],
|
||||
],
|
||||
}),
|
||||
db.lessons.findAll({
|
||||
where: { status: 'published' },
|
||||
order: [
|
||||
['courseId', 'ASC'],
|
||||
['order_index', 'ASC'],
|
||||
['title', 'ASC'],
|
||||
],
|
||||
}),
|
||||
db.enrollments.findAll({
|
||||
where: { studentId: currentUser.id },
|
||||
order: [
|
||||
['updatedAt', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const enrollmentIds = enrollments.map((item) => item.id);
|
||||
const completions = enrollmentIds.length
|
||||
? await db.lesson_completions.findAll({
|
||||
where: { enrollmentId: { [Op.in]: enrollmentIds } },
|
||||
order: [['updatedAt', 'DESC']],
|
||||
})
|
||||
: [];
|
||||
|
||||
const plainLessons = lessons.map((item) => item.get({ plain: true }));
|
||||
const lessonsByCourseId = plainLessons.reduce((accumulator, lesson) => {
|
||||
if (!accumulator[lesson.courseId]) {
|
||||
accumulator[lesson.courseId] = [];
|
||||
}
|
||||
|
||||
accumulator[lesson.courseId].push(lesson);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const plainEnrollments = enrollments.map((item) => item.get({ plain: true }));
|
||||
const enrollmentByCourseId = plainEnrollments.reduce((accumulator, enrollment) => {
|
||||
accumulator[enrollment.courseId] = enrollment;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const plainCompletions = completions.map((item) => item.get({ plain: true }));
|
||||
const completionByEnrollmentId = plainCompletions.reduce((accumulator, completion) => {
|
||||
if (!accumulator[completion.enrollmentId]) {
|
||||
accumulator[completion.enrollmentId] = {};
|
||||
}
|
||||
|
||||
accumulator[completion.enrollmentId][completion.lessonId] = completion;
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const courseCatalog = courses.map((item) => {
|
||||
const course = item.get({ plain: true });
|
||||
const courseLessons = lessonsByCourseId[course.id] || [];
|
||||
const enrollment = enrollmentByCourseId[course.id] || null;
|
||||
const courseCompletions = enrollment
|
||||
? completionByEnrollmentId[enrollment.id] || {}
|
||||
: {};
|
||||
const completedLessons = courseLessons.filter(
|
||||
(lesson) => courseCompletions[lesson.id]?.is_completed,
|
||||
).length;
|
||||
const derivedProgress = courseLessons.length
|
||||
? Math.round((completedLessons / courseLessons.length) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...course,
|
||||
lessons: courseLessons,
|
||||
enrollment,
|
||||
completedLessons,
|
||||
totalLessons: courseLessons.length,
|
||||
progressPercent:
|
||||
enrollment?.progress_percent !== null &&
|
||||
enrollment?.progress_percent !== undefined
|
||||
? Number(enrollment.progress_percent)
|
||||
: derivedProgress,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
courses: courseCatalog,
|
||||
enrollments: plainEnrollments,
|
||||
completions: plainCompletions,
|
||||
stats: {
|
||||
availableCourses: courseCatalog.length,
|
||||
enrolledCourses: plainEnrollments.length,
|
||||
completedLessons: plainCompletions.filter((item) => item.is_completed)
|
||||
.length,
|
||||
averageProgress: plainEnrollments.length
|
||||
? Math.round(
|
||||
plainEnrollments.reduce(
|
||||
(sum, enrollment) =>
|
||||
sum + Number(enrollment.progress_percent || 0),
|
||||
0,
|
||||
) / plainEnrollments.length,
|
||||
)
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static async enrollInCourse(courseId, currentUser) {
|
||||
if (!currentUser || !currentUser.id) {
|
||||
const error = new Error('Authentication required.');
|
||||
error.code = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!courseId) {
|
||||
const error = new Error('Course is required.');
|
||||
error.code = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const course = await db.courses.findOne({
|
||||
where: {
|
||||
id: courseId,
|
||||
status: 'published',
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) {
|
||||
const error = new Error('Published course not found.');
|
||||
error.code = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const existingEnrollment = await db.enrollments.findOne({
|
||||
where: {
|
||||
studentId: currentUser.id,
|
||||
courseId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingEnrollment) {
|
||||
return {
|
||||
alreadyEnrolled: true,
|
||||
enrollment: existingEnrollment.get({ plain: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const enrollment = await db.enrollments.create({
|
||||
status: 'active',
|
||||
enrolled_at: new Date(),
|
||||
completed_at: null,
|
||||
progress_percent: 0,
|
||||
last_activity_at: new Date(),
|
||||
studentId: currentUser.id,
|
||||
courseId,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
});
|
||||
|
||||
return {
|
||||
alreadyEnrolled: false,
|
||||
enrollment: enrollment.get({ plain: true }),
|
||||
};
|
||||
}
|
||||
|
||||
static async toggleLessonCompletion(enrollmentId, lessonId, currentUser) {
|
||||
if (!currentUser || !currentUser.id) {
|
||||
const error = new Error('Authentication required.');
|
||||
error.code = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!enrollmentId || !lessonId) {
|
||||
const error = new Error('Enrollment and lesson are required.');
|
||||
error.code = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
try {
|
||||
const enrollment = await db.enrollments.findByPk(enrollmentId, { transaction });
|
||||
|
||||
if (!enrollment) {
|
||||
const error = new Error('Enrollment not found.');
|
||||
error.code = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isAdministrator = currentUser?.app_role?.name === 'Administrator';
|
||||
if (!isAdministrator && enrollment.studentId !== currentUser.id) {
|
||||
const error = new Error('You can only update your own learning progress.');
|
||||
error.code = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const lesson = await db.lessons.findOne({
|
||||
where: {
|
||||
id: lessonId,
|
||||
courseId: enrollment.courseId,
|
||||
status: 'published',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!lesson) {
|
||||
const error = new Error('Lesson not found for this enrollment.');
|
||||
error.code = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
let completion = await db.lesson_completions.findOne({
|
||||
where: {
|
||||
enrollmentId,
|
||||
lessonId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (completion) {
|
||||
await completion.update(
|
||||
{
|
||||
is_completed: !completion.is_completed,
|
||||
completed_at: completion.is_completed ? null : now,
|
||||
last_viewed_at: now,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
} else {
|
||||
completion = await db.lesson_completions.create(
|
||||
{
|
||||
is_completed: true,
|
||||
completed_at: now,
|
||||
last_viewed_at: now,
|
||||
time_spent_seconds: 0,
|
||||
enrollmentId,
|
||||
lessonId,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
const totalLessons = await db.lessons.count({
|
||||
where: {
|
||||
courseId: enrollment.courseId,
|
||||
status: 'published',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
const completedLessons = await db.lesson_completions.count({
|
||||
where: {
|
||||
enrollmentId,
|
||||
is_completed: true,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: db.lessons,
|
||||
as: 'lesson',
|
||||
attributes: [],
|
||||
required: true,
|
||||
where: {
|
||||
courseId: enrollment.courseId,
|
||||
status: 'published',
|
||||
},
|
||||
},
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
|
||||
const progressPercent = totalLessons
|
||||
? Math.round((completedLessons / totalLessons) * 100)
|
||||
: 0;
|
||||
|
||||
await enrollment.update(
|
||||
{
|
||||
progress_percent: progressPercent,
|
||||
status: progressPercent === 100 ? 'completed' : 'active',
|
||||
completed_at: progressPercent === 100 ? now : null,
|
||||
last_activity_at: now,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
completion: completion.get({ plain: true }),
|
||||
enrollment: enrollment.get({ plain: true }),
|
||||
progress_percent: progressPercent,
|
||||
completed_lessons: completedLessons,
|
||||
total_lessons: totalLessons,
|
||||
};
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
|
||||
@ -132,7 +454,6 @@ module.exports = class EnrollmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [
|
||||
label: 'Dashboard',
|
||||
},
|
||||
|
||||
{
|
||||
href: '/learning-hub',
|
||||
label: 'Learning Hub',
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
icon: 'mdiSchoolOutline' in icon ? icon['mdiSchoolOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline,
|
||||
permissions: 'READ_ENROLLMENTS'
|
||||
},
|
||||
|
||||
{
|
||||
href: '/users/users-list',
|
||||
label: 'Users',
|
||||
|
||||
@ -1,166 +1,242 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
mdiBookOpenPageVariant,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiNotebookOutline,
|
||||
mdiSchoolOutline,
|
||||
} from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: 'Courses that feel structured',
|
||||
text: 'Publish polished learning paths with clear descriptions, featured content, and intentional lesson order.',
|
||||
icon: mdiBookOpenPageVariant,
|
||||
},
|
||||
{
|
||||
title: 'Lessons made for momentum',
|
||||
text: 'Deliver text and markdown lessons that are fast to author, easy to read, and simple to extend later.',
|
||||
icon: mdiNotebookOutline,
|
||||
},
|
||||
{
|
||||
title: 'Progress learners can feel',
|
||||
text: 'Enrollment, lesson completion, and course progress work together so every session shows visible movement.',
|
||||
icon: mdiCheckCircleOutline,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
|
||||
const title = 'Instructor Student LMS'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const workflowSteps = [
|
||||
{
|
||||
eyebrow: 'Instructor flow',
|
||||
title: 'Create a course',
|
||||
text: 'Set up the course shell, assign an instructor, add a concise description, then publish when it is ready.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Student flow',
|
||||
title: 'Enroll in one click',
|
||||
text: 'Learners discover published courses, join instantly, and open the lesson reader without jumping between pages.',
|
||||
},
|
||||
{
|
||||
eyebrow: 'Operations view',
|
||||
title: 'Track learning progress',
|
||||
text: 'Admins and instructors can review enrollments while students see real progress grow lesson by lesson.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('Instructor Student LMS')}</title>
|
||||
<meta
|
||||
name='description'
|
||||
content='A modern LMS for course publishing, lesson delivery, enrollment, and progress tracking.'
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your Instructor Student LMS app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
<div className='min-h-screen bg-[radial-gradient(circle_at_top,_rgba(37,99,235,0.18),_transparent_35%),linear-gradient(180deg,#F8FAFC_0%,#EEF2FF_42%,#FFFFFF_100%)] text-slate-900'>
|
||||
<div className='mx-auto max-w-7xl px-6 py-6 lg:px-10'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-4 rounded-full border border-white/80 bg-white/80 px-5 py-3 shadow-sm backdrop-blur'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-[#2563EB]'>
|
||||
Instructor Student LMS
|
||||
</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>A focused LMS for instructors, students, and learning operations.</p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/login' color='info' label='Login' />
|
||||
<BaseButton href='/dashboard' color='info' outline label='Admin interface' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<main className='mx-auto max-w-7xl px-6 pb-16 lg:px-10'>
|
||||
<section className='grid gap-10 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:py-16'>
|
||||
<div className='space-y-8'>
|
||||
<div className='space-y-5'>
|
||||
<span className='inline-flex items-center rounded-full border border-[#BFDBFE] bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#1D4ED8] shadow-sm'>
|
||||
Initial MVP slice live
|
||||
</span>
|
||||
<div className='space-y-4'>
|
||||
<h1 className='max-w-3xl text-5xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
|
||||
A clean LMS for publishing courses and helping students finish what they start.
|
||||
</h1>
|
||||
<p className='max-w-2xl text-lg leading-8 text-slate-600'>
|
||||
Built around the core journey that matters first: instructors publish courses and lessons,
|
||||
students enroll, read, and watch progress update in real time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/learning-hub' color='info' label='Open learning hub' />
|
||||
<BaseButton href='/login' color='info' outline label='Sign in' />
|
||||
<BaseButton href='/dashboard' color='whiteDark' label='Go to admin' />
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>Roles</p>
|
||||
<p className='mt-3 text-2xl font-semibold text-slate-950'>3</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Admin, Instructor, and Learner workflows are all accounted for.</p>
|
||||
</div>
|
||||
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>Lesson format</p>
|
||||
<p className='mt-3 text-2xl font-semibold text-slate-950'>Text / Markdown</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Fast authoring now, easy to extend later with media attachments.</p>
|
||||
</div>
|
||||
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>What is new</p>
|
||||
<p className='mt-3 text-2xl font-semibold text-slate-950'>Learning Hub</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Enroll, continue lessons, and see progress move without raw CRUD screens.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardBox className='overflow-hidden border border-slate-200 bg-slate-950 text-white shadow-2xl'>
|
||||
<div className='rounded-3xl bg-[radial-gradient(circle_at_top_right,_rgba(59,130,246,0.45),_transparent_25%),radial-gradient(circle_at_bottom_left,_rgba(20,184,166,0.35),_transparent_28%),linear-gradient(180deg,#0F172A_0%,#111827_100%)] p-8'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-sky-200'>Thin slice</p>
|
||||
<h2 className='mt-2 text-3xl font-semibold'>Student journey</h2>
|
||||
</div>
|
||||
<div className='rounded-2xl bg-white/10 p-4 text-sky-100'>
|
||||
<BaseIcon path={mdiSchoolOutline} size={30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 space-y-4'>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||||
<p className='text-sm font-semibold'>1. Browse published courses</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-300'>Students see a focused catalog instead of generic tables.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||||
<p className='text-sm font-semibold'>2. Enroll instantly</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-300'>One click creates the enrollment and opens the lesson path.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
|
||||
<p className='text-sm font-semibold'>3. Complete lessons</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-300'>Completion updates course progress automatically for a real sense of momentum.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</section>
|
||||
|
||||
<section className='grid gap-5 py-6 md:grid-cols-3'>
|
||||
{featureCards.map((card) => (
|
||||
<CardBox key={card.title} className='border border-slate-200 shadow-sm'>
|
||||
<div className='space-y-4'>
|
||||
<div className='inline-flex rounded-2xl bg-[#EEF2FF] p-3 text-[#3730A3]'>
|
||||
<BaseIcon path={card.icon} size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-950'>{card.title}</h3>
|
||||
<p className='mt-2 text-sm leading-7 text-slate-500'>{card.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className='py-14'>
|
||||
<div className='mb-8 max-w-3xl'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#2563EB]'>Workflow first</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-950 md:text-4xl'>
|
||||
The first delivery is an end-to-end loop, not just a prettier admin.
|
||||
</h2>
|
||||
<p className='mt-3 text-base leading-8 text-slate-600'>
|
||||
Core CRUD already exists. This iteration turns that foundation into a usable product slice that both students and instructors can understand immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-5 lg:grid-cols-3'>
|
||||
{workflowSteps.map((step, index) => (
|
||||
<CardBox key={step.title} className='border border-slate-200 shadow-sm'>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
|
||||
{step.eyebrow}
|
||||
</span>
|
||||
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700'>
|
||||
0{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-xl font-semibold text-slate-950'>{step.title}</h3>
|
||||
<p className='mt-2 text-sm leading-7 text-slate-500'>{step.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className='rounded-[2rem] border border-slate-200 bg-white px-6 py-8 shadow-sm lg:px-10'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-5'>
|
||||
<div className='max-w-2xl'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#2563EB]'>Ready to explore</p>
|
||||
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-950'>
|
||||
Jump into the learner workflow or open the admin interface.
|
||||
</h2>
|
||||
<p className='mt-3 text-base leading-8 text-slate-600'>
|
||||
The app now has a public-facing entry, a direct login path, and a branded route into the new learning hub.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/learning-hub' color='info' label='Launch learning hub' />
|
||||
<BaseButton href='/dashboard' color='info' outline label='Admin interface' />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className='border-t border-white/70 bg-white/70 backdrop-blur'>
|
||||
<div className='mx-auto flex max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-500 md:flex-row md:items-center md:justify-between lg:px-10'>
|
||||
<p>© 2026 Instructor Student LMS. Built for modern course publishing and learner progress.</p>
|
||||
<div className='flex gap-4'>
|
||||
<Link className='hover:text-slate-900' href='/privacy-policy'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link className='hover:text-slate-900' href='/login'>
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
723
frontend/src/pages/learning-hub.tsx
Normal file
723
frontend/src/pages/learning-hub.tsx
Normal file
@ -0,0 +1,723 @@
|
||||
import {
|
||||
mdiBookOpenPageVariant,
|
||||
mdiChartTimelineVariant,
|
||||
mdiCheckCircleOutline,
|
||||
mdiNotebookOutline,
|
||||
mdiSchoolOutline,
|
||||
} from '@mdi/js';
|
||||
import axios from 'axios';
|
||||
import Head from 'next/head';
|
||||
import React, {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import CardBox from '../components/CardBox';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import NotificationBar from '../components/NotificationBar';
|
||||
import SectionMain from '../components/SectionMain';
|
||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||
import { getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
import { hasPermission } from '../helpers/userPermissions';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
|
||||
type Lesson = {
|
||||
id: string;
|
||||
title: string;
|
||||
content_markdown: string;
|
||||
order_index: number;
|
||||
estimated_minutes: number;
|
||||
is_preview: boolean;
|
||||
};
|
||||
|
||||
type Enrollment = {
|
||||
id: string;
|
||||
courseId: string;
|
||||
progress_percent: number | string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type Completion = {
|
||||
id: string;
|
||||
lessonId: string;
|
||||
enrollmentId: string;
|
||||
is_completed: boolean;
|
||||
};
|
||||
|
||||
type Course = {
|
||||
id: string;
|
||||
title: string;
|
||||
short_description: string;
|
||||
description: string;
|
||||
is_featured: boolean;
|
||||
instructor?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
};
|
||||
lessons: Lesson[];
|
||||
enrollment: Enrollment | null;
|
||||
completedLessons: number;
|
||||
totalLessons: number;
|
||||
progressPercent: number;
|
||||
};
|
||||
|
||||
type LearningPayload = {
|
||||
courses: Course[];
|
||||
completions: Completion[];
|
||||
stats: {
|
||||
availableCourses: number;
|
||||
enrolledCourses: number;
|
||||
completedLessons: number;
|
||||
averageProgress: number;
|
||||
};
|
||||
};
|
||||
|
||||
const StatCard = ({ label, value, hint }: { label: string; value: string | number; hint: string }) => (
|
||||
<CardBox className='border border-slate-200 shadow-sm'>
|
||||
<div className='space-y-2'>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='text-3xl font-semibold text-slate-900'>{value}</p>
|
||||
<p className='text-sm leading-6 text-slate-500'>{hint}</p>
|
||||
</div>
|
||||
</CardBox>
|
||||
);
|
||||
|
||||
const ProgressBar = ({ value }: { value: number }) => (
|
||||
<div className='h-2 overflow-hidden rounded-full bg-slate-100'>
|
||||
<div
|
||||
className='h-full rounded-full bg-gradient-to-r from-[#2563EB] via-[#4F46E5] to-[#14B8A6] transition-all duration-300'
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const formatInstructor = (course: Course) => {
|
||||
const fullName = `${course.instructor?.firstName || ''} ${course.instructor?.lastName || ''}`.trim();
|
||||
|
||||
if (fullName) {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
return course.instructor?.email || 'Instructor';
|
||||
};
|
||||
|
||||
const LearningHub = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [completions, setCompletions] = useState<Completion[]>([]);
|
||||
const [stats, setStats] = useState<LearningPayload['stats']>({
|
||||
availableCourses: 0,
|
||||
enrolledCourses: 0,
|
||||
completedLessons: 0,
|
||||
averageProgress: 0,
|
||||
});
|
||||
const [selectedCourseId, setSelectedCourseId] = useState('');
|
||||
const [selectedLessonId, setSelectedLessonId] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [pendingCourseId, setPendingCourseId] = useState('');
|
||||
const [pendingLessonId, setPendingLessonId] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const canEnroll = hasPermission(currentUser, 'CREATE_ENROLLMENTS');
|
||||
const canManageCourses = hasPermission(currentUser, ['CREATE_COURSES', 'CREATE_LESSONS']);
|
||||
|
||||
const fetchLearningHub = useCallback(async (preferredCourseId?: string) => {
|
||||
setIsRefreshing(true);
|
||||
|
||||
try {
|
||||
const response = await axios.get<LearningPayload>('/enrollments/my-learning');
|
||||
const payload = response.data;
|
||||
const nextCourses = payload.courses || [];
|
||||
|
||||
setCourses(nextCourses);
|
||||
setCompletions(payload.completions || []);
|
||||
setStats(payload.stats);
|
||||
setErrorMessage('');
|
||||
|
||||
const resolvedCourseId =
|
||||
preferredCourseId && nextCourses.some((course) => course.id === preferredCourseId)
|
||||
? preferredCourseId
|
||||
: nextCourses.find((course) => course.enrollment)?.id || nextCourses[0]?.id || '';
|
||||
|
||||
setSelectedCourseId(resolvedCourseId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load learning hub:', error);
|
||||
setErrorMessage('We could not load your learning hub right now. Please refresh and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLearningHub();
|
||||
}, [fetchLearningHub]);
|
||||
|
||||
const activeCourse = useMemo(
|
||||
() => courses.find((course) => course.id === selectedCourseId) || courses[0] || null,
|
||||
[courses, selectedCourseId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCourse) {
|
||||
setSelectedLessonId('');
|
||||
return;
|
||||
}
|
||||
|
||||
const lessonExists = activeCourse.lessons.some((lesson) => lesson.id === selectedLessonId);
|
||||
if (!lessonExists) {
|
||||
setSelectedLessonId(activeCourse.lessons[0]?.id || '');
|
||||
}
|
||||
}, [activeCourse, selectedLessonId]);
|
||||
|
||||
const activeLesson = useMemo(
|
||||
() => activeCourse?.lessons.find((lesson) => lesson.id === selectedLessonId) || null,
|
||||
[activeCourse, selectedLessonId],
|
||||
);
|
||||
|
||||
const activeEnrollment = activeCourse?.enrollment || null;
|
||||
|
||||
const completionLookup = useMemo(() => {
|
||||
if (!activeEnrollment) {
|
||||
return {} as Record<string, Completion>;
|
||||
}
|
||||
|
||||
return completions.reduce((accumulator, completion) => {
|
||||
if (completion.enrollmentId === activeEnrollment.id) {
|
||||
accumulator[completion.lessonId] = completion;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, {} as Record<string, Completion>);
|
||||
}, [activeEnrollment, completions]);
|
||||
|
||||
const enrolledCourses = courses.filter((course) => Boolean(course.enrollment));
|
||||
const availableCourses = courses.filter((course) => !course.enrollment);
|
||||
|
||||
const handleEnroll = async (courseId: string) => {
|
||||
setPendingCourseId(courseId);
|
||||
setSuccessMessage('');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await axios.post('/enrollments/enroll-course', { courseId });
|
||||
const message = response.data?.alreadyEnrolled
|
||||
? 'You are already enrolled in this course.'
|
||||
: 'Enrollment confirmed. Your first lesson is ready.';
|
||||
|
||||
setSuccessMessage(message);
|
||||
await fetchLearningHub(courseId);
|
||||
} catch (error) {
|
||||
console.error('Failed to enroll in course:', error);
|
||||
setErrorMessage('We could not enroll you in that course. Please try again.');
|
||||
} finally {
|
||||
setPendingCourseId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleLesson = async (lessonId: string) => {
|
||||
if (!activeEnrollment) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingLessonId(lessonId);
|
||||
setSuccessMessage('');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/enrollments/${activeEnrollment.id}/toggle-lesson`, {
|
||||
lessonId,
|
||||
});
|
||||
const isCompleted = response.data?.completion?.is_completed;
|
||||
|
||||
setSuccessMessage(
|
||||
isCompleted
|
||||
? 'Lesson marked complete — your progress just moved forward.'
|
||||
: 'Lesson marked incomplete so you can revisit it later.',
|
||||
);
|
||||
await fetchLearningHub(activeCourse?.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to update lesson progress:', error);
|
||||
setErrorMessage('We could not update this lesson right now. Please try again.');
|
||||
} finally {
|
||||
setPendingLessonId('');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Learning Hub')}</title>
|
||||
</Head>
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton
|
||||
icon={mdiSchoolOutline}
|
||||
title='Learning Hub'
|
||||
main
|
||||
>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
<CardBox>
|
||||
<LoadingSpinner />
|
||||
</CardBox>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Learning Hub')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionMain>
|
||||
<SectionTitleLineWithButton icon={mdiSchoolOutline} title='Learning Hub' main>
|
||||
{''}
|
||||
</SectionTitleLineWithButton>
|
||||
|
||||
<CardBox className='mb-6 overflow-hidden border border-slate-200 bg-gradient-to-br from-[#0F172A] via-[#1E1B4B] to-[#0F766E] text-white shadow-xl'>
|
||||
<div className='grid gap-8 lg:grid-cols-[1.2fr_0.8fr]'>
|
||||
<div className='space-y-5'>
|
||||
<span className='inline-flex items-center rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-100'>
|
||||
Student MVP slice
|
||||
</span>
|
||||
<div className='space-y-3'>
|
||||
<h2 className='text-3xl font-semibold tracking-tight md:text-4xl'>
|
||||
Enroll, read lessons, and watch progress update in one clean flow.
|
||||
</h2>
|
||||
<p className='max-w-2xl text-sm leading-7 text-slate-200 md:text-base'>
|
||||
This first iteration turns the LMS data model into a usable learner journey:
|
||||
discover published courses, join instantly, and track completion lesson by lesson.
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/courses/courses-list' color='white' label='Browse admin courses' />
|
||||
<BaseButton href='/enrollments/enrollments-list' color='whiteDark' label='Review enrollments' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='grid gap-3 md:grid-cols-2 lg:grid-cols-1'>
|
||||
<div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-sky-100'>Ready right now</p>
|
||||
<p className='mt-2 text-2xl font-semibold'>{stats.enrolledCourses}</p>
|
||||
<p className='mt-1 text-sm text-slate-200'>Courses already active for your account.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
|
||||
<p className='text-xs uppercase tracking-[0.2em] text-sky-100'>Momentum</p>
|
||||
<p className='mt-2 text-2xl font-semibold'>{stats.averageProgress}%</p>
|
||||
<p className='mt-1 text-sm text-slate-200'>Average progress across enrolled courses.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
{successMessage ? (
|
||||
<NotificationBar color='success'>
|
||||
{successMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? (
|
||||
<NotificationBar color='danger'>
|
||||
{errorMessage}
|
||||
</NotificationBar>
|
||||
) : null}
|
||||
|
||||
<div className='mb-6 grid gap-4 lg:grid-cols-4'>
|
||||
<StatCard
|
||||
label='Published courses'
|
||||
value={stats.availableCourses}
|
||||
hint='Only published courses appear here so the learner journey stays focused.'
|
||||
/>
|
||||
<StatCard
|
||||
label='Enrolled'
|
||||
value={stats.enrolledCourses}
|
||||
hint='Your active course list is always one click away.'
|
||||
/>
|
||||
<StatCard
|
||||
label='Lessons done'
|
||||
value={stats.completedLessons}
|
||||
hint='Lesson completion drives the progress bar and course status.'
|
||||
/>
|
||||
<StatCard
|
||||
label='Average progress'
|
||||
value={`${stats.averageProgress}%`}
|
||||
hint='A fast pulse-check for current learning momentum.'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.45fr]'>
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border border-slate-200 shadow-sm'>
|
||||
<div className='mb-5 flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
|
||||
Course catalog
|
||||
</p>
|
||||
<h3 className='mt-2 text-xl font-semibold text-slate-900'>Choose your next course</h3>
|
||||
</div>
|
||||
{isRefreshing ? (
|
||||
<span className='text-xs font-medium text-slate-500'>Refreshing…</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{courses.length ? (
|
||||
<div className='space-y-4'>
|
||||
{courses.map((course) => {
|
||||
const isSelected = activeCourse?.id === course.id;
|
||||
const isEnrolled = Boolean(course.enrollment);
|
||||
const buttonLabel = isEnrolled ? 'Continue course' : 'Enroll now';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedCourseId(course.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
setSelectedCourseId(course.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full rounded-2xl border p-5 text-left transition duration-150 focus:outline-none focus:ring ${
|
||||
isSelected
|
||||
? 'border-[#2563EB] bg-[#EFF6FF] shadow-sm ring-blue-200'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm ring-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start justify-between gap-4'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h4 className='text-lg font-semibold text-slate-900'>{course.title}</h4>
|
||||
{course.is_featured ? (
|
||||
<span className='rounded-full bg-[#DBEAFE] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[#1D4ED8]'>
|
||||
Featured
|
||||
</span>
|
||||
) : null}
|
||||
{isEnrolled ? (
|
||||
<span className='rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700'>
|
||||
Enrolled
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='text-sm leading-6 text-slate-600'>{course.short_description}</p>
|
||||
</div>
|
||||
<div className='rounded-xl bg-slate-100 p-3 text-slate-700'>
|
||||
<BaseIcon path={mdiBookOpenPageVariant} size={22} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 grid gap-3 text-sm text-slate-500 md:grid-cols-2'>
|
||||
<div>
|
||||
<p className='font-medium text-slate-700'>Instructor</p>
|
||||
<p>{formatInstructor(course)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='font-medium text-slate-700'>Lessons</p>
|
||||
<p>{course.totalLessons} text lessons</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 space-y-2'>
|
||||
<div className='flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>
|
||||
<span>Progress</span>
|
||||
<span>{course.progressPercent}%</span>
|
||||
</div>
|
||||
<ProgressBar value={course.progressPercent} />
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex flex-wrap items-center gap-3'>
|
||||
{isEnrolled ? (
|
||||
<span className='inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600'>
|
||||
<BaseIcon path={mdiCheckCircleOutline} size={16} />
|
||||
{course.completedLessons}/{course.totalLessons} lessons completed
|
||||
</span>
|
||||
) : (
|
||||
<span className='inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600'>
|
||||
<BaseIcon path={mdiNotebookOutline} size={16} />
|
||||
{course.totalLessons} lessons ready to start
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isEnrolled && canEnroll ? (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={pendingCourseId === course.id ? 'Enrolling…' : buttonLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEnroll(course.id);
|
||||
}}
|
||||
disabled={pendingCourseId === course.id}
|
||||
/>
|
||||
) : (
|
||||
<BaseButton
|
||||
color='info'
|
||||
outline={!isEnrolled}
|
||||
label={buttonLabel}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setSelectedCourseId(course.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-6 text-sm leading-6 text-slate-500'>
|
||||
No published courses are available yet. Create or publish a course from the admin area
|
||||
to activate this learning flow.
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
{canManageCourses ? (
|
||||
<CardBox className='border border-slate-200 shadow-sm'>
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
|
||||
Instructor quick actions
|
||||
</p>
|
||||
<h3 className='mt-2 text-xl font-semibold text-slate-900'>Publish a course in three moves</h3>
|
||||
</div>
|
||||
<div className='grid gap-3'>
|
||||
<div className='rounded-2xl border border-slate-200 p-4'>
|
||||
<p className='font-semibold text-slate-900'>1. Create the course shell</p>
|
||||
<p className='mt-1 text-sm leading-6 text-slate-500'>Set the title, description, instructor, and featured status.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-4'>
|
||||
<p className='font-semibold text-slate-900'>2. Add ordered text lessons</p>
|
||||
<p className='mt-1 text-sm leading-6 text-slate-500'>Use markdown-friendly lesson content and estimated reading time.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-4'>
|
||||
<p className='font-semibold text-slate-900'>3. Watch enrollments and completion grow</p>
|
||||
<p className='mt-1 text-sm leading-6 text-slate-500'>The learner hub now turns those entities into a guided student experience.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
<BaseButton href='/courses/courses-new' color='info' label='Create course' />
|
||||
<BaseButton href='/lessons/lessons-new' color='info' outline label='Add lesson' />
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='space-y-6'>
|
||||
<CardBox className='border border-slate-200 shadow-sm'>
|
||||
{activeCourse ? (
|
||||
<div className='space-y-6'>
|
||||
<div className='rounded-3xl bg-gradient-to-br from-[#EEF2FF] via-white to-[#ECFEFF] p-6'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 shadow-sm'>
|
||||
{activeEnrollment ? 'In progress' : 'Preview'}
|
||||
</span>
|
||||
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 shadow-sm'>
|
||||
{activeCourse.totalLessons} lessons
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className='text-2xl font-semibold text-slate-900'>{activeCourse.title}</h3>
|
||||
<p className='mt-2 max-w-3xl text-sm leading-7 text-slate-600'>
|
||||
{activeCourse.description || activeCourse.short_description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!activeEnrollment && canEnroll ? (
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={pendingCourseId === activeCourse.id ? 'Enrolling…' : 'Enroll in this course'}
|
||||
onClick={() => handleEnroll(activeCourse.id)}
|
||||
disabled={pendingCourseId === activeCourse.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='mt-5 space-y-2'>
|
||||
<div className='flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>
|
||||
<span>Course progress</span>
|
||||
<span>{activeCourse.progressPercent}%</span>
|
||||
</div>
|
||||
<ProgressBar value={activeCourse.progressPercent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-[0.78fr_1.22fr]'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h4 className='text-lg font-semibold text-slate-900'>Lesson path</h4>
|
||||
<span className='text-sm text-slate-500'>
|
||||
{activeCourse.completedLessons}/{activeCourse.totalLessons} completed
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeCourse.lessons.length ? (
|
||||
<div className='space-y-2'>
|
||||
{activeCourse.lessons.map((lesson, index) => {
|
||||
const completion = completionLookup[lesson.id];
|
||||
const isSelected = activeLesson?.id === lesson.id;
|
||||
const isCompleted = Boolean(completion?.is_completed);
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
key={lesson.id}
|
||||
onClick={() => setSelectedLessonId(lesson.id)}
|
||||
className={`w-full rounded-2xl border p-4 text-left transition duration-150 ${
|
||||
isSelected
|
||||
? 'border-[#2563EB] bg-[#EFF6FF]'
|
||||
: 'border-slate-200 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className='flex items-start gap-3'>
|
||||
<div className={`mt-0.5 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<p className='font-semibold text-slate-900'>{lesson.title}</p>
|
||||
<p className='mt-1 text-sm text-slate-500'>
|
||||
{lesson.estimated_minutes || 5} min read
|
||||
</p>
|
||||
</div>
|
||||
{lesson.is_preview ? (
|
||||
<span className='rounded-full bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700'>
|
||||
Preview
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
|
||||
This course is published, but there are no published lessons attached yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='rounded-3xl border border-slate-200 bg-white p-6 shadow-sm'>
|
||||
{activeLesson ? (
|
||||
<div className='space-y-5'>
|
||||
<div className='flex flex-wrap items-start justify-between gap-4'>
|
||||
<div>
|
||||
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
|
||||
Lesson reader
|
||||
</p>
|
||||
<h4 className='mt-2 text-2xl font-semibold text-slate-900'>{activeLesson.title}</h4>
|
||||
<p className='mt-2 text-sm text-slate-500'>
|
||||
Estimated time: {activeLesson.estimated_minutes || 5} minutes
|
||||
</p>
|
||||
</div>
|
||||
{activeEnrollment ? (
|
||||
<BaseButton
|
||||
color={completionLookup[activeLesson.id]?.is_completed ? 'success' : 'info'}
|
||||
label={
|
||||
pendingLessonId === activeLesson.id
|
||||
? 'Saving…'
|
||||
: completionLookup[activeLesson.id]?.is_completed
|
||||
? 'Mark incomplete'
|
||||
: 'Mark complete'
|
||||
}
|
||||
onClick={() => handleToggleLesson(activeLesson.id)}
|
||||
disabled={pendingLessonId === activeLesson.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!activeEnrollment ? (
|
||||
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
|
||||
Enroll in this course to unlock completion tracking and persist your progress.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='rounded-2xl bg-slate-50 p-5'>
|
||||
<div className='whitespace-pre-wrap text-sm leading-7 text-slate-700'>
|
||||
{activeLesson.content_markdown || 'Lesson content has not been added yet.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex h-full min-h-[320px] flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center'>
|
||||
<div className='mb-4 rounded-full bg-slate-100 p-4 text-slate-600'>
|
||||
<BaseIcon path={mdiNotebookOutline} size={28} />
|
||||
</div>
|
||||
<h4 className='text-lg font-semibold text-slate-900'>Choose a lesson to begin</h4>
|
||||
<p className='mt-2 max-w-md text-sm leading-6 text-slate-500'>
|
||||
Select a lesson from the path to open the reader and record completion.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex min-h-[420px] flex-col items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-10 text-center'>
|
||||
<div className='mb-4 rounded-full bg-white p-4 shadow-sm'>
|
||||
<BaseIcon path={mdiChartTimelineVariant} size={28} />
|
||||
</div>
|
||||
<h3 className='text-2xl font-semibold text-slate-900'>Your learning space is ready</h3>
|
||||
<p className='mt-3 max-w-lg text-sm leading-7 text-slate-500'>
|
||||
Publish the first course from the admin area, then come back here to enroll and track lesson completion.
|
||||
</p>
|
||||
<div className='mt-5 flex flex-wrap justify-center gap-3'>
|
||||
<BaseButton href='/courses/courses-list' color='info' label='Go to courses' />
|
||||
<BaseButton href='/dashboard' color='info' outline label='Open dashboard' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
|
||||
<CardBox className='border border-slate-200 shadow-sm'>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<div className='rounded-2xl border border-slate-200 p-5'>
|
||||
<p className='text-sm font-semibold text-slate-900'>Create</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Instructors continue using the existing course and lesson CRUD pages.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-5'>
|
||||
<p className='text-sm font-semibold text-slate-900'>Enroll</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Learners join a published course with one click from the catalog.</p>
|
||||
</div>
|
||||
<div className='rounded-2xl border border-slate-200 p-5'>
|
||||
<p className='text-sm font-semibold text-slate-900'>Track progress</p>
|
||||
<p className='mt-2 text-sm leading-6 text-slate-500'>Lesson completion updates enrollment progress and completion status automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
</div>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
LearningHub.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission='READ_ENROLLMENTS'>{page}</LayoutAuthenticated>;
|
||||
};
|
||||
|
||||
export default LearningHub;
|
||||
Loading…
x
Reference in New Issue
Block a user